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

167 lines
5.6 KiB
Python

"""
frames.py - synthesize the Ethernet frames pxe-nic-check looks for.
Shared by the unit tests (fed straight to the parsers) and the namespace
lab (sent on a veth by lab_peer.py). Plain struct packing, no scapy.
"""
import os
import struct
import socket
import importlib.machinery
import importlib.util
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "..", "..", "playbook", "pxe-server-helpers",
"pxe-nic-check")
def load_script():
"""Import the extensionless script as a module (no __pycache__ next
to it: that directory is copied to the server)."""
import sys
sys.dont_write_bytecode = True
loader = importlib.machinery.SourceFileLoader("pxe_nic_check", SCRIPT)
spec = importlib.util.spec_from_loader("pxe_nic_check", loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
return mod
def mac(s):
return bytes.fromhex(s.replace(":", ""))
def eth(dst, src, etype, payload):
return mac(dst) + mac(src) + struct.pack("!H", etype) + payload
def _tlv(t, value):
return struct.pack("!H", (t << 9) | len(value)) + value
def lldp(src, system="WJ-IDF2", port="Gi1/0/14", vlan=None,
chassis_mac="00:11:22:33:44:55"):
body = _tlv(1, b"\x04" + mac(chassis_mac)) # chassis: MAC
body += _tlv(2, b"\x05" + port.encode()) # port: ifName
body += _tlv(3, struct.pack("!H", 120)) # TTL
if system is not None:
body += _tlv(5, system.encode())
if vlan is not None:
body += _tlv(127, b"\x00\x80\xc2\x01" + struct.pack("!H", vlan))
body += _tlv(0, b"")
return eth("01:80:c2:00:00:0e", src, 0x88CC, body)
def cdp(src, device="WJ-CORE1.wjs.geaerospace.net", port="GigabitEthernet1/0/3",
vlan=None):
def t(code, val):
return struct.pack("!HH", code, 4 + len(val)) + val
tlvs = t(1, device.encode()) + t(3, port.encode())
if vlan is not None:
tlvs += t(0x0A, struct.pack("!H", vlan))
body = bytes([2, 180]) + b"\x00\x00" + tlvs
llc = b"\xaa\xaa\x03" + b"\x00\x00\x0c" + b"\x20\x00" + body
return mac("01:00:0c:cc:cc:cc") + mac(src) + struct.pack("!H", len(llc)) \
+ llc
def eapol(src):
# EAPOL v2, type 0 (EAP packet), EAP Request/Identity
eap = bytes([1, 1]) + struct.pack("!H", 5) + bytes([1])
body = bytes([2, 0]) + struct.pack("!H", len(eap)) + eap
return eth("01:80:c2:00:00:03", src, 0x888E, body)
def stp(src):
bpdu = b"\x00\x00\x00\x00\x00" + b"\x80\x00" + mac(src) + b"\x00" * 23
llc = b"\x42\x42\x03" + bpdu
return mac("01:80:c2:00:00:00") + mac(src) + \
struct.pack("!H", len(llc)) + llc
def arp(src, sender_ip="172.16.9.50", target_ip="172.16.9.1"):
body = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 1) + mac(src) + \
socket.inet_aton(sender_ip) + b"\x00" * 6 + socket.inet_aton(target_ip)
return eth("ff:ff:ff:ff:ff:ff", src, 0x0806, body)
def _dns_encode(names):
out = b""
for n in names:
for label in n.split("."):
out += bytes([len(label)]) + label.encode()
out += b"\x00"
return out
def _ip_checksum(hdr):
s = 0
for i in range(0, len(hdr), 2):
s += (hdr[i] << 8) + hdr[i + 1]
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return (~s) & 0xFFFF
def dhcp_reply(src, server_ip, xid, chaddr, yiaddr="10.99.0.50",
domain=None, search=None, msgtype=2, server_id=None,
search_raw=None):
"""DHCPOFFER (msgtype 2) or ACK (5), broadcast."""
sid = server_ip if server_id is None else server_id
bootp = struct.pack("!BBBBIHH", 2, 1, 6, 0, xid, 0, 0x8000)
bootp += b"\x00" * 4 + socket.inet_aton(yiaddr) + \
socket.inet_aton(server_ip) + b"\x00" * 4
bootp += mac(chaddr) + b"\x00" * 10 + b"\x00" * 64 + b"\x00" * 128
opts = b"\x63\x82\x53\x63" + bytes([53, 1, msgtype])
if sid:
opts += bytes([54, 4]) + socket.inet_aton(sid)
opts += bytes([1, 4]) + socket.inet_aton("255.255.255.0")
opts += bytes([51, 4]) + struct.pack("!I", 3600)
if domain:
opts += bytes([15, len(domain)]) + domain.encode()
if search or search_raw:
raw = search_raw if search_raw is not None else _dns_encode(search)
opts += bytes([119, len(raw)]) + raw
opts += b"\xff"
bootp += opts
if len(bootp) < 300:
bootp += b"\x00" * (300 - len(bootp))
udp = struct.pack("!HHHH", 67, 68, 8 + len(bootp), 0) + bootp
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(udp), 1, 0, 64, 17,
0, socket.inet_aton(server_ip),
socket.inet_aton("255.255.255.255"))
ip = ip[:10] + struct.pack("!H", _ip_checksum(ip)) + ip[12:]
return eth("ff:ff:ff:ff:ff:ff", src, 0x0800, ip + udp)
def parse_discover(frame):
"""(xid, chaddr) if frame is a DHCPDISCOVER, else None."""
if len(frame) < 14 + 20 + 8 + 240 or frame[12:14] != b"\x08\x00":
return None
ip = frame[14:]
ihl = (ip[0] & 0x0F) * 4
if ip[9] != 17:
return None
sport, dport = struct.unpack("!HH", ip[ihl:ihl + 4])
if (sport, dport) != (68, 67):
return None
b = ip[ihl + 8:]
if b[0] != 1 or b[236:240] != b"\x63\x82\x53\x63":
return None
opts = b[240:]
i = 0
mtype = None
while i < len(opts) and opts[i] != 255:
if opts[i] == 0:
i += 1
continue
if opts[i] == 53:
mtype = opts[i + 2]
i += 2 + opts[i + 1]
if mtype != 1:
return None
xid = struct.unpack("!I", b[4:8])[0]
chaddr = ":".join("%02x" % x for x in b[28:34])
return xid, chaddr