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

@@ -32,7 +32,9 @@ Verdicts per interface:
no-link no carrier
isolated no foreign DHCP server, no LLDP/CDP/EAPOL seen
ge foreign DHCP with a corp domain (opt 15 / 119), or any
LLDP, CDP or EAPOL seen (unmanaged switches send none)
LLDP, CDP or authenticator EAPOL seen (unmanaged switches
send none). EAPOL from clients (iPXE and Windows send
EAPOL-Start and EAP-Response) is ignored.
foreign-dhcp a non-own DHCP server without a corp domain
error the check itself failed
@@ -46,9 +48,14 @@ Modes:
exit 0 = safe to serve, exit 1 = DO NOT serve (ge or
foreign-dhcp). Internal errors and no link exit 0 with a loud
log line: fail-open on bugs, fail-closed only on detection.
--print-imaging
the imaging NIC (and its bridge ports), for pxe-nic-carrier.
--watch systemd timer. 60 s window + probe, writes the status file,
and on a bad imaging verdict runs "systemctl stop dnsmasq"
and logs CRIT. It never starts dnsmasq.
and logs CRIT. It never starts dnsmasq. If dnsmasq was
(re)started inside the window (its guard passed after part
of the evidence was heard), it re-checks for ~6 s first and
stops dnsmasq only if that still says blocked.
Config: /etc/pxe-server/nic.conf (INI, [nic] section optional)
@@ -96,6 +103,8 @@ STATUS_PATH = "/run/pxe-nic-status.json"
DEFAULT_SUFFIXES = "geaerospace.net, ge.com"
DEFAULT_OWN_IP = "172.16.9.1"
STOP_CMD = "systemctl stop dnsmasq"
STATE_CMD = ("systemctl show -p ActiveState "
"-p ActiveEnterTimestampMonotonic dnsmasq")
ETH_P_ALL = 0x0003
PACKET_OUTGOING = 4
@@ -329,6 +338,23 @@ def parse_bootp(udp_payload):
}
EAPOL_TYPE_EAP = 0
EAP_AUTHENTICATOR_CODES = (1, 3, 4) # Request, Success, Failure
def eapol_from_authenticator(payload):
"""True only for EAPOL that an 802.1X authenticator (a switch port)
sends: EAPOL type 0 (EAP-Packet) carrying EAP code 1 Request, 3 Success
or 4 Failure. Supplicants send EAPOL-Start (type 1), Logoff (type 2)
and EAP Responses (code 2): iPXE sends three EAPOL-Starts every time
it opens a NIC, and Windows dot3svc sends Starts and Responses, so a
bay PXE-booting next to us must not read as a GE switch port."""
if len(payload) < 5:
return False
return payload[1] == EAPOL_TYPE_EAP \
and payload[4] in EAP_AUTHENTICATOR_CODES
def parse_dhcp_frame(etype, payload):
"""IPv4/UDP 67->68 server message -> bootp dict (+ src ip) or None."""
if etype != ETH_IP or len(payload) < 20:
@@ -352,7 +378,8 @@ class Observations:
def __init__(self):
self.lldp = {} # (chassis, port) -> dict
self.cdp = {} # (device, port) -> dict
self.eapol = False
self.eapol = False # EAPOL from an authenticator (a switch)
self.eapol_client = 0 # supplicant EAPOL frames, ignored
self.stp = False
self.arp_macs = set()
self.dhcp = {} # (server, src_mac) -> dict
@@ -371,7 +398,10 @@ class Observations:
self.lldp[(info["chassis"], info["port"])] = info
return
if etype == ETH_EAPOL:
self.eapol = True
if eapol_from_authenticator(payload):
self.eapol = True
else:
self.eapol_client += 1
return
if etype <= 1500: # 802.3 length field: LLC frames
if dst == MAC_CDP:
@@ -763,6 +793,45 @@ def attach_bpf(sock):
# ---------------------------------------------------------------- capture
SOL_PACKET = 263
PACKET_ADD_MEMBERSHIP = 1
PACKET_MR_MULTICAST = 0
# Link-local groups the NIC's hardware filter drops unless someone joins
# them: STP, EAPOL (PAE), LLDP, CDP/VTP/DTP, PVST+. Without them a plain
# (non-promiscuous) NIC on a CDP-only Cisco port hears nothing.
CAPTURE_GROUPS = ["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 packet_mreq(ifindex, mac):
"""struct packet_mreq {int mr_ifindex; unsigned short mr_type,
mr_alen; unsigned char mr_address[8];} for PACKET_MR_MULTICAST."""
addr = bytes.fromhex(norm_mac(mac).replace(":", ""))
if len(addr) != 6:
raise ValueError("bad MAC %r" % mac)
return struct.pack("iHH8s", ifindex, PACKET_MR_MULTICAST, len(addr),
addr)
def join_groups(sock, ifname, groups=CAPTURE_GROUPS):
"""Join each group on this socket's interface. The kernel drops the
memberships when the socket closes. Returns the groups that failed."""
failed = []
try:
ifindex = socket.if_nametoindex(ifname)
except OSError:
return list(groups)
for g in groups:
try:
sock.setsockopt(SOL_PACKET, PACKET_ADD_MEMBERSHIP,
packet_mreq(ifindex, g))
except (OSError, ValueError):
failed.append(g)
return failed
def capture(targets, window, probe_ifaces, own_macs, probe_window=6.0):
"""targets: {report_iface: [capture ifaces]}. Listens on all of them
for `window` s (at least probe_window after the probe). Returns
@@ -783,6 +852,10 @@ def capture(targets, window, probe_ifaces, own_macs, probe_window=6.0):
log("no BPF filter on %s (%s), parsing every frame"
% (cap, e), "warning")
s.bind((cap, ETH_P_ALL))
bad = join_groups(s, cap)
if bad:
log("could not join %s on %s; a quiet switch may be "
"missed" % (", ".join(bad), cap), "warning")
s.setblocking(False)
socks[s] = name
except OSError as e:
@@ -883,6 +956,10 @@ def classify(link_up, error, obs, dhcp_servers, suffixes, window,
evidence.append(txt)
if obs and obs.eapol:
evidence.append("802.1X EAPOL seen (port authentication)")
if obs and obs.eapol_client:
evidence.append("%d client EAPOL frames ignored (supplicants such "
"as iPXE EAPOL-Start, informational)"
% obs.eapol_client)
if obs and obs.stp:
evidence.append("STP BPDUs seen (informational)")
@@ -1135,6 +1212,81 @@ def summary(status):
return "\n".join(lines)
def parse_unit_state(text):
"""`systemctl show -p ActiveState -p ActiveEnterTimestampMonotonic`
output -> (active_state or None, active_enter_us or None)."""
props = {}
for line in (text or "").splitlines():
if "=" in line:
k, v = line.split("=", 1)
props[k.strip()] = v.strip()
state = props.get("ActiveState") or None
try:
since = int(props.get("ActiveEnterTimestampMonotonic", ""))
except ValueError:
since = None
return state, since
def dnsmasq_state(cmd):
try:
res = subprocess.run(cmd, shell=True, capture_output=True,
text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return None, None
if res.returncode != 0:
return None, None
return parse_unit_state(res.stdout)
def watch_decision(state, since_us, window_start_us):
"""What --watch does with a 'blocked' verdict, given dnsmasq's
ActiveState / ActiveEnterTimestampMonotonic and the CLOCK_MONOTONIC
microsecond the watch's capture began. -> "stop", "skip" (already
stopped) or "recheck" (dnsmasq was (re)started inside the window, so
its own guard passed after part of our evidence was heard; confirm
with a fresh short check before stopping it)."""
if state is None:
return "stop" # cannot tell: stopping is the safe side
if state in ("inactive", "failed", "deactivating"):
return "skip"
if state in ("activating", "reloading"):
return "recheck" # its guard is running right now
if since_us and since_us > window_start_us:
return "recheck"
return "stop"
def stop_dnsmasq(cmd):
try:
rc = subprocess.run(cmd, shell=True, timeout=60).returncode
if rc != 0:
log("'%s' exited %d" % (cmd, rc), "crit")
except (OSError, subprocess.SubprocessError) as e:
log("could not stop dnsmasq: %s" % e, "crit")
def recheck(args, cfg):
"""A guard-style check (imaging NIC, short window + probe), written to
the status file. -> (state, reason). An internal error counts as
blocked: the 60 s watch already saw the bad evidence."""
sub = argparse.Namespace(**vars(args))
sub.mode = "guard"
sub.window = 5.0
sub.probe = True
try:
status, _ = run_check(sub, cfg)
except Exception as e:
return "blocked", "re-check failed (%s); keeping the watch verdict" \
% e
if args.write:
try:
write_status(args.write, status)
except OSError as e:
log("could not write %s: %s" % (args.write, e), "err")
return status["dhcp_guard"]["state"], status["dhcp_guard"]["reason"]
def main(argv=None):
ap = argparse.ArgumentParser(
prog=PROG,
@@ -1149,6 +1301,10 @@ def main(argv=None):
mode.add_argument("--watch", dest="mode", action="store_const",
const="watch", help="timer mode: stop dnsmasq on a "
"bad imaging verdict")
mode.add_argument("--print-imaging", dest="mode", action="store_const",
const="print-imaging", help="print the imaging NIC "
"and, for a bridge, its ports (one per line; nothing "
"if unknown), then exit. Used by pxe-nic-carrier.")
ap.add_argument("--json", action="store_true",
help="print the status JSON")
ap.add_argument("--write", metavar="PATH", nargs="?", const=STATUS_PATH,
@@ -1171,6 +1327,9 @@ def main(argv=None):
ap.add_argument("--lease-dir", default=LEASE_DIR)
ap.add_argument("--stop-cmd", default=STOP_CMD,
help="command --watch runs to stop dnsmasq")
ap.add_argument("--state-cmd", default=STATE_CMD,
help="command --watch runs to read dnsmasq's "
"ActiveState and ActiveEnterTimestampMonotonic")
args = ap.parse_args(argv)
args.mode = args.mode or "check"
if args.window is None:
@@ -1180,6 +1339,22 @@ def main(argv=None):
if args.mode in ("guard", "watch") and not args.write:
args.write = STATUS_PATH
if args.mode == "print-imaging":
try:
cfg = load_config(args.config, args.dnsmasq_nic_conf)
ifaces = all_ifaces()
imaging, _ge, _notes = resolve_roles(cfg, ifaces, iface_addrs(),
args.lease_dir)
except Exception as e:
log("could not resolve the imaging NIC: %s" % e, "err")
return 2
if imaging:
print("\n".join([imaging] + (bridge_ports(imaging)
if is_bridge(imaging) else [])))
return 0
# CLOCK_MONOTONIC, the clock of ActiveEnterTimestampMonotonic
window_start_us = int(time.clock_gettime(time.CLOCK_MONOTONIC) * 1e6)
try:
cfg = load_config(args.config, args.dnsmasq_nic_conf)
status, imaging = run_check(args, cfg)
@@ -1217,15 +1392,25 @@ def main(argv=None):
return 0
if args.mode == "watch":
if state == "blocked":
log("imaging NIC is NOT isolated, stopping dnsmasq: " + reason,
"crit")
try:
rc = subprocess.run(args.stop_cmd, shell=True,
timeout=60).returncode
if rc != 0:
log("'%s' exited %d" % (args.stop_cmd, rc), "crit")
except (OSError, subprocess.SubprocessError) as e:
log("could not stop dnsmasq: %s" % e, "crit")
dstate, since = dnsmasq_state(args.state_cmd)
what = watch_decision(dstate, since, window_start_us)
if what == "recheck":
log("dnsmasq was (re)started during this watch window "
"(its guard passed); re-checking before stopping it: "
+ reason, "warning")
state, reason = recheck(args, cfg)
if state == "blocked":
what = "stop"
else:
log("re-check says %s, leaving dnsmasq running: %s"
% (state, reason), "notice")
if what == "skip":
log("imaging NIC is NOT isolated, dnsmasq already %s: %s"
% (dstate, reason), "crit")
elif what == "stop":
log("imaging NIC is NOT isolated, stopping dnsmasq: "
+ reason, "crit")
stop_dnsmasq(args.stop_cmd)
elif state == "unknown":
log("watch could not verify the imaging NIC: " + reason, "err")
else: