Files
pxe-server/webapp/services/nic_status.py
cproudlock 606659bf07 webapp: fix review findings (imaging types, NIC status, iPXE gates)
- imaging: coerce client payload and stored session fields to str/int,
  drop lists/dicts; tiles template guards pctype/current_stage; POST
  rejects non-object bodies and non-string serials.
- nic_status: normalise every check-file entry; load nic.conf as-is with
  a [nic] fallback; split interface= lists; keep the check's binding while
  fresh, resolve wildcard sockets from the dnsmasq pin (never True for an
  excluded NIC); mark vanished NICs down; include role bridges; stale or
  future-dated files downgrade to warnings and drop evidence.
- Overview renders the network pill in its own try and shows
  'Network: unknown' when status fails.
- ipxe_update: read every Authenticode signature (all WIN_CERTIFICATE
  entries plus nested SPC_NESTED_SIGNATURE) with openssl; shim swap and
  install need both Microsoft UEFI CA 2011 and 2023; install re-reads
  each file (snponly ipxe.efi only); staged.json schema version, older
  records shown stale and refused; uhci counts as native USB.
- backups describe their files; risky rollbacks need a confirm flag.
- mac_folders: guarded listdir, cached describe, configured TFTP root,
  warning for a shim without ipxe.efi.
- evidence pack: years 2000..2100, 400 days inclusive.
- a11y role=group on quick ranges; progress bar stays full during 'shim'.
- tests/test_review_fixes.py: 29 unittest cases incl. both real shims.
2026-10-15 14:20:24 -04:00

382 lines
15 KiB
Python

