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:
@@ -37,13 +37,23 @@ initrd http://${server}/win11/EFI/Microsoft/Boot/BCD EFI/Microsoft/Boot/BC
|
||||
initrd http://${server}/win11/EFI/Boot/bootx64.efi EFI/Boot/bootx64.efi
|
||||
initrd http://${server}/win11/Boot/boot.sdi Boot/boot.sdi
|
||||
initrd http://${server}/win11/sources/boot.wim sources/boot.wim
|
||||
boot
|
||||
# A command that fails without || aborts the whole iPXE script (the PC
|
||||
# drops to firmware with no explanation), so every boot line has one.
|
||||
boot || goto winpe_failed
|
||||
|
||||
:winpe_failed
|
||||
echo
|
||||
echo Windows PE boot FAILED (see the error above).
|
||||
echo Check that the PXE server is up and the win11 boot files are staged.
|
||||
prompt Press any key to return to menu... && goto menu || goto menu
|
||||
|
||||
:clonezilla
|
||||
set base http://${server}/clonezilla
|
||||
kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs || goto secureboot_warn
|
||||
initrd ${base}/initrd.img
|
||||
boot
|
||||
initrd ${base}/initrd.img || goto boot_failed
|
||||
# iPXE 2.0 accepts an unsigned EFI kernel (PE fallback) and fails only
|
||||
# here under Secure Boot.
|
||||
boot || goto secureboot_warn
|
||||
|
||||
:blancco
|
||||
iseq ${platform} pcbios && goto blancco_bios || goto blancco_efi
|
||||
@@ -55,7 +65,7 @@ initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto b
|
||||
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_bios_failed
|
||||
initrd --name config.img http://${server}/blancco/config.img || goto blancco_bios_failed
|
||||
initrd --name initramfs-bde-linux.img http://${server}/blancco/initramfs-bde-linux.img || goto blancco_bios_failed
|
||||
boot
|
||||
boot || goto blancco_bios_failed
|
||||
:blancco_bios_failed
|
||||
echo
|
||||
echo Blancco BIOS boot FAILED.
|
||||
@@ -91,7 +101,14 @@ chain http://${server}/blancco/grubx64.efi || goto secureboot_warn
|
||||
|
||||
:memtest
|
||||
kernel http://${server}/memtest/memtest.efi || goto secureboot_warn
|
||||
boot
|
||||
# iPXE 2.0 accepts an unsigned EFI image (PE fallback) and fails only
|
||||
# here under Secure Boot.
|
||||
boot || goto secureboot_warn
|
||||
|
||||
:boot_failed
|
||||
echo
|
||||
echo Download FAILED (see the error above). Check the PXE server and retry.
|
||||
prompt Press any key to return to menu... && goto menu || goto menu
|
||||
|
||||
:secureboot_warn
|
||||
echo
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# pxe-dhcp-hook.sh - dnsmasq dhcp-script hook.
|
||||
#
|
||||
# Runs every time a PXE client gets/changes/releases a DHCP lease on
|
||||
# 172.16.9.0/24. Flushes conntrack entries and drops any lingering
|
||||
# TCP sockets for that client IP. Prevents stale server-side state from
|
||||
# causing "System error 53 - network path not found" when a WinPE client
|
||||
# re-images the same machine without a clean SMB session teardown.
|
||||
# 172.16.9.0/24. Flushes that client's SMB (TCP 445 and 139) conntrack
|
||||
# entries and drops its lingering SMB sockets. Prevents stale server-side
|
||||
# state from causing "System error 53 - network path not found" when a
|
||||
# WinPE client re-images the same machine without a clean SMB session
|
||||
# teardown.
|
||||
#
|
||||
# SMB only, on purpose: the lease that fires this hook is iPXE's, and its
|
||||
# first HTTP fetch of the boot menu is racing this script. Killing every
|
||||
# socket or conntrack entry for the client IP broke that fetch.
|
||||
#
|
||||
# dnsmasq calls this with argv:
|
||||
# $1 = action (add | old | del)
|
||||
@@ -26,6 +31,9 @@
|
||||
|
||||
set +e
|
||||
|
||||
# dnsmasq's environment may lack sbin; keep any PATH it gave us first.
|
||||
PATH="${PATH:+$PATH:}/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
action="$1"
|
||||
mac="$2"
|
||||
ip="$3"
|
||||
@@ -34,11 +42,15 @@ host="${4:-}"
|
||||
[ -z "$ip" ] && exit 0
|
||||
|
||||
clear_state() {
|
||||
/usr/sbin/conntrack -D -s "$ip" >/dev/null 2>&1
|
||||
/usr/sbin/conntrack -D -d "$ip" >/dev/null 2>&1
|
||||
/usr/bin/ss -K "dst = $ip" >/dev/null 2>&1
|
||||
/usr/bin/ss -K "src = $ip" >/dev/null 2>&1
|
||||
logger -t pxe-dhcp-hook "cleared stale state for $1 $ip ($mac${host:+ $host})"
|
||||
local port
|
||||
for port in 445 139; do
|
||||
# client -> our share, and anything the other way round
|
||||
conntrack -D -p tcp -s "$ip" --dport "$port" >/dev/null 2>&1
|
||||
conntrack -D -p tcp -d "$ip" --sport "$port" >/dev/null 2>&1
|
||||
done
|
||||
# our SMB sockets (local port 445/139) whose peer is this client
|
||||
ss -K -t "( sport = :445 or sport = :139 ) and dst = $ip" >/dev/null 2>&1
|
||||
logger -t pxe-dhcp-hook "cleared stale SMB state for $1 $ip ($mac${host:+ $host})"
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
|
||||
145
playbook/pxe-server-helpers/pxe-nic-carrier
Executable file
145
playbook/pxe-server-helpers/pxe-nic-carrier
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# pxe-nic-carrier - re-check the imaging NIC whenever its cable moves.
|
||||
#
|
||||
# The dnsmasq guard (pxe-nic-check --guard) runs only when dnsmasq starts,
|
||||
# and the watch timer only every 5 min. If the imaging cable is moved into
|
||||
# a GE port while dnsmasq runs, dnsmasq would answer DHCP on the GE LAN
|
||||
# until the next watch. This hook closes that gap:
|
||||
#
|
||||
# carrier lost dnsmasq is stopped at once (if it was running) and a
|
||||
# marker remembers that this hook stopped it.
|
||||
# carrier back pxe-nic-carrier.service runs "pxe-nic-carrier --gain":
|
||||
# after a 2 s settle, if dnsmasq is running or the marker
|
||||
# is set, "systemctl restart dnsmasq" - its ExecStartPre
|
||||
# guard checks the NIC while dnsmasq is down and refuses
|
||||
# to start it on the GE network. Otherwise it only runs
|
||||
# the guard to refresh /run/pxe-nic-status.json. dnsmasq
|
||||
# that an operator or the watch stopped stays stopped.
|
||||
#
|
||||
# Only the imaging NIC (and, for a bridge, its ports) counts, as resolved
|
||||
# by "pxe-nic-check --print-imaging" from /etc/pxe-server/nic.conf or the
|
||||
# dnsmasq interface= pin. Events on any other NIC (the GE NIC) exit at
|
||||
# once and touch nothing. Link state is read from sysfs, not from the
|
||||
# event name, and remembered per interface, so the same transition seen
|
||||
# twice (carrier, then routable) acts once.
|
||||
#
|
||||
# Called by networkd-dispatcher with IFACE and STATE in the environment
|
||||
# (installed as /etc/networkd-dispatcher/<state>.d/50-pxe-nic-carrier for
|
||||
# carrier, degraded, degraded-carrier, enslaved, routable, no-carrier and
|
||||
# off), or by hand: pxe-nic-carrier IFACE. Logs: journalctl -t pxe-nic-carrier
|
||||
#
|
||||
# The dispatcher waits for this script, so the event path never runs a
|
||||
# check itself: loss is two systemctl calls, gain hands off to the service.
|
||||
|
||||
set -u
|
||||
|
||||
CHECK="${PXE_NIC_CHECK:-/usr/local/sbin/pxe-nic-check}"
|
||||
SYS_NET="${PXE_SYS_NET:-/sys/class/net}"
|
||||
RUN="${PXE_NIC_CARRIER_RUN:-/run/pxe-nic-carrier}"
|
||||
SYSTEMCTL="${PXE_SYSTEMCTL:-systemctl}"
|
||||
SETTLE="${PXE_NIC_CARRIER_SETTLE:-2}"
|
||||
GAIN_UNIT="pxe-nic-carrier.service"
|
||||
TAG="pxe-nic-carrier"
|
||||
|
||||
PATH="${PATH:+$PATH:}/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
log() { # log PRIORITY MESSAGE
|
||||
logger -t "$TAG" -p "daemon.$1" -- "$2" 2>/dev/null || \
|
||||
echo "$TAG: $2" >&2
|
||||
}
|
||||
|
||||
imaging_ifaces() {
|
||||
"$CHECK" --print-imaging 2>/dev/null
|
||||
}
|
||||
|
||||
carrier_of() { # 1 if the interface has carrier, else 0 (down reads EINVAL)
|
||||
local c
|
||||
c=$(cat "$SYS_NET/$1/carrier" 2>/dev/null) || c=0
|
||||
[ "$c" = 1 ] && echo 1 || echo 0
|
||||
}
|
||||
|
||||
any_carrier() { # any imaging interface or port with carrier
|
||||
local i
|
||||
for i in "$@"; do
|
||||
[ "$(carrier_of "$i")" = 1 ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
dnsmasq_running() { # active, starting or reloading
|
||||
case "$("$SYSTEMCTL" is-active dnsmasq 2>/dev/null)" in
|
||||
active|activating|reloading) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
on_event() {
|
||||
local iface="$1" list=() match="" i now prev
|
||||
mapfile -t list < <(imaging_ifaces)
|
||||
for i in "${list[@]}"; do
|
||||
[ -n "$i" ] && [ "$i" = "$iface" ] && match=1
|
||||
done
|
||||
[ -n "$match" ] || exit 0 # not the imaging NIC: hands off
|
||||
|
||||
mkdir -p "$RUN" || exit 0
|
||||
exec 9>"$RUN/event.lock"
|
||||
flock -w 10 9 || log warning "lock busy, handling $iface anyway"
|
||||
|
||||
now=$(carrier_of "$iface")
|
||||
prev=$(cat "$RUN/link.$iface" 2>/dev/null) || prev=unknown
|
||||
echo "$now" > "$RUN/link.$iface"
|
||||
[ "$now" = "$prev" ] && exit 0 # same state again: already handled
|
||||
|
||||
if [ "$now" = 0 ]; then
|
||||
# an in-flight gain check must not restart dnsmasq after this
|
||||
"$SYSTEMCTL" stop "$GAIN_UNIT" >/dev/null 2>&1
|
||||
if dnsmasq_running; then
|
||||
: > "$RUN/stopped-dnsmasq"
|
||||
"$SYSTEMCTL" stop --no-block dnsmasq
|
||||
log warning "carrier lost on imaging NIC $iface (${STATE:-event}): stopped dnsmasq; it restarts after the guard passes when the link returns"
|
||||
else
|
||||
log info "carrier lost on imaging NIC $iface (${STATE:-event}); dnsmasq not running"
|
||||
fi
|
||||
else
|
||||
log notice "carrier up on imaging NIC $iface (${STATE:-event}, was $prev): re-checking before dnsmasq serves"
|
||||
"$SYSTEMCTL" restart --no-block "$GAIN_UNIT"
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
on_gain() {
|
||||
local list=() rc
|
||||
sleep "$SETTLE"
|
||||
mapfile -t list < <(imaging_ifaces)
|
||||
if [ "${#list[@]}" -eq 0 ] || [ -z "${list[0]}" ]; then
|
||||
log err "imaging NIC unknown, nothing to re-check"
|
||||
exit 0
|
||||
fi
|
||||
if ! any_carrier "${list[@]}"; then
|
||||
log info "imaging NIC ${list[0]} lost carrier again, nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
if dnsmasq_running || [ -e "$RUN/stopped-dnsmasq" ]; then
|
||||
# restart = stop, then the ExecStartPre guard with dnsmasq down
|
||||
"$SYSTEMCTL" restart dnsmasq
|
||||
rc=$?
|
||||
rm -f "$RUN/stopped-dnsmasq"
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
log notice "imaging NIC ${list[0]}: guard passed, dnsmasq (re)started"
|
||||
else
|
||||
log crit "imaging NIC ${list[0]}: dnsmasq NOT started (guard refused or start failed, rc $rc); see journalctl -u dnsmasq. Fix the cabling, then systemctl start dnsmasq"
|
||||
fi
|
||||
else
|
||||
"$CHECK" --guard >/dev/null 2>&1
|
||||
rc=$?
|
||||
log info "imaging NIC ${list[0]}: dnsmasq was not running, left stopped (guard rc $rc, status refreshed)"
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--gain) on_gain ;;
|
||||
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
|
||||
*) on_event "${1:-${IFACE:-}}" ;;
|
||||
esac
|
||||
16
playbook/pxe-server-helpers/pxe-nic-carrier.service
Normal file
16
playbook/pxe-server-helpers/pxe-nic-carrier.service
Normal file
@@ -0,0 +1,16 @@
|
||||
# Started (never enabled) by pxe-nic-carrier, the networkd-dispatcher
|
||||
# hook, when the imaging NIC gets carrier back. Re-checks the imaging NIC
|
||||
# before dnsmasq serves again: if dnsmasq was running (or the hook stopped
|
||||
# it on carrier loss), "systemctl restart dnsmasq" so its ExecStartPre
|
||||
# guard decides; otherwise it only refreshes /run/pxe-nic-status.json.
|
||||
# The hook restarts this unit on every carrier gain and stops it on a
|
||||
# carrier loss, so the newest event wins.
|
||||
|
||||
[Unit]
|
||||
Description=PXE NIC carrier change: re-check the imaging NIC before dnsmasq serves
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/pxe-nic-carrier --gain
|
||||
TimeoutStartSec=120
|
||||
@@ -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:
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# /run/pxe-nic-status.json for the webapp, and STOPS dnsmasq if the imaging
|
||||
# NIC turns out to be on the GE network or next to another DHCP server.
|
||||
# It never starts dnsmasq: after fixing the cabling, start it by hand
|
||||
# (systemctl start dnsmasq), which re-runs the guard.
|
||||
# (systemctl start dnsmasq), which re-runs the guard. If dnsmasq was
|
||||
# restarted inside the window it re-checks (~6 s) before stopping it.
|
||||
|
||||
[Unit]
|
||||
Description=PXE NIC watch: keep dnsmasq off the GE corporate network
|
||||
|
||||
Reference in New Issue
Block a user