天天看點

用 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 是不同的域,之下有獨立的命名空間。

這樣就減少了命名沖突。

用 Node.js 手寫一個 DNS 伺服器

分布式的話就要劃分什麼域名讓什麼伺服器來處理,把請求的壓力分散開。

很容易想到的是頂級域、二級域、三級域分别放到不同的伺服器來解析。

所有的頂級域伺服器也有個目錄,叫做根域名伺服器。

這樣查詢某個域名的 IP 時就先向根域名伺服器查一下頂級域的位址,然後有二級域的話再查下對應伺服器的位址,一層層查,直到查到最終的 IP。

當然,之前的 hosts 的方式也沒有完全廢棄,還是會先查一下 hosts,如果查不到的話再去請求域名伺服器。

也就是這樣的:

用 Node.js 手寫一個 DNS 伺服器

比如查 www.baidu.com 這個域名的 IP,就先查本地 hosts,沒有查到的話就向根域名伺服器查 com 域的通用頂級域名伺服器的位址,之後再向這個頂級域名伺服器查詢 baidu.com 二級域名伺服器的位址,這樣一層層查,直到查到最終的 IP。

這樣就通過分布式的方式來分散了伺服器的壓力。

但是這樣設計還是有問題的,每一級域一個伺服器,如果域名的層次過多,那麼就要往返查詢好多次,效率也不高。

是以 DNS(Domain Name System)隻分了三級域名伺服器:

  • 根域名伺服器:記錄着所有頂級域名伺服器的位址,是域名解析的入口
  • 頂級域名伺服器:記錄着各個二級域名對應的伺服器的位址
  • 權威域名伺服器:該域下二級、三級甚至更多級的域名都在這裡解析

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

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

用 Node.js 手寫一個 DNS 伺服器

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

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

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

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

用 Node.js 手寫一個 DNS 伺服器

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

這個本地域名伺服器的位址是可以修改的,在 mac 裡可以打開系統偏好設定 --> 網絡 --> 進階 --> DNS來檢視和修改本地域名伺服器的位址。

用 Node.js 手寫一個 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
  • CNAME:域名對應的别名
  • MX:郵件名字尾對應的域名或者 IP
  • NS:域名需要去另一個 DNS 伺服器解析
  • PTR:IP 對應的域名

其實還是很容易了解的:

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

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

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

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

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

當 DNS 伺服器接收到 DNS 協定資料就會去這個記錄表裡查找對應的記錄,然後通過 DNS 協定的格式傳回。

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

大概是這樣:

用 Node.js 手寫一個 DNS 伺服器

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

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

Flags 是一些标志位:

用 Node.js 手寫一個 DNS 伺服器

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

再後面分别是問題的數量、回答的數量、授權的數量、附加資訊的數量。

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

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

用 Node.js 手寫一個 DNS 伺服器

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

回答的格式是這樣的:

用 Node.js 手寫一個 DNS 伺服器

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 端口,處理開始監聽的事件,列印伺服器位址和端口,處理錯誤的事件,列印錯誤堆棧。收到消息時直接列印。

用 Node.js 手寫一個 DNS 伺服器

修改系統偏好設定的本地 DNS 伺服器位址指向本機:

用 Node.js 手寫一個 DNS 伺服器

這樣再通路網頁的時候,我們的服務控制台就會列印收到的消息了:

用 Node.js 手寫一個 DNS 伺服器

一堆 Buffer 資料,這就是 DNS 協定的消息。

我們從中把查詢的域名解析出來列印下,也就是這部分:

用 Node.js 手寫一個 DNS 伺服器

問題前面的部分有 12 個位元組,是以我們截取一下再 parse:

server.on('message', (msg, rinfo) => {
  const host = parseHost(msg.subarray(12))
  console.log(`query: ${host}`)
})      

msg 是 Buffer 類型,是 Uint8Array 的子類型,也就是無符号整型。(整型存儲的時候可以帶符号也可以不帶符号,不帶符号的話可以存儲的數字會大一倍。)

調用它的 subarray 方法,截取掉前面 12 個位元組。

然後解析問題部分:

用 Node.js 手寫一個 DNS 伺服器

問題的最開始就是域名,我們隻要把域名解析出來就行。

我們表示域名是通過 . 來區分,但是存儲的時候不是,是通過

目前域長度 + 目前域内容 + 目前域長度 + 目前域内容 + 目前域長度 + 目前域内容 + 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 方法來截取某一段内容。

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

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

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

之後我們重新開機下伺服器測試下效果:

用 Node.js 手寫一個 DNS 伺服器

我們成功的從 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 指令來查詢某個域名的位址:

用 Node.js 手寫一個 DNS 伺服器

可以看到,查詢 baidu.com 是能拿到對應的 IP 位址的,在浏覽器裡也就可以通路。

而 guangguangguang.ddd.com 沒有查找到對應的 IP。

接下來實作 resolve 方法,自己構造一個 DNS 協定的消息傳回 。

還是這樣的格式:

用 Node.js 手寫一個 DNS 伺服器

大概這樣構造:

會話 ID 從傳過來的 msg 取,flags 也設定下,問題數回答數都是 1,授權數、附加數都是 0。

問題區域和回答區域按照對應的格式來設定:

用 Node.js 手寫一個 DNS 伺服器
用 Node.js 手寫一個 DNS 伺服器

需要用 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 伺服器

可以看到,對應的域名解析成功了!

這樣我們就通過 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 伺服器

電腦可以設定本地域名伺服器的位址,我們把它指向了用 Node.js 實作的本地域名伺服器。

繼續閱讀