用 Node-js 手寫一個 DNS 服務器

DNS 是實現域名到 IP 轉換的網絡協議,當訪問網頁的時候,瀏覽器首先會通過 DNS 協議把域名轉換爲 IP,然後再向這個 IP 發送 HTTP 請求。

DNS 是我們整天在用的協議,不知道大家是否瞭解它的實現原理呢?

這篇文章我們就來深入下 DNS 的原理,並且用 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 文件,記錄了世界上所有的主機(host)。

那時候全世界也沒多少機器,所以這樣的方式是可行的。

當然,這個 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(Domain Name System)只分了三級域名服務器:

其實就是把二、三、四、五甚至更多級的域名都合併在一個服務器解析了,叫做權威域名服務器(Authoritative Domain Name Server)。

這樣既通過分佈式減輕了服務器的壓力,又避免了層數過多導致的解析慢。

當然,每次查詢還是比較耗時的,查詢完之後要把結果緩存下來,並且設置一個過期時間,域名解析記錄在 DNS 服務器上的緩存時間叫做 TTL(Time-To-Live)。

但現在只是在某一臺機器上緩存了這個解析結果,可能某個區域的其他機器在訪問的時候還是需要解析的。

所以 DNS 設計了一層本地域名服務器,由它來負責完成域名的解析,並且把結果緩存下來。

這樣某臺具體的機器只要向這個本地域名服務器發請求就可以了,而且解析結果其他機器也可以直接用。

這樣的本地域名服務器是移動、聯通等 ISP(因特網服務提供商)提供的,一般在每個城市都有一個。某臺機器訪問了某個域名,解析之後會把結果緩存下來,其他機器訪問這個域名就不用再次解析了。

這個本地域名服務器的地址是可以修改的,在 mac 裏可以打開系統偏好設置 --> 網絡 --> 高級 --> DNS 來查看和修改本地域名服務器的地址。

這就是 DNS 的原理。

不知道大家看到本地域名服務器的配置可以修改的時候,是否有自己實現一個 DNS 服務器的衝動。

確實,這個 DNS 服務器完全可以自己實現,接下來我們就用 Node.js 實現一下。

我們先來分析下思路:

DNS 服務器實現思路分析

DNS 是應用層的協議,協議內容的傳輸還是要通過傳輸層的 UDP 或者 TCP。

我們知道,TCP 會先三次握手建立連接,之後再發送數據包,並且丟失了會重傳,確保數據按順序送達。

它適合一些需要進行多次請求、響應的通信,因爲這種通信需要保證處理順序,典型的就是 HTTP。

但這樣的可靠性保障也犧牲了一定的性能,效率比較低。

而 UDP 是不建立連接,直接發送數據報給對方,效率比較高。適合一些不需要保證順序的場景。

顯然,DNS 的每次查詢請求都是獨立的,沒有啥順序的要求,比較適合 UDP。

所以我們需要用 Node.js 起一個 UDP 的服務來接收客戶端的 DNS 數據報,自己實現域名的解析,或者轉發給其他域名服務器來處理。之後發送解析的結果給客戶端。

創建 UDP 服務和發送數據使用 Node.js 的 dgram 這個包。

類似這樣:

const dgram = require('dgram');

const server = dgram.createSocket('udp4')

server.on('message'(msg, rinfo) ={
    // 處理 DNS 協議的消息
})

server.on('error'(err) ={
    // 處理錯誤
})
  
server.on('listening'() ={
    // 當接收方地址確定時
});

server.bind(53);

具體代碼後面再細講,這裏知道接收 DNS 協議數據需要啓 UDP 服務就行。

DNS 服務器上存儲着域名和 IP 對應關係的記錄,這些記錄有 4 種類型:

其實還是很容易理解的:

類型 A 就是查詢到了域名對應的 IP,可以直接告訴客戶端。

類型 NS 是需要去另一臺 DNS 服務器做解析,比如頂級域名服務器需要進一步去權威域名服務器解析。

CNAME 是給當前域名起個別名,兩個域名會解析到同樣的 IP。

PTR 是由 IP 查詢域名用的,DNS 是支持反向解析的

而 MX 是郵箱對應的域名或者 IP,用於類似 @xxx.com 的郵件地址的解析。

當 DNS 服務器接收到 DNS 協議數據就會去這個記錄表裏查找對應的記錄,然後通過 DNS 協議的格式返回。

那 DNS 協議格式是怎麼樣的呢?

大概是這樣:

內容還是挺多的,我們挑幾個重點來看一下:

Transction ID 是關聯請求和響應用的。

Flags 是一些標誌位:

比如 QR 是標識是請求還是響應。OPCODE 是標識是正向查詢,也就是域名到 IP,還是反向查詢,也就是 IP 到域名。

再後面分別是問題的數量、回答的數量、授權的數量、附加信息的數量。

之後是問題、回答等的具體內容。

問題部分的格式是這樣的:

首先是查詢的名字,比如 baidu.com,然後是查詢的類型,就是上面說的那些 A、NS、CNAME、PTR 等類型。最後一個查詢類一般都是 1,表示 internet 數據。

回答的格式是這樣的:

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:\n${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 個字節,所以我們截取一下再 parse:

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 方法來截取某一段內容。

這兩個方法都要指定 offet,也就是從哪裏開始。

我們先讀取一個數字,也就是當前域的長度,然後讀這段長度的內容,然後繼續讀下一段,直到讀到 0,代表域名結束。

把中間的這些域通過 . 連接起來。比如 3 www 5 baidu 3 com 處理之後就是 www.baidu.com。

之後我們重啓下服務器測試下效果:

我們成功的從 DNS 協議數據中把 query 的域名解析了出來!

解析 query 部分只是第一步,接下來還要返回對應的響應。

這裏我們只自己處理一部分域名,其餘的域名還是交給別的本地 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 服務器處理,把結果返回給客戶端。

先實現 forward 部分:

轉發到別的 DNS 服務器,那就是創建一個 UDP 的客戶端,把收到的消息傳給它,收到消息後再轉給客戶端。

也就是這樣的:

function forward(msg, rinfo) {
    const client = dgram.createSocket('udp4');

    client.on('error'(err) ={
      console.log(`client error:\n${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:\n${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:\n${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 模塊啓動了 UDP 服務在 53 端口。

然後根據 DNS 協議的格式,解析出域名,對目標域名自己做處理,構造出 DNS 協議的消息返回。其他域名則是轉發給另一臺本地 DNS 服務器做解析,把它返回的消息傳給客戶端。

這樣,我們就用 Node.js 實現了本地 DNS 服務器。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Gl94ISY5N4BYyYmVT9-QFQ