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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user