一文搞懂DNS协议是如何工作的!
日期:2022-05-27 11:03:11
更新:2022-05-27 14:43:56
标签:前端
分类:前端
DNS是计算机网络中的一个应用层协议,它用于域名解析:将域名地址解析成对应的IP地址。 那么他是如何进行域名解析的呢?下面就来一起了解一下吧~

DNS 是计算机网络中的一个应用层协议,它用于域名解析:将域名地址解析成对应的 IP 地址。
那么他是如何进行域名解析的呢?下面就来一起了解一下吧~
查阅本文可以学习到一下知识:
-
域名解析过程与方法
-
域名解析方法
-
DNS 报文结构
-
实现一个简单的域名解析服务
域名组成
在开始之前,先介绍一下域名是怎么组成的

以www.google.com为例,域名的读顺序是从右往左,右边com为顶级域名,google和www则为标签(Label),com后的标签也称为二级域名。
顶级域名 4
顶级域名可以告诉用户域名所提供的服务类型。最通用的顶级域名(.com, .org, .net)不需要 web 服务器满足严格的标准,但一些顶级域名则执行更严格的政策。比如
- 地区的顶级域名,如.us,.fr,或.sh,可以要求必须提供给定语言的服务器或者托管在指定国家。这些 TLD 通常表明对应的网页服务从属于何种语言或哪个地区。
- 包含.gov 的顶级域名只能被政府部门使用。
- edu 只能为教育或研究机构使用。
顶级域名既可以包含拉丁字母,也可以包含特殊字符。顶级域名最长可以达到 63 个字符,不过为了使用方便,大多数顶级域名都是两到三个字符。
标签(Label)4
标签都是紧随着 TLD 的。标签由 1 到 63 个大小写不敏感的字符组成,这些字符包含字母 A-z,数字 0-9,甚至 “-” 这个符号(当然,“-” 不应该出现在标签开头或者标签的结尾)。
1.域名解析过程与方法
了解完域名的组成后,再来看 DNS 协议,DNS 是一个基于UDP的应用层协议。通过客户机-服务器的方式进行通信:也就是说,域名解析需要通过向服务器端发送请求,服务器端查询映射表或数据库后返回对应的 IP 地址给客户机(如图)。

DNS 服务是按层次结构来检索域名的。
为什么需要使用层次结构呢?假设我们只有根域名服务器,那么全球所有的 DNS 解析都会涌入该服务器,这样做服务器的压力会非常大。
DNS 层次结构如图所示

域名服务器分为 4 种:
根域名服务器:根域名服务器存储所有已经记录的域名,但是根域名服务器很少,只有几个。
顶级域名服务器:记录顶级域名下的所有域名,比如:com的顶级域名服务器会记录所有顶级域名为com的域名。
权威域名服务器: 负责一个区域的 DNS 解析的域名服务器。
本地域名服务器: 运行在本地的一个域名服务,用于向其他域名服务器请求解析的服务。
递归解析与迭代解析
域名解析流程:
在向 DNS 请求之前,本地域名服务器会先查询指定的域名是否有缓存,如果有且未过期,则立即返回。
如果没有,则向用户定义好的域名服务器查询。如下图

上面,我们也说到:DNS 服务器是一个层级结构,多个域名服务器分布在不同的地方,但每次请求只能请求一个服务器。如果请求的域名服务器没有记录该域名对应的 IP,那么他应该怎么向下一个域名服务器查询呢?
DNS 查询提供了两种方法:迭代查询和递归查询
递归查询
顾名思义,递归查询会在域名服务器查询记录的时候,继续向该域名服务器指定的下一级域名服务器查询,直到根域名服务器为止。

迭代查询
迭代查询在没有查询到对应的记录时,会将下一个可查询的域名服务器地址返回到本地域名服务器,由本地域名服务器重新发起请求查询,直到根域名服务器为止。

2.DNS 报文结构
接下来查看一下 DNS 请求发送过来的报文数据是怎样的,首先我们创建一个 UDP 服务监听本地的53端口。然后在网络中设置 DNS 服务器指向本机的域名。
请求报文
首先分析一下 DNS 请求报文,为了方便查看,我们写一个 DNS 服务,去捕捉 DNS 请求
-
修改一下 DNS 解析的地址

-
建立一个UDP服务,监听53端口
const dgram = require("dgram");
const server = dgram.createSocket("udp4");
server.on("error", (err) => {
console.log("server error:", err.message);
server.close();
});
server.on("message", (msg, rinfo) => {
try {
const dnsJson = JSON.parse(JSON.stringify(msg));
const dnsData = Array.from(dnsJson.data);
console.log(dnsData);
} catch {}
});
server.on("listening", () => {
const address = server.address();
console.log(`server listening at ${address.port}`);
});
server.bind(53);
运行服务后在命令行工具中 ping 一下baidu.com;

