
简介这份资源是北京邮电大学大二下学期计算机网络课程设计的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 字节结构如下字段偏移长度说明ID02 字节事务 ID响应必须原样返回Flags22 字节QR/Opcode/AA/TC/RD/RA/RCODEQDCOUNT42 字节问题数通常为 1ANCOUNT62 字节回答资源记录数NSCOUNT82 字节授权资源记录数ARCOUNT102 字节附加资源记录数新手最容易翻车的是 Flags 字段。查询报文里 QR0响应必须置 QR1。如果你直接把收到的报文改几个字节就发回去客户端会因为 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) # 构造一个标准响应QR1, RD1, RA1, RCODE0 # 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:offsetlength].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 记录为例TYPE1CLASS1TTL 建议设 300 到 3600 秒RDLENGTH4RDATA 是 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 记录固定 4AAAA 记录固定 16CNAME 记录是变长域名编码。3. 用 Python socket 跑通最小可用 DNS 服务端3.1 绑定 53 端口权限、地址复用和防火墙三件事DNS 默认走 UDP 53 端口。Linux/macOS 下绑定 1024 以下端口需要 rootWindows 下用管理员权限运行。如果你在实验室机器上没有管理员权限可以改用 5353 等高位端口测试时用dig 127.0.0.1 -p 5353指定端口。import socket def start_server(host0.0.0.0, port53): sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) print(fDNS 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:offset4]) 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 是否为 NOERRORflags 里 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 判断只写了 1A没处理 5CNAME。解决在 zone 字典里把 CNAME 也存进去构造响应时 TYPE 填 5RDATA 是目标域名的编码。注意 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 是域名QTYPEvalue 是 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, ttl300): 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.comstatus: NXDOMAINCNAME 查询dig 127.0.0.1 alias.example.com返回 CNAME 和目标缓存命中连续执行两次 dig看日志耗时第二次明显更短畸形报文用 scapy 发一个截断的包服务端不崩溃最后说个习惯我每次写完报文构造代码都会先用 Wireshark 抓一次真实 DNS 查询的包把自己的响应和标准响应逐字节对比。这个笨办法帮我省了至少十次“明明逻辑对但客户端就是不认”的排查时间。希望帮到你。本文还有配套的精品资源点击获取