Files
pxe-server/tests/pxe-nic-check/README.md
cproudlock bc422649e9 NIC guard: dnsmasq never serves DHCP/PXE on the GE network
pxe-nic-check classifies each NIC from what it hears: LLDP/CDP/802.1X
from managed switches, foreign DHCP offers and their domain (one
DHCPDISCOVER on the imaging NIC; the GE NIC's networkd lease is read
instead), STP and ARP hosts for information. A kernel BPF filter keeps
bulk SMB traffic out of the capture loop; bridge ports are listened on
too, since LLDP and EAPOL stop at the port.

- dnsmasq drop-in runs --guard as ExecStartPre: exit 1 on a ge or
  foreign-dhcp imaging verdict, fail open (loudly) on bugs or no link
- pxe-nic-check.timer runs --watch every 5 min: stops dnsmasq on a bad
  imaging verdict, never starts it
- /run/pxe-nic-status.json (0644, atomic) for the webapp
- playbook installs it all and writes /etc/pxe-server/nic.conf from
  pxe_imaging_mac, pxe_ge_mac, pxe_corp_domain_suffixes,
  pxe_nic_allowed_switches
- unit tests plus a no-root network-namespace lab (veth pairs, a raw
  socket DHCP/LLDP peer and real dnsmasq); docs/pxe-nic-guard.md has the
  operator notes and the two floor tests
2026-10-14 17:18:53 -04:00

2.9 KiB

pxe-nic-check tests

Tests for playbook/pxe-server-helpers/pxe-nic-check. Neither needs root or touches a real network.

Unit tests

python3 -m unittest discover -s tests/pxe-nic-check -v

Synthesized LLDP, CDP, EAPOL, STP and ARP frames and DHCPOFFER/ACK packets (frames.py) fed to the parsers; the verdict table; own-server and cloned-PXE-server handling; networkd lease parsing; config fallbacks; the dnsmasq_bound logic; the status JSON shape and atomic 0644 write.

Namespace lab

python3 tests/pxe-nic-check/lab.py              # all scenarios, ~5 min
python3 tests/pxe-nic-check/lab.py rogue br-lldp   # some

Re-runs itself under unshare --user --map-root-user --net --mount: a private network namespace with veth pairs, so raw sockets work without sudo and every frame stays inside it. Each scenario runs the real script in check (--json --probe), --guard and --watch modes (watch with --stop-cmd "touch <marker>" instead of systemctl stop dnsmasq) and asserts the imaging verdict, the guard exit code, whether watch "stopped dnsmasq", the evidence text and the status JSON shape.

Peers: lab_peer.py (a raw-socket DHCP responder and LLDP/EAPOL sender; refuses to run unless PXE_NIC_LAB=1, which only the lab sets) and real dnsmasq if installed (started with --user= --group=, since a user namespace cannot drop privileges; scenarios are skipped without it).

Scenario Peer Expect Guard
isolated none isolated 0
rogue DHCP, no domain foreign-dhcp 1
fake-ge DHCP domain test.geaerospace.net + LLDP ge 1
not-corp-domain DHCP domain notgeaerospace.net foreign-dhcp 1
lldp-only LLDP ge 1
eapol-only 802.1X EAPOL ge 1
own-server DHCP with server id 172.16.9.1 from a local MAC isolated 0
cloned-pxe DHCP with server id 172.16.9.1, foreign MAC foreign-dhcp 1
no-link peer end down no-link 0
dnsmasq-corp real dnsmasq, domain lab.geaerospace.net ge 1
dnsmasq-rogue real dnsmasq, no domain foreign-dhcp 1
own-dnsmasq real dnsmasq serving the imaging NIC itself isolated, dnsmasq_bound true 0
br-* same, imaging NIC is a bridge (like br-pxe), named in nic.conf by a port's MAC

The GE NIC (g0) is checked from a fake networkd lease with domain test.geaerospace.net and must read ge in every scenario.