scripts: add a minimal raw-socket DHCP server for BBA bring-up
sudo ./scripts/mini-dhcpd.py enp0s31f6 Serves one address, logs every exchange, and does not touch the rest of the system. Needed because the Swiss DHCP timeout proves nothing while there is no server on the segment to answer. Deliberately AF_PACKET rather than a UDP socket on port 67. A UDP socket only sees frames the kernel accepts as well-formed IP/UDP, and one of the open questions is whether the GC's frames arrive INTACT: libogc streams TX payload with EXI_Dma and the GC's DMA-write engine is documented to have a 1-bit-shift bug. A corrupted DISCOVER would never reach a UDP socket and the server would look "fine, just no client". On a raw socket every frame from the adapter is parsed by hand and anything that fails is reported as MALFORMED with the reason and a hexdump -- which is exactly the evidence that would confirm or kill the corruption hypothesis. Replies go out as raw ethernet UNICAST to the client's MAC, so a successful lease also demonstrates the new MACRAW MF filter accepts unicast to SHAR and not just broadcast. Self-tested without a network: a well-formed DISCOVER parses with the right chaddr/xid, a corrupted magic cookie is reported as malformed rather than ignored, frames from other MACs are skipped, and the generated OFFER is a valid 342-byte frame -- unicast to the client, correct IP checksum and total length, ports 67->68, BOOTP padded to the 300-byte minimum, yiaddr as configured. On-wire behaviour is NOT tested here; the devcontainer has only a veth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal DHCP server on a raw socket, for BBA bring-up.
|
||||
|
||||
sudo ./scripts/mini-dhcpd.py enp0s31f6
|
||||
|
||||
Why raw (AF_PACKET) rather than a UDP socket on port 67: a UDP socket only
|
||||
sees frames the kernel accepts as well-formed IP/UDP. One of the things we
|
||||
are trying to find out is whether the GC's frames arrive INTACT -- libogc
|
||||
streams TX payload with EXI_Dma, and the GC's DMA-write engine is documented
|
||||
to have a 1-bit-shift bug. A corrupted DISCOVER would never reach a UDP
|
||||
socket, and the server would look "fine, just no client". On a raw socket we
|
||||
see every frame the adapter sends, parse it ourselves, and say exactly why it
|
||||
did not qualify.
|
||||
|
||||
Replies go out as raw ethernet UNICAST to the client's MAC. That is also a
|
||||
test: the gateware filters RX to own-MAC + broadcast (the MACRAW MF bit), so a
|
||||
unicast reply landing is evidence the filter is set up correctly.
|
||||
|
||||
Serves ONE address, logs everything, and does not touch the rest of the
|
||||
system. Not a real DHCP server -- no persistence, no conflict detection, no
|
||||
relay support. Stop with Ctrl-C.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import binascii
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
ETH_P_ALL = 0x0003
|
||||
ETH_P_IP = 0x0800
|
||||
BOOTP_MIN = 236 # BOOTP header before the magic cookie
|
||||
MAGIC = b"\x63\x82\x53\x63"
|
||||
|
||||
DHCP_DISCOVER, DHCP_OFFER, DHCP_REQUEST, DHCP_DECLINE, DHCP_ACK, DHCP_NAK = 1, 2, 3, 4, 5, 6
|
||||
MSG_NAMES = {1: "DISCOVER", 2: "OFFER", 3: "REQUEST", 4: "DECLINE",
|
||||
5: "ACK", 6: "NAK", 7: "RELEASE", 8: "INFORM"}
|
||||
|
||||
|
||||
def mac_str(b):
|
||||
return ":".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def ip_csum(data):
|
||||
if len(data) % 2:
|
||||
data += b"\0"
|
||||
s = sum(struct.unpack("!%dH" % (len(data) // 2), data))
|
||||
while s >> 16:
|
||||
s = (s & 0xFFFF) + (s >> 16)
|
||||
return (~s) & 0xFFFF
|
||||
|
||||
|
||||
def build_reply(src_mac, dst_mac, src_ip, dst_ip, payload):
|
||||
"""Ethernet + IPv4 + UDP(67->68) around `payload`. UDP checksum 0, which
|
||||
IPv4 permits, so there is one less thing to get wrong."""
|
||||
udp = struct.pack("!HHHH", 67, 68, 8 + len(payload), 0) + payload
|
||||
ihl_total = 20 + len(udp)
|
||||
ip = struct.pack("!BBHHHBBH4s4s",
|
||||
0x45, 0, ihl_total, 0x1234, 0, 64, socket.IPPROTO_UDP, 0,
|
||||
socket.inet_aton(src_ip), socket.inet_aton(dst_ip))
|
||||
ip = ip[:10] + struct.pack("!H", ip_csum(ip)) + ip[12:]
|
||||
return dst_mac + src_mac + struct.pack("!H", ETH_P_IP) + ip + udp
|
||||
|
||||
|
||||
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."""
|
||||
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_IP:
|
||||
return "malformed", f"from {mac_str(src)} but ethertype {etype:#06x}, not IPv4"
|
||||
ip = frame[14:]
|
||||
if len(ip) < 20:
|
||||
return "malformed", "IPv4 header truncated"
|
||||
ihl = (ip[0] & 0x0F) * 4
|
||||
if ip[0] >> 4 != 4:
|
||||
return "malformed", f"IP version {ip[0] >> 4}, not 4"
|
||||
if ip[9] != socket.IPPROTO_UDP:
|
||||
return "malformed", f"IP proto {ip[9]}, not UDP"
|
||||
udp = ip[ihl:]
|
||||
if len(udp) < 8:
|
||||
return "malformed", "UDP header truncated"
|
||||
sport, dport, ulen, _ = struct.unpack("!HHHH", udp[:8])
|
||||
if dport != 67:
|
||||
return "other", None
|
||||
bootp = udp[8:]
|
||||
if len(bootp) < BOOTP_MIN + 4:
|
||||
return "malformed", f"BOOTP too short: {len(bootp)} bytes"
|
||||
if bootp[BOOTP_MIN:BOOTP_MIN + 4] != MAGIC:
|
||||
return "malformed", ("no DHCP magic cookie -- got "
|
||||
f"{binascii.hexlify(bootp[BOOTP_MIN:BOOTP_MIN+4]).decode()}")
|
||||
op, _, hlen, _ = bootp[0], bootp[1], bootp[2], bootp[3]
|
||||
xid = bootp[4:8]
|
||||
flags = struct.unpack("!H", bootp[10:12])[0]
|
||||
chaddr = bootp[28:28 + max(hlen, 6)][:6]
|
||||
opts, i, msg_type = {}, BOOTP_MIN + 4, None
|
||||
while i < len(bootp):
|
||||
code = bootp[i]
|
||||
if code == 255:
|
||||
break
|
||||
if code == 0:
|
||||
i += 1
|
||||
continue
|
||||
if i + 2 > len(bootp):
|
||||
return "malformed", "option list truncated"
|
||||
ln = bootp[i + 1]
|
||||
opts[code] = bootp[i + 2:i + 2 + ln]
|
||||
i += 2 + ln
|
||||
if 53 in opts and len(opts[53]) == 1:
|
||||
msg_type = opts[53][0]
|
||||
return "dhcp", dict(op=op, xid=xid, flags=flags, chaddr=chaddr,
|
||||
msg_type=msg_type, opts=opts, src=src)
|
||||
|
||||
|
||||
def build_bootp(msg_type, xid, flags, chaddr, offer_ip, server_ip, mask,
|
||||
router, dns, lease):
|
||||
pkt = struct.pack("!BBBB4sHH", 2, 1, 6, 0, xid, 0, flags)
|
||||
pkt += socket.inet_aton("0.0.0.0") # ciaddr
|
||||
pkt += socket.inet_aton(offer_ip) # yiaddr
|
||||
pkt += socket.inet_aton(server_ip) # siaddr
|
||||
pkt += socket.inet_aton("0.0.0.0") # giaddr
|
||||
pkt += chaddr + b"\0" * (16 - len(chaddr)) # chaddr
|
||||
pkt += b"\0" * 64 + b"\0" * 128 # sname + file
|
||||
pkt += MAGIC
|
||||
def opt(c, v):
|
||||
return bytes([c, len(v)]) + v
|
||||
pkt += opt(53, bytes([msg_type]))
|
||||
pkt += opt(54, socket.inet_aton(server_ip))
|
||||
pkt += opt(51, struct.pack("!I", lease))
|
||||
pkt += opt(1, socket.inet_aton(mask))
|
||||
if router:
|
||||
pkt += opt(3, socket.inet_aton(router))
|
||||
if dns:
|
||||
pkt += opt(6, socket.inet_aton(dns))
|
||||
pkt += b"\xff"
|
||||
if len(pkt) < 300: # pad to the BOOTP minimum
|
||||
pkt += b"\0" * (300 - len(pkt))
|
||||
return pkt
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("iface")
|
||||
ap.add_argument("--server-ip", default="10.0.0.1")
|
||||
ap.add_argument("--offer-ip", default="10.0.0.64")
|
||||
ap.add_argument("--mask", default="255.255.255.0")
|
||||
ap.add_argument("--router", default=None, help="default: the server IP")
|
||||
ap.add_argument("--dns", default=None, help="default: the server IP")
|
||||
ap.add_argument("--lease", type=int, default=3600)
|
||||
ap.add_argument("--mac", default="00:09:bf:00:00:01",
|
||||
help="only react to this client; 'any' for all")
|
||||
ap.add_argument("--hex", action="store_true",
|
||||
help="hexdump every frame from the client")
|
||||
a = ap.parse_args()
|
||||
router = a.router or a.server_ip
|
||||
dns = a.dns or a.server_ip
|
||||
want = None if a.mac == "any" else bytes.fromhex(a.mac.replace(":", ""))
|
||||
|
||||
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL))
|
||||
s.bind((a.iface, 0))
|
||||
our_mac = s.getsockname()[4][:6]
|
||||
|
||||
print(f"mini-dhcpd on {a.iface} ({mac_str(our_mac)})")
|
||||
print(f" server {a.server_ip} offering {a.offer_ip}/{a.mask}"
|
||||
f" router {router} dns {dns} lease {a.lease}s")
|
||||
print(f" client filter: {a.mac}")
|
||||
print(f" NOTE: give the interface an address in that subnet first, e.g.")
|
||||
print(f" sudo ip addr add {a.server_ip}/24 dev {a.iface}")
|
||||
print(" waiting for DISCOVER (Ctrl-C to stop)\n")
|
||||
|
||||
seen_any = False
|
||||
while True:
|
||||
frame = s.recv(65535)
|
||||
kind, info = parse_dhcp(frame, want)
|
||||
if kind == "other":
|
||||
continue
|
||||
seen_any = True
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
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")
|
||||
print(" " + binascii.hexlify(frame[:64]).decode())
|
||||
continue
|
||||
name = MSG_NAMES.get(info["msg_type"], str(info["msg_type"]))
|
||||
print(f"[{ts}] {name} from {mac_str(info['chaddr'])} "
|
||||
f"xid={binascii.hexlify(info['xid']).decode()} "
|
||||
f"flags={info['flags']:#06x} len={len(frame)}")
|
||||
if a.hex:
|
||||
print(" " + binascii.hexlify(frame[:64]).decode())
|
||||
if info["msg_type"] == DHCP_DISCOVER:
|
||||
reply_type = DHCP_OFFER
|
||||
elif info["msg_type"] == DHCP_REQUEST:
|
||||
reply_type = DHCP_ACK
|
||||
else:
|
||||
print(" (no reply needed)")
|
||||
continue
|
||||
bootp = build_bootp(reply_type, info["xid"], info["flags"],
|
||||
info["chaddr"], a.offer_ip, a.server_ip, a.mask,
|
||||
router, dns, a.lease)
|
||||
# Unicast to the client's MAC. The gateware filters RX to own-MAC +
|
||||
# broadcast, so this also proves the filter accepts unicast.
|
||||
out = build_reply(our_mac, info["chaddr"], a.server_ip,
|
||||
a.offer_ip, bootp)
|
||||
s.send(out)
|
||||
print(f" -> {MSG_NAMES[reply_type]} {a.offer_ip} "
|
||||
f"unicast to {mac_str(info['chaddr'])} ({len(out)} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except PermissionError:
|
||||
sys.exit("needs root for AF_PACKET: run with sudo")
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped")
|
||||
Reference in New Issue
Block a user