如何自己动手实现一个简易DNS服务器?

来源:MySQL教程作者:台湾程序员头衔:程序员
导读:本期聚焦于台湾程序员创作的《如何自己动手实现一个简易DNS服务器?》,敬请观看详情。当浏览器访问一个域名时,操作系统首先会向DNS服务器发起查询,这个环节看似简单,却涉及二进制报文、UDP传输和缓存策略。本文从零开始,用Python标准库实现一个简易DNS服务器,不依赖dnspython等第三方库。你会看到DNS报文头部的字段如何用struct打包,域名在报文中如何以长度前缀编码,以及如何判断查询类型并返回A记录。随后文章会把服务器扩展为支持上游转发,当本地没有目标记录时将请求交给公共DNS,并对返回结果做简单缓存。完成后可以使用dig或nslookup直接向本机53端口发起测试。全文提供完整可运行代码与排错思路,适合想理解DNS协议细节的开发者。

域名系统(DNS)是互联网最基础的服务之一,它把人类易记的域名转换成IP地址。虽然大多数开发者每天都在间接使用DNS,但很少有人真正动手实现过DNS服务器。本文的目标不是做一个生产级服务器,而是通过最小可用版本,理解DNS协议报文格式、UDP通信和解析转发流程。我们会使用Python标准库中的socket和struct,不需要安装其他依赖。实现后的程序将监听UDP 53端口,能够回答指定域名的A记录查询,并支持将无法回答的查询转发到上游公共DNS。

如何自己动手实现一个简易DNS服务器?

一、DNS报文结构与编码方式

DNS查询和响应在UDP上传输时使用同一个报文结构。每个DNS报文由五部分组成:头部、问题区、应答区、授权区和附加区。头部固定12字节,后四个区长度可变。头部包含一个16位标识符,用于匹配请求和响应;一个标志字段,用来区分查询或响应、是否递归等;后面是四个计数字段,分别表示问题、应答、授权和附加区的记录数量。对简易服务器来说,最关键的是标识符、标志位和问题计数。

问题区用来描述要查询的域名和类型。域名不是直接以点分字符串存储,而是按标签编码:每个标签先写长度,再写标签内容,最后以0字节结束。例如ipipp.com会被编码成05 69 70 69 70 70 03 63 6f 6d 00。查询类型通常为A记录,对应整数值1,表示希望获得IPv4地址。查询类固定为IN,对应整数值1。理解了这些字段后,就可以用Python的struct模块来解析和构造报文。

下面代码展示了如何把点分域名转换为DNS协议中的标签编码,以及如何从标签编码还原域名。编码时需要处理根域名和结尾0字节,解码时要跳过压缩指针,但我们在最小实现中暂不处理报文中的压缩指针,只解析问题区不涉及压缩的情况。

import struct
import socket

def encode_domain(domain):
    result = b''
    for label in domain.rstrip('.').split('.'):
        result += bytes([len(label)]) + label.encode()
    result += b'\x00'
    return result

def decode_domain(data, offset):
    labels = []
    while True:
        length = data[offset]
        offset += 1
        if length == 0:
            break
        labels.append(data[offset:offset+length].decode())
        offset += length
    return '.'.join(labels), offset

二、用UDP Socket搭建查询响应服务

DNS服务器通常使用UDP 53端口监听请求。UDP无连接,每个请求都在独立数据报中处理,很适合简单的请求响应模型。Python的socket模块可以快速创建UDP套接字,绑定到0.0.0.0:53后进入循环,每次recvfrom收到数据和客户端地址,解析问题区,再构造响应包发送回去。由于53端口是特权端口,在Linux和macOS下通常需要root权限,测试时也可以先绑定到5353端口避免权限问题。

构造响应包时,先复制请求中的标识符到头部,把标志位设置成0x8180,这是一个标准响应:QR为1表示响应,RD和RA为1表示支持递归。问题计数保持不变,应答计数设置为1。然后原样复制请求中的问题区,再在应答区添加一条A记录。A记录包含域名指针、类型、类、TTL、数据长度和IPv4地址。域名指针通常在响应中使用0xC00C这样的压缩指针,指向问题区中的域名位置,但本文为简化,可以直接把问题区的域名再次编码一遍放进应答区,虽然增大了一点包体积,但不影响功能。

def build_response(request_data, ip):
    transaction_id = request_data[:2]
    flags = b'\x81\x80'
    qdcount = b'\x00\x01'
    ancount = b'\x00\x01'
    nscount = b'\x00\x00'
    arcount = b'\x00\x00'
    header = transaction_id + flags + qdcount + ancount + nscount + arcount

    # 问题区原样复制
    question_end = request_data.find(b'\x00', 12) + 5
    question = request_data[12:question_end]

    # 应答区:域名指针、类型A、类IN、TTL、数据长度、IP
    answer = b'\xc0\x0c'
    answer += struct.pack('!HHIH', 1, 1, 300, 4)
    answer += socket.inet_aton(ip)

    return header + question + answer

