NIC guard: ignore client EAPOL, re-check on carrier change, SMB-only lease cleanup
- pxe-nic-check: count 802.1X only from an authenticator (EAPOL type 0 with EAP Request/Success/Failure). iPXE sends EAPOL-Start on every NIC open and Windows sends Start/Response, so a PXE-booting bay made the verdict 'ge' and the watch stopped dnsmasq for the floor. - pxe-nic-check: join the STP/PAE/LLDP/CDP/PVST+ multicast groups on every capture socket, so CDP-only ports are heard on a plain NIC. - pxe-nic-check --watch: if dnsmasq was (re)started inside the window, re-check (~6 s) before stopping it; skip when already stopped. New --state-cmd and --print-imaging. - pxe-nic-carrier (+ pxe-nic-carrier.service): networkd-dispatcher hook for the imaging NIC only. Carrier loss stops dnsmasq; carrier gain restarts it through its guard if it was running or the hook stopped it. - pxe-dhcp-hook.sh: clear only SMB (tcp 445/139) conntrack entries and sockets, not all traffic for the client, which raced iPXE's first HTTP fetch. - GetPxeScript.aspx: every boot line has a failure branch (iPXE 2.0 accepts an unsigned EFI kernel and fails at boot under Secure Boot, which aborted the script). - Tests: client EAPOL, packet_mreq packing, watch race, shell helpers on stub PATH, lab scenarios eapol-client, br-eapol-client, race, carrier.
This commit is contained in:
@@ -34,6 +34,8 @@ 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")
|
||||
CARRIER = os.path.normpath(os.path.join(
|
||||
HERE, "..", "..", "playbook", "pxe-server-helpers", "pxe-nic-carrier"))
|
||||
|
||||
ENTRY_KEYS = ["iface", "mac", "role", "link", "speed_mbps", "addrs",
|
||||
"verdict", "evidence", "dhcp_servers", "switch", "arp_hosts",
|
||||
@@ -56,6 +58,10 @@ SCENARIOS = [
|
||||
("lldp-only", ["--lldp", "WJ-IDF2"], "ge", 1,
|
||||
"LLDP from WJ-IDF2 port Gi1/0/14"),
|
||||
("eapol-only", ["--eapol"], "ge", 1, "EAPOL"),
|
||||
# a bay PXE-booting on the imaging switch: iPXE EAPOL-Start and a
|
||||
# Windows EAP-Response must not read as a GE switch port
|
||||
("eapol-client", ["--eapol-start"], "isolated", 0,
|
||||
"client EAPOL frames ignored"),
|
||||
("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"],
|
||||
@@ -77,6 +83,8 @@ 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-eapol-client", ["--eapol-start"], "isolated", 0,
|
||||
"client EAPOL frames ignored"),
|
||||
("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",
|
||||
@@ -85,6 +93,17 @@ BRIDGE_SCENARIOS = [
|
||||
]
|
||||
|
||||
|
||||
# Scenarios that also run --watch as if an operator restarted dnsmasq in
|
||||
# the middle of the window: the watch must re-check, and still stop it.
|
||||
RACE = {"rogue", "lldp-only", "br-lldp"}
|
||||
|
||||
# What `systemctl show` would say: dnsmasq active since boot, or since
|
||||
# just now (inside the watch window).
|
||||
STATE_EARLY = "printf 'ActiveState=active\\nActiveEnterTimestampMonotonic=1\\n'"
|
||||
STATE_LATE = ("printf 'ActiveState=active\\n"
|
||||
"ActiveEnterTimestampMonotonic=%d\\n'" % (2 ** 62))
|
||||
|
||||
|
||||
def sh(*cmd):
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
@@ -252,18 +271,97 @@ def scenario(name, spec, want, want_rc, needle, bound, conf, leases, tmp,
|
||||
# 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)
|
||||
"--stop-cmd", "touch " + marker,
|
||||
"--state-cmd", STATE_EARLY], 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")
|
||||
# 4. watch racing an operator restart: re-check, still stop
|
||||
if name in RACE:
|
||||
marker2 = marker + ".race"
|
||||
r = run_script(["--watch", "--window", "3", "--write", status,
|
||||
"--stop-cmd", "touch " + marker2,
|
||||
"--state-cmd", STATE_LATE], conf, leases)
|
||||
if "re-checking" not in r.stderr:
|
||||
fails.append("race: watch did not re-check: %s"
|
||||
% r.stderr.strip())
|
||||
if not os.path.exists(marker2):
|
||||
fails.append("race: re-check did not stop dnsmasq: %s"
|
||||
% r.stderr.strip())
|
||||
if "could not join" in r.stderr:
|
||||
fails.append("multicast join failed: %s" % r.stderr.strip())
|
||||
finally:
|
||||
stop_peer(p, spec)
|
||||
return fails, im
|
||||
|
||||
|
||||
def carrier_lab(conf, leases, tmp):
|
||||
"""pxe-nic-carrier against real sysfs carrier in the namespace: pull
|
||||
the far end of v0's veth, plug it back, and poke it with GE NIC
|
||||
events. systemctl is a stub that records argv and says dnsmasq is
|
||||
active; pxe-nic-check --print-imaging is the real script."""
|
||||
fails = []
|
||||
stub = os.path.join(tmp, "carrier-stub")
|
||||
os.makedirs(stub, exist_ok=True)
|
||||
calls = os.path.join(stub, "calls")
|
||||
with open(os.path.join(stub, "systemctl"), "w") as f:
|
||||
f.write('#!/bin/sh\necho "$*" >> %s\n'
|
||||
'[ "$1" = is-active ] && echo active\nexit 0\n' % calls)
|
||||
with open(os.path.join(stub, "check"), "w") as f:
|
||||
f.write('#!/bin/sh\nexec %s %s --config %s --dnsmasq-nic-conf '
|
||||
'/nonexistent --lease-dir %s "$@"\n'
|
||||
% (sys.executable, SCRIPT, conf, leases))
|
||||
with open(os.path.join(stub, "logger"), "w") as f: # not the host log
|
||||
f.write('#!/bin/sh\necho "$*" >> %s/log\n' % stub)
|
||||
for n in ("systemctl", "check", "logger"):
|
||||
os.chmod(os.path.join(stub, n), 0o755)
|
||||
env = dict(os.environ, PATH=stub + ":" + os.environ.get("PATH", ""),
|
||||
PXE_SYSTEMCTL=os.path.join(stub, "systemctl"),
|
||||
PXE_NIC_CHECK=os.path.join(stub, "check"),
|
||||
PXE_NIC_CARRIER_RUN=os.path.join(stub, "run"))
|
||||
|
||||
def event(iface, state):
|
||||
if os.path.exists(calls):
|
||||
os.unlink(calls)
|
||||
subprocess.run(["bash", CARRIER], env=dict(env, IFACE=iface,
|
||||
STATE=state),
|
||||
timeout=30, check=True)
|
||||
if not os.path.exists(calls):
|
||||
return []
|
||||
with open(calls) as f:
|
||||
return f.read().splitlines()
|
||||
|
||||
event("v0", "routable") # first sight: up
|
||||
sh("ip", "link", "set", "v1", "down")
|
||||
time.sleep(0.5)
|
||||
got = event("v0", "no-carrier")
|
||||
if "stop --no-block dnsmasq" not in got:
|
||||
fails.append("carrier loss did not stop dnsmasq: %s" % got)
|
||||
sh("ip", "link", "set", "v0", "down") # admin down: EINVAL
|
||||
got = event("v0", "off")
|
||||
if got:
|
||||
fails.append("repeat loss not idempotent: %s" % got)
|
||||
sh("ip", "link", "set", "v0", "up")
|
||||
sh("ip", "link", "set", "v1", "up")
|
||||
time.sleep(1.5)
|
||||
got = event("v0", "carrier")
|
||||
if "restart --no-block pxe-nic-carrier.service" not in got:
|
||||
fails.append("carrier gain did not hand off: %s" % got)
|
||||
if event("v0", "routable"):
|
||||
fails.append("repeat gain not idempotent")
|
||||
sh("ip", "link", "set", "g1", "down")
|
||||
time.sleep(0.5)
|
||||
got = event("g0", "no-carrier")
|
||||
sh("ip", "link", "set", "g1", "up")
|
||||
if got:
|
||||
fails.append("GE NIC event touched systemctl: %s" % got)
|
||||
time.sleep(1.0)
|
||||
return fails
|
||||
|
||||
|
||||
def inside(selected):
|
||||
tmp = tempfile.mkdtemp(prefix="pxe-nic-lab-")
|
||||
conf, conf_br, leases = setup(tmp)
|
||||
@@ -293,6 +391,18 @@ def inside(selected):
|
||||
print(" " + f, flush=True)
|
||||
if im and not fails:
|
||||
print(" " + " | ".join(im["evidence"]), flush=True)
|
||||
if not selected or "carrier" in selected:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
fails = carrier_lab(conf, leases, tmp)
|
||||
except Exception as e:
|
||||
fails = ["exception: %r" % e]
|
||||
state = "FAIL" if fails else "PASS"
|
||||
results.append(("carrier", state, fails))
|
||||
print("%s %-16s pxe-nic-carrier on real carrier (%.0f s)"
|
||||
% (state, "carrier", time.monotonic() - t0), flush=True)
|
||||
for f in fails:
|
||||
print(" " + f, 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")
|
||||
|
||||
Reference in New Issue
Block a user