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

  • CNAME:域名对应的别名

  • MX:邮件名后缀对应的域名或者 IP

  • NS:域名需要去另一个 DNS 服务器解析

  • PTR:IP 对应的域名

其实还是很容易理解的:

类型 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 IDconst id  = msg.subarray(0, 2)copyBuffer(id, 0, response)  offset += id.length// Flagsresponse.writeUInt16BE(0x8180, offset)  offset += 2// Questionsresponse.writeUInt16BE(1, offset)  offset += 2// Answer RRsresponse.writeUInt16BE(1, offset)  offset += 2// Authority RRs & Additional RRsresponse.writeUInt32BE(0, offset)  offset += 4copyBuffer(queryInfo, offset, response)offset += queryInfo.length// offset to domain nameresponse.writeUInt16BE(0xC00C, offset) offset += 2const typeAndClass = msg.subarray(msg.length - 4)copyBuffer(typeAndClass, offset, response)offset += typeAndClass.length// TTL, in secondsresponse.writeUInt32BE(600, offset)  offset += 4// Length of IPresponse.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 IDconst id  = msg.subarray(0, 2)copyBuffer(id, 0, response)  offset += id.length// Flagsresponse.writeUInt16BE(0x8180, offset)  offset += 2// Questionsresponse.writeUInt16BE(1, offset)  offset += 2// Answer RRsresponse.writeUInt16BE(1, offset)  offset += 2// Authority RRs & Additional RRsresponse.writeUInt32BE(0, offset)  offset += 4copyBuffer(queryInfo, offset, response)offset += queryInfo.length// offset to domain nameresponse.writeUInt16BE(0xC00C, offset) offset += 2const typeAndClass = msg.subarray(msg.length - 4)copyBuffer(typeAndClass, offset, response)offset += typeAndClass.length// TTL, in secondsresponse.writeUInt32BE(600, offset)  offset += 4// Length of IPresponse.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 服务器。

最后

我是小前端,欢迎大家围观我的朋友圈,搞搞技术,吹吹牛逼。我的微信:kujian89,秒添加,邀你进入 500人前端群。

推荐阅读

NodeJS有难度的面试题,你能答对几个?

Node 开发一个多人对战的射击游戏

53道常见NodeJS基础面试题(附答案)

关注公众号:前端开发博客

  1. 回复「小抄」,领取Vue、JavaScript 和 WebComponent 小抄 PDF

  2. 回复「Vue脑图」获取 Vue 相关脑图

  3. 回复「思维图」获取 JavaScript 相关思维图

  4. 回复「简历」获取简历制作建议

  5. 回复「简历模板」获取精选的简历模板

  6. 回复「加群」进入500人前端精英群

  7. 回复「电子书」下载我整理的大量前端资源,含面试、Vue实战项目、CSS和JavaScript电子书等。

  8. 回复「知识点」下载高清JavaScript知识点图谱

 

用 Node.js 手写一个 DNS 服务器相关推荐

  1. 使用Node.js手撸一个建静态Web服务器,内部CV指南

    文章里有全部代码,也可以积分下载 操作步骤如上图 文章结束 话说这个键盘真漂亮~~ 文章目录 使用Node.js手撸一个建静态Web服务器 一.动静态服务器的概念 1.1 静态Web服务器概念 1.2 ...

  2. [js] 手写一个trim()的方法

    [js] 手写一个trim()的方法 function trim(str) { if (str[0] === ' ' && str[str.length - 1] === ' ') { ...

  3. 【Node.js】写一个数据自动整理成表格的脚本

    前言 企业项目进行数据埋点后,埋点事件名需要整理成Excel表格便于统计,目标是将下图左侧数据转化成下图右侧的Excel表格: 考虑到左侧埋点数据是随项目迭代增加的,埋点数据每增加一次我就要把数据一条 ...

  4. 【Java】远程调用、线程池手写一个简单服务器

    " 关键字:远程调用.序列化.反序列化.反射.动态代理.客户端.服务端.线程池 > 思考题:带着这几个问题可以先思考,然后看完文章再去理解,也可以在评论区讨论喔~ 反射和动态代理关系和 ...

  5. 手写一个简易bundler打包工具带你了解Webpack原理

    用原生js手写一个简易的打包工具bundler

  6. api怎么写_使用Node.js原生API写一个web服务器

    Node.js是JavaScript基础上发展起来的语言,所以前端开发者应该天生就会一点.一般我们会用它来做CLI工具或者Web服务器,做Web服务器也有很多成熟的框架,比如Express和Koa.但 ...

  7. 【Linux服务器开发系列】手写一个用户态网络协议栈,瞬间提升你网络功底丨netmap/dpdk的实现

    手写一个用户态网络协议栈,瞬间提升你网络功底 1. 网卡基础架构 2. netmap/dpdk的实现 3. 网络协议栈实战 [Linux服务器开发系列]手写一个用户态网络协议栈,瞬间提升你网络功底丨n ...

  8. 2020年前端面试之JS手写代码题合集

    2020年前端面试之JS手写代码题合集 预计会有上千道题,后续慢慢补! 1.  写一个把字符串大小写切换的方法 function caseConvert(str){return str.replace ...

  9. NGINX配置基于Node.js服务的负载均衡服务器

    NGINX配置基于Node.js服务的负载均衡服务器 本部署指南说明了如何使用NGINX开源和NGINX Plus在Node.js应用程序服务器池之间平衡HTTP和HTTPS通信.本指南中的详细说明适 ...

最新文章

  1. 目标检测 RCNN, SPPNet, Fast RCNN, Faster RCNN 总结
  2. 汇编语言--iret指令
  3. C语言常见基础错误大全总结
  4. 计算机信息管理专业教学改革,计算机信息管理专业实践教学改革探索
  5. iOS底层探索之类的加载(四):类的关联对象AssociatedObject
  6. XMLHTTPRequest如何访问需要安全验证的网站
  7. powerbuilder mysql_powerbuilder+mysql5.0的连接过程
  8. opencv-python中文文档
  9. 随机过程在计算机领域的应用,随机过程与排队论——及其在计算机领域中的应用.doc...
  10. SPSS中系统聚类操作案例
  11. vs2013连接access2016
  12. “黑盒工坊”,轻松管理《魔兽世界》插件!
  13. Qt5初见笔记(一)使用单个.pro文件创建Hello World工程(参考Sherriff, Nicholas《Learn Qt5》)
  14. python 循环写入excel sheet_python 使用xlsxwriter循环向excel中插入数据和图片的操作...
  15. 荣耀猎人游戏本散热怎么样?测试一下就知道
  16. MySQL 索引基本原则
  17. 揭开500亿“区块链”骗局
  18. 典型相关分析、对应分析
  19. 拜读《高质量C/C++编程》笔记
  20. Marvell校招新增数字后端工程师岗位

热门文章

  1. 合肥计算机学校排名2015,2015安徽高校排名情况
  2. cocos2d-html5 游戏实战源代码大集合和下载(包括flappy,popstar等----持续更新中----)...
  3. 【第六章】 C语言之牛客力扣刷题笔记 【点进来保证让知识充实你一整天】
  4. 2021-12-07(JZ83 剪绳子(进阶版))
  5. 启动光盘制作完全手册下载
  6. 怎么把视频转成mp3音频?
  7. RK3568平台开发系列讲解(驱动篇)驱动开发之RTC 使用
  8. 字节跳动招聘研究型实习生
  9. 一句话证明你是java开发_如何用一句话证明你是程序员?41 个答案揭晓!
  10. yjv是电缆还是电线_YJV电缆与YJY电缆哪个价格高,两者的区别是什么?