从零手写DNS服务器:课设核心代码、测试与避坑指南 简介北邮大二下计算机网络课程设计中的DNS服务器实验是一套精简而完整的课程设计资料包。资源包含用C语言编写的main.c与main.h实现了DNS报文解析与查询处理逻辑a.txt存放域名到IPv4地址的A记录映射dnsrelay.txt则给出DNS中继转发的配置示例。压缩包共4个文件含2个txt、1个h、1个c整体仅9KB体积虽小却覆盖了DNS层次结构、A/AAAA/MX/CNAME/PTR等记录类型、递归与迭代两种查询方式、基于UDP 53端口的协议交互以及权威服务器与缓存服务器的职责区分等核心知识点。已有166人学习代码量小但结构清晰适合正在完成计网课程设计或希望动手实现简易DNS服务的学生参考。通过阅读与运行该资源可快速理解域名解析的全流程并基于main.c的框架扩展更多记录类型或中继策略从而提升网络编程与协议分析能力。1. 一门计网课设为什么值得自己写一遍DNS服务器BUPT大二下的计网课程设计里DNS服务器实验是一道分水岭有人从网上扒个bind配置糊弄过去有人用Python从头写了一个能解析域名的递归服务器最后成绩和答辩表现完全不在一个量级。我自己更推荐后者——DNS协议看着玄学其实报文格式是全网公开的固定结构交互只有一条UDP链路非常适合拿来练手。这篇笔记的目标很直接陪你从零自己搭建一个DNS服务器把课程设计的核心代码、测试方法和答辩常问的坑一次说清。无论你是在UOS server 20上配置还是用普通Linux或Windows做实验方向都是一样的先把最小闭环跑起来再谈加分项。2. 拆解课设需求协议范围、运行环境和评分点写代码前先花半天把需求拆清楚收益远大于直接开写。DNS服务器课设最常见的验收方式是你在本机或虚拟机上监听53端口用dig或nslookup发查询服务器能返回正确IP再严格一点的课设会要求“本地没有记录时能向上级DNS递归查询”。这个要求决定了后面代码要覆盖哪几块。2.1 自己搭建DNS服务器前先圈定协议范围一个能通过验收的DNS服务器闭环包含四步监听UDP 53端口、解析收到的查询报文、查本地记录或向上转发、把结果组包发回。协议上只依赖RFC 1035定义的固定结构核心是Header12字节、Question区、Answer区。课设至少要支持A记录域名到IPv4有余力再加CNAME和NS记录。Header里的关键字段按顺序理解Transaction ID是一次查询的标识Flags是16位QR、Opcode、AA、TC、RD、RA、RCODE全挤在里面QDCOUNT是问题个数正常查询是1后面ANCOUNT、NSCOUNT、ARCOUNT分别是答案、权威、附加区域的记录数。RCODE最常考两个值0表示成功3表示NXDOMAIN。这些字段全部是大端字节序后续解析时用struct.unpack的前缀一次搞定。举个例子dig发送的查询报文开头12字节通常是2字节ID、0x0100RD1、0x0001QDCOUNT1然后跟着QNAME。应答时把flags改成0x8180QR1、RD1、RA1、RCODE0ANCOUNT填1再拼上Answer区。很多同学一上来就去研究DNSSEC、EDNS0、TCP回退方向不对。课设评分点通常是“支持递归查询”“正确解析常见记录类型”“异常域名返回NXDOMAIN”先把这三个满足再想加分项。2.2 语言和库怎么选别一上来就bind自己搭建DNS服务器最大的坑不是协议而是选错工具。直接装bind9改named.conf实验能过但答辩时老师问“递归和迭代的区别”“缓存怎么失效”你只能背答案。用Python标准库socket从零写反而最稳不用pip装任何东西答辩时能对着代码从struct层层讲。为什么不推荐scapy或dnspython这两个库把报文解析和组包都封装好了看起来省事但课设代码查重和答辩论据都会很难看。老师点开源码问“你的socket在哪”场面会很尴尬。C语言写也可以但对大二学生来说调试太血泪一个字节序错了光是强转就够折腾半天。Python的struct.unpack能直接按大端解不容易翻车所以后面所有代码都用Python演示。运行环境上Windows、Ubuntu、UOS server 20都没问题。唯一要注意的是Linux发行版普遍带systemd-resolved它会默认占用53端口实验前要先让位第4章我会单独演示怎么处理。另外建议在项目根目录建一个README.md把启动命令、测试命令、目录结构写清楚这个文件在验收时比注释管用。程序里还需要一个全局配置区把监听地址、监听端口、上级DNS、本地zone路径集中放。我一般用dataclass写一个Config类这样答辩时可以说“配置和逻辑分离”。本地zone数据直接用dict硬编码就行课设规模用不上sqlite。上级DNS地址根据网络环境选校园网里用学校给的DNS公网环境用114.114.114.114或223.5.5.5都可以注意有些实验网络会拦截外网UDP 53这时要先找网管确认出口策略。2.3 程序整体结构socket、解析、缓存三层怎么分工我习惯把程序拆成三层UDP接收层负责bind和recvfrom报文解析层负责字节流转dict、dict组包成字节流业务层判断本地有没有记录没有就转发给上级DNS。缓存挂在业务层旁边用dict做TTL过期检查。三层别揉进一个文件至少拆成dns_server.py、dns_message.py、dns_cache.py三个文件。课设代码量不大但老师翻代码时看到清晰分层印象分会好很多。UDP接收层用while True recvfrom就够不需要自己实现重传那是客户端的事。接收缓冲给512字节对应传统DNS最大报文想支持EDNS0再放开到4096。开多线程处理并发之前先想清楚共享缓存dict的锁怎么加很多并发翻车都出在这里课设阶段用单线程同步模型完全能跑通。到这里需求就拆完了一个UDP端口、一个能解析和组报文的模块、一个本地记录表、一个上级DNS转发器、一个TTL缓存。下面第3章直接给可运行代码。3. 从零写一个能跑通的最小DNS服务器核心代码与参数这一章给的是能直接保存成.py运行的最小实现约120行。我不会把完整文件一次性甩出来而是按“解析Header→解析域名→构造应答→转发”四步拆开讲每一步你都能单独验证。3.1 解析DNS报文头标识符、标志位和QDCOUNTDNS报文头固定12字节所有字段都是大端序。Python里用一个struct.unpack就能解出来import struct def parse_header(data): if len(data) 12: return None tid, flags, qdcount, ancount, nscount, arcount struct.unpack(6H, data[:12]) return { tid: tid, flags: flags, qdcount: qdcount, ancount: ancount, nscount: nscount, arcount: arcount, qr: (flags 15) 0x1, opcode: (flags 11) 0xF, aa: (flags 10) 0x1, tc: (flags 9) 0x1, rd: (flags 8) 0x1, ra: (flags 7) 0x1, rcode: flags 0xF, }提示struct.unpack里的6H表示“大端、6个无符号短整型”。DNS协议规定字节序一律大端后续组包也一样字节序错了后面全乱。这段代码把12字节拆成6个16位整数。其中flags被进一步拆位QR是查询/响应标志客户端发来的请求QR0RD表示客户端期望递归RA在响应里表示递归可用RCODE在响应里才有意义。业务层只用三个字段就够tid用来识别查询qdcount用来过滤非法报文rd用来决定要不要走递归分支。拆出来的其它位是给答辩准备的比如老师会问TC是什么你别答不上来。3.2 处理问题区QNAME的格式和指针压缩QNAME由连续的长度前缀标签组成比如www.example.com在报文里是03www07example03com00。解析时从偏移量开始按“读长度→读标签→移动偏移”循环遇到0结束。但这里有个课设必考的点报文尾部常常会出现指针压缩最高两位是11表示“剩余域名和报文前面某处的内容一样”。不处理指针解析出来的域名会带乱码甚至死循环def parse_qname(data, offset): labels [] end_offset offset jump_to None while True: length data[end_offset] if length 0xC0 0xC0: pointer ((length 0x3F) 8) | data[end_offset 1] if jump_to is None: jump_to end_offset 2 end_offset pointer continue if length 0: end_offset 1 break end_offset 1 labels.append(data[end_offset:end_offset length]) end_offset length if jump_to is not None: end_offset jump_to return b..join(labels), end_offset这里有个关键细节遇到指针时指针本身占2字节。如果这是第一次遇到指针要把“当前解析位置2”记下来作为问题区的真实结束位置然后跳到指针指向的位置继续读标签。这样设计是为了支持“域名后半段复用报文前面出现过的名字”不处理它解析就会陷入死循环或读错偏移。解析完QNAME后紧接着的4字节是QTYPE和QCLASS用struct.unpack(HH, data[end:end4])读出来。QTYPE是1代表A记录2代表NS5代表CNAME将来扩展都从这走。3.3 构造应答A记录、TTL和问题区回显构造响应报文时最稳妥的做法是Header自己拼问题区直接把客户端发来的QNAME和QTYPE原样回显Answer区再追加一条A记录。问题区如果重新组包遇到带指针的请求反而容易出错直接回显可以保证“你问的和我答的是同一个域名”。import socket def build_answer_response(query, ip, ttl60): header query[:2] struct.pack(H, 0x8180) query[4:6] struct.pack(H, 1) struct.pack(HH, 0, 0) question query[12:] answer struct.pack(HHHIH, 0xC00C, 1, 1, ttl, 4) socket.inet_aton(ip) return header question answerflags填0x8180的含义是QR1这是响应、RD1因为客户端要求递归、RA1递归可用、RCODE0成功。这里直接把原请求的ID放在响应的前2字节保证客户端能认出这是它问的那个问题。Answer区的名字字段用了0xC00C指针指向报文偏移12字节处也就是问题区起点意思是“答案的域名就是问题里的那个域名”。这个指针是DNS应答里最常用的压缩技巧很多教材直接用c00c表示。HHHIH依次是域名指针、类型A1、类IN1、TTL、IPv4地址长度4最后跟4字节网络序IP。TTL参数单独留出来是为了后面缓存递减时能动态改。3.4 递归转发与最小缓存自己搭建DNS服务器的核心本地没有记录时服务器要把客户端发来的原始报文原样转发给上级DNS等上级返回后再回给客户端。这里不要自己重新组包因为客户端报文里可能带你解析不了的附加字段原样转发最安全def forward_to_upstream(query, upstream114.114.114.114, timeout5.0): sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(timeout) try: sock.sendto(query, (upstream, 53)) response, _ sock.recvfrom(4096) return response except socket.timeout: return None finally: sock.close() CACHE {} def get_cached(key, now): item CACHE.get(key) if item and item[expire] now: return item[data] return None def put_cache(key, data, ttl, now): CACHE[key] {data: data, expire: now ttl}第4章会详细讲测试这里先给主循环骨架实验里替换本地zone内容就能跑def main(): sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((0.0.0.0, 53)) local_ttl 60 while True: data, addr sock.recvfrom(512) hdr parse_header(data) if not hdr or hdr[qdcount] ! 1: continue qname, end parse_qname(data, 12) qtype, qclass struct.unpack(HH, data[end:end4]) key (qname, qtype) cached get_cached(key, time.time()) if cached: sock.sendto(cached, addr) continue if qtype 1 and qname bmyhost.lab.: resp build_answer_response(data, 192.168.1.10, local_ttl) else: resp forward_to_upstream(data) if resp: put_cache(key, resp, local_ttl, time.time()) sock.sendto(resp, addr)forward_to_upstream里recvfrom用4096缓冲是为了后面支持大响应时不至于直接截断。upstream参数按课程设计网络环境改成学校DNS或公共DNS比如114.114.114.114在UOS server 20这类Linux服务器上配置时也是同样逻辑——域名能解析到上级说明转发链路没问题。缓存key是(qname, qtype)value直接存整个响应报文。这里有个简化缓存命中时直接把上次的响应发回去Transaction ID还是旧值dig会因为ID不匹配丢弃响应。第5章5.2会给改ID的补丁这是课设常被追问的细节。4. 用本机环境把实验跑起来本地测试与抓包验证代码写完不代表实验做完。能把服务在真实环境跑起来用抓包工具证明“我的服务器真的在解析域名”才算达到课设验收标准。这一章给一套本地测试流程从启动到抓包照做十分钟出结果。4.1 服务启动前的端口准备让出53号端口DNS默认端口是53但Linux上systemd-resolved会抢占。无论你用Ubuntu还是UOS server 20启动自己服务器前都要先停掉系统解析器sudo systemctl stop systemd-resolved sudo systemctl disable systemd-resolved sudo ss -ulnp | grep :53ss输出为空说明53端口让出来了。Windows上先在服务里停止DNS Client或管理员命令提示符执行net stop dnscache。这一步不做最常见的现象是bind报“Address already in use”然后你怀疑代码写错了——其实大部分绑不上端口的翻车都发生在这里。启动后如果不想动系统配置可以把代码监听端口改成5353测试命令加-p 5353。我一般先用5353本地联调验收前再改回53少踩一半坑。防火墙也要放行UOS server 20和Ubuntu一样sudo ufw allow 53/udp4.2 用dig和nslookup发查询验证最小闭环服务跑起来后另开终端查询本地zone里的域名dig 127.0.0.1 -p 5353 myhost.lab A nslookup -port5353 myhost.lab 127.0.0.1正常响应里会出现status: NOERROR、ANSWER: 1、192.168.1.10。如果出现connection timed out; no servers could be reached先确认进程活着再看端口和防火墙。dig输出里的myhost.lab. 60 IN A 192.168.1.10TTL列是60说明TTL字段组包正确如果TTL是0回3.3检查参数传错了还是缓存清零了。再查询一个本地没有的域名比如www.example.com会走转发分支。上级DNS网络可达时dig返回公网IP不可达时超时。这一步验证的是递归查询链路。注意转发用的上游DNS要有外网权限校园网里建议先ping通114.114.114.114再跑测试。4.3 用Wireshark抓包验证报文结构对不对Wireshark过滤表达式填udp.port 5353然后重新执行dig能看到一个查询包和一个应答包。点开应答包DNS层对照看三处Transaction ID和查询包一致Flags里QR1表示响应Answers区域能看到Name、Type A、TTL、IP地址。我见过不少同学代码返回的“响应”其实是把请求原样丢了回去客户端超时自己完全没发觉。抓包能一眼看出区别应答包Answers区域为空或Flags里QR位没有置1。把抓包截图放进课设报告这通常是拿分点。如果上面build_answer_response没问题这里应该能直接看到myhost.lab: type A, class IN, addr 192.168.1.10。4.4 测试用例清单照着跑就不会漏测试场景输入预期结果本地zone正常解析dig myhost.lab ANOERRORANSWER1IP192.168.1.10转发查询dig www.example.com A返回公网IPTTL生效域名不存在dig no-such-name.lab ANXDOMAIN不同QNAMEdig myhost.lab.extra A走转发或返回NXDOMAIN重复查询连续两次dig同一域名第二次响应来自缓存TTL内第5条注意复用缓存时如果没处理Transaction ID连续查询的响应ID还是第一次的IDdig可能把它当成乱序重复包。课设报告里别写你也这么干第5.2节给处理办法。建议把这张表的输出贴到报告里老师会认为你做了完整测试。4.5 加日志定位转发问题服务里加一行日志能让排错效率翻倍import logging, time logging.basicConfig(levellogging.INFO, format%(asctime)s %(message)s) # 在主循环收到数据后 logging.info(query %s type%d from %s, qname.decode(), qtype, addr[0])日志里能看到每个查询进来、走了本地还是转发、耗时多久。课设报告的“排错过程”一节如果能写“在日志中发现上游响应超过3秒随后把超时调到5秒并增加SERVFAIL反馈”比写一百字空话都有说服力。我习惯在转发函数里再记一条upstream %s cost %.2fs配合Wireshark一起看。5. DNS课设常见问题排查丢包、缓存失效和报文解析错的避坑记录写DNS服务器80%调试时间耗在“看着是数据问题其实是字节问题”上。这里把课设里最容易让代码整段重写的5个坑列出来每条都是现象、原因、解决三步走。5.1 QNAME解析出乱码指针压缩没处理现象dig发出去服务器端打印的域名是myhost\x03lab或者程序直接卡死。原因parse_qname遇到0xC0开头的指针时没有单独分支而是按普通标签读逻辑长度乱了后面所有偏移全错。解决用3.2节的parse_qname先判断length 0xC0 0xC0记录真实结束位置再跟指针跳转。此外加一个跳转次数上限比如最多10次防止恶意构造的指针循环耗尽CPU这个细节答辩时能讲成“安全考虑”。定位手段也很简单在parse_qname入口打一行日志把原始报文从offset开始16字节打hex和Wireshark里对应位置逐字节对。DNS报文没有加密裸着看最直观。5.2 响应和请求的Transaction ID对不上现象客户端收到包但显示超时Wireshark里应答包ID和请求包ID不一致。原因用同一个socket向上游发多个查询收包时没校验ID或者缓存命中时直接把旧响应发了出去。解决转发时每收到上游响应先取响应前2字节和客户端请求ID比对不一致就丢弃缓存命中时把答案前2字节改成当前请求的IDdef patch_tid(response, new_tid): return struct.pack(H, new_tid) response[2:]提示UDP是乱序、可重复的不校验ID就会出现“A请求的答案被B收到”的诡异现象。这属于课程里“复用socket要处理并发”的典型例子。5.3 递归查询死等上级DNS不可达现象第一次查询能通换到教室网络后dig一直connection timed out服务端日志全是发送超时。原因forward函数没设socket超时超时时间太长或者上级DNS写成内网不可达的IP。解决settimeout设23秒超时后给客户端构造SERVFAIL响应RCODE2不要干等。SERVFAIL的构造和正常响应一样只是flags低4位改成2问题区回显原请求def build_servfail(query): return query[:2] struct.pack(H, 0x8182) query[4:6] struct.pack(HHH, 0, 0, 0) query[12:]另外把上级DNS地址放到配置里换环境时改配置不改代码。我自己会先dig 114.114.114.114 www.example.com验证一下出口到底通不通再考虑是不是代码问题这能省半小时。5.4 大响应被截断TC位和EDNS0现象查询一个有多条A记录的域名时dig报truncated自己转发给客户端却只有开头一段。原因recvfrom缓冲设512字节现代DNS响应经常超过这个数收到大响应时没检查TC位也没走TCP回退。解决实现一个最简单版本的EDNS0——在转发前往查询报文末尾附加一个OPT记录声明接收缓冲4096字节并检查响应TC位置位就再用TCP 53跑一次def append_edns0(query): opt struct.pack(HHIH, 41, 4096, 0, 0) b\x00 return query opt类型41是OPTpayload size填4096。课设不要求这个但实现出来答辩直接开讲“我处理了UDP截断”是明确的加分项。如果不想动EDNS0至少把recvfrom缓冲改成4096并把512字节截断的判断加在日志里让老师看到你意识到了这个问题。5.5 TTL缓存过期策略不对现象第一次解析出IPTTL还没过期再次查询上游响应已变自己服务器还回旧IP。原因缓存只存响应不存过期时间或TTL写死。解决参考3.4的dict结构每条存{data, expire}get时用time.time()比较expire缓存命中后把响应里的TTL减去已过去的时间客户端才能按正确秒数倒计时。缓存容量也要限制防止被恶意查询打爆if len(CACHE) 512: oldest_key min(CACHE, keylambda k: CACHE[k][expire]) CACHE.pop(oldest_key)这是最简单的过期淘汰够课设用。如果答辩被追问再补一句“生产环境会按LRU维护访问顺序”标准做法确实是这样。6. 让课设从“能跑”到“高分”扩展功能与答辩要点代码能跑通只是及格线课设真正拉开分差的是两个扩展和一次答辩。6.1 两个能写进报告的低成本扩展第一个是缓存策略。基于第3章的dict做两个细节按TTL递减和容量淘汰。前者是缓存命中时把答案里的TTL改成剩余秒数后者是缓存条数超过上限时淘汰最接近过期的条目。讲清楚这两个细节老师基本不会再问运维层面的问题。第二个是本地黑名单拦截在本地zone判断里加一张拦截表命中的域名直接返回0.0.0.0模拟“拦截恶意域名”的节点。实现成本很低但答辩能引出“缓存投毒防护”“递归服务器安全”这些话题让老师知道你在做服务器不是写解析练习题。扩展别一口气全做先保证基础测试全绿再挑一个做深。我当年就是缓存加了、EDNS0也加了结果主流程在答辩前两天崩了连夜回滚才保住后悔药不好吃。6.2 答辩时先把这三句话背熟第一句“我的服务器支持递归查询本地没有记录时会把原始请求原样转发给上游公共DNS收到响应后按TTL缓存。”第二句“缓存命中时我会校验Transaction ID并修改响应中的TTL。”第三句“遇到大于512字节的响应会检查TC位并尝试用TCP回退。”这三句话把整个实验的链路讲完了剩下的都是细节追问。把你自己的教训也说一句我第一次答辩只测过dig myhost.lab A老师随手查了一个不存在的域名我盯着NXDOMAIN的代码冷汗直流——因为没实现RCODE3分支返回了SERVFAIL。提前把异常路径测一遍比临时翻代码强得多。这个课设做下来最大的收获不是那串代码而是你终于能把UDP、字节序、超时重传这些抽象名词落到一个能抓包验证的具体对象上。以后不管做网络运维还是自己搭建DNS服务器排障都会比没写过的人少慌很多。希望帮到你。本文还有配套的精品资源点击获取