可以看到 DNS 解析的请求报文已经在 node 服务中打印出来了

将他们转换为 8 位二进制数。

再来对照一下 DNS 报文结构。

-
事务 ID:占两字节,对应 16 个二进制位,作用是作为请求的标识,即在数组中前两个数
10001010 01110110
-
标志位:占两字节,16 个二进制位,每一个二进制位都有其作用,每个标志位按顺序说明如下
00000001 00000000
| 标志 |
作用 |
| QR(1bit) |
查询(Query)/响应(Response)标志,0 为查询,1 为响应 |
| opcode(4bit) |
表示操作码,0 表示标准查询;1 表示反向查询;2 表示服务器状态请求 |
| AA(Authoritative)(1bit) |
授权应答,该字段在响应报文中有效。值为 1 时,表示名称服务器是权威服务器;值为 0 时,表示不是权威服务器。 |
| TC(Truncated)(1bit) |
表示是否被截断。值为 1 时,表示响应已超过 512 字节并已被截断,只返回前 512 个字节 |
| RD(Recursion Desired)(1bit) |
是否期望递归。0 表示迭代查询,1 表示递归查询 |
| RA(Recursion Available)(1bit) |
是否支持递归。该字段只出现在响应报文中。当值为 1 时,表示服务器支持递归查询 |
| ZERO(3bit) |
表示保留字段 |
| rcode(Reply code)(4bit) |
表示返回码,0 表示没有差错,3 表示名字差错,2 表示服务器错误(Server Failure) |
-
问题计数,对应数组中第 5 - 12 个数
| 字段 |
说明 |
| Questions(2 字节)(查询问题数) |
表示查询问题区域节的数量,在请求的时候一般为 1 |
| Answer RRs(2 字节)(回答 RR 数) |
表示回答区域的数量,根据请求一般为 1 |
| Authority RRs(2 字节)(权威 RR 数) |
表示授权区域的数量,一般为 0 |
| Additional RRs(2 字节) (附加 RR 数) |
表示附加区域的数量一般为 0 |
-
正文部分(查询问题区域)
接下来就是查询的正文部分,从请求报文第 16 个数开始到结束都属于正文部分。
正文部分包含三个字段:查询名(域名),查询类型和查询类
包含域名的可变长度字段,每个域以计数开头,最后一个字符为 0。(也会有 IP 的时候,即反向查询)
.
以上文的请求来说,从第13位开始
| 5 |
98 |
97 |
105 |
100 |
117 |
3 |
99 |
111 |
109 |
0 |
| 长度 |
b |
a |
i |
d |
u |
长度 |
c |
o |
m |
结束 |
比如,第一位为长度,后面 5 位则为ASCII码组成的字母,以此类推,直到标志位为 0 为止
| 类型 |
助记符 |
说明 |
| 1 |
A |
由域名获得 IPv4 地址,一般是这个 |
| 2 |
NS |
查询域名服务器 |
| 5 |
CNAME |
查询规范名称 |
| 6 |
SOA |
开始授权 |
| 11 |
WKS |
熟知服务 |
| 12 |
PTR |
把 IP 地址转换成域名 |
| 13 |
HINFO |
主机信息 |
| 15 |
MX |
邮件交换 |
| 28 |
AAAA |
由域名获得 IPv6 地址 |
| 252 |
AXFR |
传送整个区的请求 |
| 255 |
ANY |
对所有记录的请求 |
* 查询类,占2个数,16个位
响应报文
再来写一个转发 dns 请求的服务,用来获取 DNS 响应报文
const dgram = require("dgram");
const server = "223.5.5.5";
function forward(msg, rinfo) {
const client = dgram.createSocket("udp4");
client.on("error", (err) => {
console.log(`client error:` + err.stack);
client.close();
});
client.on("message", (fMsg, fbRinfo) => {
console.log(JSON.parse(JSON.stringify(fMsg.data)));
server.send(fMsg, rinfo.port, rinfo.address, (err) => {
err && console.log(err);
});
client.close();
});
client.send(msg, 53, fbSer, (err) => {
if (err) {
console.log(err);
client.close();
}
});
}
我们再ping一下baidu.com这个域名

同样,我们把他转位二进制数据

