- 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.
1423 lines
52 KiB
Python
Executable File
1423 lines
52 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
pxe-nic-check - classify this PXE server's network interfaces and keep
|
|
dnsmasq from ever serving DHCP/PXE on the GE corporate network.
|
|
|
|
Each PXE server has two NICs:
|
|
|
|
imaging static 172.16.9.1/24 on the isolated imaging switch. dnsmasq
|
|
serves DHCP/TFTP here and ONLY here.
|
|
ge DHCP client on the GE corporate network (updates, webapp).
|
|
|
|
If the imaging NIC ever lands on a network with another DHCP server or a
|
|
managed/corporate switch (cable swap, uplinked switch, second PXE server),
|
|
dnsmasq must not serve. This script finds out which network each NIC is
|
|
really on, without trusting cable labels:
|
|
|
|
passive an AF_PACKET raw socket listens for N seconds for LLDP and CDP
|
|
switch announcements, 802.1X EAPOL (port authentication), STP
|
|
BPDUs, DHCP OFFER/ACK sent by other servers, and counts the
|
|
distinct ARP sender MACs. Sends nothing.
|
|
probe (imaging NIC) one DHCPDISCOVER broadcast out of the interface,
|
|
offers collected for 6 s (dnsmasq ARP/ICMP-pings an address
|
|
for ~3 s before it offers it). Needs no IP config. An offer is "own"
|
|
when its server id is one of this interface's addresses AND it
|
|
came from one of this host's MACs; a box that answers with our
|
|
address from a foreign MAC is a second PXE server and is foreign.
|
|
lease (DHCP-client NICs) the systemd-networkd lease in
|
|
/run/systemd/netif/leases/<ifindex> is read instead of probing.
|
|
|
|
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 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
|
|
|
|
Healthy states: the ge NIC should be "ge"; the imaging NIC should be
|
|
"isolated" (or "no-link"). STP is reported but does not change a verdict.
|
|
|
|
Modes:
|
|
|
|
(default) check, print a summary (or --json), optionally --write.
|
|
--guard dnsmasq ExecStartPre. Imaging NIC, 5 s window + probe (~6 s).
|
|
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. 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)
|
|
|
|
imaging_iface = br-pxe # or imaging_mac = aa:bb:cc:dd:ee:ff
|
|
ge_iface = enp1s0 # or ge_mac = ...
|
|
corp_domain_suffixes = geaerospace.net, ge.com
|
|
own_ip = 172.16.9.1
|
|
allowed_switches = # LLDP/CDP names or chassis MACs of a
|
|
# managed IMAGING switch to ignore
|
|
|
|
Without imaging_iface/imaging_mac the imaging NIC is the interface= line of
|
|
/etc/dnsmasq.d/pxe-nic-only.conf, else the interface holding own_ip. A MAC
|
|
that belongs to a bridge port resolves to the bridge. Without ge_iface/ge_mac
|
|
the ge NIC is the first other interface holding a networkd DHCP lease.
|
|
|
|
Status file (--write, 0644, atomic): /run/pxe-nic-status.json, read by the
|
|
webapp. Shape: see build_status().
|
|
|
|
Operator tests (floor idle) are in docs/pxe-nic-guard.md.
|
|
|
|
Python 3 stdlib only. Runs as root (raw sockets); the parsers do not.
|
|
"""
|
|
|
|
import argparse
|
|
import configparser
|
|
import datetime
|
|
import glob
|
|
import json
|
|
import os
|
|
import random
|
|
import select
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
CONFIG_PATH = "/etc/pxe-server/nic.conf"
|
|
DNSMASQ_NIC_CONF = "/etc/dnsmasq.d/pxe-nic-only.conf"
|
|
DNSMASQ_CONF_GLOBS = ["/etc/dnsmasq.conf", "/etc/dnsmasq.d/*.conf"]
|
|
LEASE_DIR = "/run/systemd/netif/leases"
|
|
SYS_NET = "/sys/class/net"
|
|
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
|
|
ETH_LLDP = 0x88CC
|
|
ETH_EAPOL = 0x888E
|
|
ETH_ARP = 0x0806
|
|
ETH_IP = 0x0800
|
|
VLAN_TPIDS = (0x8100, 0x88A8)
|
|
MAC_CDP = bytes.fromhex("01000ccccccc")
|
|
MAC_STP = bytes.fromhex("0180c2000000")
|
|
MAC_PVST = bytes.fromhex("01000ccccccd")
|
|
DHCP_MAGIC = b"\x63\x82\x53\x63"
|
|
|
|
PROG = "pxe-nic-check"
|
|
|
|
|
|
# ---------------------------------------------------------------- logging
|
|
|
|
def log(msg, level="info"):
|
|
"""stderr; under systemd the <N> prefix sets the journal priority."""
|
|
prio = {"crit": 2, "err": 3, "warning": 4, "notice": 5, "info": 6}[level]
|
|
prefix = "<%d>" % prio if os.environ.get("JOURNAL_STREAM") else ""
|
|
tag = "" if level == "info" else level.upper() + ": "
|
|
sys.stderr.write("%s%s: %s%s\n" % (prefix, PROG, tag, msg))
|
|
sys.stderr.flush()
|
|
|
|
|
|
# ---------------------------------------------------------------- helpers
|
|
|
|
def mac_str(b):
|
|
return ":".join("%02x" % x for x in b)
|
|
|
|
|
|
def norm_mac(s):
|
|
return (s or "").strip().lower().replace("-", ":")
|
|
|
|
|
|
def ip_str(b):
|
|
return socket.inet_ntoa(b)
|
|
|
|
|
|
def read_file(path, default=""):
|
|
try:
|
|
with open(path) as f:
|
|
return f.read().strip()
|
|
except OSError:
|
|
return default
|
|
|
|
|
|
def parse_suffixes(text):
|
|
out = []
|
|
for part in (text or "").replace(";", ",").split(","):
|
|
part = part.strip().strip(".").lower()
|
|
if part:
|
|
out.append(part)
|
|
return out
|
|
|
|
|
|
def domain_matches(domain, suffixes):
|
|
"""Whole-label suffix match: wjs.geaerospace.net and geaerospace.net
|
|
match geaerospace.net; notgeaerospace.net does not."""
|
|
d = (domain or "").strip().strip(".").lower()
|
|
if not d:
|
|
return False
|
|
for s in suffixes:
|
|
if d == s or d.endswith("." + s):
|
|
return True
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------- frame parsing
|
|
|
|
def split_ether(frame):
|
|
"""(dst, src, ethertype_or_len, payload). Skips 802.1Q/QinQ tags;
|
|
returns the innermost VLAN id as well."""
|
|
if len(frame) < 14:
|
|
return None
|
|
dst, src = frame[0:6], frame[6:12]
|
|
etype = struct.unpack("!H", frame[12:14])[0]
|
|
off = 14
|
|
vlan = None
|
|
while etype in VLAN_TPIDS and len(frame) >= off + 4:
|
|
tci, etype = struct.unpack("!HH", frame[off:off + 4])
|
|
vlan = tci & 0x0FFF
|
|
off += 4
|
|
return dst, src, etype, frame[off:], vlan
|
|
|
|
|
|
def _lldp_id(subtype, value, chassis):
|
|
# chassis subtype 4 / port subtype 3 = MAC address; 5 / 4 = network addr
|
|
mac_sub = 4 if chassis else 3
|
|
net_sub = 5 if chassis else 4
|
|
if subtype == mac_sub and len(value) == 6:
|
|
return mac_str(value)
|
|
if subtype == net_sub and len(value) >= 5 and value[0] == 1:
|
|
return ip_str(value[1:5])
|
|
return value.decode("utf-8", "replace").strip("\x00 ")
|
|
|
|
|
|
def parse_lldp(payload):
|
|
"""LLDPDU -> dict(chassis, port, system, port_desc, vlan) or None."""
|
|
out = {"chassis": None, "port": None, "system": None,
|
|
"port_desc": None, "vlan": None}
|
|
off = 0
|
|
while off + 2 <= len(payload):
|
|
hdr = struct.unpack("!H", payload[off:off + 2])[0]
|
|
ttype, tlen = hdr >> 9, hdr & 0x1FF
|
|
val = payload[off + 2:off + 2 + tlen]
|
|
off += 2 + tlen
|
|
if len(val) < tlen:
|
|
return None
|
|
if ttype == 0:
|
|
break
|
|
if ttype == 1 and tlen >= 2:
|
|
out["chassis"] = _lldp_id(val[0], val[1:], True)
|
|
elif ttype == 2 and tlen >= 2:
|
|
out["port"] = _lldp_id(val[0], val[1:], False)
|
|
elif ttype == 4:
|
|
out["port_desc"] = val.decode("utf-8", "replace").strip("\x00 ")
|
|
elif ttype == 5:
|
|
out["system"] = val.decode("utf-8", "replace").strip("\x00 ")
|
|
elif ttype == 127 and tlen >= 6 and val[0:3] == b"\x00\x80\xc2" \
|
|
and val[3] == 1:
|
|
out["vlan"] = struct.unpack("!H", val[4:6])[0]
|
|
if out["chassis"] is None and out["port"] is None:
|
|
return None
|
|
return out
|
|
|
|
|
|
def parse_cdp(payload):
|
|
"""802.3 LLC/SNAP payload -> dict(device, port, vlan) or None."""
|
|
if len(payload) < 12 or payload[0:3] != b"\xaa\xaa\x03" \
|
|
or payload[3:6] != b"\x00\x00\x0c" \
|
|
or payload[6:8] != b"\x20\x00":
|
|
return None
|
|
body = payload[8:]
|
|
off = 4 # version, ttl, checksum
|
|
out = {"device": None, "port": None, "vlan": None}
|
|
while off + 4 <= len(body):
|
|
ttype, tlen = struct.unpack("!HH", body[off:off + 4])
|
|
if tlen < 4:
|
|
break
|
|
val = body[off + 4:off + tlen]
|
|
off += tlen
|
|
if ttype == 0x0001:
|
|
out["device"] = val.decode("utf-8", "replace").strip("\x00 ")
|
|
elif ttype == 0x0003:
|
|
out["port"] = val.decode("utf-8", "replace").strip("\x00 ")
|
|
elif ttype == 0x000A and len(val) >= 2:
|
|
out["vlan"] = struct.unpack("!H", val[0:2])[0]
|
|
if out["device"] is None and out["port"] is None:
|
|
return None
|
|
return out
|
|
|
|
|
|
def _dns_names(data):
|
|
"""RFC 1035 names with RFC 3397 compression (DHCP option 119)."""
|
|
names = []
|
|
i = 0
|
|
while i < len(data):
|
|
labels = []
|
|
pos = i
|
|
jumped = False
|
|
hops = 0
|
|
while pos < len(data):
|
|
n = data[pos]
|
|
if n == 0:
|
|
pos += 1
|
|
break
|
|
if n & 0xC0 == 0xC0:
|
|
if pos + 1 >= len(data):
|
|
return names
|
|
ptr = ((n & 0x3F) << 8) | data[pos + 1]
|
|
if not jumped:
|
|
i = pos + 2
|
|
jumped = True
|
|
hops += 1
|
|
if hops > 32 or ptr >= len(data):
|
|
return names
|
|
pos = ptr
|
|
continue
|
|
labels.append(data[pos + 1:pos + 1 + n].decode("ascii", "replace"))
|
|
pos += 1 + n
|
|
if not jumped:
|
|
i = pos
|
|
if labels:
|
|
names.append(".".join(labels))
|
|
return names
|
|
|
|
|
|
def parse_dhcp_options(opts):
|
|
"""Option bytes -> {code: bytes}; repeated codes are concatenated
|
|
(RFC 3396)."""
|
|
out = {}
|
|
i = 0
|
|
while i < len(opts):
|
|
code = opts[i]
|
|
if code == 255:
|
|
break
|
|
if code == 0:
|
|
i += 1
|
|
continue
|
|
if i + 1 >= len(opts):
|
|
break
|
|
ln = opts[i + 1]
|
|
out[code] = out.get(code, b"") + opts[i + 2:i + 2 + ln]
|
|
i += 2 + ln
|
|
return out
|
|
|
|
|
|
def parse_bootp(udp_payload):
|
|
"""BOOTP/DHCP message -> dict or None."""
|
|
p = udp_payload
|
|
if len(p) < 240 or p[236:240] != DHCP_MAGIC:
|
|
return None
|
|
opts = parse_dhcp_options(p[240:])
|
|
mtype = opts.get(53, b"\x00")[0] if opts.get(53) else 0
|
|
sid = opts.get(54)
|
|
domain = opts.get(15, b"").decode("ascii", "replace").strip("\x00 .") \
|
|
or None
|
|
search = _dns_names(opts[119]) if 119 in opts else []
|
|
return {
|
|
"op": p[0],
|
|
"xid": struct.unpack("!I", p[4:8])[0],
|
|
"yiaddr": ip_str(p[16:20]),
|
|
"chaddr": mac_str(p[28:34]),
|
|
"msgtype": mtype,
|
|
"server_id": ip_str(sid[0:4]) if sid and len(sid) >= 4 else None,
|
|
"domain": domain,
|
|
"search": search,
|
|
}
|
|
|
|
|
|
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:
|
|
return None
|
|
ihl = (payload[0] & 0x0F) * 4
|
|
if payload[0] >> 4 != 4 or payload[9] != 17 or len(payload) < ihl + 8:
|
|
return None
|
|
sport, dport = struct.unpack("!HH", payload[ihl:ihl + 4])
|
|
if sport != 67 or dport not in (68, 67):
|
|
return None
|
|
msg = parse_bootp(payload[ihl + 8:])
|
|
if not msg or msg["op"] != 2:
|
|
return None
|
|
msg["src_ip"] = ip_str(payload[12:16])
|
|
return msg
|
|
|
|
|
|
class Observations:
|
|
"""Everything seen on one interface (bridge + its ports)."""
|
|
|
|
def __init__(self):
|
|
self.lldp = {} # (chassis, port) -> dict
|
|
self.cdp = {} # (device, port) -> dict
|
|
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
|
|
self.frames = 0
|
|
|
|
def feed(self, frame, own_macs, probe_xid=None):
|
|
"""Classify one received (not outgoing) frame."""
|
|
parts = split_ether(frame)
|
|
if not parts:
|
|
return
|
|
dst, src, etype, payload, _vlan = parts
|
|
self.frames += 1
|
|
if etype == ETH_LLDP:
|
|
info = parse_lldp(payload)
|
|
if info:
|
|
self.lldp[(info["chassis"], info["port"])] = info
|
|
return
|
|
if etype == ETH_EAPOL:
|
|
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:
|
|
info = parse_cdp(payload)
|
|
if info:
|
|
self.cdp[(info["device"], info["port"])] = info
|
|
elif dst in (MAC_STP, MAC_PVST) or payload[0:2] == b"\x42\x42":
|
|
self.stp = True
|
|
return
|
|
if dst == MAC_PVST:
|
|
self.stp = True
|
|
return
|
|
if etype == ETH_ARP and len(payload) >= 14:
|
|
sha = mac_str(payload[8:14])
|
|
if sha not in own_macs and sha != "00:00:00:00:00:00":
|
|
self.arp_macs.add(sha)
|
|
return
|
|
msg = parse_dhcp_frame(etype, payload)
|
|
if msg and msg["msgtype"] in (2, 5): # OFFER, ACK
|
|
self.add_dhcp(msg, mac_str(src),
|
|
"probe" if msg["xid"] == probe_xid else "passive")
|
|
|
|
def add_dhcp(self, msg, src_mac, how):
|
|
server = msg["server_id"] or msg["src_ip"]
|
|
key = (server, src_mac)
|
|
if key in self.dhcp:
|
|
return
|
|
self.dhcp[key] = {
|
|
"server": server, "src_mac": src_mac,
|
|
"domain": msg["domain"], "search": msg["search"],
|
|
"yiaddr": msg["yiaddr"], "how": how,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------- DHCP probe
|
|
|
|
def _ip_checksum(hdr):
|
|
s = 0
|
|
for i in range(0, len(hdr), 2):
|
|
s += (hdr[i] << 8) + hdr[i + 1]
|
|
while s >> 16:
|
|
s = (s & 0xFFFF) + (s >> 16)
|
|
return (~s) & 0xFFFF
|
|
|
|
|
|
def build_discover(mac_bytes, xid):
|
|
"""Ethernet/IPv4/UDP DHCPDISCOVER, broadcast flag set."""
|
|
bootp = struct.pack("!BBBBIHH4s4s4s4s16s64s128s",
|
|
1, 1, 6, 0, xid, 0, 0x8000,
|
|
b"\0" * 4, b"\0" * 4, b"\0" * 4, b"\0" * 4,
|
|
mac_bytes + b"\0" * 10, b"\0" * 64, b"\0" * 128)
|
|
opts = DHCP_MAGIC + bytes([53, 1, 1]) \
|
|
+ bytes([55, 6, 1, 3, 6, 15, 54, 119]) \
|
|
+ bytes([60, len(PROG)]) + PROG.encode() + b"\xff"
|
|
bootp += opts
|
|
if len(bootp) < 300:
|
|
bootp += b"\0" * (300 - len(bootp))
|
|
udp = struct.pack("!HHHH", 68, 67, 8 + len(bootp), 0) + bootp
|
|
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0x10, 20 + len(udp),
|
|
random.randint(0, 0xFFFF), 0, 64, 17, 0,
|
|
b"\0" * 4, b"\xff" * 4)
|
|
ip = ip[:10] + struct.pack("!H", _ip_checksum(ip)) + ip[12:]
|
|
eth = b"\xff" * 6 + mac_bytes + struct.pack("!H", ETH_IP)
|
|
return eth + ip + udp
|
|
|
|
|
|
# ---------------------------------------------------------------- interfaces
|
|
|
|
def iface_mac(name):
|
|
return norm_mac(read_file(os.path.join(SYS_NET, name, "address")))
|
|
|
|
|
|
def all_ifaces():
|
|
try:
|
|
return sorted(os.listdir(SYS_NET))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def is_bridge(name):
|
|
return os.path.isdir(os.path.join(SYS_NET, name, "bridge"))
|
|
|
|
|
|
def bridge_ports(name):
|
|
try:
|
|
return sorted(os.listdir(os.path.join(SYS_NET, name, "brif")))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def bridge_master(name):
|
|
link = os.path.join(SYS_NET, name, "master")
|
|
if os.path.islink(link):
|
|
return os.path.basename(os.readlink(link))
|
|
return None
|
|
|
|
|
|
def is_physical(name):
|
|
return os.path.exists(os.path.join(SYS_NET, name, "device"))
|
|
|
|
|
|
def link_info(name):
|
|
carrier = read_file(os.path.join(SYS_NET, name, "carrier"), "0")
|
|
oper = read_file(os.path.join(SYS_NET, name, "operstate"), "down")
|
|
up = carrier == "1" and oper in ("up", "unknown")
|
|
speed = None
|
|
if up:
|
|
try:
|
|
v = int(read_file(os.path.join(SYS_NET, name, "speed"), "-1"))
|
|
speed = v if v > 0 else None
|
|
except ValueError:
|
|
speed = None
|
|
return up, speed
|
|
|
|
|
|
def iface_addrs():
|
|
"""{iface: ["a.b.c.d/nn", ...]} IPv4 only, via iproute2."""
|
|
out = {}
|
|
try:
|
|
res = subprocess.run(["ip", "-j", "-4", "addr", "show"],
|
|
capture_output=True, text=True, timeout=5)
|
|
for entry in json.loads(res.stdout or "[]"):
|
|
name = entry.get("ifname")
|
|
for a in entry.get("addr_info", []):
|
|
if a.get("family") == "inet":
|
|
out.setdefault(name, []).append(
|
|
"%s/%s" % (a["local"], a["prefixlen"]))
|
|
except (OSError, ValueError, subprocess.SubprocessError):
|
|
pass
|
|
return out
|
|
|
|
|
|
def parse_lease(text):
|
|
"""systemd-networkd lease file -> dict of KEY=VALUE."""
|
|
out = {}
|
|
for line in text.splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
out[k.strip()] = v.strip()
|
|
return out
|
|
|
|
|
|
def read_lease(name, lease_dir=LEASE_DIR):
|
|
idx = read_file(os.path.join(SYS_NET, name, "ifindex"))
|
|
if not idx:
|
|
return None
|
|
path = os.path.join(lease_dir, idx)
|
|
if not os.path.exists(path):
|
|
return None
|
|
return parse_lease(read_file(path))
|
|
|
|
|
|
# ---------------------------------------------------------------- config
|
|
|
|
def load_config(path=CONFIG_PATH, dnsmasq_nic_conf=DNSMASQ_NIC_CONF):
|
|
cfg = {"imaging_iface": "", "imaging_mac": "", "ge_iface": "",
|
|
"ge_mac": "", "corp_domain_suffixes": DEFAULT_SUFFIXES,
|
|
"own_ip": DEFAULT_OWN_IP, "allowed_switches": "",
|
|
"dnsmasq_ifaces": []}
|
|
text = read_file(path)
|
|
if text:
|
|
cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";"))
|
|
try:
|
|
try:
|
|
cp.read_string(text)
|
|
except configparser.MissingSectionHeaderError:
|
|
cp = configparser.ConfigParser(
|
|
inline_comment_prefixes=("#", ";"))
|
|
cp.read_string("[nic]\n" + text)
|
|
for sect in cp.sections():
|
|
for k, v in cp.items(sect):
|
|
if k in cfg and v.strip():
|
|
cfg[k] = v.strip()
|
|
except configparser.Error as e:
|
|
log("bad config %s: %s (using defaults)" % (path, e), "warning")
|
|
cfg["imaging_mac"] = norm_mac(cfg["imaging_mac"])
|
|
cfg["ge_mac"] = norm_mac(cfg["ge_mac"])
|
|
cfg["suffixes"] = parse_suffixes(cfg["corp_domain_suffixes"])
|
|
cfg["allowed"] = allowed_list(cfg["allowed_switches"])
|
|
for line in read_file(dnsmasq_nic_conf).splitlines():
|
|
line = line.split("#", 1)[0].strip()
|
|
if line.startswith("interface="):
|
|
for name in line.split("=", 1)[1].split(","):
|
|
if name.strip():
|
|
cfg["dnsmasq_ifaces"].append(name.strip())
|
|
return cfg
|
|
|
|
|
|
def find_by_mac(mac, ifaces):
|
|
if not mac:
|
|
return None
|
|
hits = [i for i in ifaces if iface_mac(i) == mac]
|
|
# a bridge often clones a port's MAC; the port's master owns the IP
|
|
for i in hits:
|
|
master = bridge_master(i)
|
|
if master:
|
|
return master
|
|
return hits[0] if hits else None
|
|
|
|
|
|
def resolve_roles(cfg, ifaces, addrs, lease_dir=LEASE_DIR):
|
|
"""-> (imaging, ge, notes). Either may be None."""
|
|
notes = []
|
|
imaging = None
|
|
if cfg["imaging_iface"] and cfg["imaging_iface"] in ifaces:
|
|
imaging = cfg["imaging_iface"]
|
|
elif cfg["imaging_mac"]:
|
|
imaging = find_by_mac(cfg["imaging_mac"], ifaces)
|
|
if not imaging:
|
|
notes.append("imaging_mac %s not found" % cfg["imaging_mac"])
|
|
if not imaging:
|
|
for name in cfg["dnsmasq_ifaces"]:
|
|
if name in ifaces:
|
|
imaging = name
|
|
notes.append("imaging NIC from %s" % DNSMASQ_NIC_CONF)
|
|
break
|
|
if not imaging:
|
|
for name, alist in sorted(addrs.items()):
|
|
if any(a.split("/")[0] == cfg["own_ip"] for a in alist):
|
|
imaging = name
|
|
notes.append("imaging NIC = holder of %s" % cfg["own_ip"])
|
|
break
|
|
ge = None
|
|
if cfg["ge_iface"] and cfg["ge_iface"] in ifaces:
|
|
ge = cfg["ge_iface"]
|
|
elif cfg["ge_mac"]:
|
|
ge = find_by_mac(cfg["ge_mac"], ifaces)
|
|
if not ge:
|
|
notes.append("ge_mac %s not found" % cfg["ge_mac"])
|
|
if not ge:
|
|
for name in ifaces:
|
|
if name != imaging and name != "lo" and \
|
|
read_lease(name, lease_dir) is not None:
|
|
ge = name
|
|
notes.append("ge NIC = holder of a DHCP lease")
|
|
break
|
|
if ge == imaging:
|
|
ge = None
|
|
return imaging, ge, notes
|
|
|
|
|
|
# ---------------------------------------------------------------- dnsmasq binding
|
|
|
|
def dnsmasq_conf_rules(globs=DNSMASQ_CONF_GLOBS):
|
|
rules = {"interface": set(), "except": set(), "no_dhcp": set(),
|
|
"listen": set()}
|
|
keymap = {"interface": "interface", "except-interface": "except",
|
|
"no-dhcp-interface": "no_dhcp", "listen-address": "listen"}
|
|
for g in globs:
|
|
for path in sorted(glob.glob(g)):
|
|
for line in read_file(path).splitlines():
|
|
line = line.split("#", 1)[0].strip()
|
|
if "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
if k.strip() in keymap:
|
|
for item in v.split(","):
|
|
if item.strip():
|
|
rules[keymap[k.strip()]].add(item.strip())
|
|
return rules
|
|
|
|
|
|
def udp67_sockets():
|
|
"""[(addr, dev, proc)] or None if ss is unavailable."""
|
|
try:
|
|
res = subprocess.run(["ss", "-H", "-uanp", "sport = :67"],
|
|
capture_output=True, text=True, timeout=5)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if res.returncode != 0:
|
|
return None
|
|
out = []
|
|
for line in res.stdout.splitlines():
|
|
cols = line.split()
|
|
if len(cols) < 4:
|
|
continue
|
|
local = cols[3]
|
|
hostpart = local.rsplit(":", 1)[0]
|
|
dev = None
|
|
if "%" in hostpart:
|
|
hostpart, dev = hostpart.split("%", 1)
|
|
proc = None
|
|
if "users:((" in line:
|
|
proc = line.split('users:(("', 1)[1].split('"', 1)[0]
|
|
out.append((hostpart.strip("[]"), dev, proc))
|
|
return out
|
|
|
|
|
|
def dnsmasq_bound(iface, addrs, socks, rules):
|
|
if socks is None:
|
|
return None
|
|
mine = [s for s in socks if s[2] is None or "dnsmasq" in s[2]]
|
|
if not mine:
|
|
return False
|
|
ips = [a.split("/")[0] for a in addrs]
|
|
for addr, dev, _proc in mine:
|
|
if dev == iface:
|
|
return True
|
|
if dev is None and addr in ips:
|
|
return True
|
|
if not any(dev is None and addr in ("0.0.0.0", "*", "")
|
|
for addr, dev, _p in mine):
|
|
return False
|
|
# wildcard socket: dnsmasq filters per interface by its config
|
|
if iface in rules["no_dhcp"] or iface in rules["except"]:
|
|
return False
|
|
if iface in rules["interface"]:
|
|
return True
|
|
if any(ip in rules["listen"] for ip in ips):
|
|
return True
|
|
if not rules["interface"] and not rules["listen"]:
|
|
return bool(ips)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------- BPF filter
|
|
|
|
# The imaging NIC moves gigabits of SMB while a watch runs. A classic BPF
|
|
# program in the kernel passes only the frames this script parses, so the
|
|
# Python loop never sees bulk traffic. Ops are (code, k, jt, jf) with jt/jf
|
|
# as labels (None = next instruction).
|
|
_BPF = {"ldh": 0x28, "ldb": 0x30, "jeq": 0x15, "jset": 0x45, "ret": 0x06,
|
|
"ldxmsh": 0xB1, "ldhind": 0x48}
|
|
BPF_ACCEPT = 0x40000
|
|
|
|
BPF_PROGRAM = [
|
|
("ldh", 12), # ethertype / 802.3 length
|
|
("jeq", ETH_LLDP, "ok"),
|
|
("jeq", ETH_EAPOL, "ok"),
|
|
("jeq", ETH_ARP, "ok"),
|
|
("jeq", 0x8100, "ok"), # tagged: let Python unwrap it
|
|
("jeq", 0x88A8, "ok"),
|
|
("jeq", ETH_IP, "ip"),
|
|
("ldh", 0), # destination MAC prefix
|
|
("jeq", 0x0180, "ok"), # 01:80:c2 STP, LLDP, EAPOL
|
|
("jeq", 0x0100, None, "drop"),
|
|
("ldh", 2),
|
|
("jeq", 0x0CCC, "ok", "drop"), # 01:00:0c:cc:cc:cc/cd CDP, PVST
|
|
("label", "ip"),
|
|
("ldb", 23), # IP protocol
|
|
("jeq", 17, None, "drop"), # UDP only
|
|
("ldh", 20),
|
|
("jset", 0x1FFF, "drop"), # not a later fragment
|
|
("ldxmsh", 14), # X = IP header length
|
|
("ldhind", 14), # UDP source port
|
|
("jeq", 67, "ok", "drop"), # from a DHCP server
|
|
("label", "ok"),
|
|
("ret", BPF_ACCEPT),
|
|
("label", "drop"),
|
|
("ret", 0),
|
|
]
|
|
|
|
|
|
def assemble_bpf(program=BPF_PROGRAM):
|
|
"""-> list of (code, jt, jf, k) with relative jumps resolved."""
|
|
labels = {}
|
|
insns = []
|
|
for op in program:
|
|
if op[0] == "label":
|
|
labels[op[1]] = len(insns)
|
|
else:
|
|
insns.append(op)
|
|
out = []
|
|
for i, op in enumerate(insns):
|
|
name, k = op[0], op[1]
|
|
jt = jf = 0
|
|
if name in ("jeq", "jset"):
|
|
t = op[2] if len(op) > 2 else None
|
|
f = op[3] if len(op) > 3 else None
|
|
jt = labels[t] - i - 1 if t else 0
|
|
jf = labels[f] - i - 1 if f else 0
|
|
if not (0 <= jt < 256 and 0 <= jf < 256):
|
|
raise ValueError("BPF jump out of range at %d" % i)
|
|
out.append((_BPF[name], jt, jf, k))
|
|
return out
|
|
|
|
|
|
def attach_bpf(sock):
|
|
import ctypes
|
|
insns = assemble_bpf()
|
|
raw = b"".join(struct.pack("HBBI", c, jt, jf, k)
|
|
for c, jt, jf, k in insns)
|
|
buf = ctypes.create_string_buffer(raw, len(raw))
|
|
fprog = struct.pack("HL", len(insns), ctypes.addressof(buf))
|
|
sock.setsockopt(socket.SOL_SOCKET, 26, fprog) # SO_ATTACH_FILTER
|
|
|
|
|
|
# ---------------------------------------------------------------- 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
|
|
({report_iface: Observations}, {report_iface: error or None},
|
|
{report_iface: probe_xid or None})."""
|
|
obs = {name: Observations() for name in targets}
|
|
errors = {name: None for name in targets}
|
|
xids = {name: None for name in targets}
|
|
socks = {}
|
|
for name, cap_list in targets.items():
|
|
for cap in cap_list:
|
|
try:
|
|
# protocol 0 = receive nothing until the filter is on
|
|
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, 0)
|
|
try:
|
|
attach_bpf(s)
|
|
except (OSError, ValueError) as e:
|
|
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:
|
|
if cap == name:
|
|
errors[name] = "capture on %s failed: %s" % (cap, e)
|
|
start = time.monotonic()
|
|
for name in probe_ifaces:
|
|
if errors.get(name) or name not in targets:
|
|
continue
|
|
try:
|
|
mac = bytes.fromhex(iface_mac(name).replace(":", ""))
|
|
xid = random.randint(1, 0xFFFFFFFF)
|
|
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW)
|
|
s.bind((name, 0))
|
|
s.send(build_discover(mac, xid))
|
|
s.close()
|
|
xids[name] = xid
|
|
except (OSError, ValueError) as e:
|
|
errors[name] = "DHCP probe on %s failed: %s" % (name, e)
|
|
deadline = start + max(window, probe_window if any(xids.values()) else 0)
|
|
try:
|
|
while socks:
|
|
left = deadline - time.monotonic()
|
|
if left <= 0:
|
|
break
|
|
ready, _, _ = select.select(list(socks), [], [], min(left, 1.0))
|
|
for s in ready:
|
|
name = socks[s]
|
|
while True:
|
|
try:
|
|
frame, addr = s.recvfrom(65535)
|
|
except BlockingIOError:
|
|
break
|
|
except OSError:
|
|
break
|
|
if len(addr) > 2 and addr[2] == PACKET_OUTGOING:
|
|
continue
|
|
try:
|
|
obs[name].feed(frame, own_macs, xids[name])
|
|
except Exception as e: # never let one frame kill a run
|
|
log("frame parse error on %s: %s" % (name, e),
|
|
"warning")
|
|
finally:
|
|
for s in socks:
|
|
s.close()
|
|
return obs, errors, xids
|
|
|
|
|
|
# ---------------------------------------------------------------- verdicts
|
|
|
|
def allowed_list(text):
|
|
"""Names match case-insensitively; MACs in any separator style."""
|
|
out = set()
|
|
for x in parse_suffixes(text):
|
|
out.add(x.lower())
|
|
out.add(norm_mac(x))
|
|
return out
|
|
|
|
|
|
def _allowed(names, allowed):
|
|
return any(n and (n.lower() in allowed or norm_mac(n) in allowed)
|
|
for n in names)
|
|
|
|
|
|
def classify(link_up, error, obs, dhcp_servers, suffixes, window,
|
|
probed, lease_used, allowed=()):
|
|
"""-> (verdict, evidence). dhcp_servers: list of dicts with keys
|
|
server, domain, search, own, src_mac, yiaddr, how. `allowed` lists
|
|
LLDP/CDP system names or chassis ids of a managed imaging switch that
|
|
should not count (EAPOL always counts)."""
|
|
evidence = []
|
|
if error:
|
|
return "error", [error]
|
|
if not link_up:
|
|
return "no-link", ["no carrier"]
|
|
|
|
switch = False
|
|
for info in obs.lldp.values() if obs else []:
|
|
txt = "LLDP from %s port %s" % (info["system"] or info["chassis"],
|
|
info["port"])
|
|
if info.get("port_desc"):
|
|
txt += " (%s)" % info["port_desc"]
|
|
if info.get("vlan") is not None:
|
|
txt += " vlan %s" % info["vlan"]
|
|
if _allowed([info["system"], info["chassis"]], allowed):
|
|
txt += " (allowed imaging switch, ignored)"
|
|
else:
|
|
switch = True
|
|
evidence.append(txt)
|
|
for info in obs.cdp.values() if obs else []:
|
|
txt = "CDP from %s port %s" % (info["device"], info["port"])
|
|
if info.get("vlan") is not None:
|
|
txt += " vlan %s" % info["vlan"]
|
|
if _allowed([info["device"]], allowed):
|
|
txt += " (allowed imaging switch, ignored)"
|
|
else:
|
|
switch = True
|
|
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)")
|
|
|
|
corp = False
|
|
foreign = False
|
|
for d in dhcp_servers:
|
|
doms = [x for x in [d.get("domain")] + list(d.get("search") or [])
|
|
if x]
|
|
is_corp = any(domain_matches(x, suffixes) for x in doms)
|
|
if d.get("own"):
|
|
evidence.append("own DHCP server %s answered (ignored)"
|
|
% d["server"])
|
|
continue
|
|
txt = "DHCP from %s" % d["server"]
|
|
txt += ", domain %s" % ", ".join(doms) if doms else ", no domain"
|
|
if d.get("yiaddr") and d["yiaddr"] != "0.0.0.0":
|
|
txt += ", offered %s" % d["yiaddr"]
|
|
if d.get("how"):
|
|
txt += " (%s)" % d["how"]
|
|
if d.get("claims_own"):
|
|
txt += ", CLAIMS OUR ADDRESS from %s (second PXE server?)" \
|
|
% d.get("src_mac")
|
|
evidence.append(txt)
|
|
foreign = True
|
|
corp = corp or is_corp
|
|
|
|
switch = switch or bool(obs is not None and obs.eapol)
|
|
if switch or corp:
|
|
verdict = "ge"
|
|
elif foreign:
|
|
verdict = "foreign-dhcp"
|
|
else:
|
|
verdict = "isolated"
|
|
quiet = []
|
|
what = "no foreign DHCP server" if any(
|
|
d.get("own") for d in dhcp_servers) else "no DHCP server"
|
|
quiet.append(what if (probed or lease_used)
|
|
else what + " seen (not probed)")
|
|
quiet.append("no switch announcements in %d s" % window)
|
|
evidence.append(", ".join(quiet))
|
|
if obs is not None:
|
|
evidence.append("%d ARP hosts seen" % len(obs.arp_macs))
|
|
return verdict, evidence
|
|
|
|
|
|
def dhcp_list(obs, own_ips, local_macs):
|
|
out = []
|
|
for d in (obs.dhcp.values() if obs else []):
|
|
claims_own = d["server"] in own_ips
|
|
own = claims_own and d["src_mac"] in local_macs
|
|
e = dict(d)
|
|
e["own"] = own
|
|
e["claims_own"] = claims_own and not own
|
|
out.append(e)
|
|
return out
|
|
|
|
|
|
def lease_servers(lease):
|
|
if not lease:
|
|
return []
|
|
server = lease.get("SERVER_ADDRESS") or lease.get("ROUTER") or "?"
|
|
search = [x for x in lease.get("DOMAIN_SEARCH_LIST", "").split() if x]
|
|
return [{"server": server, "domain": lease.get("DOMAINNAME") or None,
|
|
"search": search, "own": False, "src_mac": None,
|
|
"yiaddr": lease.get("ADDRESS"), "how": "networkd lease",
|
|
"claims_own": False}]
|
|
|
|
|
|
def guard_state(imaging_entry):
|
|
if imaging_entry is None:
|
|
return "unknown", "imaging NIC could not be determined"
|
|
v = imaging_entry["verdict"]
|
|
name = imaging_entry["iface"]
|
|
if v in ("ge", "foreign-dhcp"):
|
|
why = "; ".join(e for e in imaging_entry["evidence"]
|
|
if not e.endswith("ARP hosts seen"))
|
|
return "blocked", "imaging NIC %s verdict %s: %s" % (name, v, why)
|
|
if v == "isolated":
|
|
return "allowed", "imaging NIC %s is isolated" % name
|
|
if v == "no-link":
|
|
return "allowed", "imaging NIC %s has no link" % name
|
|
return "unknown", "imaging NIC %s check failed: %s" % (
|
|
name, "; ".join(imaging_entry["evidence"]))
|
|
|
|
|
|
def build_status(entries, duration, guard):
|
|
"""The webapp contract. Field names are fixed."""
|
|
return {
|
|
"checked_at": datetime.datetime.now().astimezone().isoformat(
|
|
timespec="seconds"),
|
|
"duration_s": int(round(duration)),
|
|
"dhcp_guard": {"state": guard[0], "reason": guard[1]},
|
|
"interfaces": entries,
|
|
}
|
|
|
|
|
|
def write_status(path, status):
|
|
d = os.path.dirname(os.path.abspath(path)) or "."
|
|
fd, tmp = tempfile.mkstemp(prefix=".pxe-nic-status.", dir=d)
|
|
try:
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(status, f, indent=2)
|
|
f.write("\n")
|
|
os.chmod(tmp, 0o644)
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
# ---------------------------------------------------------------- main run
|
|
|
|
def run_check(args, cfg):
|
|
ifaces = all_ifaces()
|
|
addrs = iface_addrs()
|
|
imaging, ge, notes = resolve_roles(cfg, ifaces, addrs, args.lease_dir)
|
|
for n in notes:
|
|
log(n)
|
|
|
|
if args.iface:
|
|
report = [i for i in args.iface if i in ifaces]
|
|
for i in args.iface:
|
|
if i not in ifaces:
|
|
log("no such interface: %s" % i, "warning")
|
|
elif args.mode == "guard":
|
|
# guard: imaging NIC only, plus the GE NIC when its networkd lease
|
|
# (or lack of link) says something without capturing on it
|
|
report = [imaging] if imaging else []
|
|
if ge and (read_lease(ge, args.lease_dir) is not None
|
|
or not link_info(ge)[0]):
|
|
report.append(ge)
|
|
else:
|
|
report = [x for x in (imaging, ge) if x]
|
|
for i in ifaces:
|
|
if i not in report and is_physical(i) and not bridge_master(i):
|
|
report.append(i)
|
|
|
|
local_macs = set(iface_mac(i) for i in ifaces) - {"00:00:00:00:00:00"}
|
|
links = {i: link_info(i) for i in report}
|
|
leases = {i: read_lease(i, args.lease_dir) for i in report}
|
|
|
|
targets = {}
|
|
for i in report:
|
|
if not links[i][0]:
|
|
continue
|
|
if args.mode == "guard" and i != imaging:
|
|
continue # guard: GE NIC from its lease only, no capture
|
|
cap = [i]
|
|
if is_bridge(i):
|
|
# link-local frames (LLDP, EAPOL, STP) stop at the bridge port
|
|
cap += bridge_ports(i)
|
|
targets[i] = cap
|
|
|
|
# Probe the imaging NIC (or every --iface given). A NIC holding a
|
|
# networkd lease is a DHCP client: its lease says who the server is,
|
|
# so it is never probed.
|
|
probe_ifaces = []
|
|
if args.probe:
|
|
probe_ifaces = [i for i in targets
|
|
if (i == imaging or args.iface)
|
|
and leases.get(i) is None]
|
|
|
|
t0 = time.monotonic()
|
|
obs, errors, xids = {}, {}, {}
|
|
if targets:
|
|
obs, errors, xids = capture(targets, args.window, probe_ifaces,
|
|
local_macs, args.probe_window)
|
|
duration = time.monotonic() - t0
|
|
|
|
socks = udp67_sockets()
|
|
rules = dnsmasq_conf_rules()
|
|
entries = []
|
|
imaging_entry = None
|
|
for i in report:
|
|
up, speed = links[i]
|
|
role = "imaging" if i == imaging else "ge" if i == ge else "other"
|
|
o = obs.get(i)
|
|
own_ips = set(a.split("/")[0] for a in addrs.get(i, []))
|
|
servers = dhcp_list(o, own_ips, local_macs)
|
|
lease_used = False
|
|
if leases.get(i) is not None:
|
|
servers += lease_servers(leases[i])
|
|
lease_used = True
|
|
window = int(round(duration)) if i in targets else 0
|
|
verdict, evidence = classify(
|
|
up, errors.get(i), o if i in targets else None, servers,
|
|
cfg["suffixes"], window, bool(xids.get(i)), lease_used,
|
|
cfg["allowed"])
|
|
if i not in targets and lease_used:
|
|
evidence.append("from the networkd lease only (guard mode)")
|
|
if xids.get(i) and not any(s["how"] == "probe" for s in servers):
|
|
evidence.append("DHCP probe: no offers in %g s"
|
|
% args.probe_window)
|
|
if role == "imaging" and verdict not in ("isolated", "no-link"):
|
|
evidence.append("UNHEALTHY: imaging NIC must be isolated")
|
|
if role == "ge" and verdict not in ("ge", "no-link"):
|
|
evidence.append("note: GE NIC does not look like the GE network")
|
|
seen = set()
|
|
dhcp_out = []
|
|
for s in servers:
|
|
key = (s["server"], s["domain"], s["own"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
dom = s["domain"] or (s["search"][0] if s.get("search") else None)
|
|
dhcp_out.append({"server": s["server"], "domain": dom,
|
|
"own": bool(s["own"])})
|
|
entry = {
|
|
"iface": i,
|
|
"mac": iface_mac(i),
|
|
"role": role,
|
|
"link": "up" if up else "down",
|
|
"speed_mbps": speed,
|
|
"addrs": addrs.get(i, []),
|
|
"verdict": verdict,
|
|
"evidence": evidence,
|
|
"dhcp_servers": dhcp_out,
|
|
"switch": {
|
|
"lldp": [{"system": x["system"] or x["chassis"],
|
|
"port": x["port"], "vlan": x["vlan"]}
|
|
for x in (o.lldp.values() if o else [])],
|
|
"cdp": [{"device": x["device"], "port": x["port"]}
|
|
for x in (o.cdp.values() if o else [])],
|
|
"eapol": bool(o and o.eapol),
|
|
"stp": bool(o and o.stp),
|
|
},
|
|
"arp_hosts": len(o.arp_macs) if o else 0,
|
|
"dnsmasq_bound": dnsmasq_bound(i, addrs.get(i, []), socks, rules),
|
|
}
|
|
entries.append(entry)
|
|
if role == "imaging":
|
|
imaging_entry = entry
|
|
guard = guard_state(imaging_entry)
|
|
return build_status(entries, duration, guard), imaging_entry
|
|
|
|
|
|
def summary(status):
|
|
lines = ["checked %s (%d s), dhcp_guard %s: %s" % (
|
|
status["checked_at"], status["duration_s"],
|
|
status["dhcp_guard"]["state"], status["dhcp_guard"]["reason"])]
|
|
for e in status["interfaces"]:
|
|
lines.append(" %-16s %-8s %-4s %-13s %s" % (
|
|
e["iface"], e["role"], e["link"], e["verdict"],
|
|
", ".join(e["addrs"]) or "-"))
|
|
for ev in e["evidence"]:
|
|
lines.append(" " + ev)
|
|
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,
|
|
description="Classify the PXE server's NICs (imaging vs GE "
|
|
"corporate) and keep dnsmasq off the corporate network.",
|
|
epilog="Operator tests: docs/pxe-nic-guard.md. Exit codes: --guard "
|
|
"0 = safe to serve, 1 = DO NOT serve; other modes 0, or 2 on "
|
|
"an internal error.")
|
|
mode = ap.add_mutually_exclusive_group()
|
|
mode.add_argument("--guard", dest="mode", action="store_const",
|
|
const="guard", help="dnsmasq ExecStartPre check")
|
|
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,
|
|
help="write the status JSON atomically (default %s)"
|
|
% STATUS_PATH)
|
|
ap.add_argument("--window", type=float, default=None,
|
|
help="passive capture seconds (guard 5, otherwise 60)")
|
|
ap.add_argument("--probe", dest="probe", action="store_true",
|
|
default=None, help="send one DHCPDISCOVER on the "
|
|
"imaging NIC (default on in --guard/--watch)")
|
|
ap.add_argument("--no-probe", dest="probe", action="store_false")
|
|
# 6 s, not 3: dnsmasq (the likeliest rogue, a second PXE server) pings
|
|
# the address it is about to offer and only answers ~3 s later.
|
|
ap.add_argument("--probe-window", type=float, default=6.0,
|
|
help="seconds to collect DHCP offers (default 6)")
|
|
ap.add_argument("--iface", action="append",
|
|
help="check only this interface (repeatable)")
|
|
ap.add_argument("--config", default=CONFIG_PATH)
|
|
ap.add_argument("--dnsmasq-nic-conf", default=DNSMASQ_NIC_CONF)
|
|
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:
|
|
args.window = 5.0 if args.mode == "guard" else 60.0
|
|
if args.probe is None:
|
|
args.probe = args.mode in ("guard", "watch")
|
|
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)
|
|
except Exception as e: # fail open on bugs, but loudly
|
|
import traceback
|
|
log("INTERNAL ERROR, check did not complete: %s" % e, "crit")
|
|
traceback.print_exc()
|
|
if args.mode == "guard":
|
|
log("guard FAILING OPEN: dnsmasq allowed to start unchecked",
|
|
"crit")
|
|
return 0
|
|
return 2
|
|
|
|
if args.write:
|
|
try:
|
|
write_status(args.write, status)
|
|
except OSError as e:
|
|
log("could not write %s: %s" % (args.write, e), "err")
|
|
if args.json:
|
|
print(json.dumps(status, indent=2))
|
|
elif args.mode == "check":
|
|
print(summary(status))
|
|
|
|
state, reason = status["dhcp_guard"]["state"], \
|
|
status["dhcp_guard"]["reason"]
|
|
if args.mode == "guard":
|
|
if state == "blocked":
|
|
log("REFUSING to start dnsmasq: " + reason, "crit")
|
|
return 1
|
|
if state == "unknown" or (imaging and imaging["verdict"] == "no-link"):
|
|
log("guard could not verify the imaging NIC, allowing dnsmasq: "
|
|
+ reason, "err")
|
|
else:
|
|
log("ok: " + reason)
|
|
return 0
|
|
if args.mode == "watch":
|
|
if state == "blocked":
|
|
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:
|
|
log("ok: " + reason)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|