☰
用Java手写DHCP:从DORA报文交互到租约分配服务实现
2026/10/17 2:54:08 网站建设 项目流程

简介:一份基于Java实现DHCP协议(动态主机配置协议)的完整源代码资源,面向有一定Java网络编程基础、需要构建自定义DHCP服务器/客户端或深入研究协议实现的开发者与学习者。资源围绕DHCP工作流程的发现、提供、请求、确认四个阶段,提供了UDP通信、数据包编解码、IP地址池管理、多线程处理等关键模块的Java实现。压缩包共84个文件,包括16个Java源码文件、64个HTML格式的Javadoc文档,以及CSS、properties等辅助文件,便于阅读API与项目理解,包体约186KB,结构清晰。目前已有632人学习,适合用作课程设计、协议实验或网络工具开发的参考。借助这份代码,读者可以掌握DatagramSocket在网络编程中的应用、BOOTP/DHCP报文解析构造方法,以及地址分配与线程安全等工程实践要点,为后续扩展或二次开发奠定扎实基础。

1. 用 Java 手写 DHCP:从报文交互到可用的分配服务

做网络工具链的人大概率都遇到过这种场景:内网设备激增,路由器自带的 DHCP 池不够分,或者想给特定 MAC 固定 IP,结果 Web 管理后台根本不给你这个选项。我一直觉得,网络协议里最能体现"客户端-服务器"博弈本质的就是 DHCP,它不复杂,但每个字段都有存在的理由。这份 Java 实现 DHCP 的源代码,就是在 JVM 生态里完整还原 DHCP 服务端与客户端交互的开箱资源,覆盖 DORA 四步、租约管理、Option 扩展与多网卡监听,适合正在做 IoT 设备管理、内网自动化开局、或者想彻底搞懂 BOOTP 演进逻辑的后端开发者直接拿去改。

2. 先看清 DHCP 的底牌:协议状态机与源码映射

2.1 DORA 之外,源码里还藏着哪些关键帧

抓包看 DHCP 只能看到 Discover、Offer、Request、Ack 四条报文,但用 Java 复现的时候,这四条报文背后至少牵扯三类编码细节:UDP 广播、二进制封装、Option 链式解析。源码里服务端核心监听端口是 67,客户端目标端口是 68,这个不对称关系决定了代码里DatagramSocket的绑定方式不能照搬普通的客户端服务器模型。

receive()拿到的DatagramPacket,前 20 字节是 BOOTP 头,从第 236 字节开始才是可变长的 Option 区。源码里对这块的拆解做得比较干净:用ByteArrayInputStream流式读取,每次先读 Tag(1 字节)再读 Len(1 字节),遇到 255 表示 Option 结束,这种解析方式比一次性readFully整个缓冲区更符合报文实际填充习惯。

另外有个容易忽略的字段:flags的第 15 位(广播标志位)。正常客户端该位为 0 时,Offer 可以直接单播到客户端的 MAC 地址(前提是服务器记录到了客户端端口),但很多客户端实现里该位会被置 1。这份源码的处理方式是直接读flags并判断0x8000,存在单独的布尔变量里,后续封装 Offer 时再写回去,保证响应帧格式和请求帧对称。

2.2 状态机不是只在文档里,代码里就是一张枚举表

很多开源 DHCP 实现喜欢用 if-else 串流程,但这套源码在 server 端维护了一个租约状态表,每个租约对应一个枚举状态,对照关系是这样的:

状态触发条件对应的动作
OFFERED发送 Offer 后等待 Request启动短租约计时器,超时回收
REQUESTED收到 Request 帧校验 Requested IP 与 xid
BOUND发送 Ack 完成写租约文件,正式分配
EXPIRED租约时间归零释放地址池条目
RELEASED收到 Release 帧立即回收地址

初看这个表会觉得平淡,但实际代码里两个小设计很值得注意:一个是OFFERED状态也有超时定时器,因为客户端可能在收到 Offer 后换了个 IP 重新请求,旧 Offer 必须被回收;另一个是RELEASED状态不会直接给同一客户端重新分发这个 IP,而是标记为AVAILABLE,避免刚回收的地址又被原客户端续租,导致其他设备分配到冲突地址。

2.3 租约数据库:HashMap 不是给生产环境用的

源码里默认用ConcurrentHashMap<String, Lease>存租约,看起来能用,但我在拆这份源码的时候直接把它换成了 SQLite 或者文件快照。原因很简单:DHCP 服务一旦跑起来,客户端可能随时掉线,租约必须持久化。源码作者很聪明地在接口层定义了一个LeaseStore接口,put()、get()、remove()、expireAll()四个方法就能覆盖服务端对租约的全部操作。

