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:
cproudlock
2026-10-15 14:26:39 -04:00
parent 53d39bfc50
commit 21ff95af78
13 changed files with 1022 additions and 36 deletions

View File

@@ -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")