使用Node.js編寫DNS伺服器
DNS工作原理
不知道你有沒有想過,為什麼要有網域名稱?
我們知道,識別電腦靠的是IP地址,IPv4有32位,IPv6有128位。
IPv4通常用十進位表示:
192.10.128.240
IPv6太長了,一般用十六進位表示:
3C0B:0000:2667:BC2F:0000:0000:4669:AB4D
不管是IPv4還是IPv6,這一串數字太難記了,訪問網頁時輸入這麼一串數字也太麻煩了。
而且IP不是固定的,如果機房搬遷,IP也會跟著變。
怎樣才能用一種好記且不受固定IP限制的方式訪問目標伺服器呢?
可以給目標機器起一個名字,用戶端通過這個名字來訪問目標機器,而不需要記IP。
名字和IP的綁定關係可以更改,每次訪問都需要經過解析名字對應IP的過程。
這個名字就叫做網域名稱。
那怎麼維護這個網域名稱和IP的映射關係呢?
最簡單的方式就是在一個檔案裡記錄所有網域名稱和IP的對應關係,每次解析網域名稱時查一下這個檔案。
最初確實是這麼設計的。這樣的檔案叫做hosts檔案,記錄了全世界所有的主機。
那時候全世界機器不多,所以這種方式是可行的。
當然,這個hosts的配置是統一維護的。當新主機要接入互連網時,在這裡註冊自己的網域名稱和IP,其他機器拉取最新配置就能訪問這台主機了。
但隨著機器數量增加,這種方式就行不通了,有兩個突出問題:
全世界都要從某台機器同步配置,這台機器壓力太大。
網域名稱多了之後,命名容易衝突。
所以網域名稱伺服器必須是分布式的,通過多台伺服器提供服務,並且最好按命名空間劃分,減少命名衝突。
於是網域名稱體系就誕生了。比如baidu.com中的com叫做頂級網域名稱,baidu是com網域名稱的次層網域。
這樣一來,如果有另一個baidu.xyz也是可以的,因為xyz和com是不同的網域名稱,下面有各自獨立的命名空間。
這樣就減少了命名衝突。
既然是分布式的,就需要劃分網域名稱讓哪台伺服器來處理,以此分散請求的壓力。
很容易想到,頂級網域名稱、次層網域、第三層網域名分別放在不同的伺服器上解析。
所有頂級網域名稱伺服器上面還有一個目錄,叫做根網域名稱伺服器。
這樣查詢某個網域名稱的IP時,先跟根網域名稱伺服器查頂級網域名稱的地址,有次層網域再查對應伺服器的地址,一層層查下去直到找到最終IP。
當然,之前的hosts方式並沒有完全廢棄,我們還是會先查hosts,找不到再請求網域名稱伺服器。
就是這樣:
比如要查網域名稱www.baidu.com的IP,先查本地hosts,找不到的話,從根網域名稱伺服器查com網域名稱的通用頂級網域名稱伺服器地址,再查頂級網域名稱伺服器擷取baidu.com次層網域伺服器的地址,一層層往下查直到找到最終IP。
這樣一來,伺服器的壓力就以分布式的方式分散了。
但這種設計還有一個問題。每頂層網域對應一台伺服器,如果網域名稱層級太多,就需要多次往返查詢,效率不高。
所以DNS(網域名稱系統)只分了第三層網域名伺服器:
根網域名稱伺服器:記錄所有頂級網域名稱伺服器的地址,是網域名稱解析的入口
頂級網域名稱伺服器:記錄各次層網域對應伺服器的地址
權威網域名稱伺服器:網域名稱下的二級、三級甚至更多級網域名稱都在這裡解析
實際上,二級、三級、四級、五級甚至更多級網域名稱都合并到一台伺服器上解析,這台伺服器就叫權威網域名稱伺服器(Authoritative Domain Name Server)。
這樣既通過分布式減輕了伺服器壓力,又避免了層級過多導致的解析緩慢。
當然,每次查詢仍然耗時,查詢完成後應該緩衝結果並設定到期時間。DNS伺服器上網域名稱解析記錄的緩衝時間叫做TTL(Time-To-Live,存留時間)。
但這個解析結果只緩衝在某台機器上,某個地區的其他機器訪問時可能還需要重新解析。
所以DNS設計了一層本地區名伺服器,負責完成網域名稱解析並緩衝結果。
這樣某台機器只需向本地區名伺服器發送請求,其他機器可以直接使用解析結果。
這樣的本地區名伺服器由ISP(互連網服務提供者)提供,比如中國移動、中國聯通,一般每個城市都有一個。當某台機器訪問某個網域名稱時,解析後會緩衝結果,其他機器再訪問這個網域名稱就不需要重新解析了。
本地區名伺服器的地址可以修改。在macOS中,可以開啟系統喜好設定 --> 網路 --> 進階 --> DNS來查看和修改本地區名伺服器的地址。
這就是DNS的工作原理。
不知道大家看到本地區名伺服器的地址可以修改,有沒有衝動自己實現一個DNS伺服器。
確實,DNS伺服器可以自己實現,接下來我們就用Node.js來實現。
先分析一下思路:
DNS伺服器實現思路分析
DNS是應用程式層協議,協議內容的傳輸仍然需要通過傳輸層的UDP或TCP。
我們知道TCP會先通過三向交握建立串連,然後發送資料包,如果丟包會重傳,保證資料按順序送達。
它適合需要多次請求和響應的通訊情境,因為這種通訊需要保證處理順序,典型的就是HTTP。
但這種可靠性保障也犧牲了一定效能,效率相對較低。
UDP不建立串連,直接向對方發送資料報,效率更高。適合一些不需要保證順序的情境。
顯然,DNS的每個查詢請求都是獨立的,沒有順序要求,更適合用UDP。
所以我們需要用Node.js啟動一個UDP服務,接收用戶端的DNS資料報,自己實現網域名稱解析,或者轉寄給其他網域名稱伺服器處理。然後將解析結果發送給用戶端。
使用Node.js的dgram包建立UDP服務和發送資料。
大概是這樣的:
const dgram = require('dgram');
const server = dgram.createSocket('udp4')
server.on('message', (msg, rinfo) => {
// handle DNS protocol messages
})
server.on('error', (err) => {
// handle errors
})
server.on('listening', () => {
// when the recipient address is determined
});
server.bind(53);
具體代碼後面會詳細講。這裡我們知道需要開啟UDP服務來接收DNS協議資料就行了。
DNS伺服器儲存了網域名稱和IP對應關係的記錄。這些記錄有以下幾種類型:
A:網域名稱對應的IP
CNAME:網域名稱對應的別名
MX:郵箱名稱尾碼對應的網域名稱或IP
NS:網域名稱需要到另一個DNS伺服器解析
PTR:IP對應的網域名稱
其實挺好理解的:
A類型是查詢網域名稱對應的IP,可以直接告訴用戶端。
NS類型需要到另一個DNS伺服器解析。比如頂級網域名稱伺服器需要進一步到權威網域名稱伺服器解析。
CNAME是當前網域名稱的別名,兩個網域名稱會解析到同一個IP。
PTR是用IP查詢網域名稱,DNS支援反向解析。
MX是郵箱對應的網域名稱或IP,用來解析類似@xxx.com的郵箱地址。
DNS伺服器收到DNS協議資料後,會去這個記錄表尋找對應記錄,然後按DNS協議格式返回。
DNS協議的格式是什麼樣的呢?
大概是這樣:
內容還挺多的,我們挑幾個重點來看:
Transaction ID用來關聯請求和響應。
Flags是一些標誌位:
比如QR用來標識是請求還是響應。OPCODE標識是正向查詢(網域名稱查IP)還是反向查詢(IP查網域名稱)。
然後是問題數、回答數、授權數和附加資訊數。
接著是問題和回答的具體內容。
問題部分的格式是這樣的:
首先是查詢的名稱,比如baidu.com,然後是查詢類型,就是前面說的A、NS、CNAME、PTR等類型。最後的查詢類別一般是1,表示互連網資料。
回答部分的格式是這樣的:
Name也是要查詢的網域名稱,Type是A、NS、CNAME、PTR等,Class和問題部分一樣,是1。
然後需要指定Time to live,也就是這條解析記錄緩衝多長時間。DNS用這個來控制用戶端和本地DNS伺服器的緩衝到期時間。
最後是資料的長度和內容。
這就是DNS協議的格式。
我們知道怎麼啟動UDP服務以及收到的DNS協議資料是什麼格式了,那就可以開始實現DNS伺服器了。解析問題部分的網域名稱,自己實現解析,返回對應的響應資料。
原理大概講清楚了,我們來寫代碼:
手寫DNS伺服器
首先建立一個UDP服務,監聽53連接埠,這是DNS協議的預設連接埠。
const dgram = require('dgram')
const server = dgram.createSocket('udp4')
server.on('message', (msg, rinfo) => {
console.log(msg)
});
server.on('error', (err) => {
console.log(`server error: ${err.stack}`)
server.close()
})
server.on('listening', () => {
const address = server.address()
console.log(`server listening ${address.address}:${address.port}`)
})
server.bind(53)
通過dgram模組建立UDP服務,在53連接埠啟動,處理開始監聽事件列印伺服器地址和連接埠,處理錯誤事件列印錯誤堆棧。收到訊息時直接列印。
在系統喜好設定中修改本地DNS伺服器位址,指向本機:
這樣,當我們再次訪問網頁時,服務控制台就會列印收到的訊息:
一堆Buffer資料,這就是DNS協議的訊息。
我們從中解析並列印出查詢的網域名稱,也就是這部分:
問題部分前面是12位元組,我們先截取這部分來解析:
server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`)
})
msg的類型是Buffer,是Uint8Array的子類,儲存的是不帶正負號的整數。(整數儲存可以有符號或無符號,無符號能存的數字是有符號的兩倍。)
調用它的subarray方法截取前12位元組。
然後解析問題部分:
第一個問題是網域名稱,我們只需要解析網域名稱。
我們用.來區分網域名稱,但儲存時不是這樣的,而是按以下格式:
當前欄位長度 + 當前欄位內容 + 當前欄位長度 + 當前欄位內容 + 當前欄位長度 + 當前欄位內容 + 0
按這種格式儲存,網域名稱以0結尾。
所以解析邏輯是這樣的:
function parseHost(msg) {
let num = msg.readUInt8(0);
let offset = 1;
let host = "";
while (num !== 0) {
host += msg.subarray(offset, offset + num).toString();
offset += num;
num = msg.readUInt8(offset);
offset += 1;
if (num !== 0) {
host += '.'
}
}
return host
}
通過Buffer的readUInt8方法讀取一個不帶正負號的整數,通過Buffer的subarray方法截取一段內容。
兩個方法都指定了位移量,也就是從哪裡開始。
我們先讀一個數字,這是當前網域名稱的長度,然後讀取這個長度的內容,再繼續讀下一段,直到讀到0表示網域名稱結束。
中間用.串連各欄位。比如3 www 5 baidu 3 com處理後就是www.baidu.com。
然後我們重啟伺服器測試效果:
我們成功從DNS協議資料中解析出了查詢的網域名稱!
解析問題部分只是第一步,接下來要返回對應的響應。
這裡我們自己處理部分網域名稱,其餘網域名稱交給其他本地DNS伺服器處理:
server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`);
if (/guangguangguang/.test(host)) {
resolve(msg, rinfo)
} else {
forward(msg, rinfo)
}
});
如果解析的網域名稱包含guangguangguang,就自己處理,構造對應的DNS協議訊息返回。
否則轉寄給另一個本地DNS伺服器處理,將結果返回給用戶端。
先實現轉寄部分:
轉寄給另一個DNS伺服器,就是建立一個UDP用戶端,把收到的訊息傳給它,收到訊息後再轉寄給用戶端。
是這樣的:
function forward(msg, rinfo) {
const client = dgram.createSocket('udp4');
client.on('error', (err) => {
console.log(`client error: ${err.stack}`);
client.close();
});
client.on('message', (fbMsg, fbRinfo) => {
server.send(fbMsg, rinfo.port, rinfo.address, (err) => {
err && console.log(err)
})
client.close();
});
client.send(msg, 53, '192.168.199.1', (err) => {
if (err) {
console.log(err)
client.close()
}
});
}
通過dgram.createSocket建立UDP用戶端,參數udp4表示IPv4地址。
處理錯誤,監聽訊息,將msg轉寄給目標DNS伺服器(這裡的DNS伺服器位址可以改成別的)。
收到返回的訊息後,傳遞給用戶端。
用戶端的IP和連接埠通過參數傳入。
這樣就實現了DNS協議的轉寄。我們先測試一下當前效果。
使用nslookup命令查詢某個網域名稱的地址:
可以看到查詢baidu.com能擷取到對應的IP地址,在瀏覽器中可以訪問。
而guangguangguang.ddd.com沒有找到對應的IP。
接下來實現resolve方法,構造DNS協議訊息返回。
還是這個格式:
大概這樣構造:
會話ID從傳入的msg中取,Flags也設定一下。問題數和回答數都是1,授權數和附加數都是0。
問題地區和回答地區按對應格式設定:
需要用Buffer.alloc建立一個Buffer對象。
過程中還會用到buffer.writeUInt16BE寫入無符號雙位元組整數。
這裡的BE是Big Endian,大端序,也就是高位放在右邊,低位放在左邊。
比如00000000 00000001是大端序雙位元組不帶正負號的整數1。小端序的1是00000001 00000000,也就是高位放在左邊。
組裝DNS協議訊息挺麻煩的,看一下就好:
function copyBuffer(src, offset, dst) {
for (let i = 0; i < src.length; ++i) {
dst.writeUInt8(src.readUInt8(i), offset + i)
}
}
function resolve(msg, rinfo) {
const queryInfo = msg.subarray(12)
const response = Buffer.alloc(28 + queryInfo.length)
let offset = 0
// Transaction ID
const id = msg.subarray(0, 2)
copyBuffer(id, 0, response)
offset += id.length
// Flags
response.writeUInt16BE(0x8180, offset)
offset += 2
// Questions
response.writeUInt16BE(1, offset)
offset += 2
// Answer RRs
response.writeUInt16BE(1, offset)
offset += 2
// Authority RRs & Additional RRs
response.writeUInt32BE(0, offset)
offset += 4
copyBuffer(queryInfo, offset, response)
offset += queryInfo.length
// offset to domain name
response.writeUInt16BE(0xC00C, offset)
offset += 2
const typeAndClass = msg.subarray(msg.length - 4)
copyBuffer(typeAndClass, offset, response)
offset += typeAndClass.length
// TTL, in seconds
response.writeUInt32BE(600, offset)
offset += 4
// Length of IP
response.writeUInt16BE(4, offset)
offset += 2
'11.22.33.44'.split('.').forEach(value => {
response.writeUInt8(parseInt(value), offset)
offset += 1
})
server.send(response, rinfo.port, rinfo.address, (err) => {
if (err) {
console.log(err)
server.close()
}
})
}
最後將拼接完成的DNS協議訊息發送給對方。
這樣就實現了對guangguangguang網域名稱的解析。
在上面的代碼中,我把它解析到了11.22.33.44這個IP。
用nslookup測試一下:
可以看到,對應網域名稱解析成功了!
這樣我們就通過Node.js實現了DNS伺服器。
貼一下完整代碼,大家可以自己運行,然後把電腦的本地DNS伺服器指向它試試:
const dgram = require('dgram')
const server = dgram.createSocket('udp4')
function parseHost(msg) {
let num = msg.readUInt8(0);
let offset = 1;
let host = "";
while (num !== 0) {
host += msg.subarray(offset, offset + num).toString();
offset += num;
num = msg.readUInt8(offset);
offset += 1;
if (num !== 0) {
host += '.'
}
}
return host
}
function copyBuffer(src, offset, dst) {
for (let i = 0; i < src.length; ++i) {
dst.writeUInt8(src.readUInt8(i), offset + i)
}
}
function resolve(msg, rinfo) {
const queryInfo = msg.subarray(12)
const response = Buffer.alloc(28 + queryInfo.length)
let offset = 0
// Transaction ID
const id = msg.subarray(0, 2)
copyBuffer(id, 0, response)
offset += id.length
// Flags
response.writeUInt16BE(0x8180, offset)
offset += 2
// Questions
response.writeUInt16BE(1, offset)
offset += 2
// Answer RRs
response.writeUInt16BE(1, offset)
offset += 2
// Authority RRs & Additional RRs
response.writeUInt32BE(0, offset)
offset += 4
copyBuffer(queryInfo, offset, response)
offset += queryInfo.length
// offset to domain name
response.writeUInt16BE(0xC00C, offset)
offset += 2
const typeAndClass = msg.subarray(msg.length - 4)
copyBuffer(typeAndClass, offset, response)
offset += typeAndClass.length
// TTL, in seconds
response.writeUInt32BE(600, offset)
offset += 4
// Length of IP
response.writeUInt16BE(4, offset)
offset += 2
'11.22.33.44'.split('.').forEach(value => {
response.writeUInt8(parseInt(value), offset)
offset += 1
})
server.send(response, rinfo.port, rinfo.address, (err) => {
if (err) {
console.log(err)
server.close()
}
})
}
function forward(msg, rinfo) {
const client = dgram.createSocket('udp4')
client.on('error', (err) => {
console.log(`client error: ${err.stack}`)
client.close()
})
client.on('message', (fbMsg, fbRinfo) => {
server.send(fbMsg, rinfo.port, rinfo.address, (err) => {
err && console.log(err)
})
client.close()
})
client.send(msg, 53, '192.168.199.1', (err) => {
if (err) {
console.log(err)
client.close()
}
})
}
server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`);
if (/guangguangguang/.test(host)) {
resolve(msg, rinfo)
} else {
forward(msg, rinfo)
}
});
server.on('error', (err) => {
console.log(`server error: ${err.stack}`)
server.close()
})
server.on('listening', () => {
const address = server.address()
console.log(`server listening ${address.address}:${address.port}`)
})
server.bind(53)
總結
在本文中,我們學習了DNS的原理,並使用Node.js自己實現了一個本地DNS伺服器。
網域名稱解析時會先查詢hosts檔案,找不到就請求本地區名伺服器,這是由ISP提供的,一般每個城市都有一個。
本地區名伺服器負責解析網域名稱對應的IP,它會依次請求根網域名稱伺服器、頂級網域名稱伺服器、權威網域名稱伺服器來擷取最終IP,並返回給用戶端。
電腦可以設定本地區名伺服器的地址,我們將其指向用Node.js實現的本地區名伺服器。
DNS協議基於UDP傳輸,所以我們通過dgram模組在53連接埠啟動了UDP服務。
然後按照DNS協議的格式解析網域名稱,目標網域名稱自己處理並構造DNS協議訊息返回,其他網域名稱轉寄給另一個本地DNS伺服器解析,將其返回的訊息傳遞給用戶端。
這樣我們就用Node.js實現了一個本地DNS伺服器。