如果自己扩展,我一般会在每次租约状态变化时强制落盘,落盘文件用临时文件加原子重命名的方式写,防止服务进程在中途被杀导致租约文件写了一半。这个是分布式系统里的习惯,DDIA 那本书也强调过:先写临时文件,再 rename,比直接写原文件可靠得多。

3. 源码骨架拆解:核心类职责与消息封装细节

3.1 目录结构与核心类职责

打开源码包,建议按这个顺序看代码,比直接从头读到尾效率高得多:

src/ main/java/ dhcp/ DhcpServer.java // 入口 DhcpServerHandler.java // 线程循环处理报文 message/ DhcpMessage.java // 报文封装/解封装 Option.java // Option Tag 类型常量 MessageType.java // DHCP 消息类型映射 store/ LeaseStore.java // 租约存储接口 MemoryLeaseStore.java network/ UdpTransport.java // DatagramSocket 封装 PacketDispatcher.java util/ ByteUtils.java // 短路寻址、字节处理

DhcpServer.java负责启动阶段的所有初始化:优先级最高的一步是创建DatagramSocket并绑定到 0.0.0.0:67,注意不是本机实际 IP。这是因为 DHCP 的 Discover 报文目标地址是 255.255.255.255,如果绑到具体 IP,广播帧根本收不到。这个细节我在自己写的时候踩过坑,一开始绑了192.168.x.x,结果同一交换机下的客户端全部无法获取地址。

UdpTransport里还封装了broadcast()方法,Offer/Ack 发出去时DatagramPacket的目标地址也是 255.255.255.255,端口 68。这样客户端不管是不是还在 INIT 状态,都能收到。ByteUtils里比较实用的是ipToInt和intToIp,因为 DHCP 报文里的 IP 全是四字节定长,用字符串处理会慢一个量级。

3.2 DhcpMessage 封装与字节级别的读写顺序

DhcpMessage是整个项目的数据核心,parse方法直接把收到的字节数组逐字段切分。DHCP 报文的定长部分是 BOOTP 格式,Java 里没有像 C 语言那样的struct直接映射内存,所以源码用的是ByteBuffer的get()系列方法一个个读。要注意的是数字类型的大小端:DHCP 报文统一用大端序(网络字节序),JavaByteBuffer默认也是大端,但如果你在ByteBuffer上调用order(ByteOrder.LITTLE_ENDIAN),整个解析就会全乱,这属于大家最容易踩的类型。

选项区的读写关键代码如下,这一段是整套源码最值得抄的部分:

