Files
pxe-server/tests/pxe-nic-check/lab_peer.py
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

82 lines
2.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""
lab_peer.py - the "other end of the cable" for the namespace lab.
Runs on one end of a veth pair inside a private network namespace and
plays a switch and/or a DHCP server with raw AF_PACKET sockets:
--dhcp IP answer every DHCPDISCOVER with a DHCPOFFER from IP
--server-id IP option 54 to put in the offer (default = --dhcp IP)
--domain NAME option 15 in the offer
--lldp SYSNAME send an LLDP frame every second
--eapol send an EAPOL Request/Identity every second
--mac MAC source MAC for the frames (default: the iface's MAC)
Never run this outside the lab netns: it is a rogue DHCP server.
"""
import argparse
import os
import select
import socket
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import frames as F # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--iface", required=True)
ap.add_argument("--dhcp")
ap.add_argument("--server-id")
ap.add_argument("--domain")
ap.add_argument("--lldp")
ap.add_argument("--eapol", action="store_true")
ap.add_argument("--mac")
ap.add_argument("--duration", type=float, default=120)
args = ap.parse_args()
if os.environ.get("PXE_NIC_LAB") != "1":
sys.exit("lab_peer.py: refusing to run outside the lab (PXE_NIC_LAB)")
with open("/sys/class/net/%s/address" % args.iface) as f:
mac = args.mac or f.read().strip()
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(3))
s.bind((args.iface, 0))
end = time.monotonic() + args.duration
next_beacon = 0.0
answered = set()
sys.stdout.write("ready\n")
sys.stdout.flush()
while time.monotonic() < end:
now = time.monotonic()
if now >= next_beacon:
if args.lldp:
s.send(F.lldp(mac, system=args.lldp, port="Gi1/0/14",
vlan=309))
if args.eapol:
s.send(F.eapol(mac))
next_beacon = now + 1.0
r, _, _ = select.select([s], [], [], 0.2)
if not r:
continue
frame, addr = s.recvfrom(65535)
if addr[2] == 4 or not args.dhcp: # our own outgoing frames
continue
d = F.parse_discover(frame)
if not d or d[0] in answered:
continue
answered.add(d[0])
xid, chaddr = d
s.send(F.dhcp_reply(mac, args.dhcp, xid, chaddr,
yiaddr="10.99.0.50", domain=args.domain,
server_id=args.server_id))
sys.stdout.write("offered xid %08x to %s\n" % (xid, chaddr))
sys.stdout.flush()
if __name__ == "__main__":
main()