拉家常
這個系列荒廢了很久了,不知道還有多少人記得??。如果還記得的話,我在找個時間把先前的氣象站補了,不然就這樣過了。
今天帶來這個系列的第三篇文章,主要使用了兩個模塊(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的細節
關于這個兩個,需要知道的是,
- 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..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
啟動函數開頭對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.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