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
This commit is contained in:
cproudlock
2026-10-14 17:18:53 -04:00
parent 045a1fb97f
commit bc422649e9
11 changed files with 2634 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
# 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.

View File

@@ -0,0 +1,166 @@
"""
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

313
tests/pxe-nic-check/lab.py Executable file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
lab.py - run the real pxe-nic-check against fake networks, no root needed.
Re-executes itself inside a private user + network + mount namespace
(unshare --user --map-root-user --net --mount), so every frame stays on
veth pairs that exist only in that namespace. Nothing touches a real NIC.
Topology inside the namespace:
v0 (172.16.9.1/24, "imaging NIC") <-veth-> v1 (lab_peer.py / dnsmasq)
g0 (10.200.0.7/24, "GE NIC") <-veth-> g1 (quiet), plus a fake
networkd lease for g0
br0 (172.16.9.1/24, bridge) port b0 <-veth-> b1 (lab_peer.py), the
br-pxe layout of the live box
Each scenario starts a peer on v1, runs the script in check (--json),
--guard and --watch modes, and asserts the verdict, the guard exit code,
whether --watch called its stop command, and the status JSON shape.
python3 tests/pxe-nic-check/lab.py # all scenarios
python3 tests/pxe-nic-check/lab.py rogue # one scenario
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.normpath(os.path.join(
HERE, "..", "..", "playbook", "pxe-server-helpers", "pxe-nic-check"))
PEER = os.path.join(HERE, "lab_peer.py")
ENTRY_KEYS = ["iface", "mac", "role", "link", "speed_mbps", "addrs",
"verdict", "evidence", "dhcp_servers", "switch", "arp_hosts",
"dnsmasq_bound"]
# name, peer (None = no peer, "down" = cable pulled, "dnsmasq:<domain>" =
# real dnsmasq on v1 as a foreign server, "own-dnsmasq" = real dnsmasq on
# v0 as our own server, else lab_peer.py args), expected imaging verdict,
# expected guard rc, evidence substring, expected dnsmasq_bound on v0
SCENARIOS = [
("isolated", None, "isolated", 0, "no DHCP server, no switch", False),
("rogue", ["--dhcp", "10.99.0.1"], "foreign-dhcp", 1,
"DHCP from 10.99.0.1, no domain"),
("fake-ge", ["--dhcp", "10.134.48.1", "--domain", "test.geaerospace.net",
"--lldp", "WJ-IDF2"], "ge", 1,
"DHCP from 10.134.48.1, domain test.geaerospace.net"),
("not-corp-domain", ["--dhcp", "10.99.0.1", "--domain",
"notgeaerospace.net"], "foreign-dhcp", 1,
"domain notgeaerospace.net"),
("lldp-only", ["--lldp", "WJ-IDF2"], "ge", 1,
"LLDP from WJ-IDF2 port Gi1/0/14"),
("eapol-only", ["--eapol"], "ge", 1, "EAPOL"),
("own-server", ["--dhcp", "172.16.9.1"], "isolated", 0,
"own DHCP server 172.16.9.1 answered (ignored)"),
("cloned-pxe", ["--dhcp", "172.16.9.1", "--mac", "02:cc:00:00:00:01"],
"foreign-dhcp", 1, "CLAIMS OUR ADDRESS"),
("no-link", "down", "no-link", 0, "no carrier"),
("dnsmasq-corp", "dnsmasq:lab.geaerospace.net", "ge", 1,
"domain lab.geaerospace.net"),
("dnsmasq-rogue", "dnsmasq:", "foreign-dhcp", 1, "no domain"),
# our own dnsmasq serving v0 never hears our own probe and must not
# trip the guard; dnsmasq_bound must say it is serving v0
("own-dnsmasq", "own-dnsmasq", "isolated", 0, "no DHCP server", True),
]
# Same idea with the imaging NIC as a bridge (br0, port b0 <-veth-> b1),
# like br-pxe on the live server, and nic.conf naming it by a PORT's MAC.
# LLDP/EAPOL stop at the bridge port and never reach br0, so these prove
# the script also listens on the bridge's ports.
BRIDGE_SCENARIOS = [
("br-isolated", None, "isolated", 0, "no DHCP server, no switch"),
("br-lldp", ["--lldp", "WJ-IDF2"], "ge", 1, "LLDP from WJ-IDF2"),
("br-eapol", ["--eapol"], "ge", 1, "EAPOL"),
("br-rogue", ["--dhcp", "10.99.0.1"], "foreign-dhcp", 1,
"DHCP from 10.99.0.1, no domain"),
("br-fake-ge", ["--dhcp", "10.134.48.1", "--domain",
"test.geaerospace.net"], "ge", 1,
"domain test.geaerospace.net"),
]
def sh(*cmd):
subprocess.run(cmd, check=True)
def setup(tmp):
sh("mount", "-t", "sysfs", "sysfs", "/sys")
sh("ip", "link", "set", "lo", "up")
for a, b in (("v0", "v1"), ("g0", "g1")):
sh("ip", "link", "add", a, "type", "veth", "peer", "name", b)
sh("ip", "addr", "add", "172.16.9.1/24", "dev", "v0")
sh("ip", "addr", "add", "10.200.0.7/24", "dev", "g0")
sh("ip", "link", "add", "b0", "type", "veth", "peer", "name", "b1")
sh("ip", "link", "add", "br0", "type", "bridge", "stp_state", "0")
sh("ip", "link", "set", "b0", "master", "br0")
sh("ip", "addr", "add", "172.16.9.1/24", "dev", "br0")
for i in ("v0", "v1", "g0", "g1", "b0", "b1", "br0"):
sh("ip", "link", "set", i, "up")
leases = os.path.join(tmp, "leases")
os.mkdir(leases)
with open("/sys/class/net/g0/ifindex") as f:
idx = f.read().strip()
with open(os.path.join(leases, idx), "w") as f:
f.write("ADDRESS=10.200.0.7\nSERVER_ADDRESS=10.200.0.1\n"
"DOMAINNAME=test.geaerospace.net\n")
conf = os.path.join(tmp, "nic.conf")
with open(conf, "w") as f:
f.write("[nic]\nimaging_iface = v0\nge_iface = g0\n")
with open("/sys/class/net/b0/address") as f:
b0_mac = f.read().strip()
conf_br = os.path.join(tmp, "nic-bridge.conf")
with open(conf_br, "w") as f:
f.write("# lab\nimaging_mac = %s\nge_mac = %s\n" % (
b0_mac.upper(), open("/sys/class/net/g0/address").read().strip()))
time.sleep(1.5) # let carrier settle
return conf, conf_br, leases
def start_peer(spec, tmp, peer_if="v1"):
env = dict(os.environ, PXE_NIC_LAB="1")
if spec is None:
return None
if spec == "down":
sh("ip", "link", "set", peer_if, "down")
time.sleep(0.5)
return None
if isinstance(spec, str) and (spec == "own-dnsmasq"
or spec.startswith("dnsmasq:")):
if not shutil.which("dnsmasq"):
raise Skip("dnsmasq not installed")
if spec == "own-dnsmasq":
domain, dev, rng = "", "v0", "172.16.9.10,172.16.9.100,1h"
else:
domain, dev, rng = spec.split(":", 1)[1], "v1", \
"10.99.0.10,10.99.0.20,1h"
sh("ip", "addr", "replace", "10.99.0.1/24", "dev", "v1")
# --user= --group= : skip the privilege drop, which a user
# namespace with setgroups denied cannot do
cmd = ["dnsmasq", "-k", "-C", "/dev/null", "--port=0",
"--user=", "--group=", "--interface=" + dev,
"--bind-interfaces", "--dhcp-range=" + rng,
"--dhcp-leasefile=" + os.path.join(tmp, "dm.leases"),
"--pid-file=" + os.path.join(tmp, "dm.pid"),
"--log-facility=-", "--log-dhcp"]
if domain:
cmd.append("--dhcp-option=15," + domain)
log = open(os.path.join(tmp, "dnsmasq.log"), "w")
p = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
time.sleep(1.0)
if p.poll() is not None:
stop_peer(None, spec)
with open(os.path.join(tmp, "dnsmasq.log")) as f:
raise Skip("dnsmasq would not start in the userns: "
+ f.read().strip()[-300:])
return p
p = subprocess.Popen([sys.executable, PEER, "--iface", peer_if] + spec,
stdout=subprocess.PIPE, text=True, env=env)
line = p.stdout.readline()
if line.strip() != "ready":
raise RuntimeError("peer did not start: %r" % line)
return p
def stop_peer(p, spec):
if p is not None:
p.terminate()
try:
p.wait(5)
except subprocess.TimeoutExpired:
p.kill()
if spec == "down":
sh("ip", "link", "set", "v1", "up")
time.sleep(1.0)
if isinstance(spec, str) and spec.startswith("dnsmasq:"):
subprocess.run(["ip", "addr", "del", "10.99.0.1/24", "dev", "v1"],
stderr=subprocess.DEVNULL)
class Skip(Exception):
pass
def run_script(args, conf, leases):
cmd = [sys.executable, SCRIPT, "--config", conf, "--dnsmasq-nic-conf",
"/nonexistent", "--lease-dir", leases] + args
return subprocess.run(cmd, capture_output=True, text=True, timeout=120)
def check_shape(st):
assert sorted(st) == ["checked_at", "dhcp_guard", "duration_s",
"interfaces"], sorted(st)
assert isinstance(st["duration_s"], int)
assert st["dhcp_guard"]["state"] in ("allowed", "blocked", "unknown")
assert sorted(st["dhcp_guard"]) == ["reason", "state"]
for e in st["interfaces"]:
assert sorted(e) == sorted(ENTRY_KEYS), sorted(e)
assert sorted(e["switch"]) == ["cdp", "eapol", "lldp", "stp"]
for d in e["dhcp_servers"]:
assert sorted(d) == ["domain", "own", "server"], d
for x in e["switch"]["lldp"]:
assert sorted(x) == ["port", "system", "vlan"], x
for x in e["switch"]["cdp"]:
assert sorted(x) == ["device", "port"], x
def scenario(name, spec, want, want_rc, needle, bound, conf, leases, tmp,
im_if="v0", peer_if="v1"):
p = start_peer(spec, tmp, peer_if)
fails = []
try:
status = os.path.join(tmp, name + ".json")
# 1. check mode with probe, short window
r = run_script(["--json", "--probe", "--window", "3"], conf, leases)
st = json.loads(r.stdout)
check_shape(st)
by = {e["iface"]: e for e in st["interfaces"]}
im = by[im_if]
if im["role"] != "imaging" or im["verdict"] != want:
fails.append("check: imaging verdict %s (want %s) %s"
% (im["verdict"], want, im["evidence"]))
if not any(needle in ev for ev in im["evidence"]):
fails.append("check: evidence lacks %r: %s"
% (needle, im["evidence"]))
if bound is not None and im["dnsmasq_bound"] != bound:
fails.append("check: v0 dnsmasq_bound %s (want %s)"
% (im["dnsmasq_bound"], bound))
ge = by.get("g0")
if not ge or ge["role"] != "ge" or ge["verdict"] != "ge":
fails.append("check: g0 not ge: %s" % ge)
# 2. guard
r = run_script(["--guard", "--window", "2", "--write", status],
conf, leases)
if "no BPF filter" in r.stderr:
fails.append("BPF filter did not attach: %s" % r.stderr.strip())
if r.returncode != want_rc:
fails.append("guard rc %d (want %d): %s"
% (r.returncode, want_rc, r.stderr.strip()))
with open(status) as f:
gst = json.load(f)
check_shape(gst)
if os.stat(status).st_mode & 0o777 != 0o644:
fails.append("status file not 0644")
want_state = "blocked" if want_rc else "allowed"
if gst["dhcp_guard"]["state"] != want_state:
fails.append("guard state %s" % gst["dhcp_guard"])
# 3. watch, with a marker file standing in for systemctl stop
marker = os.path.join(tmp, name + ".stopped")
r = run_script(["--watch", "--window", "3", "--write", status,
"--stop-cmd", "touch " + marker], conf, leases)
stopped = os.path.exists(marker)
if stopped != bool(want_rc):
fails.append("watch stop called=%s (want %s): %s"
% (stopped, bool(want_rc), r.stderr.strip()))
if want_rc and "<2>" not in r.stderr and "CRIT" not in r.stderr:
fails.append("watch did not log CRIT")
finally:
stop_peer(p, spec)
return fails, im
def inside(selected):
tmp = tempfile.mkdtemp(prefix="pxe-nic-lab-")
conf, conf_br, leases = setup(tmp)
results = []
runs = [(sc, conf, "v0", "v1") for sc in SCENARIOS] + \
[(sc, conf_br, "br0", "b1") for sc in BRIDGE_SCENARIOS]
for sc, sconf, im_if, peer_if in runs:
name, spec, want, rc, needle = sc[:5]
bound = sc[5] if len(sc) > 5 else None
if selected and name not in selected:
continue
t0 = time.monotonic()
try:
fails, im = scenario(name, spec, want, rc, needle, bound, sconf,
leases, tmp, im_if, peer_if)
except Skip as e:
results.append((name, "SKIP", str(e)))
print("SKIP %-16s %s" % (name, e), flush=True)
continue
except Exception as e: # report and carry on
fails, im = ["exception: %r" % e], None
state = "FAIL" if fails else "PASS"
results.append((name, state, fails))
print("%s %-16s %-13s guard_rc=%d (%.0f s)" % (
state, name, want, rc, time.monotonic() - t0), flush=True)
for f in fails:
print(" " + f, flush=True)
if im and not fails:
print(" " + " | ".join(im["evidence"]), flush=True)
n_pass = sum(1 for r in results if r[1] == "PASS")
n_fail = sum(1 for r in results if r[1] == "FAIL")
n_skip = sum(1 for r in results if r[1] == "SKIP")
print("\nlab: %d passed, %d failed, %d skipped" % (n_pass, n_fail, n_skip))
return 1 if n_fail else 0
def main():
args = sys.argv[1:]
if args[:1] == ["--inside"]:
sys.exit(inside(args[1:]))
cmd = ["unshare", "--user", "--map-root-user", "--net", "--mount",
sys.executable, os.path.abspath(__file__), "--inside"] + args
sys.exit(subprocess.run(cmd).returncode)
if __name__ == "__main__":
main()

81
tests/pxe-nic-check/lab_peer.py Executable file
View File

@@ -0,0 +1,81 @@
#!/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()

View File

@@ -0,0 +1,499 @@
"""
Unit tests for playbook/pxe-server-helpers/pxe-nic-check. No root, no
network: frames are synthesized by frames.py and fed to the parsers.
python3 -m unittest discover -s tests/pxe-nic-check -v
"""
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import frames as F # noqa: E402
M = F.load_script()
SW = "00:11:22:33:44:55" # "switch" MAC
ME = "02:00:00:00:00:01" # this host
SUFFIXES = M.parse_suffixes(M.DEFAULT_SUFFIXES)
def fed(*frs, own=(ME,), xid=None):
o = M.Observations()
for fr in frs:
o.feed(fr, set(own), xid)
return o
class DomainMatch(unittest.TestCase):
def test_table(self):
cases = {
"wjs.geaerospace.net": True,
"xyz.geaerospace.net": True,
"geaerospace.net": True,
"GEAEROSPACE.NET.": True,
"notgeaerospace.net": False,
"geaerospace.net.evil.com": False,
"rd.ds.ge.com": True,
"ge.com": True,
"huge.com": False,
"": False,
None: False,
}
for dom, want in cases.items():
self.assertEqual(M.domain_matches(dom, SUFFIXES), want, dom)
def test_suffix_parsing(self):
self.assertEqual(M.parse_suffixes(" geaerospace.net, .ge.com ;x.org,"),
["geaerospace.net", "ge.com", "x.org"])
class Frames(unittest.TestCase):
def test_lldp(self):
o = fed(F.lldp(SW, system="WJ-IDF2", port="Gi1/0/14", vlan=309))
(info,) = o.lldp.values()
self.assertEqual(info["system"], "WJ-IDF2")
self.assertEqual(info["port"], "Gi1/0/14")
self.assertEqual(info["chassis"], SW)
self.assertEqual(info["vlan"], 309)
def test_lldp_no_sysname_no_vlan(self):
o = fed(F.lldp(SW, system=None, port="7"))
(info,) = o.lldp.values()
self.assertIsNone(info["system"])
self.assertIsNone(info["vlan"])
def test_lldp_vlan_tagged_frame(self):
fr = F.lldp(SW)
tagged = fr[:12] + b"\x81\x00\x01\x35" + fr[12:]
self.assertEqual(len(fed(tagged).lldp), 1)
def test_cdp(self):
o = fed(F.cdp(SW, device="WJ-CORE1", port="Gi1/0/3", vlan=12))
(info,) = o.cdp.values()
self.assertEqual((info["device"], info["port"], info["vlan"]),
("WJ-CORE1", "Gi1/0/3", 12))
self.assertFalse(o.stp)
def test_eapol(self):
o = fed(F.eapol(SW))
self.assertTrue(o.eapol)
def test_stp(self):
o = fed(F.stp(SW))
self.assertTrue(o.stp)
self.assertFalse(o.lldp or o.cdp or o.eapol)
def test_arp_counts_distinct_and_skips_own(self):
o = fed(F.arp("02:aa:00:00:00:01"), F.arp("02:aa:00:00:00:01"),
F.arp("02:aa:00:00:00:02"), F.arp(ME))
self.assertEqual(len(o.arp_macs), 2)
def test_truncated_garbage_does_not_raise(self):
for junk in (b"", b"\x00" * 13, F.lldp(SW)[:20], F.cdp(SW)[:30],
F.dhcp_reply(SW, "10.1.1.1", 1, ME)[:100]):
fed(junk)
class Dhcp(unittest.TestCase):
def offer(self, **kw):
return F.dhcp_reply(SW, "10.134.48.1", 42, ME, **kw)
def test_offer_domains(self):
for dom in ("wjs.geaerospace.net", "xyz.geaerospace.net",
"notgeaerospace.net", "rd.ds.ge.com", None):
o = fed(self.offer(domain=dom), xid=42)
(d,) = o.dhcp.values()
self.assertEqual(d["domain"], dom)
self.assertEqual(d["server"], "10.134.48.1")
self.assertEqual(d["yiaddr"], "10.99.0.50")
self.assertEqual(d["how"], "probe")
def test_passive_ack_is_recorded(self):
o = fed(self.offer(msgtype=5), xid=7)
(d,) = o.dhcp.values()
self.assertEqual(d["how"], "passive")
def test_option_119_with_compression(self):
# "a.geaerospace.net" then "b" + pointer to "geaerospace.net" (off 2)
raw = F._dns_encode(["a.geaerospace.net"])
raw += b"\x01b\xc0\x02"
o = fed(self.offer(search_raw=raw))
(d,) = o.dhcp.values()
self.assertEqual(d["search"], ["a.geaerospace.net", "b.geaerospace.net"])
def test_option_119_plain(self):
o = fed(self.offer(search=["rd.ds.ge.com", "x.example"]))
(d,) = o.dhcp.values()
self.assertEqual(d["search"], ["rd.ds.ge.com", "x.example"])
def test_missing_server_id_uses_source_ip(self):
o = fed(self.offer(server_id=""))
(d,) = o.dhcp.values()
self.assertEqual(d["server"], "10.134.48.1")
def test_discover_roundtrip(self):
fr = M.build_discover(F.mac(ME), 0xDEADBEEF)
self.assertEqual(F.parse_discover(fr), (0xDEADBEEF, ME))
# IPv4 header checksum verifies to zero
self.assertEqual(M._ip_checksum(fr[14:34]), 0)
# our own discover is not mistaken for a server
self.assertEqual(fed(fr).dhcp, {})
def run_bpf(insns, pkt):
"""Minimal classic-BPF interpreter for the opcodes pxe-nic-check uses.
Out-of-bounds loads reject, as in the kernel."""
import struct
A = X = 0
pc = 0
while True:
code, jt, jf, k = insns[pc]
pc += 1
if code == 0x28: # ldh [k]
if k + 2 > len(pkt):
return 0
A = struct.unpack("!H", pkt[k:k + 2])[0]
elif code == 0x30: # ldb [k]
if k + 1 > len(pkt):
return 0
A = pkt[k]
elif code == 0x48: # ldh [x + k]
if X + k + 2 > len(pkt):
return 0
A = struct.unpack("!H", pkt[X + k:X + k + 2])[0]
elif code == 0xB1: # ldxb 4*([k]&0xf)
if k + 1 > len(pkt):
return 0
X = 4 * (pkt[k] & 0x0F)
elif code == 0x15: # jeq #k
pc += jt if A == k else jf
elif code == 0x45: # jset #k
pc += jt if A & k else jf
elif code == 0x06: # ret #k
return k
else:
raise AssertionError("opcode %#x" % code)
class Bpf(unittest.TestCase):
def test_filter_passes_what_we_parse_and_drops_bulk(self):
insns = M.assemble_bpf()
keep = {
"lldp": F.lldp(SW), "cdp": F.cdp(SW), "eapol": F.eapol(SW),
"stp": F.stp(SW), "arp": F.arp(SW),
"offer": F.dhcp_reply(SW, "10.1.1.1", 1, ME),
"tagged lldp": F.lldp(SW)[:12] + b"\x81\x00\x00\x05"
+ F.lldp(SW)[12:],
}
for name, fr in keep.items():
self.assertTrue(run_bpf(insns, fr), name)
# TCP/445 SMB, our own DISCOVER (sport 68), IPv6, short junk
import struct
ip = bytearray(F.dhcp_reply(SW, "10.1.1.1", 1, ME))
smb = bytes(ip[:23]) + b"\x06" + bytes(ip[24:34]) + \
struct.pack("!HH", 445, 50000) + bytes(ip[38:])
drop = {
"smb": smb,
"discover": M.build_discover(F.mac(ME), 5),
"ipv6": F.eth("33:33:00:00:00:01", SW, 0x86DD, b"\x60"
+ b"\x00" * 60),
"junk": b"\x00" * 10,
}
for name, fr in drop.items():
self.assertFalse(run_bpf(insns, fr), name)
class OwnServer(unittest.TestCase):
def test_own_needs_own_ip_and_local_mac(self):
o = M.Observations()
o.feed(F.dhcp_reply(ME, "172.16.9.1", 1, "02:bb:00:00:00:09",
yiaddr="172.16.9.20"), set(), 1)
lst = M.dhcp_list(o, {"172.16.9.1"}, {ME})
self.assertTrue(lst[0]["own"])
v, ev = M.classify(True, None, o, lst, SUFFIXES, 60, True, False)
self.assertEqual(v, "isolated")
self.assertTrue(any("own DHCP server 172.16.9.1" in e for e in ev))
def test_cloned_pxe_server_is_foreign(self):
o = M.Observations()
o.feed(F.dhcp_reply("02:cc:00:00:00:01", "172.16.9.1", 1, ME,
yiaddr="172.16.9.20"), set(), 1)
lst = M.dhcp_list(o, {"172.16.9.1"}, {ME})
self.assertFalse(lst[0]["own"])
v, ev = M.classify(True, None, o, lst, SUFFIXES, 60, True, False)
self.assertEqual(v, "foreign-dhcp")
self.assertTrue(any("CLAIMS OUR ADDRESS" in e for e in ev))
class Verdicts(unittest.TestCase):
def run_case(self, frs, link=True, error=None, own_ips=()):
o = fed(*frs) if frs is not None else None
lst = M.dhcp_list(o, set(own_ips), {ME})
return M.classify(link, error, o, lst, SUFFIXES, 60, True, False)
def test_table(self):
def offer(dom):
return F.dhcp_reply(SW, "10.134.48.1", 1, ME, domain=dom)
table = [
("quiet", [], "isolated"),
("arp only", [F.arp("02:aa:00:00:00:05")], "isolated"),
("stp only", [F.stp(SW)], "isolated"),
("lldp", [F.lldp(SW)], "ge"),
("cdp", [F.cdp(SW)], "ge"),
("eapol", [F.eapol(SW)], "ge"),
("dhcp wjs", [offer("wjs.geaerospace.net")], "ge"),
("dhcp xyz", [offer("xyz.geaerospace.net")], "ge"),
("dhcp rd.ds.ge.com", [offer("rd.ds.ge.com")], "ge"),
("dhcp notgeaerospace", [offer("notgeaerospace.net")],
"foreign-dhcp"),
("dhcp no domain", [offer(None)], "foreign-dhcp"),
("rogue + lldp", [offer(None), F.lldp(SW)], "ge"),
("search list only",
[F.dhcp_reply(SW, "10.1.1.1", 1, ME,
search=["wjs.geaerospace.net"])], "ge"),
]
for name, frs, want in table:
v, ev = self.run_case(frs)
self.assertEqual(v, want, "%s: %s" % (name, ev))
def test_allowed_imaging_switch(self):
o = fed(F.lldp(SW, system="IMG-SW1"), F.cdp(SW, device="IMG-SW1"))
v, ev = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
M.allowed_list("IMG-SW1"))
self.assertEqual(v, "isolated", ev)
self.assertTrue(any("allowed imaging switch" in e for e in ev))
# by chassis MAC, any case/separator
o = fed(F.lldp(SW, system=None))
cfg_allowed = M.allowed_list("00-11-22-33-44-55")
v, _ = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
cfg_allowed)
self.assertEqual(v, "isolated")
# another switch, or EAPOL, still counts
o = fed(F.lldp(SW, system="IMG-SW1"),
F.lldp("02:00:00:00:00:99", system="WJ-IDF2",
chassis_mac="02:00:00:00:00:99"))
v, _ = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
M.allowed_list("IMG-SW1"))
self.assertEqual(v, "ge")
o = fed(F.lldp(SW, system="IMG-SW1"), F.eapol(SW))
v, _ = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
M.allowed_list("IMG-SW1"))
self.assertEqual(v, "ge")
def test_no_link_and_error(self):
self.assertEqual(self.run_case([], link=False)[0], "no-link")
self.assertEqual(self.run_case([], error="boom")[0], "error")
def test_evidence_strings(self):
_, ev = self.run_case([F.dhcp_reply(SW, "10.134.48.1", 1, ME,
domain="wjs.geaerospace.net")])
self.assertTrue(any(e.startswith(
"DHCP from 10.134.48.1, domain wjs.geaerospace.net") for e in ev))
_, ev = self.run_case([F.lldp(SW, system="WJ-IDF2", port="Gi1/0/14")])
self.assertIn("LLDP from WJ-IDF2 port Gi1/0/14", ev)
_, ev = self.run_case([])
self.assertIn("no DHCP server, no switch announcements in 60 s", ev)
def test_guard_state(self):
e = {"iface": "br-pxe", "verdict": "foreign-dhcp",
"evidence": ["DHCP from 10.0.0.1, no domain", "3 ARP hosts seen"]}
st, why = M.guard_state(e)
self.assertEqual(st, "blocked")
self.assertNotIn("ARP", why)
for v, want in (("ge", "blocked"), ("isolated", "allowed"),
("no-link", "allowed"), ("error", "unknown")):
e["verdict"] = v
self.assertEqual(M.guard_state(e)[0], want, v)
self.assertEqual(M.guard_state(None)[0], "unknown")
class Lease(unittest.TestCase):
SAMPLE = """# This is private data. Do not parse.
ADDRESS=10.134.49.77
NETMASK=255.255.252.0
ROUTER=10.134.48.1
SERVER_ADDRESS=10.134.48.10
NEXT_SERVER=0.0.0.0
DNS=10.134.48.11 10.134.48.12
DOMAINNAME=wjs.geaerospace.net
DOMAIN_SEARCH_LIST=wjs.geaerospace.net rd.ds.ge.com
LIFETIME=86400
"""
def test_parse(self):
d = M.parse_lease(self.SAMPLE)
self.assertEqual(d["SERVER_ADDRESS"], "10.134.48.10")
self.assertEqual(d["DOMAINNAME"], "wjs.geaerospace.net")
lst = M.lease_servers(d)
self.assertEqual(lst[0]["server"], "10.134.48.10")
self.assertEqual(lst[0]["search"],
["wjs.geaerospace.net", "rd.ds.ge.com"])
v, ev = M.classify(True, None, None, lst, SUFFIXES, 0, False, True)
self.assertEqual(v, "ge")
self.assertTrue(ev[0].startswith(
"DHCP from 10.134.48.10, domain wjs.geaerospace.net"))
def test_lease_without_domain(self):
lst = M.lease_servers(M.parse_lease("ADDRESS=192.168.1.5\n"
"SERVER_ADDRESS=192.168.1.1\n"))
v, _ = M.classify(True, None, None, lst, SUFFIXES, 0, False, True)
self.assertEqual(v, "foreign-dhcp")
def test_lease_falls_back_to_router(self):
lst = M.lease_servers({"ROUTER": "10.0.0.1"})
self.assertEqual(lst[0]["server"], "10.0.0.1")
def test_read_lease_missing(self):
self.assertIsNone(M.read_lease("no-such-iface-xyz", "/nonexistent"))
class Config(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def path(self, name, text):
p = os.path.join(self.tmp, name)
with open(p, "w") as f:
f.write(text)
return p
def test_defaults(self):
cfg = M.load_config(os.path.join(self.tmp, "missing"),
os.path.join(self.tmp, "missing2"))
self.assertEqual(cfg["own_ip"], "172.16.9.1")
self.assertEqual(cfg["suffixes"], ["geaerospace.net", "ge.com"])
self.assertEqual(cfg["dnsmasq_ifaces"], [])
def test_sectionless_ini_and_mac_normalising(self):
p = self.path("nic.conf", "imaging_mac = AA-BB-CC-00-11-22\n"
"ge_iface = enp1s0 # corp\n"
"corp_domain_suffixes = example.org\n")
cfg = M.load_config(p, "/nonexistent")
self.assertEqual(cfg["imaging_mac"], "aa:bb:cc:00:11:22")
self.assertEqual(cfg["ge_iface"], "enp1s0")
self.assertEqual(cfg["suffixes"], ["example.org"])
def test_allowed_switches(self):
p = self.path("nic.conf", "allowed_switches = IMG-SW1, 00-11-22-33-44-55\n")
cfg = M.load_config(p, "/nonexistent")
self.assertIn("img-sw1", cfg["allowed"])
self.assertIn("00:11:22:33:44:55", cfg["allowed"])
def test_section_ini(self):
p = self.path("nic.conf", "# managed by ansible\n[nic]\n"
"imaging_iface = br-pxe\n"
"own_ip = 172.16.9.2\n")
cfg = M.load_config(p, "/nonexistent")
self.assertEqual(cfg["imaging_iface"], "br-pxe")
self.assertEqual(cfg["own_ip"], "172.16.9.2")
def test_empty_values_keep_defaults(self):
p = self.path("nic.conf", "corp_domain_suffixes =\nimaging_mac =\n")
cfg = M.load_config(p, "/nonexistent")
self.assertEqual(cfg["suffixes"], ["geaerospace.net", "ge.com"])
def test_dnsmasq_interface_fallback(self):
d = self.path("pxe-nic-only.conf",
"# pin dnsmasq\ninterface=br-pxe\n"
"except-interface=enp1s0\nno-dhcp-interface=enp1s0\n")
cfg = M.load_config(os.path.join(self.tmp, "missing"), d)
self.assertEqual(cfg["dnsmasq_ifaces"], ["br-pxe"])
im, ge, notes = M.resolve_roles(cfg, ["lo", "br-pxe", "enp1s0"], {},
"/nonexistent")
self.assertEqual(im, "br-pxe")
def test_own_ip_fallback(self):
cfg = M.load_config("/nonexistent", "/nonexistent")
im, ge, _ = M.resolve_roles(
cfg, ["lo", "eno1", "enx1"],
{"eno1": ["10.134.49.7/22"], "enx1": ["172.16.9.1/24"]},
"/nonexistent")
self.assertEqual(im, "enx1")
self.assertIsNone(ge)
def test_configured_iface_wins(self):
cfg = M.load_config("/nonexistent", "/nonexistent")
cfg["imaging_iface"] = "eno1"
cfg["ge_iface"] = "enx1"
im, ge, _ = M.resolve_roles(
cfg, ["eno1", "enx1"], {"enx1": ["172.16.9.1/24"]}, "/nonexistent")
self.assertEqual((im, ge), ("eno1", "enx1"))
class DnsmasqBound(unittest.TestCase):
RULES = {"interface": {"br-pxe"}, "except": {"enp1s0"},
"no_dhcp": {"enp1s0"}, "listen": {"172.16.9.1"}}
def test_unknown_and_not_running(self):
self.assertIsNone(M.dnsmasq_bound("x", [], None, self.RULES))
self.assertFalse(M.dnsmasq_bound("x", [], [], self.RULES))
self.assertFalse(M.dnsmasq_bound(
"x", [], [("0.0.0.0", None, "dhclient")], self.RULES))
def test_device_bound(self):
socks = [("0.0.0.0", "br-pxe", "dnsmasq")]
self.assertTrue(M.dnsmasq_bound("br-pxe", [], socks, self.RULES))
self.assertFalse(M.dnsmasq_bound("enp1s0", [], socks, self.RULES))
def test_wildcard_uses_config(self):
socks = [("0.0.0.0", None, "dnsmasq")]
self.assertTrue(M.dnsmasq_bound("br-pxe", ["172.16.9.1/24"], socks,
self.RULES))
self.assertFalse(M.dnsmasq_bound("enp1s0", ["10.134.49.7/22"], socks,
self.RULES))
open_rules = {"interface": set(), "except": set(), "no_dhcp": set(),
"listen": set()}
self.assertTrue(M.dnsmasq_bound("enp1s0", ["10.134.49.7/22"], socks,
open_rules))
class StatusShape(unittest.TestCase):
KEYS = ["iface", "mac", "role", "link", "speed_mbps", "addrs", "verdict",
"evidence", "dhcp_servers", "switch", "arp_hosts",
"dnsmasq_bound"]
def test_shape_and_atomic_write(self):
entry = {k: None for k in self.KEYS}
st = M.build_status([entry], 5.4, ("allowed", "ok"))
self.assertEqual(sorted(st), ["checked_at", "dhcp_guard",
"duration_s", "interfaces"])
self.assertEqual(st["duration_s"], 5)
self.assertEqual(st["dhcp_guard"], {"state": "allowed",
"reason": "ok"})
# ISO 8601 with a UTC offset
self.assertRegex(st["checked_at"],
r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d$")
d = tempfile.mkdtemp()
p = os.path.join(d, "s.json")
M.write_status(p, st)
self.assertEqual(os.stat(p).st_mode & 0o777, 0o644)
with open(p) as f:
self.assertEqual(json.load(f), st)
self.assertEqual(os.listdir(d), ["s.json"])
class CommandLine(unittest.TestCase):
"""main() without root: capture fails, the guard must fail open."""
def test_guard_fails_open_when_nothing_resolves(self):
d = tempfile.mkdtemp()
out = os.path.join(d, "st.json")
rc = M.main(["--guard", "--config", "/nonexistent",
"--dnsmasq-nic-conf", "/nonexistent",
"--iface", "lo", "--window", "0.1", "--no-probe",
"--write", out])
self.assertEqual(rc, 0)
with open(out) as f:
st = json.load(f)
self.assertEqual(st["dhcp_guard"]["state"], "unknown")
(e,) = st["interfaces"]
self.assertEqual(sorted(e), sorted(StatusShape.KEYS))
self.assertEqual(sorted(e["switch"]), ["cdp", "eapol", "lldp", "stp"])
if __name__ == "__main__":
unittest.main()