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:
@@ -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]}
|
||||
|
||||
Reference in New Issue
Block a user