简介:这份资源是北京邮电大学大二下学期计算机网络课程设计的DNS服务器实验压缩包,面向正在学习计网、需要完成课程设计或想深入理解域名系统原理的本科生。实验围绕DNS基本概念、层次结构、记录类型、递归与迭代查询过程、权威与缓存服务器区别以及DNS中继配置等核心知识点展开,帮助读者通过动手实现掌握域名解析的完整流程。包内共4个文件,包含2个txt文本、1个h头文件和1个c源文件,压缩包约9KB,其中源文件用于实现DNS查询解析逻辑,文本文件则承载配置与记录数据,结构精简但覆盖实验关键环节。目前已有166人学习下载。通过研读与调试这些代码和配置,读者可以理解DNS报文格式、套接字编程与查询转发机制,获得一份可直接参考的课程设计实现思路,适合作为网络编程入门与实验排错的实践素材。
1. 从零写一个 DNS 服务器:BUPT 计网课设到底在考什么
如果你在 BUPT 读大二下,计网课程设计大概率会撞上 DNS 服务器实验。这个标题对应的不是让你配一个 BIND 就交差,而是要求你从 socket 开始,手写一个能响应标准 DNS 查询报文的服务端程序。核心考点就三个:DNS 报文格式的编解码、UDP 传输层的收发逻辑、以及递归/迭代查询的基本流程。很多同学第一反应是去搜“计网课程设计案例源码”,找到一份 Java 或 C 的代码改改就交,但答辩时老师问一句“资源记录里 RDLength 字段怎么算的”就露馅了。这篇笔记按我当年踩坑的顺序,把从环境搭建到通过验收的完整路径拆开讲,适合还没动手或者卡在报文解析阶段的同学。读完你能得到一个可运行的 DNS 服务端,理解每个字段为什么这么填,以及哪些地方最容易翻车。
2. DNS 报文结构:为什么你的第一个响应包总是被客户端丢弃
2.1 头部 12 字节里藏着三个必须手算的字段
DNS 报文分头部和问题/资源记录部分。头部固定 12 字节,结构如下:
| 字段 | 偏移 | 长度 | 说明 |
|---|---|---|---|
| ID | 0 | 2 字节 | 事务 ID,响应必须原样返回 |
| Flags | 2 | 2 字节 | QR/Opcode/AA/TC/RD/RA/RCODE |
| QDCOUNT | 4 | 2 字节 | 问题数,通常为 1 |
| ANCOUNT | 6 | 2 字节 | 回答资源记录数 |
| NSCOUNT | 8 | 2 字节 | 授权资源记录数 |
| ARCOUNT | 10 | 2 字节 | 附加资源记录数 |
新手最容易翻车的是 Flags 字段。查询报文里 QR=0,响应必须置 QR=1。如果你直接把收到的报文改几个字节就发回去,客户端会因为 QR 位没翻转而丢弃。另一个坑是 ANCOUNT:你回答了几个 A 记录,这里就填几,填 0 客户端会认为你没有给出答案。
用 Python 的 struct 模块打包头部:
import struct def build_header(tx_id, flags, qd, an, ns, ar): # ! 表示网络字节序,6 个 H 对应 6 个 2 字节字段 return struct.pack('!HHHHHH', tx_id, flags, qd, an, ns, ar) # 构造一个标准响应:QR=1, RD=1, RA=1, RCODE=0 # Flags 二进制:1000 0001 1000 0000 = 0x8180 header = build_header(0x1234, 0x8180, 1, 1, 0, 0)参数说明:tx_id 从查询报文前两字节解包得到,必须原样回传;flags 中 0x8180 是最常见的“标准成功响应”值,如果你要返回 NXDOMAIN,把低 4 位改成 3,即 0x8183。qd/an/ns/ar 分别对应问题、回答、授权、附加记录的数量,初学阶段 ns 和 ar 填 0 即可。
2.2 域名编码:长度前缀 + 标签 + 终止零字节
DNS 不直接用字符串存域名,而是把www.example.com编码成3www7example3com0。每个标签前面加一个长度字节,最后以 0 结尾。解析时从偏移 12 开始读,遇到 0 就结束。
def encode_domain(domain): parts = domain.split('.') buf = b'' for p in parts: buf += bytes([len(p)]) + p.encode() buf += b'\x00' return buf def decode_domain(data, offset): labels = [] while data[offset] != 0: length = data[offset] offset += 1 labels.append(data[offset:offset+length].decode()) offset += length return '.'.join(labels), offset + 1注意:decode 返回的 offset 是跳过终止零字节后的位置,后续解析 QTYPE/QCLASS 要从这里继续。如果你忘了加 1,后面所有字段都会错位,表现为 QTYPE 读出来是 0 或者乱码。
2.3 资源记录:TTL 和 RDLength 是两个必填但常被忽略的字段
一条完整的资源记录包含:NAME(可用指针压缩)、TYPE、CLASS、TTL、RDLENGTH、RDATA。以 A 记录为例,TYPE=1,CLASS=1,TTL 建议设 300 到 3600 秒,RDLENGTH=4,RDATA 是 4 字节 IP。
def build_a_record(name_offset, ip): # 使用指针压缩:0xC0 | 偏移高6位,再跟偏移低8位 name_ptr = struct.pack('!H', 0xC000 | name_offset) rtype = struct.pack('!H', 1) # A 记录 rclass = struct.pack('!H', 1) # IN ttl = struct.pack('!I', 300) rdlength = struct.pack('!H', 4) rdata = bytes(map(int, ip.split('.'))) return name_ptr + rtype + rclass + ttl + rdlength + rdata指针压缩是 DNS 报文里唯一允许“偷懒”的地方:如果 NAME 和问题部分的域名相同,可以用 2 字节指针代替完整域名,省带宽。但如果你不熟悉偏移计算,建议第一版直接写完整域名,等跑通了再优化。RDLENGTH 必须和 RDATA 实际长度一致,A 记录固定 4,AAAA 记录固定 16,CNAME 记录是变长域名编码。
3. 用 Python socket 跑通最小可用 DNS 服务端
3.1 绑定 53 端口:权限、地址复用和防火墙三件事
DNS 默认走 UDP 53 端口。Linux/macOS 下绑定 1024 以下端口需要 root,Windows 下用管理员权限运行。如果你在实验室机器上没有管理员权限,可以改用 5353 等高位端口,测试时用dig @127.0.0.1 -p 5353指定端口。
import socket def start_server(host='0.0.0.0', port=53): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) print(f'DNS server listening on {host}:{port}') while True: data, addr = sock.recvfrom(512) # 后续处理逻辑 response = handle_query(data) sock.sendto(response, addr)SO_REUSEADDR 解决的是“地址已在使用”报错,尤其是你反复调试时上一次的 socket 还没完全释放。recvfrom 的 512 是 UDP DNS 的传统最大长度,如果你要支持 EDNS0 可以放宽到 4096,但课设阶段 512 够用。
3.2 解析查询并构造响应:一个完整的 handle_query
把前面几段拼起来,核心逻辑是:解包头部拿到 ID 和 Flags,跳过问题部分拿到域名和 QTYPE,查本地字典得到 IP,然后按“头部 + 原问题 + 回答记录”的顺序拼响应。
def handle_query(data): tx_id, flags, qd, an, ns, ar = struct.unpack('!HHHHHH', data[:12]) domain, offset = decode_domain(data, 12) qtype, qclass = struct.unpack('!HH', data[offset:offset+4]) offset += 4 # 本地解析表 zone = {'www.example.com': '192.168.1.100', 'mail.example.com': '192.168.1.101'} if qtype == 1 and domain in zone: ip = zone[domain] resp_header = build_header(tx_id, 0x8180, 1, 1, 0, 0) question = data[12:offset] # 原样回传问题部分 answer = build_a_record(12, ip) return resp_header + question + answer else: # 返回 NXDOMAIN resp_header = build_header(tx_id, 0x8183, 1, 0, 0, 0) return resp_header + data[12:offset]关键点:question 部分必须从原始请求里原样截取,不要自己重新编码,否则大小写或压缩指针可能导致客户端不认。answer 里的 name_offset 填 12,因为问题部分的域名从偏移 12 开始,指针指向那里即可。
3.3 用 dig 和 nslookup 验证:三个必看的返回字段
启动服务后,另开终端执行:
dig @127.0.0.1 www.example.com A +noall +answer +comments看三个地方:status 是否为 NOERROR,flags 里 qr 和 rd 是否置位,answer section 是否返回了你配置的 IP。如果 status 是 SERVFAIL,多半是报文长度或字段错位;如果超时,检查防火墙是否拦了 UDP 53。Windows 下用nslookup www.example.com 127.0.0.1也能测,但 nslookup 对畸形报文容忍度低,适合做最终验证。
4. 避坑与排查:课设验收现场最容易被问住的五个问题
4.1 现象:dig 显示 connection timed out,服务端无任何输出
原因:服务端绑定的是 0.0.0.0 但客户端用了 IPv6 的 ::1,或者防火墙拦截了入站 UDP。解决:先用ss -ulnp | grep 53确认监听地址,再用dig @127.0.0.1强制 IPv4。Windows 下检查“高级安全 Windows Defender 防火墙”的入站规则。
4.2 现象:响应包发出去了,但 dig 报 FORMERR
原因:头部 QDCOUNT 和实际问题数不一致,或者问题部分的域名编码多了/少了一个终止零字节。解决:打印原始请求的十六进制,逐字节对照 RFC 1035 的格式图,重点检查偏移 12 开始的域名编码。
4.3 现象:A 记录能返回,但 CNAME 查询返回空
原因:QTYPE 判断只写了 1(A),没处理 5(CNAME)。解决:在 zone 字典里把 CNAME 也存进去,构造响应时 TYPE 填 5,RDATA 是目标域名的编码。注意 CNAME 的 RDLENGTH 是变长的,不能用固定 4。
4.4 现象:连续查询几次后服务端崩溃
原因:recvfrom 缓冲区设了 512,但某些客户端发了带 EDNS0 的大包,解包时越界。解决:把缓冲区改成 4096,并在 decode_domain 里加边界检查,遇到 offset 超出 data 长度直接返回错误响应。
4.5 现象:答辩时被问“为什么用 UDP 不用 TCP”答不上来
原因:只背了“DNS 主要用 UDP”但不知道边界。解决:记住两点——UDP 用于常规查询,报文小于 512 字节时效率高;TCP 用于区域传送(AXFR)和响应超过 512 字节且不支持 EDNS0 的场景。课设里实现 UDP 就够了,但要知道 TCP 53 的存在。
5. 进阶技巧:用缓存和日志让课设从及格变优秀
5.1 加一层 TTL 缓存,把递归查询耗时降下来
如果你的课设要求实现递归查询(向外部 DNS 转发),每次请求都发出去会很慢。加一个字典做缓存,key 是域名+QTYPE,value 是 IP 和过期时间戳。
import time cache = {} def get_from_cache(domain, qtype): key = (domain, qtype) if key in cache: ip, expire = cache[key] if time.time() < expire: return ip else: del cache[key] return None def put_into_cache(domain, qtype, ip, ttl=300): cache[(domain, qtype)] = (ip, time.time() + ttl)参数说明:ttl 建议跟资源记录里的 TTL 保持一致,课设演示时设 60 秒即可,方便观察缓存命中。注意缓存要加锁,如果你用了多线程,不加锁在并发查询时会出玄学 bug。
5.2 打印结构化日志,答辩时直接拿日志说话
不要用 print 乱打,按“时间戳 | 客户端地址 | 查询域名 | QTYPE | 响应类型 | 耗时”格式输出。这样老师问“你怎么证明缓存生效了”,你直接翻日志指出第二次查询耗时从 200ms 降到 0.5ms。
import datetime def log_query(addr, domain, qtype, rcode, elapsed): ts = datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3] print(f'{ts} | {addr[0]}:{addr[1]} | {domain} | {qtype} | {rcode} | {elapsed*1000:.1f}ms')5.3 用配置文件管理解析表,别把 IP 写死在代码里
把 zone 字典抽到 JSON 文件,启动时加载。这样换解析记录不用改代码,也方便演示“修改配置后立即生效”。
{ "www.example.com": {"type": "A", "value": "192.168.1.100", "ttl": 300}, "mail.example.com": {"type": "A", "value": "192.168.1.101", "ttl": 300}, "alias.example.com": {"type": "CNAME", "value": "www.example.com", "ttl": 600} }加载时根据 type 字段决定构造 A 记录还是 CNAME 记录。CNAME 的 RDATA 需要把目标域名重新编码,RDLENGTH 等于编码后的字节数。
5.4 一个我踩过的坑:别在响应里回传 OPT 记录
如果你解析请求时发现 ARCOUNT 大于 0,说明客户端带了 EDNS0 的 OPT 记录。课设阶段最简单的做法是忽略它,但不要在响应里伪造 OPT。我当年画蛇添足加了一个空的 OPT,结果 dig 报 “bad OPT version”,排查了两个小时。后来直接不处理 ARCOUNT,客户端也能正常解析。
5.5 验收前自测清单
| 测试项 | 命令 | 预期 |
|---|---|---|
| A 记录查询 | dig @127.0.0.1 www.example.com A | 返回配置的 IP |
| 不存在的域名 | dig @127.0.0.1 nope.example.com | status: NXDOMAIN |
| CNAME 查询 | dig @127.0.0.1 alias.example.com | 返回 CNAME 和目标 |
| 缓存命中 | 连续执行两次 dig,看日志耗时 | 第二次明显更短 |
| 畸形报文 | 用 scapy 发一个截断的包 | 服务端不崩溃 |
最后说个习惯:我每次写完报文构造代码,都会先用 Wireshark 抓一次真实 DNS 查询的包,把自己的响应和标准响应逐字节对比。这个笨办法帮我省了至少十次“明明逻辑对但客户端就是不认”的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取