mini-dhcpd: stop reporting ARP as a corrupted frame

Swiss got a DHCP lease on hardware (10.0.0.64/24), but the script flagged
every ARP from the GC as "frame did NOT parse as DHCP ... this is what a
corrupted TX frame looks like".  That was wrong and actively misleading: the
frames are valid lwIP duplicate-address probes (sender 0.0.0.0, target =
the offered address), sent to check the lease is free before accepting it.

The parser conflated "not DHCP" with "corrupted".  A diagnostic that cries
wolf on normal traffic is worse than none, because 'malformed' then means
nothing.  Now:

  'info'      from the client but not DHCP -- ARP decoded properly (probe vs
              request vs reply, with addresses), other ethertypes named
  'malformed' CLAIMED IPv4/UDP->67 and then failed to parse, i.e. what a
              genuinely damaged TX frame would look like

So the corruption diagnostic still works -- that was the point of using a raw
socket -- but it only fires when it means something.

scripts/test-mini-dhcpd.py keeps the self-test, now including the exact
60-byte frame from the console (T6), a damaged DHCP packet that must still be
flagged (T7), and a raw ethertype (T8).

One note for anyone extending that test: the real frame goes in as ONE string
literal.  Splitting the hex across two adjacent literals is how the first
version of T6 silently lost a byte and blamed the parser for decoding the
target as 0.10.0.0 -- the parser was correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 15:07:44 +02:00
parent 29ce281a7f
commit 0c5a7bf9db
3 changed files with 188 additions and 6 deletions
+49 -6
View File
@@ -30,6 +30,7 @@ import time
ETH_P_ALL = 0x0003
ETH_P_IP = 0x0800
ETH_P_ARP = 0x0806
BOOTP_MIN = 236 # BOOTP header before the magic cookie
MAGIC = b"\x63\x82\x53\x63"
@@ -63,16 +64,50 @@ def build_reply(src_mac, dst_mac, src_ip, dst_ip, payload):
return dst_mac + src_mac + struct.pack("!H", ETH_P_IP) + ip + udp
def describe_arp(pl):
"""Decode an ARP body far enough to be useful during bring-up."""
if len(pl) < 28:
return "ARP, truncated"
htype, ptype, hlen, plen, op = struct.unpack("!HHBBH", pl[:8])
if (htype, ptype, hlen, plen) != (1, ETH_P_IP, 6, 4):
return (f"ARP with unexpected htype/ptype/hlen/plen "
f"{htype}/{ptype:#06x}/{hlen}/{plen}")
sha, spa = pl[8:14], socket.inet_ntoa(pl[14:18])
tha, tpa = pl[18:24], socket.inet_ntoa(pl[24:28])
kind = {1: "request", 2: "reply"}.get(op, f"op {op}")
if op == 1 and spa == "0.0.0.0":
return (f"ARP probe for {tpa} from {mac_str(sha)} -- lwIP checking the "
f"DHCP lease is not already taken (normal)")
if op == 1:
return f"ARP request: who has {tpa}? tell {spa} ({mac_str(sha)})"
return f"ARP reply: {spa} is at {mac_str(sha)} -> {tpa} ({mac_str(tha)})"
def parse_dhcp(frame, want_mac):
"""Return (kind, info). kind is 'dhcp', 'other', or 'malformed' -- the
last one is the interesting case, so it carries a reason."""
"""Return (kind, info).
kind is one of:
'dhcp' a DHCP message, info is the decoded fields
'info' a frame from the client that is simply not DHCP (ARP, etc.)
'malformed' it CLAIMED to be IPv4/UDP->67 and then failed to parse
'other' not from the client we care about
⚠️ 'info' and 'malformed' used to be the same thing, which made this tool
report every ARP as a corrupted frame. A diagnostic that cries wolf on
normal traffic is worse than no diagnostic: the whole point is that
'malformed' means something. ARP from the GC is expected -- lwIP probes
its DHCP lease before accepting it, and the probe also happens to prove
the TX path works for non-IP ethertypes.
"""
if len(frame) < 14:
return "other", None
dst, src, etype = frame[0:6], frame[6:12], struct.unpack("!H", frame[12:14])[0]
if want_mac and src != want_mac:
return "other", None
if etype == ETH_P_ARP:
return "info", describe_arp(frame[14:])
if etype != ETH_P_IP:
return "malformed", f"from {mac_str(src)} but ethertype {etype:#06x}, not IPv4"
return "info", f"ethertype {etype:#06x} (not IPv4 or ARP)"
ip = frame[14:]
if len(ip) < 20:
return "malformed", "IPv4 header truncated"
@@ -181,10 +216,18 @@ def main():
continue
seen_any = True
ts = time.strftime("%H:%M:%S")
if kind == "info":
print(f"[{ts}] {info} ({len(frame)} bytes)")
if a.hex:
print(" " + binascii.hexlify(frame[:64]).decode())
continue
if kind == "malformed":
print(f"[{ts}] ⚠️ frame from the client did NOT parse as DHCP: {info}")
print(f" {len(frame)} bytes -- this is what a corrupted")
print(f" TX frame looks like; compare against a known-good one")
# Reached only when the frame said IPv4/UDP->67 and then did not
# hold up -- i.e. a genuinely damaged DHCP packet, which is what
# a corrupted TX path would produce.
print(f"[{ts}] ⚠️ DAMAGED DHCP frame from the client: {info}")
print(f" {len(frame)} bytes -- a well-formed DISCOVER "
f"would have parsed; suspect the TX path")
print(" " + binascii.hexlify(frame[:64]).decode())
continue
name = MSG_NAMES.get(info["msg_type"], str(info["msg_type"]))