public void writeOptions(ByteBuffer buf) throws Exception { for (Option opt : options) { buf.put((byte) opt.getCode()); // Tag buf.put((byte) opt.getLength()); // Len buf.put(opt.getValue()); // 数据区 } buf.put((byte) 255); // End Mark }

逻辑上是标准的三段式写入:先写 Tag,再写长度,最后写数据区,一个 Option 结束后下一个紧跟着写,直到最后补一个0xFF结束标记。Option.getLength()返回的是数据区字节数,不包含 Tag 和 Length 本身,这个约定在 RFC 2132 里是明确的。写错长度的话客户端解析会直接错乱,常见的表现是客户端拿到的网关地址是反的——其实是长度字段写多了 2 字节,把下一个 Option 的 Tag 也吞进去了。

3.3 解析 Discover 帧并回 Offer 的完整流程

服务端主循环里,收到 Discover 后的响应逻辑是这样的,去掉业务代码,核心骨架如下:

if (msgType == MessageType.DISCOVER) { String mac = ByteUtils.macToString(message.getChaddr()); byte[] requestedIp = message.requestedIp(); // Option 50 InetAddress offerIp = leaseStore.allocate(mac, requestedIp); if (offerIp == null) { // 无可分配地址 sendNak(message); return; } DhcpMessage offer = message.createReply(MessageType.OFFER); offer.setYourIp(offerIp); offer.setOption(Option.SUBNET_MASK, dstMask); offer.setOption(Option.ROUTER, gatewayIp); offer.setOption(Option.DNS_SERVERS, dnsList); offer.setOption(Option.LEASE_TIME, leaseSeconds); transport.broadcast(offer); }

这段流程有三个要点:一是requestedIp不是必选,客户端在初始状态可能不带 Option 50,那分配完全由服务器地址池策略决定;二是createReply会把收到帧的 xid、chaddr、flags 原样回填,这是客户端识别响应与请求对应关系的关键;三是 Offer 里如果带了租约时间,客户端后续 T1(租约 50%)、T2(87.5%)的续租计时都依赖这个值,给太短会导致内网广播频繁。

源码里默认租约时间是 86400 秒(1 天),T1 在客户端侧计算,服务端不需要管。但如果你在改这个参数,建议先把LEASE_TIME的解析在客户端 Print 出来核对,我见过把单位搞混的,以为填的是分钟,实际是秒,结果地址每 50 分钟就断一次,排查起来特别痛苦。

4. 本地复现与参数调整:从源码到可运行实例

4.1 运行环境与编译命令

这份源码是标准 Maven 工程,没有第三方依赖,JDK 8 以上就能编译。我在本地用 OpenJDK 11 试过,没有任何兼容性问题。编译命令如下:

mvn clean package -DskipTests java -cp target/dhcp.jar dhcp.DhcpServer \ --pool-start 192.168.50.100 \ --pool-end 192.168.50.200 \ --subnet-mask 255.255.255.0 \ --router 192.168.50.1 \ --dns 223.5.5.5

启动后控制台会输出每条报文的解析摘要,包括客户端 MAC、分配 IP、报文类型。参数说明:--pool-start和--pool-end定义了地址池范围;--router和--dns都会封装成对应 Option 下发给客户端;--dns可以传多个,用逗号分隔,源码里会自动把多个 IP 拼成一个 Option 数据区。如果--subnet-mask不传,默认使用255.255.255.0,对应Option.MASK。

4.2 实验环境搭建:用虚拟网卡隔离、避免影响办公室网络

这里建议大家不要在办公网络的物理网卡上直接开启这个服务,尤其是公司网络里有统一 DHCP 服务器时,两个 DHCP 服务同时存在会引发地址分配混乱。最稳妥的方式是在测试机上的虚拟网卡上跑。我一般用 VMware 的仅主机模式或者 Linux 上直接创建macvlan虚拟接口,保证物理机不受影响。

sudo ip link add link eth0 name dhcp-test type macvlan mode bridge sudo ip addr add 192.168.50.1/24 dev dhcp-test sudo ip link set dhcp-test up

macvlan模式会让虚拟接口拥有独立的 MAC,这样客户端在虚拟网卡上发的广播帧都会被独立处理。再启动上面的 Java 服务,--pool-start 192.168.50.100 --pool-end 192.168.50.200,然后准备另一台虚拟机,设置网卡为 DHCP 自动获取,就能完成全流程测试。

注意macvlan接口与物理接口之间不能直接互通,也就是说在同一台 Linux 机器上,客户端走物理网卡无法访问这个 DHCP 服务,必须让客户端和 DHCP 服务在同一个 macvlan 子接口下,或者用另一个虚拟网卡连同一网桥。这个属于网络隔离的拓扑约束,不是源码的 bug,遇到连不上的情况先查链路。

4.3 抓包验证:让 Wireshark 帮你确认 Offer 内容的正确性

调试 DHCP 交互必须抓包,用 Wireshark 打开测试网卡的流量,过滤条件填port 67 or port 68。关键要看的几个点如下:

  1. Discover 的chaddr是否等于发起端 MAC;
  2. Offer 里的yiaddr是否落在地址池范围内;
  3. Option 53(DHCP Message Type)的值是否为 2(表示 Offer);
  4. Option 51(IP Address Lease Time)是否与你启动参数一致;
  5. Option 1(Subnet Mask)和Option 3(Router)是否按预期填充。

如果 Wireshark 没装图形界面,用tcpdump -i dhcp-test -n port 67 or port 68 -vv也能看到关键字段。肉眼核对一遍选项区页码,可以免去很多后续因配置错误导致客户端无法上网的排查步骤。

5. 避坑指南:五个高频问题与根因分析

5.1 服务器收不到 Discover 帧

现象:服务端控制台无任何输出,抓包也看不到port 67上的 UDP 帧。

原因:DatagramSocket绑到了具体 IP,而不是0.0.0.0。我在 3.1 已经强调过,客户端在拿到 IP 之前目标地址是受限广播地址255.255.255.255,如果监听地址是具体的网卡 IP,内核会把广播帧过滤掉。

解决:绑定new DatagramSocket(67, InetAddress.getByName("0.0.0.0"))。同时确认系统的 IP 转发没有启用,否则广播帧会从别的网卡被转发出去,造成奇怪的双路径回包。

5.2 多个 DHCP 服务器导致地址冲突

现象:客户端拿到的 IP 有时来自这个服务,有时来自另一个服务,网络时通时断。

原因:局域网内同时存在 Windows 的 Internet Connection Sharing、路由器内置 DHCP、虚拟机 NAT 模式的 DHCP,客户端广播 Discover 后多个服务器都回复 Offer,客户端一般选取最先到达的,但不同客户端的策略不一样。

解决:测试环境关闭所有其他 DHCP 服务;生产环境在交换机上配置 DHCP Snooping,只允许信任端口上来的 DHCP 响应,这个是网络设备层面的操作,Java 代码里无法规避。最简单可靠的做法是实验环境物理链路隔离,用专用交换机或用上面提到的macvlan隔离。

5.3 客户端拿到了别的网段的地址

现象:地址池写的是192.168.50.100-200,但客户端拿到172.16.x.x。

原因:--pool-start和--pool-end只是地址池范围,但服务端发出的Option 1(子网掩码)或网关地址与实际链路不一致,客户端按照广播域规则可能会忽略 Offer 里的 IP,转去请求自己网段内的地址。

解决:启动参数必须和实验网段保持同一二层域,网关 IP 必须是客户端能到达的真实地址。用--subnet-mask 255.255.255.0 --router 192.168.50.1时,确保障碍链路里确实存在这个网关。如果只是纯测试,可以让网关 IP 设为实验机的网卡 IP,这样客户端拿到地址后能互相访问,但不能出外网,因为缺少路由转发。

5.4 租约到期后客户端没有续租

现象:客户端地址在租约到期后丢失,但服务端地址池里显示还在租出。

原因:客户端续租是单播 Request 到分配时的服务器地址,不是广播。如果客户端换了网卡或服务端重启后租约丢失,它无法完成单播续租,会退回 INIT 状态重新走 DORA。

解决:服务端重启前确认租约有持久化;客户端侧抓包确认是否有REQUEST单播帧到达 67 端口。源码里把LeaseStore换成文件存储后,这个问题基本能消除,重启期间只要文件还在,重启后客户端单播续租依然能成功。

5.5 Option 编码后客户端解析异常

现象:Wireshark 里看报文结构完全正确,但客户端拿到的子网掩码是0.0.0.0,或网关地址反了。

原因:Option 的长度字段写错。长度是value.length,而不是value.length + 2,很多人把 Tag 和 Length 字节也算进去了。另外网关地址是多字节时,顺序必须和ByteUtils里的ipToInt保持一致,否则网关值会对不上。

解决:写 Option 前先用工具类把 IP 转成 4 字节数组,再把数组长度填入长度字段。逐字段对照 Wireshark 解析结果,排查时把writeOptions里每个 Option 的 Tag-Len-Value 打出来,基本十分钟内能定位。

6. 进阶:加一个 IP 冲突检测与日志留存模块

做网络相关开发的人都清楚,DHCP 最怕的不是协议本身出错,而是分配的 IP 被别处手动配死。虽然 DHCP 协议里有DHCPDECLINE机制,但客户端主动发 Decline 的场景并不常见。所以我在拆完这份源码后,立刻给它加了一个主动冲突探测模块:在 Offer 发出之前,先发送 ICMP Echo 请求探测目标 IP,如果收到响应,说明该地址已被占用,跳过这个 IP 继续尝试下一个。

代码实现思路非常简单,写在allocate()方法里:

for (int i = poolStart; i <= poolEnd; i++) { InetAddress candidate = intToIp(i); if (leaseStore.contains(candidate)) continue; if (isReachable(candidate, 300)) { logger.warn("Candidate {} is occupied, skip", candidate); continue; } leaseStore.put(mac, new Lease(candidate, now)); return candidate; }

isReachable为了避免被防火墙干扰,不建议用 Java 自带的InetAddress.isReachable(),因为它依赖 ICMP 的 echo,很多 Windows 客户端默认禁 ICMP。我一般用 TCP 连接探测常见端口(比如 445、22、3389),因为 DHCP 分配之前如果设备在线,最小概率会开这些端口中的一个。不过这个方案不适合大规模网络,每次分配前做一轮端口扫描会拖慢响应速度,所以我一般只在池子比较小(比如 50 个以内)或者跨网段开局的时候启用。

另一个值得扩展的是 DHCP 日志留存。源码自带的控制台输出只能看实时状态,出了问题回溯困难。我加了一个简单的DhcpLogger,每次 DORA 完成或者租约变化时,以 JSON 行格式追加写入/var/log/dhcpd-java.log,核心字段包括时间戳、客户端 MAC、分配 IP、报文类型、租约时长、网关。长期跑下来,这份日志能直接当成资产台账用,配合定时脚本扫一遍还能找出长期在线设备。

从那以后我每次做 DHCP 相关项目都会强制把日志和冲突检测加上,哪怕源码里没有——先用一个小脚本模拟 50 台客户端并发请求,确认没有任何地址冲突、也没有响应丢失,再上真实设备。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询