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.
This commit is contained in:
cproudlock
2026-10-15 14:20:24 -04:00
parent 53d39bfc50
commit 606659bf07
11 changed files with 1185 additions and 166 deletions

View File

@@ -21,6 +21,8 @@ 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
@@ -37,26 +39,73 @@ def _read(path):
return ""
def _roles():
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 = {}
cp = configparser.ConfigParser()
try:
cp.read_string("[nic]\n" + open(config.NIC_CONF).read())
sec = cp["nic"]
for role in ("imaging", "ge"):
for key in (f"{role}_iface", f"{role}_mac"):
if sec.get(key):
roles[sec.get(key).strip().lower()] = role
except (OSError, configparser.Error):
pass
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:
for line in _read(config.DNSMASQ_NIC_CONF).splitlines():
key, _, val = line.split("#", 1)[0].strip().partition("=")
if key == "interface" and val:
roles[val.strip()] = "imaging"
elif key == "except-interface" and val:
roles.setdefault(val.strip(), "ge")
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
@@ -68,39 +117,93 @@ def _addrs():
except (OSError, subprocess.SubprocessError, ValueError):
return {}
res = {}
for link in data:
res[link.get("ifname", "")] = [
f"{a['local']}/{a['prefixlen']}" for a in link.get("addr_info", [])
if a.get("family") == "inet" or (a.get("family") == "inet6" and a.get("scope") == "global")]
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_bound():
"""Interfaces dnsmasq's DHCP socket (UDP 67) is bound to; None if unknown."""
def _dhcp_sockets():
"""[(addr, dev)] of the UDP 67 sockets; dev is None for an unbound one.
None if ss cannot be run."""
try:
out = subprocess.run(["ss", "-H", "-uln", "sport", "= :67"], capture_output=True, text=True,
timeout=5).stdout
r = subprocess.run(["ss", "-H", "-uln", "sport", "= :67"], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return None
bound = set(re.findall(r"%([\w.-]+):67\b", out))
if re.search(r"(^|\s)(0\.0\.0\.0|\*|\[::\]):67\b", out):
bound.add("*") # bound on every interface
return bound
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 only)."""
roles, addrs, bound = _roles(), _addrs(), _dhcp_bound()
out = []
"""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:
names = []
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)
if name == "lo" or not os.path.exists(os.path.join(base, "device")):
continue # skip loopback, bridges, veths, tunnels
mac = _read(os.path.join(base, "address")).lower()
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")))
@@ -108,21 +211,30 @@ def _kernel_view():
except ValueError:
speed = None
notes = []
driver = os.path.basename(os.path.realpath(os.path.join(base, "device", "driver")))
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": roles.get(name) or roles.get(mac) or "other",
"link": "up" if oper == "up" else "down",
"speed_mbps": speed if oper == "up" else None,
"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 bound is None else (name in bound or "*" in bound)),
"dnsmasq_bound": None if name in port_of else _bound(name, ips, socks, rules),
})
return out
return out, True
def _age_seconds(stamp):
@@ -131,21 +243,73 @@ def _age_seconds(stamp):
if t.tzinfo is None:
t = t.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - t).total_seconds()
except ValueError:
except (ValueError, TypeError, OverflowError):
return None
def _problems(ifaces, guard):
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":
out.append(("bad", "DHCP/PXE is blocked: " + (guard.get("reason") or "the imaging NIC is not isolated")))
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"):
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)."))
@@ -162,7 +326,8 @@ def _problems(ifaces, guard):
def status():
"""{interfaces, source, checked_at, stale, guard, problems, state}.
state: 'ok' | 'warn' | 'bad'."""
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:
@@ -172,27 +337,45 @@ def status():
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
stale = age is None or age > STALE_SECONDS or age < -FUTURE_SKEW_SECONDS
source = "check"
live = {i["iface"]: i for i in _kernel_view()}
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 i in data["interfaces"]:
i = dict(i)
k = live.get(i.get("iface"))
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"])
if k["dnsmasq_bound"] is not None:
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)
guard = data.get("dhcp_guard") or {}
g = data.get("dhcp_guard")
guard = {"state": _str(g.get("state")), "reason": _str(g.get("reason"))} if isinstance(g, dict) else {}
else:
ifaces, guard = list(live.values()), {}
ifaces, guard = kernel, {}
ifaces.sort(key=lambda i: {"imaging": 0, "ge": 1}.get(i.get("role"), 2))
problems = _problems(ifaces, guard)
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": (data or {}).get("checked_at") if source == "check" else None,
"guard": guard, "problems": problems, "state": state}
"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]}