拉家常
这个系列荒废了很久了,不知道还有多少人记得。如果还记得的话,我在找个时间把先前的气象站补了,不然就这样过了。
今天带来这个系列的第三篇文章,主要使用了两个模块(net, file),涉及的内容也广泛。不过,还是打算用一篇文章来讲完。
在开始之前先说一下工作原理。APP在访问某个域名的时候,会先发起DNS请求,向服务器问域名的IP地址。然后再发起HTTP请求,请求想要的内容。
强制门户认证
在这里,由于nodemcu充当了AP的角色,可以接收到APP发起的DNS请求包。只要让nodemcu把回复请求的IP地址指向自己的IP就行了。这样一来,APP就会向设备IP发起HTTP请求。那么,nodemcu在收到HTTP请求后,不管对方请求什么内容,都回复本地的HTML文件。手机(小米4)就会弹出这个页面,比如弹出下面这个难看的页面。
难看的页面
如何实现
从上面的原理可以看出来,需要实现一个DNS服务器和一个TCP服务器,还要撸HTML来实现一个难看的页面。下面将分三步走实现功能。
DNS服务器
首先需要知道的是,DNS走的是UDP协议,使用的端口号是53。这个DNS服务器主要任务是,不管三七二十一,见到请求就回复带有本设备IP地址的DNS响应。
实现这个DNS服务器之前,需要了解一下DNS协议。只有知道DNS的数据帧长什么样子之后,才能构造一个回复数据包。这里有一篇讲的比较清晰明了的文章,感兴趣的可以阅读一下。或者看这里的第4部分(message)。
DNS的协议帧看起来是这样子的,包括的头部,问题(就是域名),答案(就是IP)。
协议帧
下面是header细节,
头
关于header,需要知道的是,
1.DNS的请求和响应数据帧格式是一样的;
2.响应头部的ID是直接复制请求头部的ID;
3.头部占了12个字节,意味着question从第13个字节开始。
接下来,是question和answer的细节
question
answer
关于这个两个,需要知道的是,
qname和name的内容是一样的,就是域名;
qname和name的长度是不确定的(对于不同域名来讲),其结尾是0x00。位置从第13个字节开始。
rdata就是IP地址,占4个字节。
更多细节请参考具体的文档,用wireShark抓包来看,也是一个不错的选择。这样有助于你对DNS的了解。
抓个包来看看
从上面的分析结果可以知道,这个DNS服务器的核心功能就是解析复制请求帧里面的qname。这个现实起来也不难,就是从请求帧的第13个字节开始找到第1个0x00,将这个区间的内容复制出来即可。
下面开始直播写代码!
module = {}local dns_ip=wifi.ap.getip()local i1,i2,i3,i4=dns_ip:match("(%d+)%.(%d+)%.(%d+)%.(%d+)")local x00=string.char(0)local x01=string.char(1)local dns_str1=string.char(128)..x00..x00..x01..x00..x01..x00..x00..x00..x00local dns_str2=x00..x01..x00..x01..string.char(192)..string.char(12)..x00..x01..x00..x01..x00..x00..string.char(3)..x00..x00..string.char(4)local dns_strIP=string.char(i1)..string.char(i2)..string.char(i3)..string.char(i4)local dnsServer = nil
看到开头的table变量(module)没,DNS服务器这部分的代码最终会打包成一个模块,供给其他文件或者说模块调用。
模块化的好处就是,封装,私有化变量,既有利于解耦和, 也方便维护代码。
中间一堆变量,主要用来给后面构建响应帧的。
最后一个变量用来存储创建的DNS服务器。因为一个实例化的net server模块只能listen一次。封装一下,免得报错。
接着就是核心代码了,解析请求帧,找出qname。多说一句,假设域名是 WWW.1234.COM。那么qname里面存储的3 WWW 4 1234 3 COM
这种格式,点·是不会被写入qname里面的。
-- get the questionlocal function decodeQuery(payload) local len = #payload local pos = 13 local char = "" while string.byte(payload, pos) ~= 0 do pos = pos + 1 end return string.sub(payload, 13, pos)end
然后就是创建DNS服务器的代码,
--start the dns serverfunction module.startdnsServer() if dnsServer == nil then dnsServer = net.createUDPSocket() dnsServer:on("receive", function(sck, data, port, ip) local id = string.sub(data, 1, 2) local query = decodeQuery(data) local response = id..dns_str1..query..dns_str2..dns_strIP -- print(string.byte(query, 1, #query)) -- print(string.byte(response, 1, #response)) sck:send(port, ip, response) end) dnsServer:listen(53) print("dns server start, heap = "..node.heap()) end return trueend
在创建一个UDPSocket实例之前,先判断dnsServer
是不是nil
。如果是,才创建实例并监听53端口。同时为receive
事件加入一个回调。当收到请求帧之后,对数据帧进行解析,并打包响应帧,最后回复响应帧。注意,启动端口监听要放在最后面。
最后是关闭DNS服务,和返回module。启动和关闭服务的函数都是table里面,其他地方的代码可以通过访问这个table中的key来使用这两个函数。
--stop the dns serverfunction module.stopdnsServer() if dnsServer ~= nil then dnsServer:close() dnsServer = nil end return trueendreturn module
到这里,DNS服务器就完成了。所有APP的DNS请求,都会得到一个带本设备IP地址的响应包。接下来APP将会向这个IP地址发起HTTP请求。
TCP服务器
为了能够响应HTTP请求,需要使用net模块创建一个TCP实例。除非有特殊指定,不然访问的都是80端口。所以,只要创建一个监听80端口的TCP实例即可。那些非80端口的请求就不要理会了。
TCP服务器的工作很简单,当监听到有来自80端口的请求的时候,就把HTML文件回复出去。也不用过对方的请求是什么,抓到一个回一个,简单粗暴。
module = {}local server = nillocal f = nilokHeader = "HTTP/1.0 200 OK\r\nServer: NodeMCU on ESP8266\r\nContent-Type: text/html\r\n\r\n"local function serverOnSent(sck, payload) local content = f.read(500)-- print(content) if content then sck:send(content) else sck:close() sent = false endend
和DNS模块化差不多,不需要外界知道的变量加个local关键词。okHeader
这个变量存储了HTTP响应头。serverOnSent
函数是sent事件的回调函数,功能很简单,就是读取文件并以500字节的大小分包发送(TCP协议有规定最多帧长,所以需要分包发送)。
除了sent(发送完成)事件外,还有个receive(接收到请求帧)事件。下面是其对应的代码
local function serverOnReceive(sck, payload, callback) local _, _, method, path, query = string.find(payload, "([A-Z]+) (.+)?(.+) HTTP") if method == nil then _, _, method, path = string.find(payload, "([A-Z]+) (.+) HTTP") end callback(sck, method, path, query) if method ~= nil then if f then f.seek("set", 0) end sck:send(okHeader) endend
函数开头是对请求头解析,具体后面讲。紧接着是一个回调函数。最后是一个无脑回复,回复一个响应头,以此来触发sent事件。当然,为了避免太无脑,做了简单的过滤。只有接收的数据包含请求头才会响应。
这里使用回调的原因是,serverOnReceive
是一个内部函数,加入回调方便外面的函数扩展具体的功能。
最后是启动函数和关闭函数,关闭函数很简单。
function module.startServer(callback, path, p) local port = p or 80 local exists = file.exists(path or "index.html") if server == nil then server = net.createServer() if server == nil then return false, "server create failed" end server:listen(80, function(sck) sck:on("receive", function(sck, payload) serverOnReceive(sck, payload, callback) end) sck:on("sent", function(sck, payload) serverOnSent(sck, payload) end) end) end if exists ~= true then return false, "file not exist" end f = file.open(path or "index.html") if f == nil then return false, "file open failed" end print("html server start, heap = "..node.heap()) return trueendfunction module.stopServer() if server ~= nil then server:close() server = nil end return result end
启动函数开头对path
,p
参数做默认参数处理。这样,当这个两个参数为 nil 的时候,赋予默认值。
然后就是创建TCP实例和监听80端口。TCP实例的listen函数是带回调的。这点和UDP不一样。
另外,函数里面还有一些有效性的判断。如果没有通过有效性判断,则返回错误标志,和错误信息。lua支持多参数返回。具体用法是
local result, msg = startServer()
启动服务
两个服务器都写好了,在写一个文件来启动就可以了。代码相当简单
local htmlServer = require "server"local dnsServer = require "dnsServer"dnsServer.startdnsServer() htmlServer.startServer(function (sck, method, path, query) print(method, path, query)end)
首先使用require
这个关键字导入两个模块,并重命名。导入的前提是将上面两个文件存储到nodemcu里面,文件名分别是server.lua和dnsServer.lau。
将print(method, path, query)
这里替换成其他代码,就可以实现任何你能想到的功能了。
还差HTML
实际上,上面的内容并不完整。因为,还差一个HTML文件。这个文件的内容也很简单。不过涉及前端的内容了,不打算细说。完整的代码看这里。
就简单的说一下xhr请求
function connect() { let url = '/setwifi?ssid=' + encodeURIComponent($('#ssid').value) + '&pwd=' + encodeURIComponent($('#pwd').value); let xhr = new XMLHttpRequest(); xhr.end = function () { $('#success').style.display = 'inline'; } xhr.open('GET', url, true); xhr.send(); }
这里使用xhr提交一个GET请求。请求头大概是长这样的GET /setwifi?ssid=X&pwd=Y HTTP...
。请问的receive回调函数解析这个头部可以得到GET /setwifi ssid=X&pwd=Y
,并且存储在3个变量里面。
如果看过之前的文章,可能还有印象,之前的文章不需要使用xhr来提交请求的。而是通过解析浏览器访问的url。这回不一样了,因为TCP收到的请求头是有APP发起的,所以长什么样子并不知道。如果不知道内容,就没办法下一部操作了。不过只要借助xhr就可以发起一个知道的请求了。
欢迎star
至此,强制门户认证的工程就完成了。这个项目的代码可以在GitHub上面找到。后面如果还是其他新文章更新,代码会一起更新到上面
作者:谢mingmin
链接:https://www.jianshu.com/p/0293e1a30b89
共同学习,写下你的评论
评论加载中...
作者其他优质文章