物联网loT从业者Lua程序员

强制门户认证(captive Portal),不了解一下?

2018-06-24  本文已影响61人  谢mingmin

拉家常

这个系列荒废了很久了,不知道还有多少人记得😂。如果还记得的话,我在找个时间把先前的气象站补了,不然就这样过了。

今天带来这个系列的第三篇文章,主要使用了两个模块(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,需要知道的是,

接下来,是question和answer的细节


question
answer

关于这个两个,需要知道的是,

更多细节请参考具体的文档,用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..x00
local 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 question
local 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 server
function 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 true
end

在创建一个UDPSocket实例之前,先判断dnsServer是不是nil。如果是,才创建实例并监听53端口。同时为receive事件加入一个回调。当收到请求帧之后,对数据帧进行解析,并打包响应帧,最后回复响应帧。注意,启动端口监听要放在最后面。

最后是关闭DNS服务,和返回module。启动和关闭服务的函数都是table里面,其他地方的代码可以通过访问这个table中的key来使用这两个函数。

--stop the dns server
function module.stopdnsServer()
  if dnsServer ~= nil then
    dnsServer:close()
    dnsServer = nil
  end
  return true
end

return module

到这里,DNS服务器就完成了。所有APP的DNS请求,都会得到一个带本设备IP地址的响应包。接下来APP将会向这个IP地址发起HTTP请求。

TCP服务器

为了能够响应HTTP请求,需要使用net模块创建一个TCP实例。除非有特殊指定,不然访问的都是80端口。所以,只要创建一个监听80端口的TCP实例即可。那些非80端口的请求就不要理会了。

TCP服务器的工作很简单,当监听到有来自80端口的请求的时候,就把HTML文件回复出去。也不用过对方的请求是什么,抓到一个回一个,简单粗暴。

module = {}

local server = nil
local f = nil

okHeader = "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
  end
end

和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)
  end
end

函数开头是对请求头解析,具体后面讲。紧接着是一个回调函数。最后是一个无脑回复,回复一个响应头,以此来触发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 true
end

function module.stopServer()
  if server ~= nil then
    server:close()
    server = nil
  end
  return result 
end

启动函数开头对pathp参数做默认参数处理。这样,当这个两个参数为 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.onloadend = 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上面找到。后面如果还是其他新文章更新,代码会一起更新到上面

总之,欢迎star就是了

点完赞再走啊!

上一篇下一篇

猜你喜欢

热点阅读