DHCP服務(wù)器:報(bào)文解析與租約管理的完整實(shí)踐)
簡(jiǎn)介面向DHCP協(xié)議與Java網(wǎng)絡(luò)編程開(kāi)發(fā)者提供一套可直接運(yùn)行的DHCP服務(wù)器與客戶(hù)端源代碼適合用于課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)以及企業(yè)內(nèi)網(wǎng)管理工具的二次開(kāi)發(fā)參考。壓縮包共84個(gè)文件大小僅186KB其中包含16個(gè)Java源文件作為核心實(shí)現(xiàn)代碼64個(gè)HTML格式的Javadoc頁(yè)面用于查閱類(lèi)與接口說(shuō)明另含CSS樣式、properties配置和GIF圖片等輔助文件整體結(jié)構(gòu)清晰便于按需取用。代碼完整實(shí)現(xiàn)了DHCP發(fā)現(xiàn)、提供、請(qǐng)求、確認(rèn)四個(gè)階段讀者可學(xué)習(xí)到基于UDP廣播的報(bào)文交互、BOOTP協(xié)議的編解碼方式以及服務(wù)器端多線(xiàn)程并發(fā)處理和IP地址池的動(dòng)態(tài)分配策略。通過(guò)閱讀源碼和文檔既能掌握DatagramSocket、NIO等網(wǎng)絡(luò)編程技巧也能理解真實(shí)協(xié)議落地時(shí)的異常處理、配置存儲(chǔ)等工程問(wèn)題為深入網(wǎng)絡(luò)協(xié)議開(kāi)發(fā)打下基礎(chǔ)。截至當(dāng)前已有632人學(xué)習(xí)使用是一份實(shí)用且輕量的協(xié)議源碼學(xué)習(xí)資料。1. 當(dāng)你的設(shè)備拿不到 IPJava 手寫(xiě) DHCP 到底在做什么排查網(wǎng)絡(luò)時(shí)最讓人血壓飆升的場(chǎng)景之一就是終端瘋狂廣播 DHCP Discover 卻始終收不到 Offer。抓包一看服務(wù)器根本沒(méi)回應(yīng)或者回應(yīng)了但報(bào)文格式錯(cuò)得離譜。這時(shí)候你如果能有一份 Java 實(shí)現(xiàn)的 DHCP 源代碼在手可以直接在 JVM 里跑起來(lái)調(diào)試比反復(fù)翻 RFC 2131 和路由器日志要直觀得多。DHCP 協(xié)議本身不復(fù)雜復(fù)雜的是 UDP 廣播、報(bào)文選項(xiàng)解析、租約狀態(tài)機(jī)這些細(xì)節(jié)在 Java 里怎么落地。這個(gè)方向的價(jià)值在于它不是讓你去替代現(xiàn)成的 dhcpd 或路由器內(nèi)置服務(wù)而是幫你理解協(xié)議交互的每一幀報(bào)文、每一個(gè)狀態(tài)轉(zhuǎn)換。無(wú)論是做網(wǎng)絡(luò)設(shè)備模擬器、接入認(rèn)證系統(tǒng)、還是在測(cè)試環(huán)境里模擬 DHCP 攻擊與防護(hù)場(chǎng)景一份能跑的 Java 源碼都是最直接的學(xué)習(xí)和調(diào)試工具。適合的人群是網(wǎng)絡(luò)協(xié)議棧開(kāi)發(fā)者、物聯(lián)網(wǎng)設(shè)備接入層的工程師以及那些在面試?yán)锉粏?wèn)到「DHCP 報(bào)文是怎么組裝和解析的」之后想弄個(gè)明白的 Java 后端。2. 實(shí)現(xiàn)前必須吃透的 DHCP 報(bào)文結(jié)構(gòu)從 BOOTP 到選項(xiàng)域的字節(jié)級(jí)拆解2.1 為什么 DHCP 報(bào)文要用字節(jié)數(shù)組硬拼而不是 JSON 或?qū)ο罅骱芏嗳藙偵鲜謺r(shí)第一反應(yīng)是我需要一個(gè) DHCP 報(bào)文類(lèi)把 op、htype、xid 這些字段用 Java 對(duì)象表示然后序列化。這個(gè)方向沒(méi)錯(cuò)但注意 DHCP 報(bào)文在網(wǎng)絡(luò)上傳輸時(shí)就是純字節(jié)流而且它沒(méi)有現(xiàn)成的 Java 序列化協(xié)議必須按 RFC 2131 規(guī)定的固定偏移量逐字節(jié)塞進(jìn)去。報(bào)文頭固定部分只有 236 字節(jié)從 op 字段到 sname 的 64 字節(jié)加上 file 的 128 字節(jié)后面接著的是 64 字節(jié)的 options 區(qū)。這里的第一個(gè)坑是你用的 ByteBuffer 是 Big-Endian 字節(jié)序而 DHCP 報(bào)文里絕大多數(shù)多字節(jié)字段比如 yiaddr、ciaddr都是網(wǎng)絡(luò)字節(jié)序也就是 Big-Endian。這意味著直接調(diào)用ByteBuffer.wrap()然后putInt()是安全的但如果你為了取某個(gè)字段用getInt()時(shí)忘記考慮緩沖區(qū)位置就會(huì)讀到錯(cuò)位的數(shù)據(jù)。常見(jiàn)的實(shí)現(xiàn)方式是寫(xiě)一個(gè)專(zhuān)門(mén)的 DHCPPacket 類(lèi)內(nèi)部持有byte[] packet對(duì)外提供getOp()、setOp()之類(lèi)的訪問(wèn)方法。這樣在解碼時(shí)不需要反復(fù) copy 字節(jié)直接對(duì)底層數(shù)組按偏移量操作。我一般還會(huì)內(nèi)置一個(gè)parseOptions()方法把 options 區(qū)從 240 字節(jié)偏移開(kāi)始循環(huán)讀取先讀 option code1 字節(jié)如果是 0 是 padding如果是 255 是 end否則讀長(zhǎng)度再讀值。這個(gè)循環(huán)邏輯是整個(gè)解析器最容易出錯(cuò)的地方原因在于你必須在讀 code 前判斷剩余長(zhǎng)度是否至少為 2code len否則直接拋異常。2.2 報(bào)文類(lèi)型選項(xiàng)53 號(hào)選項(xiàng)和事務(wù) ID區(qū)分 Discover / Offer / Request 的分水嶺如果你只是在網(wǎng)絡(luò)上抓包看源端口 68 和 67 就能猜出大概。但代碼要處理的是具體邏輯分支Discover類(lèi)型 1、Offer類(lèi)型 2、Request類(lèi)型 3、Ack類(lèi)型 5、Nak類(lèi)型 6這些行為全靠 options 區(qū)里的第 53 號(hào)選項(xiàng)區(qū)分。byte類(lèi)型取值有符號(hào)的問(wèn)題在這里就暴露了。Java 里的byte是 -128 到 127而 DHCP 報(bào)文里某個(gè) code 可能是 0x80 甚至 0xFF。如果你寫(xiě)int code buffer[index]你會(huì)得到一個(gè)負(fù)數(shù)比如 0xFF 變成 -1。正確做法是int code buffer[index] 0xFF。這一點(diǎn)不著重說(shuō)明后面的報(bào)文解析一定會(huì)翻車(chē)。事務(wù) ID 是 xid 字段4 字節(jié)由客戶(hù)端隨機(jī)生成。服務(wù)器在回復(fù)時(shí)原樣帶回 xid客戶(hù)端用它匹配請(qǐng)求和響應(yīng)。在 Java 里你用SecureRandom.nextLong()然后截取低 32 位就可以。注意不要用Math.random()去生成因?yàn)樗诟卟l(fā)或多次快速重啟時(shí)可能重復(fù)導(dǎo)致客戶(hù)端把舊 Offer 當(dāng)成新 Offer。2.3 租約時(shí)間、子網(wǎng)掩碼、網(wǎng)關(guān)和 DNS選項(xiàng)的讀寫(xiě)順序決定兼容性拿到選項(xiàng)值之后你要按標(biāo)準(zhǔn)順序把它們編碼回去。常見(jiàn)的順序是53報(bào)文類(lèi)型、 server identifier54、 lease time51、 subnet mask1、 router3、 dns6。有些舊客戶(hù)端對(duì)選項(xiàng)順序敏感比如 Windows 老版本 DHCP 客戶(hù)端必須把 53 號(hào)選項(xiàng)放在最前面否則直接丟棄報(bào)文。選項(xiàng)值里額外要留意的是 IP 地址數(shù)組的對(duì)齊router 選項(xiàng)3和 DNS 選項(xiàng)6都是可以包含多個(gè) IP 的列表以 4 字節(jié)為間隔。讀取時(shí)要循環(huán)讀滿(mǎn)length才算完不能只讀第一個(gè)。寫(xiě)入時(shí)同樣按 4 字節(jié)一組。租約時(shí)間51 號(hào)是 4 字節(jié)整數(shù)單位是秒不能錯(cuò)成毫秒否則客戶(hù)端會(huì)在幾小時(shí)內(nèi)以為租約到期瘋狂續(xù)租。提示寫(xiě)一個(gè)獨(dú)立的DhcpOption類(lèi)字段包括 code、length、byte[] data。解析時(shí)先把 options 全部拆成若干 DhcpOption 對(duì)象再按 code 去查而不是在讀循環(huán)里當(dāng)場(chǎng)做分支處理。這樣的設(shè)計(jì)避免后續(xù)新增選項(xiàng)時(shí)改一大段解析代碼。3. 從零寫(xiě)最小 DHCP 服務(wù)器UDP 廣播收發(fā)與租約管理的完整 Java 代碼3.1 綁定 67 端口監(jiān)聽(tīng)廣播Socket 選項(xiàng)和網(wǎng)絡(luò)接口選擇DHCP 服務(wù)器監(jiān)聽(tīng) UDP 67 端口客戶(hù)端從 68 端口發(fā)送。在 Java 里這不是普通的DatagramSocket因?yàn)槟阆M盏桨l(fā)往 255.255.255.255 的廣播包。先看最精簡(jiǎn)的監(jiān)聽(tīng)代碼import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.net.SocketException; public class DhcpServerSocket { private DatagramSocket socket; private InetAddress broadcastAddress; public DhcpServerSocket(int port, String broadcastIp) throws SocketException { // 廣播地址由網(wǎng)卡配置決定通常取 255.255.255.255 或子網(wǎng)定向廣播 this.broadcastAddress InetAddress.getByName(broadcastIp); // 第一個(gè)參數(shù)為端口第二個(gè)參數(shù)為綁定的本地地址null 表示所有地址 this.socket new DatagramSocket(port, null); // 允許廣播這步不做會(huì)收不到 255.255.255.255 的報(bào)文 socket.setBroadcast(true); // 加大接收緩沖區(qū)防止瞬間大量 Discover 導(dǎo)致丟包 socket.setReceiveBufferSize(64 * 1024); } public DatagramPacket receive() throws Exception { byte[] buf new byte[1500]; DatagramPacket packet new DatagramPacket(buf, buf.length); socket.receive(packet); return packet; } public void send(byte[] data, int length, InetAddress destIp, int destPort) throws Exception { DatagramPacket reply new DatagramPacket(data, length, destIp, destPort); // 目標(biāo)端口固定為 68客戶(hù)端監(jiān)聽(tīng)端口 socket.send(reply); } }參數(shù)說(shuō)明new DatagramSocket(port, null)中的 null 代表綁定到所有網(wǎng)卡。如果你的機(jī)器有多塊網(wǎng)卡只希望在特定網(wǎng)卡上服務(wù)這里可以傳InetAddress.getByName(192.168.1.100)。setBroadcast(true)是為了能夠向廣播地址發(fā)送回復(fù)包不設(shè)置就只能單播。監(jiān)聽(tīng)端口時(shí)如果提示Address already in use在 Linux 上可以用setsockopt的 SO_REUSEADDR 解決但 Java 里沒(méi)有直接暴露這個(gè)選項(xiàng)最簡(jiǎn)單的辦法是確認(rèn)沒(méi)有其他 DHCP 服務(wù)占著 67 端口。3.2 解析 Discover 報(bào)文并組裝 Offer一個(gè)完整的請(qǐng)求處理鏈路收到 Discover 后你要做的是讀取客戶(hù)端 MACchaddr 字段從偏移 28 開(kāi)始16 字節(jié)根據(jù) MAC 查租約表分配一個(gè)空閑地址然后組裝 Offer 報(bào)文。下面這段代碼集中展示了核心處理流程public class DhcpRequestHandler { // 租約表MAC - IP 和過(guò)期時(shí)間 private final MapString, Lease leaseMap new ConcurrentHashMap(); // 可用 IP 池用隊(duì)列模擬分配 private final QueueString addressPool; public DhcpRequestHandler(ListString poolAddresses) { addressPool new LinkedList(poolAddresses); // 啟動(dòng)一個(gè)定時(shí)線(xiàn)程定期清理過(guò)期租約 Executors.newSingleThreadScheduledExecutor() .scheduleAtFixedRate(this::cleanupExpiredLeases, 60, 60, TimeUnit.SECONDS); } public byte[] handleDiscover(byte[] requestPacket) { // 1. 解析客戶(hù)端 MAC包偏移 28 處長(zhǎng)度 6但 chaddr 字段是 16 字節(jié) byte[] chaddr Arrays.copyOfRange(requestPacket, 28, 44); String macKey toMacString(chaddr); String offeredIp leaseMap.containsKey(macKey) ? leaseMap.get(macKey).ipAddress : allocateIp(macKey); if (offeredIp null) { return null; // 地址池耗盡 } // 2. 構(gòu)造 Offer 報(bào)文的基礎(chǔ)字段 byte[] response new byte[240]; // 只占固定頭選項(xiàng)在后面追加 response[0] (byte) 0x02; // op: BOOTREPLY response[1] (byte) 0x01; // htype: Ethernet response[2] (byte) 0x06; // hlen: MAC 地址長(zhǎng)度 // 復(fù)制 xid客戶(hù)端用這個(gè)字段匹配 Offer System.arraycopy(requestPacket, 4, response, 4, 4); // yiaddr 字段從偏移 16 開(kāi)始填入分配的 IP byte[] ipBytes ipToBytes(offeredIp); System.arraycopy(ipBytes, 0, response, 16, 4); // 復(fù)制客戶(hù)端 MAC 到 chaddr System.arraycopy(chaddr, 0, response, 28, 6); // 3. 追加 options53 號(hào)報(bào)文類(lèi)型 Offer、54server id、1掩碼、51租約 ByteBuffer options ByteBuffer.allocate(256); options.put((byte) 53).put((byte) 1).put((byte) 2); // Offer 類(lèi)型 options.put((byte) 54).put((byte) 4) .put(ipToBytes(192.168.1.1)); // 服務(wù)器自身 IP options.put((byte) 1).put((byte) 4) .put(ipToBytes(255.255.255.0)); options.put((byte) 51).put((byte) 4) .putInt(7200); // 租約 2 小時(shí) options.put((byte) 255); // End 選項(xiàng) byte[] responseWithOptions new byte[240 options.position()]; System.arraycopy(response, 0, responseWithOptions, 0, 240); System.arraycopy(options.array(), 0, responseWithOptions, 240, options.position()); return responseWithOptions; } private String allocateIp(String mac) { // 簡(jiǎn)化實(shí)現(xiàn)從隊(duì)列取地址不考慮釋放問(wèn)題 String ip addressPool.poll(); if (ip ! null) { leaseMap.put(mac, new Lease(ip, System.currentTimeMillis() 7200_000L)); } return ip; } }邏輯說(shuō)明handleDiscover的輸入是整個(gè) UDP 報(bào)文的有效載荷字節(jié)數(shù)組。第一步解析 chaddr 時(shí)注意只取前 6 字節(jié)做 MAC因?yàn)?Ethernet 的 hlen 是 6但報(bào)文里該字段長(zhǎng)度是 16 字節(jié)剩余部分為零填充。response[0] 0x02表示這是 BOOTREPLY而請(qǐng)求包對(duì)應(yīng)值是 0x01BOOTREQUEST。xid 必須從請(qǐng)求包偏移 4 的位置復(fù)制 4 字節(jié)否則響應(yīng)對(duì)不上客戶(hù)端。Options 的組裝用了ByteBuffer且每個(gè) option 的格式是 code length value。長(zhǎng)度字段必須是實(shí)際字節(jié)數(shù)比如 51 號(hào) option 的租約時(shí)間是 4所以先put((byte) 4)再putInt(7200)。positon()返回當(dāng)前寫(xiě)入位置作為 options 區(qū)總長(zhǎng)度。這種寫(xiě)法比逐字節(jié)往一個(gè)byte[]里塞要清晰調(diào)試時(shí)也方便中途打印。3.3 收到 Request 后回 Ack狀態(tài)機(jī)里的最后一步Offer 發(fā)出后客戶(hù)端會(huì)廣播一個(gè) Request類(lèi)型為 3。這時(shí)候服務(wù)器要做的是確認(rèn)該 Request 中的 requested IP50 號(hào)選項(xiàng)確實(shí)是本服務(wù)器分配的然后回復(fù) Ack。下面代碼展示如何處理 Requestpublic byte[] handleRequest(byte[] requestPacket) { // 1. 從 options 里找到 50 號(hào)選項(xiàng)取請(qǐng)求的 IP ByteBuffer options ByteBuffer.wrap(requestPacket, 240, requestPacket.length - 240); String requestedIp null; String serverIdentifier null; while (options.remaining() 0) { int code options.get() 0xFF; if (code 0) { continue; // padding } if (code 255) { break; } int len options.get() 0xFF; byte[] val new byte[len]; options.get(val); if (code 50) { requestedIp bytesToIp(val); // 客戶(hù)端請(qǐng)求的地址 } else if (code 54) { serverIdentifier bytesToIp(val); // 客戶(hù)端期望的服務(wù)器地址 } } // 2. 判斷是否應(yīng)該由本服務(wù)器響應(yīng)客戶(hù)端指定了其他 server id 則忽略 if (serverIdentifier ! null !serverIdentifier.equals(localServerIp)) { return null; } // 3. 校驗(yàn)地址池里確實(shí)有這個(gè)地址且未分配給別人 if (!addressPool.contains(requestedIp) !isLeasedToClient(requestedIp)) { return null; } // 4. 組裝 Ack報(bào)文類(lèi)型 5返回分配結(jié)果 byte[] ack buildReply(requestPacket, (byte) 5, requestedIp); return ack; }邏輯說(shuō)明handleRequest里的循環(huán)解析 options特別要注意options.get()返回的是有符號(hào)字節(jié)這里用 0xFF規(guī)避負(fù)數(shù)問(wèn)題。當(dāng)客戶(hù)端拿到 Offer 后會(huì)發(fā) Request并把 50 號(hào)選項(xiàng)設(shè)成自己想要的 IP。如果網(wǎng)絡(luò)里有多個(gè) DHCP 服務(wù)器同時(shí)收到了 Request客戶(hù)端會(huì)用 54 號(hào)選項(xiàng)指定它認(rèn)可的服務(wù)器其他服務(wù)器看到 54 號(hào)不是自己就直接忽略。這一步邏輯不寫(xiě)就會(huì)出現(xiàn)兩臺(tái)服務(wù)器同時(shí)回 Ack 的沖突場(chǎng)景。3.4 定時(shí)清理租約ConcurrentHashMap 怎么和地址池配合實(shí)際運(yùn)行中客戶(hù)端釋放地址時(shí)的 Release 報(bào)文是單播發(fā)給服務(wù)器的所以handleRequest方法里還需要處理報(bào)文類(lèi)型 7Release。Release 報(bào)文里含客戶(hù)端 IPciaddr 字段服務(wù)器拿到后把地址放回池子、從租約表刪除。但客戶(hù)端可能異常斷電永遠(yuǎn)不發(fā) Release所以必須依賴(lài)租約過(guò)期機(jī)制。private void cleanupExpiredLeases() { long now System.currentTimeMillis(); for (Map.EntryString, Lease entry : leaseMap.entrySet()) { if (entry.getValue().expireTime now) { // 地址回收放回隊(duì)列尾部允許被重新分配 addressPool.offer(entry.getValue().ipAddress); leaseMap.remove(entry.getKey()); } } }這里的隱患是地址池用ConcurrentHashMap和LinkedList組合本身就不是線(xiàn)程安全的。cleanupExpiredLeases在獨(dú)立線(xiàn)程里跑而handleDiscover在主接收線(xiàn)程里也會(huì)操作addressPool。解決方式是給地址池操作加鎖或者直接用BlockingQueue并用ConcurrentHashMap記錄租約和到期時(shí)間。我在實(shí)際項(xiàng)目里會(huì)用ConcurrentHashMap存mac - ip的映射再用ConcurrentHashMapString, Long存ip - expireTime清理時(shí)同時(shí)刪兩個(gè) map分配時(shí)用AtomicInteger做輪詢(xún)游標(biāo)而不是直接 poll 隊(duì)列因?yàn)獒尫藕瓦^(guò)期回收都會(huì)動(dòng)態(tài)往隊(duì)列里放地址。private final AtomicInteger cursor new AtomicInteger(0); private final ListString allIps; // 固定列表初始化時(shí)排序好 private String allocateIp(String mac) { for (int i 0; i allIps.size(); i) { int idx Math.abs(cursor.getAndIncrement() % allIps.size()); String candidate allIps.get(idx); if (!leaseMap.containsKey(mac) !leasedIpSet.contains(candidate)) { leaseMap.put(mac, new Lease(candidate, System.currentTimeMillis() 7200_000L)); leasedIpSet.add(candidate); return candidate; } } return null; }參數(shù)說(shuō)明allIps在初始化時(shí)從配置的網(wǎng)段展開(kāi)比如192.168.1.100到192.168.1.200生成 101 個(gè)地址。cursor每次分配時(shí)累加再取模實(shí)現(xiàn)輪詢(xún)分配避免每次都從列表頭部開(kāi)始找導(dǎo)致早期地址快被用光、后續(xù)地址閑置。leasedIpSet是一個(gè)SetString用來(lái)快速判斷地址是否已被占用否則每次都要遍歷租約表。4. 客戶(hù)端實(shí)現(xiàn)與抓包驗(yàn)證三步確認(rèn)你的 DHCP 協(xié)議棧沒(méi)寫(xiě)歪4.1 構(gòu)造 Discover 報(bào)文的細(xì)節(jié)68 端口和全零地址客戶(hù)端代碼比服務(wù)器少很多核心是構(gòu)造 Discover 并發(fā)送到 255.255.255.255:67。關(guān)鍵約束是源端口必須是 68源 IP 可以是 0.0.0.0因?yàn)榭蛻?hù)端此時(shí)還沒(méi)有 IP。public class DhcpClient { private DatagramSocket socket; public void startDiscovery() throws Exception { socket new DatagramSocket(68, InetAddress.getByName(0.0.0.0)); socket.setBroadcast(true); socket.setSoTimeout(5000); // 5 秒收不到就超時(shí) byte[] discover buildDiscoverPacket(); DatagramPacket pkt new DatagramPacket(discover, discover.length, InetAddress.getByName(255.255.255.255), 67); socket.send(pkt); // 等待 Offer,讀到報(bào)文后校驗(yàn) xid 與自己的匹配 byte[] buf new byte[1500]; DatagramPacket response new DatagramPacket(buf, buf.length); socket.receive(response); parseOffer(buf); } private byte[] buildDiscoverPacket() { byte[] packet new byte[240 4]; packet[0] (byte) 0x01; // BOOTREQUEST packet[1] (byte) 0x01; packet[2] (byte) 0x06; // xid: 4 字節(jié)隨機(jī)數(shù) int xid new SecureRandom().nextInt(); packet[4] (byte) (xid 24); packet[5] (byte) (xid 16); packet[6] (byte) (xid 8); packet[7] (byte) xid; // 自己的 MAC假設(shè)網(wǎng)卡 MAC 是 01:02:03:04:05:06 byte[] mac new byte[]{(byte) 0x01, (byte) 0x02, (byte) 0x03, (byte) 0x04, (byte) 0x05, (byte) 0x06}; System.arraycopy(mac, 0, packet, 28, 6); // options53 號(hào)選項(xiàng) Discover1 packet[240] (byte) 53; packet[241] (byte) 1; packet[242] (byte) 1; packet[243] (byte) 255; // End return packet; } }邏輯說(shuō)明new DatagramSocket(68, 0.0.0.0)綁定到 68 端口這樣收到的 Offer 會(huì)被操作系統(tǒng)路由到該 socket。xid 的生成用SecureRandom.nextInt()而不是Math.random()是為了避免在多線(xiàn)程場(chǎng)景下的重復(fù)性。注意把 xid 拆成 4 個(gè)字節(jié)的順序——Java 的 byte 是有符號(hào)的xid 24的結(jié)果 0~255賦值給 byte 時(shí) 0xFF 會(huì)變成 -1但在傳輸中字節(jié)序列是正確的因?yàn)槎M(jìn)制表達(dá)沒(méi)變。實(shí)際解析時(shí)用 0xFF再拼回 int 即可。4.2 收到 Offer 后如何提取租約信息ip 和 options 的解析順序在parseOffer中要依次做檢查 op 字段是否為 2、xid 是否匹配、拿到 yiaddr偏移 16 起的 4 字節(jié)、掃描 options 找 51 號(hào)租約時(shí)間和 54 號(hào)服務(wù)器標(biāo)識(shí)。解析順序別看反了很多人先處理 options 后取 IP就會(huì)發(fā)現(xiàn) options 區(qū)的偏移計(jì)算有誤。private void parseOffer(byte[] response) { if (response[0] ! (byte) 0x02) { throw new IllegalStateException(不是 BOOTREPLY); } // 檢查 xid 是否匹配這里略去與發(fā)送時(shí)保存的 xid 比較 byte[] yiaddr Arrays.copyOfRange(response, 16, 20); String assignedIp InetAddress.getByAddress(yiaddr).getHostAddress(); // 掃描 options int offset 240; while (offset response.length) { int code response[offset] 0xFF; if (code 0) { offset; continue; } if (code 255) break; int len response[offset 1] 0xFF; byte[] value Arrays.copyOfRange(response, offset 2, offset 2 len); if (code 1) { // 子網(wǎng)掩碼 String mask bytesToIp(value); // 后續(xù)計(jì)算可用地址范圍會(huì)用到 } else if (code 3) { // 網(wǎng)關(guān)列表一個(gè)選項(xiàng)可能包含多個(gè) IP for (int i 0; i value.length; i 4) { String gateway bytesToIp(Arrays.copyOfRange(value, i, i 4)); } } else if (code 51) { long leaseTimeSec ((long) (value[0] 0xFF) 24) | ((value[1] 0xFF) 16) | ((value[2] 0xFF) 8) | (value[3] 0xFF); // leaseTimeSec 單位是秒 } offset 2 len; } }參數(shù)說(shuō)明租約時(shí)間如果直接用ByteBuffer.wrap(value).getInt()也可以但要注意getInt()會(huì)得到有符號(hào) int最長(zhǎng)租約 0xFFFFFFFF 會(huì)變成 -1。這里我演示了手工拼接的方式結(jié)果用 long 承接。網(wǎng)關(guān)選項(xiàng)按 4 字節(jié)一組遍歷時(shí)要小心 len 不是 4 的倍數(shù)這種畸形包用value.length / 4作為循環(huán)次數(shù)更穩(wěn)妥。4.3 用 Wireshark 對(duì)照驗(yàn)證你寫(xiě)的源碼和標(biāo)準(zhǔn)報(bào)文差在哪代碼寫(xiě)完后別急著部署先在本地起服務(wù)器再起客戶(hù)端用 Wireshark 抓bootp或dhcp過(guò)濾。如果要驗(yàn)證 Java 客戶(hù)端發(fā)的 Discover 是否規(guī)范抓包后看這幾個(gè)點(diǎn)檢查項(xiàng)正確值/行為常見(jiàn)錯(cuò)誤源端口68錯(cuò)寫(xiě)成 67 或隨機(jī)端口目標(biāo)端口67錯(cuò)寫(xiě)成 68op 字段1請(qǐng)求/ 2響應(yīng)兩個(gè)方向搞反xid 一致性請(qǐng)求與響應(yīng)相同響應(yīng)時(shí)復(fù)制偏移搞成 8 或 12options 的 End 標(biāo)記必須有 255 結(jié)尾漏掉后服務(wù)端解析會(huì)越界chaddr 填充前 6 字節(jié) MAC后 10 字節(jié)補(bǔ) 0直接 copy 16 字節(jié)導(dǎo)致把雜數(shù)據(jù)帶進(jìn)去我習(xí)慣在寫(xiě)代碼時(shí)加一段 debug 日志每次收到 / 發(fā)出的報(bào)文用 HexFormat 或DatatypeConverter.printHexBinary()把前 40 字節(jié)打出來(lái)。這樣不依賴(lài) Wireshark 也能在控制臺(tái)里對(duì)著報(bào)文字段偏移人工校驗(yàn)。提示如果你在 Windows 上跑 Java 服務(wù)端收到的廣播包源地址可能是 0.0.0.0而目標(biāo)地址是 255.255.255.255。如果綁定 67 端口時(shí)報(bào)SocketException: Permission denied是因?yàn)榉枪芾韱T進(jìn)程無(wú)權(quán)綁定 1024 以下端口。開(kāi)發(fā)時(shí)用 6767 端口調(diào)試需要聯(lián)調(diào)時(shí)再以管理員權(quán)限啟動(dòng)。5. Java 實(shí)現(xiàn) DHCP 的 5 個(gè)高頻踩坑點(diǎn)與對(duì)應(yīng)排查手段5.1 報(bào)文里大于 127 的字節(jié)變成負(fù)數(shù)解析全盤(pán)錯(cuò)亂現(xiàn)象客戶(hù)端發(fā)來(lái)的 Discover 包服務(wù)器讀到選項(xiàng) code 是負(fù)數(shù)比如 -128實(shí)際是 0x80然后直接數(shù)組越界或走錯(cuò)分支。原因Java byte 是有符號(hào)類(lèi)型0x80 到 0xFF 會(huì)被解釋為 -128 到 -1。你在int code buffer[offset]時(shí)沒(méi)做掩碼處理。解決所有從報(bào)文里讀單字節(jié)并轉(zhuǎn)成「無(wú)符號(hào)整數(shù)」的地方統(tǒng)一寫(xiě)成buffer[offset] 0xFF。不要覺(jué)得這行代碼多余特別是在 options 循環(huán)、長(zhǎng)度字段和 IP 地址首字節(jié)處。另外在把 int 寫(xiě)成 byte 時(shí)比如packet[240] (byte) 53如果 int 值超過(guò) 127必須強(qiáng)轉(zhuǎn)否則編譯不通過(guò)。寫(xiě)一個(gè)toUnsignedByte(int)工具方法可以減少重復(fù)出錯(cuò)。5.2 廣播包收不到setBroadcast(true) 漏了或者網(wǎng)卡綁定錯(cuò)了現(xiàn)象服務(wù)器啟動(dòng)后沒(méi)有任何日志客戶(hù)端一直顯示發(fā)送成功但等不到 Offer。用 Wireshark 看服務(wù)器所在機(jī)器發(fā)現(xiàn)根本沒(méi)有收到包。原因有兩類(lèi)。第一類(lèi)是 Java 的DatagramSocket默認(rèn)不接收廣播地址的包必須顯式socket.setBroadcast(true)。第二類(lèi)是服務(wù)器綁定的 IP 和客戶(hù)端廣播到達(dá)的網(wǎng)卡不是同一個(gè)。如果機(jī)器有 eth0 和 eth1客戶(hù)端廣播從 eth1 進(jìn)來(lái)但你綁定的是 eth0 的 IP內(nèi)核同樣會(huì)丟包。排查方法先臨時(shí)寫(xiě)一段代碼打印本機(jī)所有網(wǎng)卡地址然后把new DatagramSocket(port, null)改成new DatagramSocket(port, InetAddress.getByName(0.0.0.0))保證綁定到所有接口。如果還不行用tcpdump -i any port 67在 Linux 上確認(rèn)報(bào)文是否到達(dá)內(nèi)核。5.3 Options 解析越界漏了 padding 和 End 選項(xiàng)的處理現(xiàn)象服務(wù)器解析某些客戶(hù)端比如老舊設(shè)備發(fā)來(lái)的請(qǐng)求時(shí)代碼直接拋ArrayIndexOutOfBoundsException或讀到一個(gè)巨大的 len 值。原因DHCP 報(bào)文 options 區(qū)不是每次都恰好填滿(mǎn) 64 字節(jié)。RFC 2131 規(guī)定 options 區(qū)末尾以 255 結(jié)束但在它之前可能有一串值為 0 的 padding 選項(xiàng)。你的循環(huán)如果只看了 255 沒(méi)看 0或者認(rèn)為 len 字段一定是合理的遇到 0 時(shí)把 padding 當(dāng)普通選項(xiàng)的 code 和 len 去讀就會(huì)讀到錯(cuò)亂的字節(jié)。解決循環(huán)里的處理順序必須是讀 code如果 code 0跳過(guò)當(dāng)前字節(jié)繼續(xù)下一個(gè)如果 code 255立即結(jié)束否則再讀 len 并跳過(guò) len 字節(jié)。注意不能把 padding 當(dāng) code 為 0 的選項(xiàng)因?yàn)?padding 沒(méi)有 len 字段。另外解析時(shí)要用offset 2 len packet.length做邊界保護(hù)一旦條件不滿(mǎn)足直接丟棄該包并記日志。5.4 客戶(hù)端對(duì) Ack 不買(mǎi)賬Server Identifier 選項(xiàng)寫(xiě)錯(cuò)地址現(xiàn)象服務(wù)器日志顯示已發(fā)出 AckWireshark 也看到了但客戶(hù)端就是報(bào)錯(cuò)或者客戶(hù)端狀態(tài)停在 Request 階段不進(jìn)入 Bound。原因Ack 里必須包含 54 號(hào)選項(xiàng)Server Identifier且該值必須是服務(wù)器自身的 IP 地址。如果寫(xiě)成 255.255.255.255 或者 0.0.0.0客戶(hù)端按 RFC 就認(rèn)為這個(gè) Ack 不合法。還有一種情況是服務(wù)器有多個(gè) IP寫(xiě)成了其中某個(gè)不是客戶(hù)端網(wǎng)關(guān)所在網(wǎng)段的地址客戶(hù)端可能無(wú)法路由這個(gè)單播 Ack。解決在組裝 Ack 時(shí)用DatagramPacket的getLocalAddress()或提前從網(wǎng)卡讀到的地址確保54號(hào)選項(xiàng)的值與收包時(shí)客戶(hù)端發(fā)來(lái)的目標(biāo)地址段匹配。實(shí)在無(wú)法確定時(shí)用收到 Discover 報(bào)文的源地址所在子網(wǎng)推斷服務(wù)器該用哪個(gè)地址回復(fù)。5.5 租約過(guò)期后地址沒(méi)釋放cleanup 線(xiàn)程跑飛或者占用檢查漏了現(xiàn)象地址池明明只有 50 個(gè)地址跑了幾天后全部被占用新設(shè)備拿不到 IP。但檢查 DHCP 租約表很多租約早已過(guò)期。原因cleanupExpiredLeases方法里刪除 map 條目時(shí)沒(méi)有同步更新地址池。比如你把leaseMap.remove(entry.getKey())寫(xiě)了卻忘了把 IP 放回addressPool。還有可能是在移除租約時(shí)遍歷的是 entrySet但后面又有新請(qǐng)求往同一個(gè) key 寫(xiě)入導(dǎo)致 remove 和 put 競(jìng)爭(zhēng)。解決清理邏輯要和分配邏輯用同一個(gè)鎖。我習(xí)慣做法是把租約表和地址池封裝成一個(gè)LeaseManager類(lèi)所有方法加synchronized或者直接用ReentrantLock。清理時(shí)先標(biāo)記 IP 釋放再刪租約順序不能反否則中間態(tài)有分配線(xiàn)程可能拿到這個(gè) IP 又被后續(xù)刪除動(dòng)作誤傷。提示這些坑里前三個(gè)是 Java 獨(dú)有的后兩個(gè)是 DHCP 協(xié)議實(shí)現(xiàn)共有的。如果你在做自己的項(xiàng)目建議每解決一個(gè)坑就補(bǔ)一條單測(cè)比如構(gòu)造一個(gè)含 padding 的畸形報(bào)文驗(yàn)證解析器是否崩潰覆蓋這些場(chǎng)景后改代碼會(huì)安心很多。6. 讓實(shí)現(xiàn)可以上生產(chǎn)報(bào)文一致性校驗(yàn)與性能壓測(cè)的小工具我的習(xí)慣是給 DHCP 服務(wù)器代碼加一個(gè)獨(dú)立的「報(bào)文自檢」入口用真實(shí)的客戶(hù)端抓包文件回放而不是每次手動(dòng)敲命令。流程是先用 Wireshark 把真實(shí)環(huán)境的 Discover 和 Request 報(bào)文導(dǎo)出為二進(jìn)制文件然后用 Java 程序讀取每個(gè)包并調(diào)用你自己的解析邏輯最后把輸出的 Offer/Ack 再與 Wireshark 里抓到的真實(shí)響應(yīng)逐字節(jié)對(duì)比。核心校驗(yàn)代碼可以寫(xiě)成一個(gè)獨(dú)立方法public boolean validatePacket(byte[] packet, int maxLength) { if (packet.length 240 || packet.length maxLength) { return false; } // op 只能是 1 或 2 if ((packet[0] 0xFF) ! 1 (packet[0] 0xFF) ! 2) { return false; } // htype 為 1 時(shí) hlen 必須是 6 if (packet[1] 1 (packet[2] 0xFF) ! 6) { return false; } // 掃描 options驗(yàn)證每個(gè) option 都不越界 int offset 240; while (offset packet.length) { int code packet[offset] 0xFF; if (code 0) { offset; continue; } if (code 255) break; if (offset 1 packet.length) return false; int len packet[offset 1] 0xFF; if (offset 2 len packet.length) return false; offset 2 len; } return true; }運(yùn)行單測(cè)時(shí)我把抓到的包分兩類(lèi)合法報(bào)文和畸形報(bào)文。合法報(bào)文必須通過(guò)校驗(yàn)畸形報(bào)文必須被拒絕。這個(gè)校驗(yàn)器本質(zhì)上就是你的防御性編程的集中體現(xiàn)值很值得維護(hù)。有了能跑通的核心邏輯和校驗(yàn)器下一步建議做一次壓力測(cè)試。用循環(huán)線(xiàn)程模擬 500 個(gè)客戶(hù)端同時(shí)啟動(dòng)ExecutorService pool Executors.newFixedThreadPool(50); CountDownLatch startSignal new CountDownLatch(1); for (int i 0; i 500; i) { final int clientId i; pool.submit(() - { try { startSignal.await(); DhcpClient client new DhcpClient(); client.setMac(randomMac(clientId)); client.startDiscovery(); } catch (Exception e) { System.err.println(client clientId failed: e.getMessage()); } }); } startSignal.countDown(); pool.shutdown();注意這里每個(gè)線(xiàn)程都創(chuàng)建獨(dú)立DatagramSocket綁定 68 端口這本身就會(huì)遇到端口沖突——多個(gè)進(jìn)程不能同時(shí)綁同一個(gè)端口。所以在壓測(cè)時(shí)要讓客戶(hù)端隨機(jī)使用 68 到 70 范圍內(nèi)的端口或者用同一個(gè) socket 串行發(fā)。更實(shí)際的做法是直接用服務(wù)端日志里的xid去重統(tǒng)計(jì)單位時(shí)間收到多少個(gè)不同 xid 的 Discover再用單線(xiàn)程代碼往服務(wù)器灌大量手工構(gòu)造的 Discover 報(bào)文來(lái)測(cè)試吞吐。最后一個(gè)技巧把核心的報(bào)文組裝和解析邏輯與網(wǎng)絡(luò)收發(fā)解耦。網(wǎng)絡(luò)層可以換 Netty 的 DatagramChannel 或者 OIO 實(shí)現(xiàn)但解析和組裝是純函數(shù)式的輸入 byte[] 輸出 byte[]。這樣將來(lái)想把服務(wù)跑在 Vert.x 上或者遷移到 GraalVM 原生鏡像都不需要?jiǎng)訁f(xié)議代碼。我的個(gè)人習(xí)慣是任何時(shí)候都會(huì)以 Wireshark 抓包為最終真理寫(xiě)完代碼先抓包對(duì)比 20 個(gè)請(qǐng)求再談可靠性。DHCP 看起來(lái)簡(jiǎn)單但報(bào)文里的每個(gè)字節(jié)都有自己的脾氣希望這套實(shí)現(xiàn)思路能幫你少走幾個(gè)彎路。本文還有配套的精品資源點(diǎn)擊獲取