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:
@@ -12,6 +12,15 @@ Synthesized LLDP, CDP, EAPOL, STP and ARP frames and DHCPOFFER/ACK packets
|
||||
cloned-PXE-server handling; networkd lease parsing; config fallbacks; the
|
||||
dnsmasq_bound logic; the status JSON shape and atomic 0644 write.
|
||||
|
||||
## Shell helpers
|
||||
|
||||
`test_helpers.py` runs `pxe-dhcp-hook.sh` (add/del/old with PXEClient,
|
||||
MSFT 5.0 and no vendor class: only SMB 445/139 conntrack entries and
|
||||
sockets are cleared) and `pxe-nic-carrier` (loss stops dnsmasq, gain
|
||||
hands off once, GE NIC events touch nothing, bridge ports count) with
|
||||
stub `ss`, `conntrack`, `logger`, `systemctl` and `pxe-nic-check` on a
|
||||
fake PATH and a fake `/sys/class/net`. Part of the unit run above.
|
||||
|
||||
## Namespace lab
|
||||
|
||||
python3 tests/pxe-nic-check/lab.py # all scenarios, ~5 min
|
||||
@@ -38,6 +47,7 @@ namespace cannot drop privileges; scenarios are skipped without it).
|
||||
| not-corp-domain | DHCP domain notgeaerospace.net | foreign-dhcp | 1 |
|
||||
| lldp-only | LLDP | ge | 1 |
|
||||
| eapol-only | 802.1X EAPOL | ge | 1 |
|
||||
| eapol-client | EAPOL-Start + EAP-Response, like a PXE-booting bay | isolated | 0 |
|
||||
| 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 |
|
||||
@@ -46,5 +56,9 @@ namespace cannot drop privileges; scenarios are skipped without it).
|
||||
| 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 | | |
|
||||
|
||||
`rogue`, `lldp-only` and `br-lldp` also run `--watch` as if dnsmasq had
|
||||
been restarted inside the window: it must log the re-check and still stop.
|
||||
Every watch gets `--state-cmd` (a printf standing in for `systemctl show`).
|
||||
|
||||
The GE NIC (g0) is checked from a fake networkd lease with domain
|
||||
test.geaerospace.net and must read `ge` in every scenario.
|
||||
|
||||
@@ -66,13 +66,25 @@ def cdp(src, device="WJ-CORE1.wjs.geaerospace.net", port="GigabitEthernet1/0/3",
|
||||
+ llc
|
||||
|
||||
|
||||
def eapol(src):
|
||||
# EAPOL v2, type 0 (EAP packet), EAP Request/Identity
|
||||
eap = bytes([1, 1]) + struct.pack("!H", 5) + bytes([1])
|
||||
def eapol(src, code=1):
|
||||
"""EAPOL v2, type 0 (EAP packet). code 1 Request/Identity (what a
|
||||
switch port sends), 2 Response/Identity (a supplicant), 3 Success,
|
||||
4 Failure."""
|
||||
if code in (1, 2):
|
||||
eap = bytes([code, 1]) + struct.pack("!H", 5) + bytes([1])
|
||||
else:
|
||||
eap = bytes([code, 1]) + struct.pack("!H", 4)
|
||||
body = bytes([2, 0]) + struct.pack("!H", len(eap)) + eap
|
||||
return eth("01:80:c2:00:00:03", src, 0x888E, body)
|
||||
|
||||
|
||||
def eapol_start(src, eapol_type=1):
|
||||
"""Supplicant EAPOL-Start (type 1) or Logoff (type 2), empty body:
|
||||
iPXE sends three Starts each time it opens a NIC."""
|
||||
body = bytes([2, eapol_type]) + struct.pack("!H", 0)
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -10,6 +10,8 @@ plays a switch and/or a DHCP server with raw AF_PACKET sockets:
|
||||
--domain NAME option 15 in the offer
|
||||
--lldp SYSNAME send an LLDP frame every second
|
||||
--eapol send an EAPOL Request/Identity every second
|
||||
--eapol-start send EAPOL-Start + EAP-Response like a PXE-booting
|
||||
client (iPXE, Windows dot3svc) 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.
|
||||
@@ -34,6 +36,7 @@ def main():
|
||||
ap.add_argument("--domain")
|
||||
ap.add_argument("--lldp")
|
||||
ap.add_argument("--eapol", action="store_true")
|
||||
ap.add_argument("--eapol-start", action="store_true")
|
||||
ap.add_argument("--mac")
|
||||
ap.add_argument("--duration", type=float, default=120)
|
||||
args = ap.parse_args()
|
||||
@@ -58,6 +61,9 @@ def main():
|
||||
vlan=309))
|
||||
if args.eapol:
|
||||
s.send(F.eapol(mac))
|
||||
if args.eapol_start:
|
||||
s.send(F.eapol_start(mac))
|
||||
s.send(F.eapol(mac, code=2))
|
||||
next_beacon = now + 1.0
|
||||
r, _, _ = select.select([s], [], [], 0.2)
|
||||
if not r:
|
||||
|
||||
290
tests/pxe-nic-check/test_helpers.py
Normal file
290
tests/pxe-nic-check/test_helpers.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Tests for the shell helpers next to pxe-nic-check: pxe-dhcp-hook.sh (the
|
||||
dnsmasq dhcp-script) and pxe-nic-carrier (the networkd-dispatcher hook).
|
||||
No root, no network: every command they run (ss, conntrack, logger,
|
||||
systemctl, pxe-nic-check) is a stub on a fake PATH that records its argv.
|
||||
|
||||
python3 -m unittest discover -s tests/pxe-nic-check -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
HELPERS = os.path.normpath(os.path.join(HERE, "..", "..", "playbook",
|
||||
"pxe-server-helpers"))
|
||||
HOOK = os.path.join(HELPERS, "pxe-dhcp-hook.sh")
|
||||
CARRIER = os.path.join(HELPERS, "pxe-nic-carrier")
|
||||
|
||||
# Each stub appends "name arg1 arg2 ..." (args joined by \x1f) to $CALLS.
|
||||
STUB = r"""#!/bin/bash
|
||||
{ printf '%s' "$(basename "$0")"; for a in "$@"; do printf '\x1f%s' "$a"; done
|
||||
printf '\n'; } >> "$CALLS"
|
||||
"""
|
||||
|
||||
SYSTEMCTL = STUB + r"""
|
||||
case "$1" in
|
||||
is-active) s=$(cat "$STUBDIR/dnsmasq.state" 2>/dev/null || echo active)
|
||||
echo "$s"; [ "$s" = active ] ;;
|
||||
restart) [ "$2" = dnsmasq ] && exit "$(cat "$STUBDIR/restart.rc" 2>/dev/null || echo 0)"
|
||||
exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
"""
|
||||
|
||||
CHECK = STUB + r"""
|
||||
[ "$1" = --print-imaging ] && cat "$STUBDIR/imaging" 2>/dev/null
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
class Stubs:
|
||||
def __init__(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="pxe-helper-test-")
|
||||
self.bin = os.path.join(self.dir, "bin")
|
||||
os.mkdir(self.bin)
|
||||
self.calls = os.path.join(self.dir, "calls")
|
||||
for name, body in (("ss", STUB), ("conntrack", STUB),
|
||||
("logger", STUB), ("systemctl", SYSTEMCTL),
|
||||
("pxe-nic-check", CHECK)):
|
||||
self.write(os.path.join(self.bin, name), body, 0o755)
|
||||
self.sys = os.path.join(self.dir, "sys")
|
||||
self.run_dir = os.path.join(self.dir, "run")
|
||||
|
||||
def write(self, path, text, mode=0o644):
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
os.chmod(path, mode)
|
||||
|
||||
def env(self, **extra):
|
||||
env = {"PATH": self.bin + ":/usr/bin:/bin", "CALLS": self.calls,
|
||||
"STUBDIR": self.dir,
|
||||
"PXE_NIC_CHECK": os.path.join(self.bin, "pxe-nic-check"),
|
||||
"PXE_SYSTEMCTL": os.path.join(self.bin, "systemctl"),
|
||||
"PXE_SYS_NET": self.sys,
|
||||
"PXE_NIC_CARRIER_RUN": self.run_dir,
|
||||
"PXE_NIC_CARRIER_SETTLE": "0"}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def get_calls(self, name=None):
|
||||
try:
|
||||
with open(self.calls) as f:
|
||||
lines = f.read().splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
out = [ln.split("\x1f") for ln in lines]
|
||||
return [c for c in out if name is None or c[0] == name]
|
||||
|
||||
def reset(self):
|
||||
if os.path.exists(self.calls):
|
||||
os.unlink(self.calls)
|
||||
|
||||
def link(self, iface, carrier):
|
||||
d = os.path.join(self.sys, iface)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
path = os.path.join(d, "carrier")
|
||||
if carrier is None: # admin down: reading carrier fails
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
else:
|
||||
self.write(path, "%d\n" % carrier)
|
||||
|
||||
def cleanup(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
|
||||
class DhcpHook(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.s = Stubs()
|
||||
|
||||
def tearDown(self):
|
||||
self.s.cleanup()
|
||||
|
||||
def run_hook(self, action, vendor=None):
|
||||
env = self.s.env()
|
||||
if vendor is not None:
|
||||
env["DNSMASQ_VENDOR_CLASS"] = vendor
|
||||
r = subprocess.run(["bash", HOOK, action, "02:aa:00:00:00:05",
|
||||
"172.16.9.50", "bay3105"], env=env,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
return self.s.get_calls()
|
||||
|
||||
def assert_smb_only(self, calls):
|
||||
ct = [c[1:] for c in calls if c[0] == "conntrack"]
|
||||
self.assertEqual(sorted(ct), sorted([
|
||||
["-D", "-p", "tcp", "-s", "172.16.9.50", "--dport", "445"],
|
||||
["-D", "-p", "tcp", "-d", "172.16.9.50", "--sport", "445"],
|
||||
["-D", "-p", "tcp", "-s", "172.16.9.50", "--dport", "139"],
|
||||
["-D", "-p", "tcp", "-d", "172.16.9.50", "--sport", "139"],
|
||||
]))
|
||||
ss = [c[1:] for c in calls if c[0] == "ss"]
|
||||
self.assertEqual(ss, [["-K", "-t", "( sport = :445 or sport = :139 )"
|
||||
" and dst = 172.16.9.50"]])
|
||||
logs = [c for c in calls if c[0] == "logger"]
|
||||
self.assertEqual(len(logs), 1)
|
||||
self.assertIn("SMB", logs[0][-1])
|
||||
|
||||
def test_add_and_del_clear_smb_state_whatever_the_vendor(self):
|
||||
for action in ("add", "del"):
|
||||
for vendor in ("PXEClient:Arch:00007:UNDI:003016",
|
||||
"MSFT 5.0", None):
|
||||
self.s.reset()
|
||||
self.assert_smb_only(self.run_hook(action, vendor))
|
||||
|
||||
def test_old_only_for_network_boot(self):
|
||||
self.s.reset()
|
||||
calls = self.run_hook("old", "PXEClient:Arch:00007:UNDI:003016")
|
||||
self.assert_smb_only(calls)
|
||||
self.assertIn("network boot, PXEClient:Arch:00007",
|
||||
[c for c in calls if c[0] == "logger"][0][-1])
|
||||
for vendor in ("MSFT 5.0", None, ""):
|
||||
self.s.reset()
|
||||
self.assertEqual(self.run_hook("old", vendor), [], vendor)
|
||||
|
||||
def test_never_clears_all_traffic(self):
|
||||
calls = self.run_hook("add", "PXEClient")
|
||||
for c in calls:
|
||||
if c[0] == "conntrack":
|
||||
self.assertIn("-p", c)
|
||||
self.assertIn("tcp", c)
|
||||
if c[0] == "ss":
|
||||
self.assertIn("sport = :445", c[-1])
|
||||
|
||||
def test_no_ip_does_nothing(self):
|
||||
r = subprocess.run(["bash", HOOK, "add", "02:aa:00:00:00:05", ""],
|
||||
env=self.s.env(), capture_output=True, timeout=10)
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertEqual(self.s.get_calls(), [])
|
||||
|
||||
|
||||
class Carrier(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.s = Stubs()
|
||||
self.s.write(os.path.join(self.s.dir, "imaging"), "pxe0\n")
|
||||
self.s.link("pxe0", 1)
|
||||
self.s.link("corp0", 1)
|
||||
|
||||
def tearDown(self):
|
||||
self.s.cleanup()
|
||||
|
||||
def event(self, iface, state="routable"):
|
||||
r = subprocess.run(["bash", CARRIER], env=self.s.env(
|
||||
IFACE=iface, STATE=state), capture_output=True, text=True,
|
||||
timeout=20)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
return self.s.get_calls()
|
||||
|
||||
def gain(self):
|
||||
r = subprocess.run(["bash", CARRIER, "--gain"], env=self.s.env(),
|
||||
capture_output=True, text=True, timeout=20)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
return self.s.get_calls()
|
||||
|
||||
def systemctl(self, calls):
|
||||
return [c[1:] for c in calls if c[0] == "systemctl"]
|
||||
|
||||
def dnsmasq(self, state):
|
||||
self.s.write(os.path.join(self.s.dir, "dnsmasq.state"), state + "\n")
|
||||
|
||||
def marker(self):
|
||||
return os.path.exists(os.path.join(self.s.run_dir, "stopped-dnsmasq"))
|
||||
|
||||
def test_ge_nic_is_never_touched(self):
|
||||
for carrier in (0, 1, None, 1):
|
||||
self.s.reset()
|
||||
self.s.link("corp0", carrier)
|
||||
calls = self.event("corp0", "no-carrier")
|
||||
self.assertEqual([c[0] for c in calls], ["pxe-nic-check"])
|
||||
self.assertFalse(os.path.exists(os.path.join(self.s.run_dir,
|
||||
"link.corp0")))
|
||||
|
||||
def test_unknown_imaging_nic_does_nothing(self):
|
||||
self.s.write(os.path.join(self.s.dir, "imaging"), "")
|
||||
calls = self.event("pxe0", "no-carrier")
|
||||
self.assertEqual([c[0] for c in calls], ["pxe-nic-check"])
|
||||
|
||||
def test_loss_stops_running_dnsmasq_and_marks(self):
|
||||
self.event("pxe0", "routable") # first sight: up
|
||||
self.s.reset()
|
||||
self.s.link("pxe0", 0)
|
||||
sc = self.systemctl(self.event("pxe0", "no-carrier"))
|
||||
self.assertIn(["stop", "pxe-nic-carrier.service"], sc)
|
||||
self.assertIn(["stop", "--no-block", "dnsmasq"], sc)
|
||||
self.assertLess(sc.index(["stop", "pxe-nic-carrier.service"]),
|
||||
sc.index(["stop", "--no-block", "dnsmasq"]))
|
||||
self.assertTrue(self.marker())
|
||||
# same state again (off after no-carrier): idempotent, no calls
|
||||
self.s.reset()
|
||||
self.s.link("pxe0", None)
|
||||
self.assertEqual(self.systemctl(self.event("pxe0", "off")), [])
|
||||
|
||||
def test_loss_with_dnsmasq_stopped_leaves_no_marker(self):
|
||||
self.dnsmasq("inactive")
|
||||
self.s.link("pxe0", 0)
|
||||
sc = self.systemctl(self.event("pxe0", "no-carrier"))
|
||||
self.assertNotIn(["stop", "--no-block", "dnsmasq"], sc)
|
||||
self.assertFalse(self.marker())
|
||||
|
||||
def test_gain_hands_off_once(self):
|
||||
self.s.link("pxe0", 0)
|
||||
self.event("pxe0", "no-carrier")
|
||||
self.s.reset()
|
||||
self.s.link("pxe0", 1)
|
||||
sc = self.systemctl(self.event("pxe0", "carrier"))
|
||||
self.assertEqual(sc[-1], ["restart", "--no-block",
|
||||
"pxe-nic-carrier.service"])
|
||||
self.assertNotIn("dnsmasq", [x for c in sc for x in c])
|
||||
self.s.reset() # routable right after carrier
|
||||
self.assertEqual(self.systemctl(self.event("pxe0", "routable")), [])
|
||||
|
||||
def test_bridge_port_events_count(self):
|
||||
self.s.write(os.path.join(self.s.dir, "imaging"), "br-pxe\npxe0\n")
|
||||
self.s.link("br-pxe", 1)
|
||||
self.event("pxe0", "enslaved")
|
||||
self.s.reset()
|
||||
self.s.link("pxe0", 0)
|
||||
sc = self.systemctl(self.event("pxe0", "no-carrier"))
|
||||
self.assertIn(["stop", "--no-block", "dnsmasq"], sc)
|
||||
|
||||
def test_gain_restarts_dnsmasq_through_its_guard(self):
|
||||
os.makedirs(self.s.run_dir)
|
||||
self.s.write(os.path.join(self.s.run_dir, "stopped-dnsmasq"), "")
|
||||
self.dnsmasq("inactive") # the hook stopped it
|
||||
sc = self.systemctl(self.gain())
|
||||
self.assertIn(["restart", "dnsmasq"], sc)
|
||||
self.assertFalse(self.marker())
|
||||
|
||||
def test_gain_restarts_running_dnsmasq(self):
|
||||
# loss never seen (fast swap, boot): dnsmasq running, no marker
|
||||
sc = self.systemctl(self.gain())
|
||||
self.assertIn(["restart", "dnsmasq"], sc)
|
||||
|
||||
def test_gain_logs_crit_when_guard_refuses(self):
|
||||
self.s.write(os.path.join(self.s.dir, "restart.rc"), "1\n")
|
||||
calls = self.gain()
|
||||
logs = [c for c in calls if c[0] == "logger"]
|
||||
self.assertTrue(any("daemon.crit" in c and "NOT started" in c[-1]
|
||||
for c in logs), logs)
|
||||
|
||||
def test_gain_leaves_operator_stopped_dnsmasq_alone(self):
|
||||
self.dnsmasq("inactive")
|
||||
calls = self.gain()
|
||||
self.assertNotIn(["restart", "dnsmasq"], self.systemctl(calls))
|
||||
self.assertNotIn(["start", "dnsmasq"], self.systemctl(calls))
|
||||
self.assertIn(["pxe-nic-check", "--guard"],
|
||||
[c for c in calls if c[0] == "pxe-nic-check"])
|
||||
|
||||
def test_gain_does_nothing_if_link_dropped_again(self):
|
||||
self.s.link("pxe0", 0)
|
||||
calls = self.gain()
|
||||
self.assertEqual(self.systemctl(calls), [])
|
||||
self.assertNotIn(["pxe-nic-check", "--guard"], calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -82,6 +82,21 @@ class Frames(unittest.TestCase):
|
||||
o = fed(F.eapol(SW))
|
||||
self.assertTrue(o.eapol)
|
||||
|
||||
def test_eapol_only_from_authenticator(self):
|
||||
client = "02:aa:00:00:00:07"
|
||||
for name, fr, want in [
|
||||
("request", F.eapol(SW, code=1), True),
|
||||
("success", F.eapol(SW, code=3), True),
|
||||
("failure", F.eapol(SW, code=4), True),
|
||||
("response", F.eapol(client, code=2), False),
|
||||
("start", F.eapol_start(client), False),
|
||||
("logoff", F.eapol_start(client, eapol_type=2), False),
|
||||
("truncated", F.eapol(SW)[:17], False),
|
||||
]:
|
||||
o = fed(fr)
|
||||
self.assertEqual(o.eapol, want, name)
|
||||
self.assertEqual(o.eapol_client, 0 if want else 1, name)
|
||||
|
||||
def test_stp(self):
|
||||
o = fed(F.stp(SW))
|
||||
self.assertTrue(o.stp)
|
||||
@@ -207,6 +222,113 @@ class Bpf(unittest.TestCase):
|
||||
self.assertFalse(run_bpf(insns, fr), name)
|
||||
|
||||
|
||||
class Multicast(unittest.TestCase):
|
||||
def test_packet_mreq_packing(self):
|
||||
import struct
|
||||
raw = M.packet_mreq(7, "01:80:C2:00:00:0E")
|
||||
self.assertEqual(len(raw), 16) # sizeof(struct packet_mreq)
|
||||
idx, typ, alen, addr = struct.unpack("iHH8s", raw)
|
||||
self.assertEqual((idx, typ, alen), (7, M.PACKET_MR_MULTICAST, 6))
|
||||
self.assertEqual(addr, bytes.fromhex("0180c200000e") + b"\0\0")
|
||||
self.assertEqual(M.SOL_PACKET, 263)
|
||||
self.assertEqual(M.PACKET_ADD_MEMBERSHIP, 1)
|
||||
with self.assertRaises(ValueError):
|
||||
M.packet_mreq(1, "01:80:c2")
|
||||
|
||||
def test_groups(self):
|
||||
self.assertEqual(sorted(M.CAPTURE_GROUPS), sorted([
|
||||
"01:80:c2:00:00:00", "01:80:c2:00:00:03", "01:80:c2:00:00:0e",
|
||||
"01:00:0c:cc:cc:cc", "01:00:0c:cc:cc:cd"]))
|
||||
|
||||
def test_join_groups_calls_setsockopt_per_group(self):
|
||||
calls = []
|
||||
|
||||
class FakeSock:
|
||||
def setsockopt(self, level, opt, val):
|
||||
calls.append((level, opt, val))
|
||||
if val[8:14] == bytes.fromhex("01000ccccccd"):
|
||||
raise OSError("nope")
|
||||
|
||||
import socket
|
||||
real = socket.if_nametoindex
|
||||
socket.if_nametoindex = lambda name: 3
|
||||
try:
|
||||
failed = M.join_groups(FakeSock(), "eth9")
|
||||
finally:
|
||||
socket.if_nametoindex = real
|
||||
self.assertEqual(failed, ["01:00:0c:cc:cc:cd"])
|
||||
self.assertEqual(len(calls), 5)
|
||||
for level, opt, val in calls:
|
||||
self.assertEqual((level, opt), (263, 1))
|
||||
self.assertEqual(val[:4], M.packet_mreq(3, "01:80:c2:00:00:00")[:4])
|
||||
self.assertEqual(M.join_groups(FakeSock(), "no-such-if-xyz"),
|
||||
M.CAPTURE_GROUPS)
|
||||
|
||||
|
||||
class WatchRace(unittest.TestCase):
|
||||
def test_parse_unit_state(self):
|
||||
self.assertEqual(M.parse_unit_state(
|
||||
"ActiveState=active\nActiveEnterTimestampMonotonic=123456\n"),
|
||||
("active", 123456))
|
||||
self.assertEqual(M.parse_unit_state(
|
||||
"ActiveEnterTimestampMonotonic=0\nActiveState=inactive\n"),
|
||||
("inactive", 0))
|
||||
self.assertEqual(M.parse_unit_state(""), (None, None))
|
||||
|
||||
def test_decision(self):
|
||||
start = 1000000
|
||||
self.assertEqual(M.watch_decision("active", 500, start), "stop")
|
||||
self.assertEqual(M.watch_decision("active", start + 1, start),
|
||||
"recheck")
|
||||
self.assertEqual(M.watch_decision("activating", 0, start), "recheck")
|
||||
self.assertEqual(M.watch_decision("inactive", 0, start), "skip")
|
||||
self.assertEqual(M.watch_decision("failed", 0, start), "skip")
|
||||
self.assertEqual(M.watch_decision(None, None, start), "stop")
|
||||
self.assertEqual(M.watch_decision("active", None, start), "stop")
|
||||
|
||||
def _main_watch(self, state_out, recheck_state):
|
||||
d = tempfile.mkdtemp()
|
||||
marker = os.path.join(d, "stopped")
|
||||
saved = M.run_check, M.recheck
|
||||
blocked = {"checked_at": "x", "duration_s": 1, "interfaces": [],
|
||||
"dhcp_guard": {"state": "blocked", "reason": "LLDP"}}
|
||||
rechecked = []
|
||||
M.run_check = lambda args, cfg: (blocked, None)
|
||||
|
||||
def fake_recheck(args, cfg):
|
||||
rechecked.append(1)
|
||||
return recheck_state, "re-check"
|
||||
M.recheck = fake_recheck
|
||||
try:
|
||||
rc = M.main(["--watch", "--config", "/nonexistent",
|
||||
"--write", os.path.join(d, "s.json"),
|
||||
"--stop-cmd", "touch " + marker,
|
||||
"--state-cmd", "printf '%s'" % state_out])
|
||||
finally:
|
||||
M.run_check, M.recheck = saved
|
||||
self.assertEqual(rc, 0)
|
||||
return os.path.exists(marker), bool(rechecked)
|
||||
|
||||
def test_watch_stops_when_dnsmasq_predates_window(self):
|
||||
self.assertEqual(self._main_watch(
|
||||
"ActiveState=active\nActiveEnterTimestampMonotonic=1\n",
|
||||
"allowed"), (True, False))
|
||||
|
||||
def test_watch_rechecks_after_operator_restart(self):
|
||||
late = "ActiveState=active\nActiveEnterTimestampMonotonic=%d\n" \
|
||||
% (2 ** 62)
|
||||
self.assertEqual(self._main_watch(late, "allowed"), (False, True))
|
||||
self.assertEqual(self._main_watch(late, "blocked"), (True, True))
|
||||
|
||||
def test_watch_skips_stopped_dnsmasq(self):
|
||||
self.assertEqual(self._main_watch(
|
||||
"ActiveState=inactive\nActiveEnterTimestampMonotonic=0\n",
|
||||
"allowed"), (False, False))
|
||||
|
||||
def test_watch_stops_when_state_unknown(self):
|
||||
self.assertEqual(self._main_watch("", "allowed"), (True, False))
|
||||
|
||||
|
||||
class OwnServer(unittest.TestCase):
|
||||
def test_own_needs_own_ip_and_local_mac(self):
|
||||
o = M.Observations()
|
||||
@@ -284,6 +406,24 @@ class Verdicts(unittest.TestCase):
|
||||
M.allowed_list("IMG-SW1"))
|
||||
self.assertEqual(v, "ge")
|
||||
|
||||
def test_client_eapol_stays_isolated(self):
|
||||
# a bay PXE-booting next to us: iPXE sends 3 EAPOL-Starts on every
|
||||
# NIC open, Windows dot3svc sends Starts and EAP-Responses
|
||||
client = "02:aa:00:00:00:07"
|
||||
starts = [F.eapol_start(client)] * 3
|
||||
v, ev = self.run_case(starts)
|
||||
self.assertEqual(v, "isolated", ev)
|
||||
self.assertTrue(any("3 client EAPOL frames ignored" in e
|
||||
for e in ev), ev)
|
||||
v, ev = self.run_case([F.eapol(client, code=2)])
|
||||
self.assertEqual(v, "isolated", ev)
|
||||
v, ev = self.run_case(starts + [F.eapol(client, code=2)])
|
||||
self.assertEqual(v, "isolated", ev)
|
||||
# a switch port asking for identity is still decisive
|
||||
v, ev = self.run_case(starts + [F.eapol(SW, code=1)])
|
||||
self.assertEqual(v, "ge", ev)
|
||||
self.assertIn("802.1X EAPOL seen (port authentication)", ev)
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user