"""Network interfaces for the Overview: link, addresses, role and verdict.
The server has two NICs: the imaging NIC (static 172.16.9.1, the only one
dnsmasq may serve DHCP/PXE on) and the GE NIC (corporate DHCP client). The
classifier pxe-nic-check (playbook/pxe-server-helpers) writes its verdicts
to config.NIC_STATUS_FILE; when that file is missing or stale this module
falls back to what the kernel says (link, speed, addresses) and which
interface dnsmasq is bound to, without a verdict.
Read-only: never changes interfaces or services.
"""
from __future__ import annotations
import configparser
import json
import os
import re
import subprocess
from datetime import datetime, timezone
import config
STALE_SECONDS = 20 * 60 # the watch timer runs every 5 min
# A checked_at this far in the future is clock skew or a bad file: stale.
FUTURE_SKEW_SECONDS = 300
SYS_NET = "/sys/class/net"
# cdc_ncm (the generic USB NIC driver, e.g. a WisdPI RTL8157 5G adapter on a
# kernel whose r8152 does not know the chip) keeps the link speed as a 32-bit
# bits-per-second value, so multi-gig links wrap: 5 Gb/s reads 705 Mb/s and
# 10 Gb/s reads 1410 Mb/s.
CDC_NCM_WRAPPED = {705: 5000, 1410: 10000}
def _read(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return ""
def _norm_mac(v):
return str(v or "").strip().lower().replace("-", ":")
def _nic_conf():
"""{key: value} from nic.conf, loaded like pxe-nic-check does: as-is
(the playbook writes a [nic] header), else with a [nic] header added."""
try:
with open(config.NIC_CONF) as f:
text = f.read()
except (OSError, UnicodeDecodeError):
return {}
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)
out = {}
for sect in cp.sections():
for k, v in cp.items(sect):
if v and v.strip():
out[k] = v.strip()
return out
except configparser.Error:
return {}
def _dnsmasq_rules():
"""dnsmasq's NIC pin: {interface, except, no_dhcp, listen} sets, from
DNSMASQ_CONF (and what it includes) plus DNSMASQ_NIC_CONF. None when
neither could be read (the pin is then unknown)."""
from services import ipxe_update # its include-aware dnsmasq.conf reader
seen = set()
lines = ipxe_update._conf_lines(config.DNSMASQ_CONF, seen=seen)
lines += ipxe_update._conf_lines(config.DNSMASQ_NIC_CONF, seen=seen)
if not lines:
return None
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 _src, line in lines:
key, _, val = line.partition("=")
key = key.strip()
if key in keymap:
for item in val.split(","):
if item.strip():
rules[keymap[key]].add(item.strip())
return rules
def _roles(rules=None):
"""{iface or mac: role} from nic.conf, else from dnsmasq's NIC pin."""
roles = {}
conf = _nic_conf()
for role in ("imaging", "ge"):
if conf.get(f"{role}_iface"):
roles[conf[f"{role}_iface"].lower()] = role
if conf.get(f"{role}_mac"):
roles[_norm_mac(conf[f"{role}_mac"])] = role
if not roles:
rules = _dnsmasq_rules() if rules is None else rules
for name in sorted((rules or {}).get("interface", ())):
roles[name] = "imaging"
for name in sorted((rules or {}).get("except", ())):
roles.setdefault(name, "ge")
return roles
def _addrs():
"""{iface: ["a.b.c.d/nn", ...]} (IPv4 and global IPv6) from `ip -j addr`."""
try:
out = subprocess.run(["ip", "-j", "addr", "show"], capture_output=True, text=True, timeout=5).stdout
data = json.loads(out or "[]")
except (OSError, subprocess.SubprocessError, ValueError):
return {}
res = {}
for link in data if isinstance(data, list) else []:
if not isinstance(link, dict):
continue
res[str(link.get("ifname", ""))] = [
f"{a.get('local')}/{a.get('prefixlen')}" for a in link.get("addr_info", []) or []
if isinstance(a, dict) and a.get("local")
and (a.get("family") == "inet" or (a.get("family") == "inet6" and a.get("scope") == "global"))]
return res
def _dhcp_sockets():
"""[(addr, dev)] of the UDP 67 sockets; dev is None for an unbound one.
None if ss cannot be run."""
try:
r = subprocess.run(["ss", "-H", "-uln", "sport", "= :67"], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return None
if r.returncode != 0:
return None
out = []
for line in r.stdout.splitlines():
fields = line.split()
if len(fields) < 4:
continue
host = fields[3].rsplit(":", 1)[0]
host, _, dev = host.partition("%")
out.append((host.strip("[]"), dev or None))
return out
def _bound(name, ips, socks, rules):
"""Does dnsmasq answer DHCP on `name`? True/False, or None if unknown.
Mirrors pxe-nic-check: a wildcard socket serves only what dnsmasq's
interface pin allows, so it never marks an excluded NIC as served."""
if socks is None:
return None
if not socks:
return False
for addr, dev in socks:
if dev == name or (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 in socks):
return False
if rules is None:
return None # wildcard socket and the pin is unknown
if name in rules["no_dhcp"] or name in rules["except"]:
return False
if name in rules["interface"] or any(ip in rules["listen"] for ip in ips):
return True
if not rules["interface"] and not rules["listen"]:
return bool(ips)
return False
def _kernel_view():
"""Interfaces as the kernel sees them: physical NICs, plus bridges that
carry a role (the imaging NIC is often a bridge over the port).
Returns (interfaces, ok) where ok is False if /sys could not be read."""
rules = _dnsmasq_rules()
roles, addrs, socks = _roles(rules), _addrs(), _dhcp_sockets()
try:
names = sorted(os.listdir(SYS_NET))
except OSError:
return [], False
macs = {n: _read(os.path.join(SYS_NET, n, "address")).lower() for n in names}
bridges, port_of = {}, {}
for name in names:
brif = os.path.join(SYS_NET, name, "brif")
if os.path.isdir(os.path.join(SYS_NET, name, "bridge")):
try:
ports = sorted(os.listdir(brif))
except OSError:
ports = []
role = roles.get(name) or roles.get(macs.get(name, ""))
role = role or next((roles.get(p) or roles.get(macs.get(p, "")) for p in ports
if roles.get(p) or roles.get(macs.get(p, ""))), None)
if role:
bridges[name] = role
for p in ports:
port_of[p] = name
out = []
for name in names:
base = os.path.join(SYS_NET, name)
physical = os.path.exists(os.path.join(base, "device"))
if name == "lo" or not (physical or name in bridges):
continue # skip loopback, plain bridges, veths, tunnels
mac = macs.get(name, "")
oper = _read(os.path.join(base, "operstate"))
try:
speed = int(_read(os.path.join(base, "speed")))
speed = speed if speed > 0 else None
except ValueError:
speed = None
notes = []
driver = os.path.basename(os.path.realpath(os.path.join(base, "device", "driver"))) if physical else ""
if driver == "cdc_ncm" and speed in CDC_NCM_WRAPPED:
notes.append(f"Link {CDC_NCM_WRAPPED[speed] // 1000} Gb/s (the cdc_ncm driver reports "
f"{speed} Mb/s: its 32-bit speed counter wraps above 4.29 Gb/s)")
speed = CDC_NCM_WRAPPED[speed]
if name in bridges:
role = bridges[name]
notes.append("bridge")
elif name in port_of:
role = "other" # the bridge carries the role and the address
notes.append(f"port of bridge {port_of[name]}")
else:
role = roles.get(name) or roles.get(mac) or "other"
up = oper == "up" or (name in bridges and oper == "unknown")
ips = [a.split("/")[0] for a in addrs.get(name, [])]
out.append({
"iface": name, "mac": mac, "role": role,
"link": "up" if up else "down",
"speed_mbps": speed if up else None,
"addrs": addrs.get(name, []),
"verdict": None, "evidence": [], "notes": notes,
"dnsmasq_bound": None if name in port_of else _bound(name, ips, socks, rules),
})
return out, True
def _age_seconds(stamp):
try:
t = datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
if t.tzinfo is None:
t = t.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - t).total_seconds()
except (ValueError, TypeError, OverflowError):
return None
def _str(v):
return v.strip() if isinstance(v, str) else ""
def _str_list(v):
if isinstance(v, str):
v = [v]
if not isinstance(v, (list, tuple)):
return []
return [x if isinstance(x, str) else str(x) for x in v
if isinstance(x, (str, int, float)) and not isinstance(x, bool)]
def _speed(v):
if isinstance(v, bool):
return None
try:
n = int(v)
except (TypeError, ValueError, OverflowError):
return None
return n if 0 < n < 10_000_000 else None
def _norm_iface(i):
"""One interface entry of the check file with every field the page uses
in the type it expects; None for something that is not an entry."""
if not isinstance(i, dict):
return None
bound = i.get("dnsmasq_bound")
return {
"iface": _str(i.get("iface")) or "?",
"mac": _norm_mac(_str(i.get("mac"))),
"role": _str(i.get("role")) or "other",
"link": "up" if _str(i.get("link")) == "up" else "down",
"speed_mbps": _speed(i.get("speed_mbps")),
"addrs": _str_list(i.get("addrs")),
"verdict": _str(i.get("verdict")) or None,
"evidence": _str_list(i.get("evidence")),
"notes": _str_list(i.get("notes")),
"dnsmasq_bound": bound if isinstance(bound, bool) else None,
}
def _when(stamp):
"""checked_at for a sentence: 'YYYY-MM-DD HH:MM'."""
return str(stamp or "?").replace("T", " ")[:16]
def _problems(ifaces, guard, stale=False, checked_at=None):
"""[(level, text)]: 'bad' is a DHCP-on-the-wrong-network risk."""
out = []
if (guard or {}).get("state") == "blocked":
reason = guard.get("reason") or "the imaging NIC is not isolated"
if stale:
out.append(("warn", f"The last network check ({_when(checked_at)}) said DHCP/PXE is blocked: {reason}"))
else:
out.append(("bad", "DHCP/PXE is blocked: " + reason))
imaging = [i for i in ifaces if i["role"] == "imaging"]
for i in ifaces:
if i["role"] == "imaging" and i.get("verdict") in ("ge", "foreign-dhcp"):
what = "the GE network" if i["verdict"] == "ge" else "a network with another DHCP server"
out.append(("bad", f"Imaging NIC {i['iface']} is on {what}. Do not serve DHCP/PXE here."))
if i["role"] != "imaging" and i.get("dnsmasq_bound") is True:
out.append(("bad", f"dnsmasq is answering DHCP on {i['iface']} ({i['role']} NIC), not only on the imaging NIC."))
if not imaging:
out.append(("warn", "No imaging NIC configured (/etc/pxe-server/nic.conf or dnsmasq's interface= pin)."))
for i in imaging:
if i["link"] != "up":
out.append(("warn", f"Imaging NIC {i['iface']} has no link: no PC can network-boot."))
elif i.get("dnsmasq_bound") is False:
out.append(("warn", f"dnsmasq is not listening for DHCP on the imaging NIC {i['iface']}."))
for i in ifaces:
if i["role"] == "ge" and i["link"] != "up":
out.append(("warn", f"GE NIC {i['iface']} has no link (no updates or remote admin)."))
return out
def status():
"""{interfaces, source, checked_at, stale, guard, problems, state}.
state: 'ok' | 'warn' | 'bad'. Every interface entry is normalised (see
_norm_iface), whatever the check file holds."""
data, source = None, "kernel"
try:
with open(config.NIC_STATUS_FILE) as f:
data = json.load(f)
except (OSError, ValueError):
data = None
stale = True
if isinstance(data, dict) and isinstance(data.get("interfaces"), list):
age = _age_seconds(data.get("checked_at"))
stale = age is None or age > STALE_SECONDS or age < -FUTURE_SKEW_SECONDS
source = "check"
kernel, kernel_ok = _kernel_view()
live = {i["iface"]: i for i in kernel}
checked_at = None
if source == "check":
checked_at = data.get("checked_at") if isinstance(data.get("checked_at"), str) else None
ifaces = []
for raw in data["interfaces"]:
i = _norm_iface(raw)
if i is None:
continue
k = live.get(i["iface"])
if k: # link and addresses are cheap to read now; keep the check's verdict
i.update(link=k["link"], speed_mbps=k["speed_mbps"], addrs=k["addrs"],
notes=k["notes"] + [n for n in i["notes"] if n not in k["notes"]])
# The check knows the dnsmasq config and process; the
# kernel fallback is only used once the check is stale.
if stale or i["dnsmasq_bound"] is None:
i["dnsmasq_bound"] = k["dnsmasq_bound"]
elif kernel_ok:
i.update(link="down", speed_mbps=None, addrs=[], dnsmasq_bound=False,
notes=i["notes"] + ["not present (unplugged?)"])
if stale:
i["verdict"] = None
i["evidence"] = []
ifaces.append(i)
g = data.get("dhcp_guard")
guard = {"state": _str(g.get("state")), "reason": _str(g.get("reason"))} if isinstance(g, dict) else {}
else:
ifaces, guard = kernel, {}
ifaces.sort(key=lambda i: {"imaging": 0, "ge": 1}.get(i.get("role"), 2))
problems = _problems(ifaces, guard, stale and source == "check", checked_at)
state = "bad" if any(l == "bad" for l, _ in problems) else "warn" if problems else "ok"
return {"interfaces": ifaces, "source": source, "stale": stale and source == "check",
"checked_at": checked_at, "guard": guard, "problems": problems, "state": state}
def unknown(error=""):
"""What the Overview shows when status() itself failed."""
return {"interfaces": [], "source": "error", "stale": False, "checked_at": None, "guard": {},
"problems": [], "state": "unknown", "error": str(error)[:200]}