美文网首页物联网loT从业者Lua程序员
强制门户认证(captive Portal),不了解一下?

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

作者: 谢mingmin | 来源:发表于2018-06-24 17:19 被阅读61次

    拉家常

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

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

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

      1. qname和name的内容是一样的,就是域名;
      1. qname和name的长度是不确定的(对于不同域名来讲),其结尾是0x00。位置从第13个字节开始。
      1. 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..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就是了

    点完赞再走啊!

    相关文章

      网友评论

      • 简单的梦想_f454:您好,请教您一个别的问题,烧录了含有HX711模块的固件。看官方文档就两个命令hx711.init(clk, data)和hx711.read(0)两个命令。只接一个HX711有读数了,现在想接两个HX711怎么弄啊?期待您的指教。
        简单的梦想_f454:@谢mingmin 非常感谢您的回复,看来还是arduino更成熟些
        谢mingmin:@简单的梦想_f454 从文档看起来应该只能开一个😅

      本文标题:强制门户认证(captive Portal),不了解一下?

      本文链接:https://www.haomeiwen.com/subject/lfmjyftx.html