- 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.
382 lines
15 KiB
Python
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]}
|