def start_server(ip='127.0.0.1', port=53):
    server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    server.bind(('0.0.0.0', port))
    print(f'DNS server listening on port {port}')
    while True:
        request_data, client_addr = server.recvfrom(512)
        domain, _ = decode_domain(request_data, 12)
        print(f'query: {domain} from {client_addr}')
        response = build_response(request_data, ip)
        server.sendto(response, client_addr)

上述代码中,问题区结束位置通过从偏移12开始查找第一个0字节来确定。找到域名结束的0字节后,再往后跳过4字节的类型和类字段,就得到了问题区的完整结束位置。这个简单假设成立的前提是请求中没有其他压缩指针,对于普通A记录查询足够使用。实际DNS请求还可能包含EDNS0扩展,处理起来会更复杂,但本文先聚焦核心链路。

三、增加上游转发与本地缓存

一个只会返回固定IP的DNS服务器实际用途有限。更合理的行为是:如果本地配置了某些域名的映射则直接返回;否则把请求原封不动转发给上游公共DNS服务器,如8.8.8.8或114.114.114.114,再把上游响应回传给客户端。转发时需要注意,客户端请求是递归查询,上游返回的响应中可能包含压缩指针,回传时不需要修改,直接把UDP数据包透传即可。我们只需创建一个新的UDP套接字发送给上游,等待响应,然后发送给原客户端。

转发会带来延迟和重复查询。可以在内存中维护一个简单字典作为缓存,键为域名和类型,值为解析结果和过期时间。当收到查询时先查缓存,命中则构造响应;未命中则转发并缓存。生产环境还会考虑TTL、缓存淘汰、并发安全等问题,但本文的简易实现只保存IPv4地址和过期时间戳,能让重复查询明显加快。

import time

UPSTREAM = ('8.8.8.8', 53)
cache = {}

def lookup_upstream(request_data):
    upstream_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    upstream_socket.settimeout(3)
    upstream_socket.sendto(request_data, UPSTREAM)
    response, _ = upstream_socket.recvfrom(512)
    upstream_socket.close()
    return response

def extract_ip_from_response(response):
    # 简化处理:跳过头部和问题区,从应答区读取RDATA最后的IPv4地址
    question_end = response.find(b'\x00', 12) + 5
    answer_start = question_end
    # 应答区第一字节通常是指针0xC0,接着是偏移,再跳过类型、类、TTL
    answer_start += 2 + 2 + 2 + 4 + 2
    return socket.inet_ntoa(response[answer_start:answer_start+4])

def handle_query(request_data, client_addr, server_socket):
    domain, _ = decode_domain(request_data, 12)
    key = domain.lower()
    if key in cache:
        expire, ip = cache[key]
        if time.time() < expire:
            print(f'cache hit: {key}')
            server_socket.sendto(build_response(request_data, ip), client_addr)
            return
    response = lookup_upstream(request_data)
    ip = extract_ip_from_response(response)
    cache[key] = (time.time() + 300, ip)
    server_socket.sendto(response, client_addr)

extract_ip_from_response函数做了很多简化假设:它只处理应答区第一条记录,并且假设域名部分占用2字节压缩指针,类型、类、TTL、数据长度字段按标准长度跳过。如果上游响应包含CNAME链或多条A记录,这个函数可能取到错误位置。但对于原理演示已经足够。若要解析更复杂的响应,可以引入压缩指针检测和循环读取资源记录的逻辑。

四、测试、调试与常见问题

完成代码后,可以使用dig命令在Linux或macOS上直接测试。假设程序绑定在5353端口,执行dig @127.0.0.1 -p 5353 ipipp.com A,应该能看到返回的IP地址。若绑定在53端口,则dig @127.0.0.1 ipipp.com A即可。Windows用户可安装nslookup或使用WSL,也可以写一个简单的Python客户端发送构造好的查询。

测试中最常见的问题是端口被占用或权限不足。53端口通常被systemd-resolved、dnsmasq等系统服务占用,可以先停止这些服务或改用5353。如果程序没有响应,请检查防火墙是否放行UDP端口。抓包工具如Wireshark可以观察请求与响应的字节级内容,帮助确认头部字段是否正确。在Linux下也可以使用tcpdump -i lo udp port 5353 -X查看十六进制和ASCII数据。

另一个容易出错的地方是字节序。DNS报文头部的多字节字段全部使用网络字节序,即大端。Python的struct.pack和unpack中首字符使用感叹号可以自动处理平台差异,不要直接使用默认的本机字节序。域名编码时长度值必须是无符号单字节,否则超过127的标签会解析错误,虽然常规域名标签不会超过63字节,但这是协议规范。

最终,这个简易DNS服务器虽然离生产级还差很多,比如未实现TCP模式、DNSSEC、EDNS0和完整压缩指针处理,但已经覆盖了DNS服务器的核心链路:监听UDP、解析问题、查询本地或上游、返回应答。理解这些基础后,再阅读dnspython、CoreDNS或BIND源码会轻松很多。

DNS服务器域名解析Python网络编程修改时间:2026-08-27 09:24:00

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。