強制門戶認證(captive Portal),不了解一下?

拉家常

這個系列荒廢了很久了,不知道還有多少人記得??。如果還記得的話,我在找個時間把先前的氣象站補了,不然就這樣過了。

今天帶來這個系列的第三篇文章,主要使用了兩個模塊(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就是了

點完贊再走啊!

簡書評論不能貼圖, 如有需要可以到我的GitHub上提issues

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容