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:
@@ -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