使用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服务器。
