前言
最近在公司敲 DHCP server 的相关代码。或者说,是 ai 敲代码,我只是 review 。现在的 ai 非常厉害。DHCP server 这种纯用户态场景开发,对 ai 没有难度。
为了知道,要写这样的代码(目标),以及能看懂 ai 写的代码,我得找 AI 带我读下 DHCP RFC 相关的文档(让 AI 把东西嚼烂了,为我嘴里。啊,恶心~)
网上的其他参考链接:
- DHCP技术白皮书-新华三集团-H3C
- insomniacslk/dhcp: DHCPv6 and DHCPv4 packet library, client and server written in Go
RFC 2131 – Dynamic Host Configuration Protocol
DHCP 是什么
DHCP(Dynamic Host Configuration Protocol,动态主机配置协议)是一种运行在 UDP/IP 网络上的 配置下发协议。它的职责只有一句话:让一台主机接入网络时,自动获得一切运行所需的 IP 层配置参数——包括 IP 地址、子网掩码、默认网关、DNS 服务器等——而无需人工逐台设置。
DHCP 采用 客户端-服务器 模型。网络中的 DHCP 服务器维护着一个地址池和配置参数库;客户端启动时广播请求,服务器从中分配合适的网络地址和参数并返回给客户端。一个 DHCP 客户端从开机到完成配置,只需要在网络上完成一次简短的握手交互。
按照地址分配方式,DHCP 提供了三种机制:
- 自动分配(Automatic allocation):服务器为客户端分配一个永久的 IP 地址,一旦分配就不再变更。
- 动态分配(Dynamic allocation):服务器将 IP 地址以租约(lease)的形式有限期地分配给客户端。租约到期后地址可回收复用——这是 DHCP 最核心的能力,也是它区别于此前 BOOTP 等协议的关键所在。
- 手动分配(Manual allocation):由管理员预先指定客户端的 IP 地址,DHCP 仅负责将该地址传达给客户端。
从技术渊源上看,DHCP 是 BOOTP 协议的扩展,复用了 BOOTP 的消息格式、UDP 传输和中继代理机制。但它在两方面做出了根本性改进:(1) 引入有限租约,使网络地址可以复用,解决了 IP 地址紧缺的问题;(2) 提供了一套完整的参数获取机制,使客户端在单次交互中即可拿到所需的全部配置信息。
简而言之,DHCP 解决的核心问题是:让一台主机”即插即用”地接入 IP 网络。
DHCP 的报文结构与通信流程
首先是定义报文结构,即网络上数据包要承载的内容。这保证通信的双方都认识彼此,都知道对方在说什么。然后是通信流程,双方按照一定的步骤协商。几乎所有的网络协议都是这样的。上层的应用层通信也是如此。网络协议主要是用来解决网络通不通的问题,比如 DHCP, ARP, TCP等。
报文结构
DHCP 报文直接复用了 BOOTP 的报文格式。整个报文是一个定长头部 + 可变长选项区的结构:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| op (1) | htype (1) | hlen (1) | hops (1) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| xid (4) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| secs (2) | flags (2) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ciaddr (4) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| yiaddr (4) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| siaddr (4) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| giaddr (4) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| chaddr (16) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| sname (64) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| file (128) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| options (variable) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
几个关键字段的含义:
- op:消息方向。1 = 客户端 → 服务器(BOOTREQUEST),2 = 服务器 → 客户端(BOOTREPLY)。
- htype / hlen:硬件地址类型和长度。以太网下 htype=1,hlen=6。
- hops:跳数。客户端初始为 0,每经过一个中继代理加 1。
- xid:事务 ID。客户端随机生成,用于将请求与响应一一匹配——类似 TCP 的序列号,是整场对话的”对暗号”。
- secs:客户端从开始请求到现在经过的秒数。
- flags:16 位,只有最高位有意义——广播标志位(BROADCAST flag)。当客户端尚未获得 IP 地址、无法接收单播时,将这个位置 1,要求服务器以广播方式回复。其余 15 位保留,必须为 0。
- ciaddr:客户端当前 IP 地址。仅在客户端处于 BOUND / RENEWING / REBINDING 状态且有 IP 时填写。
- yiaddr:”你的 IP 地址”(your IP address)。服务器在 OFFER/ACK 中将要分配给客户端的地址填在这里。
- siaddr:下一阶段引导服务器的 IP 地址(比如 TFTP 服务器),服务器在 OFFER/ACK 中填写。
- giaddr:中继代理(relay agent)的 IP 地址。如果请求经过了中继代理,此字段记录中继代理所在子网的接口地址,服务器据此判断客户端属于哪个子网。
- chaddr:客户端硬件地址(MAC 地址)。16 字节长,以太网下只用前 6 字节。
- sname / file:服务器主机名和启动文件名。这两个字段继承自 BOOTP 的引导功能,在 DHCP 中一般不使用,但可以作为 options 字段的”溢出区”(option overload)。
- options:可变长的选项区,这是 DHCP 真正的配置信息承载区。前 4 字节必须是 magic cookie
99.130.83.99,后面跟着一个个 TLV(Type-Length-Value)格式的选项。最关键的选项是 DHCP Message Type(option 53),取值 1~8,标识这条报文属于哪种 DHCP 消息。
DHCP 一共定义了 8 种消息类型,以 DHCP Message Type 选项的值区分:
| 值 | 消息类型 | 方向 | 含义 |
|---|---|---|---|
| 1 | DHCPDISCOVER | 客户端 → 服务器 | “这里有 DHCP 服务器吗?” 客户端广播寻找可用服务器 |
| 2 | DHCPOFFER | 服务器 → 客户端 | “你可以用这个地址。” 服务器响应 DISCOVER,提供一个 IP |
| 3 | DHCPREQUEST | 客户端 → 服务器 | “我就要这个地址了。” 客户端选定服务器并请求绑定;也用于续租 |
| 4 | DHCPACK | 服务器 → 客户端 | “这个地址归你了。” 服务器确认绑定,同时携带租约和配置参数 |
| 5 | DHCPNAK | 服务器 → 客户端 | “这个地址不能给你。” 服务器拒绝请求,客户端需从头开始 |
| 6 | DHCPDECLINE | 客户端 → 服务器 | “这个地址已经被占用了。” 客户端用 ARP 检测到冲突后通知服务器 |
| 7 | DHCPRELEASE | 客户端 → 服务器 | “我不要这个地址了。” 客户端主动归还地址,放弃剩余租约 |
| 8 | DHCPINFORM | 客户端 → 服务器 | “我已经有 IP 了,给点别的配置就行。” 客户端仅请求 DNS 等参数 |
通信流程
DHCP 的通信流程可归纳为 五个典型场景:
场景一:初次获取 IP 地址——四次握手
这是最常见的情况:一台新主机接入网络,没有任何缓存状态。
客户端 (Client) 服务器 (Server)
| |
| (1) DHCPDISCOVER (广播) |
|------------------------------------->|
| |
| (2) DHCPOFFER (广播或单播) |
|<-------------------------------------|
| |
| (3) DHCPREQUEST (广播) |
|------------------------------------->|
| |
| (4) DHCPACK (广播或单播) |
|<-------------------------------------|
| |
| (可选) ARP check (免费ARP) |
| |
DHCPDISCOVER:客户端在本地子网广播(目的 IP 255.255.255.255,源 IP 0.0.0.0)。消息中可选地携带期望的 IP 地址、期望的租约时长、需要的参数列表。xid 由客户端随机生成。
DHCPOFFER:每台收到 DISCOVER 的服务器,如果能够满足客户端请求,就从地址池中选择一个可用 IP,通过 yiaddr 字段提供给客户端。OFFER 中可以附带子网掩码、路由器、DNS、租约时间等参数。多台服务器可能同时回复,客户端后续自己选择。
DHCPREQUEST:客户端从收到的 OFFER 中选择一个(通常选最先到达的),然后再次广播 DHCPREQUEST。注意这里是广播而非单播——目的是让那些没被选中的服务器也知道”这个地址已经被别的服务器承诺了,你们不必再留”。REQUEST 中通过
server identifier选项指明选中了哪台服务器,requested IP address选项指明想要的地址。DHCPACK:被选中的服务器收到 REQUEST 后,正式将地址与客户端绑定(commit binding),回复 ACK。ACK 中携带最终的租约时间和全套配置参数。客户端收到 ACK 后,通常还会发送一次免费 ARP 来探测地址是否真的空闲;如果发现冲突,发送 DHCPDECLINE 并回到初始状态重来。
为什么客户端不直接使用收到的第一个 OFFER,还要再多一轮 REQUEST/ACK?
因为同一个网络上可能有多台 DHCP 服务器。客户端广播 DISCOVER 后,每台服务器都各自返回 OFFER 并暂时预留了地址。客户端选择其中一台,广播 REQUEST——选中的服务器收到后正式绑定,未选中的服务器收到后释放预留。如果只有 DISCOVER → OFFER 两步,就无法实现这个”多服务器竞选”的互斥机制。为什么上图里 OFFER 和 ACK 是”广播或单播”?
这取决于客户端是否设置了 flags 字段的广播标志位。客户端在 DISCOVER/REQUEST 中,如果还没有 IP、无法接收单播,就把广播位置 1,服务器就以广播回复;如果客户端实现能处理单播(比如提前把 yiaddr 临时配到网卡上),就不用设广播位,服务器直接单播到 yiaddr。简单说:客户端告诉服务器”我还不能收单播”,服务器就广播;否则单播。
场景二:客户端还记得上次的地址——快速恢复
如果客户端之前拿到过地址并且还记得(比如重启后),它可以跳过 DISCOVER 阶段,直接广播 DHCPREQUEST,其中携带 requested IP address 选项,问服务器”我上次用的这个地址还能继续用吗”。
- 服务器同意 → DHCPACK,客户端直接进入 BOUND 状态。
- 服务器不同意(比如地址已经给了别人,或者客户端换到了另一个子网)→ DHCPNAK,客户端回到 INIT 状态重新走完整流程。
场景三:续租——悄悄话式的单播
每个 IP 地址都有租约期限。客户端在租约的 T1 时刻(默认为租约的一半)进入 RENEWING 状态,开始向服务器单播 DHCPREQUEST 续租。因为此时客户端已经有 IP 了,所以不需要广播。
如果服务器回应 DHCPACK,租约被延长,一切照旧。如果服务器不回应(比如挂掉了),客户端持续重试直到 T2 时刻(默认为租约的 87.5%),此时进入 REBINDING 状态,转为广播 DHCPREQUEST——因为原来的服务器可能已经挂了,客户端需要向网络上任何可用的服务器求助。
如果直到租约到期都没有收到任何 ACK,客户端必须停止使用该地址,回到 INIT 状态重新开始。
场景四:主动释放
客户端不再需要 IP 地址时(比如关机),可以发送 DHCPRELEASE 主动归还。这是单播消息,直接发给服务器。注意这是个”尽力而为”的消息——客户端不等待确认,服务器也不需要回复。
场景五:已有 IP,只想要配置参数
如果客户端已经通过其他途径获得了 IP 地址(比如手动配置),但仍然需要子网掩码、路由器、DNS 等参数,可以发送 DHCPINFORM。服务器收到后回复 DHCPACK,其中不包含 IP 地址分配和租约信息,只携带配置参数。
客户端状态机
上述过程,如果从客户端内部视角来看,就是下面这张状态转换图。RFC 2131 的 Figure 5 给了一个很清晰的图示,这里用文字描述:
+-----------+
| INIT | <------------------------------------+
+-----+-----+ |
| 发送 DHCPDISCOVER |
v |
+-----------+ |
| SELECTING | 等待 DHCPOFFER |
+-----+-----+ |
| 收集 OFFER, 选定服务器, 发送 DHCPREQUEST |
v |
+------------+ |
| REQUESTING | 等待 DHCPACK |
+-----+------+ |
| DHCPACK 到达 |
v |
+----------+ |
+---->| BOUND | |
| +----+-----+ |
| | T1 (0.5 * 租约) 到期, 发送单播 DHCPREQUEST |
| v |
| +----------+ 收到 DHCPACK → 回到 BOUND |
| | RENEWING | |
| +-----+----+ |
| | T2 (0.875 * 租约) 到期, 发送广播 DHCPREQUEST |
| v |
| +----------+ 收到 DHCPACK → 回到 BOUND |
| |REBINDING | |
| +-----+----+ |
| | 租约到期 / 收到 DHCPNAK |
| +--------------------------------------------+
| 发送 DHCPRELEASE → 回到 INIT
+-------------------------------------------------+
几个关键时间点:
- T1(默认租约的 50%):从 BOUND 进入 RENEWING,单播续租。
- T2(默认租约的 87.5%):从 RENEWING 进入 REBINDING,广播求救。
- 租约到期:停止使用地址,回到 INIT。
重传机制
DHCP 的可靠性完全基于 UDP,没有 TCP 的确认重传机制,所以自己在应用层实现了一套随机化指数退避的重传策略:
- 客户端发送消息后,如果在超时时间内未收到响应,就重发。
- 初始超时时间为 4 秒,之后每次超时翻倍(8s → 16s → 32s),上限 64 秒。
- 每次重传前加入一个小的随机抖动(-1s ~ +1s),避免大量客户端同时启动时形成”报文风暴”。
- 特殊处理:客户端在 INIT 状态下发送 DHCPDISCOVER 时,第一次超时可以缩短为 0~4 秒之间的随机值,加快初始接入速度。
服务器如何选择地址
服务器在收到 DHCPDISCOVER 后,按以下优先级选择分配给客户端的地址:
- 当前绑定:该客户端(通过 client identifier 或 chaddr 识别)当前已经绑定的地址,直接续用。
- 历史绑定:该客户端之前用过、目前仍空闲的地址。
- 客户端建议的地址:DHCPDISCOVER 中
requested IP address选项填写的地址,如果可用。 - 池中取新地址:以上都不满足时,从空闲地址池中分配一个新的。
选好地址后,服务器在回复 OFFER/ACK 时,会按照固定的优先级填充配置参数:显式配置的默认值 > Host Requirements RFC 定义的默认值 > 该客户端历史 binding 中的参数 > 为该客户端单独配置的参数 > 为客户端类别配置的参数 > 为该子网配置的参数。
现实世界
我之前在一家,和我年纪一样大的软件安全公司,打过工。上班的电脑,都要使用静态IP。这种管理方式不好。
应该使用 DHCP MAC 白名单的方式,这样保证了只有允许的设备可以联网(但是,桥接的虚拟网卡,也要去申请,有点麻烦)。或者,采用认证的方式,保证了只有允许的人(账户)可以联网。
RFC 2132 – DHCP Options and BOOTP Vendor Extensions
如果说 RFC 2131 定义了 DHCP 的骨架(报文格式、通信流程、状态机),那 RFC 2132 定义的就是 DHCP 的血肉——DHCP 到底能下发哪些配置参数,每项参数怎么编码。
编码格式:TLV
所有选项放在 options 可变长区,采用 TLV(Type-Length-Value) 格式:1 字节 code + 1 字节 length + length 字节 value。前 4 字节必须是 magic cookie 99.130.83.99。两个特殊 code:0(Pad) 用于对齐填充,255(End) 标识选项区结束。所有多字节数值一律大端。
关键选项
RFC 2132 定义了 60 多个选项,下面是理解 DHCP 协议行为最核心的几个:
| Code | 名称 | 作用 |
|---|---|---|
| 1 | Subnet Mask | 子网掩码 |
| 3 | Router | 默认网关 |
| 6 | DNS Server | DNS 服务器列表 |
| 15 | Domain Name | DNS 域名后缀 |
| 50 | Requested IP Address | 客户端向服务器暗示/确认想要的 IP |
| 51 | IP Address Lease Time | 租约时长(秒),客户端可以请求,服务器返回实际值 |
| 53 | DHCP Message Type | 消息类型(1~8),每条 DHCP 报文必带 |
| 54 | Server Identifier | 服务器 IP。SELECTING 时必带(选谁),RENEWING 时不带(谁都行) |
| 55 | Parameter Request List | 客户端列出想要哪些选项(1 字节一个编号),减少不必要传输 |
| 58 / 59 | Renewal (T1) / Rebinding (T2) Time | 控制租约生命周期的两个计时器 |
| 61 | Client-identifier | 客户端唯一标识,优先于 chaddr 用于识别客户端 |
两个值得了解的机制
Option Overload(code 52):当选项太多装不下时,sname(64B)和 file(128B)字段可以被征用来装选项。原来放在这两个字段的内容改用 code 66(TFTP Server Name)和 code 67(Bootfile Name)传递。
Vendor-Specific(code 60 + code 43):客户端通过 code 60 自报厂商类型,服务器通过 code 43 返回厂商自定义数据(可嵌套子 TLV,无需 magic cookie)。
DHCP Relay
为什么需要中继
DHCP 客户端启动时靠广播发现服务器。但广播包止于路由器——不会跨子网转发。这意味着如果每个子网都要部署一台 DHCP 服务器,运维成本就太高了。DHCP Relay(BOOTP 中继代理)解决了这个问题:在每个子网放一个轻量的中继代理,它把客户端的广播请求转成单播发给远端的 DHCP 服务器。
工作流程
客户端 (无 IP) 中继代理 DHCP 服务器
192.168.1.0/24 两个接口都有 IP 10.0.0.0/24
| | |
| DHCPDISCOVER | |
| (广播) | |
|------------------>| |
| | DHCPDISCOVER |
| | (单播, giaddr= |
| | 192.168.1.1, |
| | hops=1) |
| |--------------------->|
| | |
| | DHCPOFFER |
| | (单播到 giaddr, |
| | yiaddr=192.168.1.100)|
| |<---------------------|
| DHCPOFFER | |
|<------------------| |
| | |
| DHCPREQUEST | |
| (广播) | |
|------------------>| |
| | DHCPREQUEST |
| |--------------------->|
| | |
| | DHCPACK |
| |<---------------------|
| DHCPACK | |
|<------------------| |
关键机制:
- 中继代理收到客户端广播后,把
giaddr设为自己在客户端子网上的接口 IP,hops加 1,然后单播发给配置好的 DHCP 服务器。 - 服务器根据
giaddr判断客户端属于哪个子网,从对应地址池中分配 IP。回复时也是单播到giaddr,由中继代理转发给客户端(广播或单播,取决于广播标志位)。
与报文字段的关系
- giaddr:中继代理的入接口 IP。服务器靠它选地址池,回复也靠它回到正确的中继。
- hops:跳数。默认最多 16 跳(可配置),防止环路。
- chaddr / client-identifier:中继代理不碰这些,服务器直接用来识别客户端。
最后
在没有阅读过 DHCP server 开源实现的情况下,从头实现一个完善的 DHCP server ,即使有 AI 也不太可能。主要原因是 (1) DHCP 的 option 很多,不可能把这些 option 都搞搞明白。(2) DHCP 没有一个标准的功能和性能测试工具。没有办法,很好的验证,我们写的代码,是否完善。
但是,实现一个简单凑活用的 DHCPv4 server,没啥难度。
产品需要在被使用的过程中,螺旋迭代完善。
这种纯用户态的网络应用,不要用 C/C++ 开发。一个字节一个字节的填充构造数据包,太容易翻车了。应用层开发,直接调用库,最好。