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
314 lines
13 KiB
Python
Executable File
314 lines
13 KiB
Python
Executable File
#!/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()
|