可以看到,第三,四个数(标志位)变为129,128,对应的二进制位为10000001 10000000
上文也说到,第一个二进制位为:QR 查询(Query)/响应(Response)标志,1 为响应,因此可以知道他是响应报文。
其余的前半部分与请求报文一致。后面多出来的部分则为响应的数据,即
[
192, 12, 0, 1, 0, 1, 0, 0, 1, 103, 0, 4, 220, 181, 38, 148, 192, 12, 0, 1, 0,
1, 0, 0, 1, 103, 0, 4, 220, 181, 38, 251,
];
-
偏移量(2 字节)
其中[192,12]是一个(2 字节)指针,一般响应报文中,资源部分的地址(域名)一般都是指针 C00C(1100000000001100),偏移量是 12,指向请求部分的地址(域名)。
-
资源记录的响应类型
响应类型,也就是后面的[0,1],含义与查询问题部分的类型相同
-
资源记录的响应类
响应类,也就是后面的[0,1],含义与查询问题部分的类相同
-
生存时间(4 字节)
接下去的是[0, 0, 1, 103],以秒为单位,表示的是资源记录的生命周期,可以理解为获取到的资源记录的缓存时间
-
资源长度
资源长度是[0, 4],ipv4 是 00 04
-
资源数据
资源数据是可变长度的字段,在这里我们拿它来指向 IP 地址,例如上文例子为:[220, 181, 38, 148]
后面又从[192, 0]开始,表示改域名能解析出多个IP地址
至此,DNS 的请求报文与响应报文都已介绍完。
4.实现一个简单的域名解析服务
最后,让我们来做一个属于自己的 DNS 服务器吧~
const dgram = require("dgram");
const server = dgram.createSocket("udp4");
const dns = require("dns");
dns.setServers(["223.5.5.5"]);
let translateObj = {
"test.bbbbb.com": [220, 181, 38, 148],
};
function explainDomain(dnsArr) {
let arr = [];
let queryType = [];
let queryClass = [];
let len = 0;
while (dnsArr.length) {
if (dnsArr[0] === 0) {
dnsArr.splice(0, 1);
queryType = dnsArr.splice(0, 2);
queryClass = dnsArr.splice(0, 2);
} else {
if (len === 0) {
len = dnsArr.splice(0, 1);
} else {
arr = arr.concat(dnsArr.splice(0, len), [46]);
len = 0;
}
}
}
arr.pop();
return {
domain: arr.map((val) => String.fromCharCode(val)).join(""),
queryType,
queryClass,
};
}
function createResponse(requestArr, ip) {
const response = new ArrayBuffer(requestArr.length + 16);
const resArr = [192, 12, 0, 1, 0, 1, 0, 0, 0, 218, 0, 4].concat(ip);
let bufView = new Uint8Array(response);
for (let i = 0; i < requestArr.length; i++) bufView[i] = requestArr[i];
for (let i = 0; i < resArr.length; i++) {
bufView[requestArr.length + i] = resArr[i];
}
bufView[2] = 129;
bufView[3] = 128;
bufView[7] = 1;
return bufView;
}
server.on("error", (err) => {
console.log("server error:", err.message);
server.close();
});
server.on("message", (msg, rinfo) => {
try {
const dnsJson = JSON.parse(JSON.stringify(msg));
const dnsData = Array.from(dnsJson.data);
let target = dnsData.splice(0, 2);
let flag = dnsData.splice(0, 2);
let data = dnsData.splice(0, 8);
let domainData = explainDomain(dnsData);
if (translateObj[domainData.domain]) {
const responseArr = createResponse(
dnsJson.data,
translateObj[domainData.domain]
);
server.send(responseArr, rinfo.port, rinfo.address, (err) => {
if (err) {
console.log("send error", err);
server.close();
}
});
} else {
dns.resolve(domainData.domain, (err, address) => {
if (err) {
console.log("dns lookup err", err);
return;
}
if (!address.length) return;
console.log(domainData.domain, address);
const responseArr = createResponse(
dnsJson.data,
address[0].split(".").map((val) => Number(val))
);
server.send(responseArr, rinfo.port, rinfo.address, (err) => {
if (err) {
console.log("send error", err);
server.close();
}
});
});
}
} catch {}
});
server.on("listening", () => {
const address = server.address();
console.log(`server listening at ${address.port}`);
});
server.bind(53);
运行服务后,我们ping一下自定义的域名test.bbbbb.com看一下是否有解析到对应的 IP

小结
本文主要介绍了:
- 域名的组成(顶级域名,二级域名)
- DNS 服务架构(根域名服务器,顶级域名服务器,权威域名服务器,本地域名服务器)
- DNS 查询方法(递归查询,迭代查询)
- DNS 请求和响应报文
- 使用 node 实现一个 DNS 服务
参考
- 计算机网络原理 机械工业出版社 2018
- DNS 报文格式解析
- NodeJS 编写简单的 DNS 服务器
- 什么是域名?