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

@@ -101,6 +101,27 @@ app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo
# Routes - pages
# ---------------------------------------------------------------------------
def _nic_parts():
"""(pill, notice) HTML for the Overview's network status. Rendered here,
each inside a try, so a bad status file or a template slip shows
'Network: unknown' and never takes the Overview down."""
from markupsafe import Markup
try:
nic = nic_status.status()
except Exception as ex: # the page must render even if this breaks
app.logger.exception("network status failed")
nic = nic_status.unknown(ex)
parts = []
for part in ("pill", "notice"):
try:
parts.append(Markup(render_template("_nic_status.html", nic=nic, part=part)))
except Exception as ex:
app.logger.exception("network status render failed")
parts.append(Markup(render_template("_nic_status.html", nic=nic_status.unknown(ex), part=part))
if part == "pill" else Markup(""))
return parts[0], parts[1]
@app.route("/")
def dashboard():
image_list = [images.image_status(it) for it in config.IMAGE_TYPES]
@@ -114,11 +135,7 @@ def dashboard():
img["os_label"], img["driver_count"] = "", 0
img["lint"] = image_pages._lint_counts(img["image_type"])
services = [system.service_status(s) for s in ("dnsmasq", "apache2", "smbd")]
try:
nic = nic_status.status()
except Exception: # the page must render even if this breaks
app.logger.exception("network status failed")
nic = None
nic_pill, nic_notice = _nic_parts()
sessions, _hidden = imaging_status.hide_stale(imaging_status.list_sessions())
floor = imaging_status.floor_order(sessions)
real = [x for x in floor if x["floor_state"] != "logs"]
@@ -127,7 +144,8 @@ def dashboard():
"dashboard.html",
images=image_list,
services=services,
nic=nic,
nic_pill=nic_pill,
nic_notice=nic_notice,
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
sessions=real[:6],
@@ -810,7 +828,11 @@ def blancco_evidence_pack():
return redirect(url_for("blancco_reports"))
if last < first:
first, last = last, first
if (last - first).days > 400:
if first.year < 2000 or last.year > 2100:
# 9999-12-31 + 1 day overflows the date maths further down.
flash("Pick dates between the years 2000 and 2100.", "danger")
return redirect(url_for("blancco_reports"))
if (last - first).days + 1 > 400: # both ends count
flash("An evidence pack covers at most about a year (400 days).", "danger")
return redirect(url_for("blancco_reports"))
out = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024)
@@ -1113,8 +1135,12 @@ def imaging_stream():
def imaging_status_post():
# CSRF-exempt machine-to-machine endpoint; see services/csrf.py exempt list.
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify({"error": "body must be a JSON object"}), 400
if not payload.get("serial"):
return jsonify({"error": "missing serial"}), 400
if not isinstance(payload.get("serial"), str):
return jsonify({"error": "serial must be a string"}), 400
try:
state = imaging_status.update_session(payload)
except ValueError as ex:

View File

@@ -39,6 +39,110 @@ ACTIVE_WINDOW_HOURS = 6
_SAFE_SERIAL = re.compile(r"[^A-Za-z0-9._-]")
# Payload and stored-session field types. The pages compare and look these
# up (`pctype in {...}`, `'%]' in current_stage`), so a list or dict from a
# client or a hand-edited file must never reach them.
INT_FIELDS = ("stage_index", "stage_total")
# Written by this module only; a client cannot set them.
SERVER_FIELDS = ("log_tail", "stage_history", "started_at", "last_updated", "previous_run_at")
STR_MAX = 4096
LOG_LINE_MAX = 2000
_SAFE_KEY = re.compile(r"[A-Za-z0-9_]{1,64}")
def _scalar_str(v, limit=STR_MAX):
"""str for a scalar, None for anything else (dict, list, None)."""
if isinstance(v, str):
return v[:limit]
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
return None
def _as_int(v):
if isinstance(v, bool):
return None
try:
return int(v)
except (TypeError, ValueError, OverflowError):
return None
def _str_lines(v):
"""A str or a list of scalars as a list of str lines; [] otherwise."""
if isinstance(v, str):
v = [v]
if not isinstance(v, list):
return []
out = []
for x in v:
x = _scalar_str(x, LOG_LINE_MAX)
if x is not None:
out.append(x)
return out
def _clean_payload(payload: dict) -> dict:
"""A client payload with every value in the type the pages expect:
stage_index/stage_total int, log_lines a list of str, everything else a
str. Non-scalar values and server-owned fields are dropped."""
out = {}
for key, value in payload.items():
if not isinstance(key, str) or not _SAFE_KEY.fullmatch(key) or key in SERVER_FIELDS:
continue
if key in INT_FIELDS:
n = _as_int(value)
if n is not None:
out[key] = n
elif key == "log_lines":
lines = _str_lines(value)
if lines:
out[key] = lines
else:
v = _scalar_str(value)
if v is not None:
out[key] = v
return out
def sanitize_session(state) -> Optional[dict]:
"""A stored session made safe to render: same rules as a payload, plus
log_tail (list of str) and stage_history (list of dicts). None when it is
not a JSON object or has no usable serial."""
if not isinstance(state, dict):
return None
out = {}
for key, value in state.items():
if not isinstance(key, str):
continue
if key in INT_FIELDS:
n = _as_int(value)
if n is not None:
out[key] = n
elif key == "log_tail":
out[key] = _str_lines(value)[-LOG_TAIL_MAX:]
elif key == "stage_history":
hist = []
for h in value if isinstance(value, list) else []:
if isinstance(h, dict):
hist.append({"ts": _scalar_str(h.get("ts")) or "",
"stage_index": _as_int(h.get("stage_index")) or 0,
"current_stage": _scalar_str(h.get("current_stage")) or "",
"status": _scalar_str(h.get("status")) or ""})
out[key] = hist[-STAGE_HISTORY_MAX:]
elif key == "log_lines":
continue
else:
v = _scalar_str(value)
if v is not None:
out[key] = v
if not out.get("serial"):
return None
return out
def _ensure_dir():
os.makedirs(config.IMAGING_DIR, exist_ok=True)
@@ -83,9 +187,15 @@ def update_session(payload: dict) -> dict:
present overwrites the existing field. log_tail is appended to (capped).
Returns the resulting full state.
"""
if not isinstance(payload, dict):
raise ValueError("payload must be a JSON object")
if not isinstance(payload.get("serial") or "", str):
raise ValueError("serial must be a string")
payload = _clean_payload(payload)
serial = (payload.get("serial") or "").strip()
if not serial:
raise ValueError("payload missing 'serial'")
payload["serial"] = serial
# "inferred" is the server's own label for log-only bays; a client that
# sends it would hide its row from the floor's failure-first order.
if payload.get("source") not in (None, "client", "blancco"):
@@ -101,8 +211,8 @@ def update_session(payload: dict) -> dict:
if os.path.isfile(path):
try:
with open(path, "r") as f:
state = json.load(f)
except (json.JSONDecodeError, OSError):
state = sanitize_session(json.load(f)) or {}
except (ValueError, OSError):
state = {}
# Reimage detection: if the new payload's stage_index <= 2 (WinPE startnet
@@ -216,9 +326,11 @@ def list_sessions(include_inferred: bool = True) -> list[dict]:
path = os.path.join(config.IMAGING_DIR, name)
try:
with open(path, "r") as f:
out.append(json.load(f))
except (json.JSONDecodeError, OSError):
state = sanitize_session(json.load(f))
except (ValueError, OSError):
continue
if state is not None: # a broken or hand-edited file must not 500 the pages
out.append(state)
if include_inferred:
from services import imaging_log_tail
out = imaging_log_tail.merge_with_client_sessions(out)
@@ -292,8 +404,8 @@ def get_session(serial: str) -> Optional[dict]:
return None
try:
with open(path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return sanitize_session(json.load(f))
except (ValueError, OSError):
return None

View File

@@ -24,8 +24,10 @@ https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz, with
the signed x86 files in ipxeboot/x86_64-sb/. GitHub lists a sha256 digest per
release asset, which is checked before anything is unpacked, and every EFI
file's Authenticode chain is read with sbverify before it can be installed.
The chain check is by certificate name (sbverify --list); the cryptographic
trust comes from the GitHub digest. Do not swap in `sbverify --cert`: it
The chain check is by certificate name (sbverify --list, plus every
signature in the PE security directory, nested ones included, read with
openssl for the shim's Microsoft CAs); the cryptographic trust comes from
the GitHub digest. Do not swap in `sbverify --cert`: it
prints "Signature verification OK" even for a missing or unrelated cert.
Why it matters: the Broadcom build, and the shim in the iPXE tarball, are
@@ -85,6 +87,12 @@ NEED_LABEL = {"microsoft": "a Microsoft UEFI CA", "ipxe": "the iPXE Secure Boot
MS_CA_2011 = "Microsoft Corporation UEFI CA 2011"
MS_CA_2023 = "Microsoft UEFI CA 2023"
IPXE_CA = "iPXE Secure Boot CA"
MS_CAS = (MS_CA_2011, MS_CA_2023)
# Bumped when the staged.json layout or the checks behind it change. A
# record written by an older version was verified by weaker checks and is
# shown as stale (download it again) and never installed.
STAGED_SCHEMA = 2
def _dir():
@@ -115,6 +123,111 @@ def signers(path):
return pairs
# Authenticode nested signature (SPC_NESTED_SIGNATURE, 1.3.6.1.4.1.311.2.4.1),
# DER-encoded OID. A second signature can sit in a second WIN_CERTIFICATE
# entry or nested inside the first one's unsigned attributes; sbverify may
# list only the outer one, so both places are read here.
NESTED_SIG_OID = bytes.fromhex("060a2b060104018237020401")
WIN_CERT_TYPE_PKCS_SIGNED_DATA = 0x0002
def _der_tlv(buf, i):
"""(content start, content end) of the DER element at buf[i]; raises
ValueError when it runs past the buffer."""
if i + 2 > len(buf):
raise ValueError("short DER")
n = buf[i + 1]
if n < 0x80:
start, length = i + 2, n
else:
k = n & 0x7F
if not 1 <= k <= 4 or i + 2 + k > len(buf):
raise ValueError("bad DER length")
start, length = i + 2 + k, int.from_bytes(buf[i + 2:i + 2 + k], "big")
if start + length > len(buf):
raise ValueError("DER runs past the buffer")
return start, start + length
def _pe_signatures(data):
"""The PKCS#7 blobs in a PE file's security directory, [] if none."""
try:
pe = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe:pe + 4] != b"PE\0\0":
return []
magic = struct.unpack_from("<H", data, pe + 24)[0]
dirs = pe + 24 + (96 if magic == 0x10B else 112)
off, size = struct.unpack_from("<II", data, dirs + 4 * 8)
except struct.error:
return []
out, i, end = [], off, min(off + size, len(data))
while off and i + 8 <= end:
length, _rev, ctype = struct.unpack_from("<IHH", data, i)
if length < 8:
break
if ctype == WIN_CERT_TYPE_PKCS_SIGNED_DATA:
blob = data[i + 8:min(i + length, end)]
try:
out.append(blob[:_der_tlv(blob, 0)[1]]) # drop the 8-byte padding
except ValueError:
pass
i += (length + 7) & ~7
return out
def _nested_signatures(blob, depth=0):
"""ContentInfo blobs nested in `blob` under SPC_NESTED_SIGNATURE, deep
nests included."""
out = []
pos = blob.find(NESTED_SIG_OID)
while pos >= 0 and depth < 4:
try:
i = pos + len(NESTED_SIG_OID)
if blob[i] == 0x31: # SET OF ContentInfo
start, end = _der_tlv(blob, i)
j = start
while j < end:
_cs, ce = _der_tlv(blob, j)
inner = blob[j:ce]
if blob[j] == 0x30:
out.append(inner)
out += _nested_signatures(inner, depth + 1)
j = ce
except (ValueError, IndexError):
pass
pos = blob.find(NESTED_SIG_OID, pos + 1)
return out
def _cert_issuers(blob):
"""Issuer CNs of every certificate in one PKCS#7 blob (openssl)."""
r = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-print_certs", "-noout"],
input=blob, capture_output=True, timeout=30)
text = r.stdout.decode("utf-8", errors="replace")
return {m.strip() for m in re.findall(r"^issuer=.*?CN\s*=\s*([^,/\n]+)", text, re.M)}
def microsoft_cas(path):
"""Which of the Microsoft UEFI CAs (2011, 2023) sign this file, looking
at every signature: each WIN_CERTIFICATE entry and any nested inside one.
Raises FileNotFoundError when openssl is not installed."""
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return []
issuers = set()
for blob in _pe_signatures(data):
for b in [blob] + _nested_signatures(blob):
issuers |= _cert_issuers(b)
return [ca for ca in MS_CAS if ca in issuers]
def dual_signed(info):
"""True when a described file carries both Microsoft UEFI CAs."""
return bool(info) and all(ca in (info.get("ms_cas") or []) for ca in MS_CAS)
def signature_kind(pairs):
"""microsoft-2023 | microsoft-2011 | microsoft | ipxe | other | unsigned.
A file signed under both Microsoft CAs counts as 2023."""
@@ -221,29 +334,74 @@ def drivers_of(path):
return ""
if b"iPXE" not in data or b"$Version: " in data:
return "" # not iPXE, or a shim
return "native" if re.search(rb"\0xhci\0|\0ehci\0", data) else "firmware"
return "native" if re.search(rb"\0(?:xhci|ehci|uhci)\0", data) else "firmware"
_DESCRIBE_CACHE = {}
def describe(path):
"""Size, hash, version, signers and drivers of one boot file, or None.
Cached per (path, size, mtime): reading and sbverify-ing every file on
each page load is slow. Returns a copy the caller may change."""
try:
st = os.stat(path)
except OSError:
return None
if not os.path.isfile(path):
return None
st = os.stat(path)
key = (os.path.abspath(path), st.st_size, st.st_mtime_ns)
hit = _DESCRIBE_CACHE.get(key)
if hit is None:
hit = _describe(path, st)
if len(_DESCRIBE_CACHE) > 256:
_DESCRIBE_CACHE.clear()
_DESCRIBE_CACHE[key] = hit
return dict(hit)
def _describe(path, st):
efi = path.endswith(".efi")
pairs, error = [], ""
pairs, error, ms_cas = [], "", []
if efi:
try:
pairs = signers(path)
except (OSError, subprocess.SubprocessError) as ex:
error = f"sbverify failed: {ex}"
return {
kind = (signature_kind(pairs) if not error else "error") if efi else "bios"
if kind.startswith("microsoft"):
try:
ms_cas = microsoft_cas(path)
except (OSError, subprocess.SubprocessError) as ex:
error = f"openssl failed: {ex}"
kind = "error"
info = {
"size": st.st_size, "modified": st.st_mtime,
"sha256": uc.file_hash(path), "version": version_of(path),
"sbat": sbat_generations(path) if efi else "",
"signers": pairs, "kind": (signature_kind(pairs) if not error else "error") if efi else "bios",
"signers": pairs, "kind": kind,
"trusts": vendor_cert_cn(path) if efi else None,
"drivers": drivers_of(path) if efi else "",
"sig_error": error,
"sig_error": error, "ms_cas": ms_cas,
}
info["dual"] = dual_signed(info)
return info
def risks(name, info):
"""Why a boot file should not go live, [] when none: an ipxe.efi that is
not the snponly (firmware drivers) build, or a shim that does not carry
both Microsoft UEFI CAs."""
if not info:
return []
if name == "ipxe.efi" and info.get("drivers") != "firmware":
if info.get("drivers") == "native":
return ["ipxe.efi drives USB itself (not snponly): USB keyboards stop working at the iPXE menu"]
return ["ipxe.efi is not recognised as an iPXE snponly build"]
if name in SHIMS and not info.get("dual"):
have = " and ".join(info.get("ms_cas") or []) or info.get("kind") or "no Microsoft CA"
return [f"{name} is not dual-signed (2011 + 2023): it carries {have}"]
return []
def _installed_log():
@@ -289,14 +447,25 @@ def mac_folders():
folder = os.path.join(config.TFTP_ROOT, d)
if not (MAC_DIR_RE.match(d) and os.path.isdir(folder)):
continue
files = []
for f in sorted(os.listdir(folder)):
files, warnings = [], []
try:
entries = sorted(os.listdir(folder))
except OSError as ex:
entries = []
warnings.append(f"cannot read the folder: {ex.strerror or ex}")
for f in entries:
info = describe(os.path.join(folder, f))
if info:
files.append({"name": f, "info": info})
by = {f["name"]: f["info"] for f in files}
_check_chain(next((by[n] for n in SHIMS if n in by), None), [by.get("ipxe.efi")])
out.append({"mac": d.replace("-", ":"), "dir": d, "files": files})
shim = next((by[n] for n in SHIMS if n in by), None)
if shim is None:
shim = next((f["info"] for f in files if (f["info"].get("version") or "").startswith("shim ")), None)
_check_chain(shim, [by.get("ipxe.efi")])
if shim is not None and "ipxe.efi" not in by:
warnings.append("This folder has a shim but no ipxe.efi. The shim loads ipxe.efi from this folder "
"(no fallback to the root), so this client stops after the shim.")
out.append({"mac": d.replace("-", ":"), "dir": d, "files": files, "warnings": warnings})
return out
@@ -530,10 +699,21 @@ def stage(info=None):
if shim.get("ok") and ipxe.get("ok") and shim.get("trusts"):
if shim["trusts"] not in {i for _, i in ipxe["signers"]}:
ipxe.update(ok=False, error=f"not signed under {shim['trusts']}, which this shim trusts")
if ipxe.get("ok") and ipxe.get("drivers") == "native":
# The full build would kill USB keyboards on every client.
ipxe.update(ok=False, error="this build drives USB itself (not snponly); refusing it as ipxe.efi")
record = {"tag": info["tag"], "published": info.get("published"), "folder": folder,
if ipxe.get("ok") and ipxe.get("drivers") != "firmware":
# The full build would kill USB keyboards on every client; anything
# not recognised as snponly is refused too.
ipxe.update(ok=False, error=("this build drives USB itself (not snponly); refusing it as ipxe.efi"
if ipxe.get("drivers") == "native" else
"not recognised as an iPXE snponly build; refusing it as ipxe.efi"))
for n in SHIMS:
f = files.get(n) or {}
if f.get("ok") and not f.get("dual"):
# Kept on disk and shown, but never installed: a 2011-only shim
# stops booting once machines drop or revoke the 2011 CA.
have = " and ".join(f.get("ms_cas") or []) or f.get("kind")
f.update(ok=False, error=f"not dual-signed (needs {MS_CA_2011} and {MS_CA_2023}; has {have}). "
+ (f.get("note") or ""))
record = {"schema": STAGED_SCHEMA, "tag": info["tag"], "published": info.get("published"), "folder": folder,
"tarball_sha256": info["sha256"], "files": files, "staged": uc.now()}
uc.write_json(os.path.join(folder, "staged.json"), record)
return record
@@ -541,10 +721,11 @@ def stage(info=None):
def _use_dual_signed_shim(folder, files):
"""Swap the tarball's shim for the ipxe/shim release build when that one
is signed under the Microsoft UEFI CA 2023 and trusts the same iPXE CA.
Any problem leaves the tarball shim in place, with the reason noted."""
carries both Microsoft UEFI CAs (2011 and 2023, read from every
signature, nested ones included) and trusts the same iPXE CA. Any
problem leaves the tarball shim in place, with the reason noted."""
tar_shim = files.get("ipxe-shim.efi") or {}
if not tar_shim.get("ok") or tar_shim.get("kind") == "microsoft-2023":
if not tar_shim.get("ok") or tar_shim.get("dual"):
return
try:
rel = json.loads(uc.fetch_text(SHIM_API, timeout=20))
@@ -559,8 +740,12 @@ def _use_dual_signed_shim(folder, files):
os.remove(dest)
uc.download(asset["browser_download_url"], dest, expect=sha, timeout=300)
d = describe(dest)
if d["kind"] != "microsoft-2023":
raise RuntimeError(f"{SHIM_ASSET} is {d['kind']}, not signed under {MS_CA_2023}")
if d["sig_error"]:
raise RuntimeError(f"{SHIM_ASSET}: {d['sig_error']}")
if not d["dual"]:
have = " and ".join(d["ms_cas"]) or d["kind"]
raise RuntimeError(f"{SHIM_ASSET} is not signed under both {MS_CA_2011} and {MS_CA_2023} "
f"(found {have})")
if d.get("trusts") != tar_shim.get("trusts"):
raise RuntimeError(f"{SHIM_ASSET} trusts {d.get('trusts')}, the tarball's iPXE needs "
f"{tar_shim.get('trusts')}")
@@ -574,7 +759,18 @@ def _use_dual_signed_shim(folder, files):
if n in files:
shutil.copy2(dest, os.path.join(folder, n))
files[n] = dict(d, ok=True, error="", source=f"github.com/ipxe/shim {rel.get('tag_name')} "
f"{SHIM_ASSET} (dual-signed, 2011 + 2023)")
f"{SHIM_ASSET} (dual-signed: {' + '.join(d['ms_cas'])})")
def _record_stale(rec):
try:
schema = int(rec.get("schema") or 0)
except (TypeError, ValueError):
schema = 0
if schema < STAGED_SCHEMA:
return True
return any(isinstance(f, dict) and f.get("signers") is not None and "drivers" not in f
for f in rec["files"].values())
def staged(tag=None):
@@ -590,6 +786,12 @@ def staged(tag=None):
if tag is None or rec.get("tag") == tag:
# Where it is now, not the path recorded at staging time.
rec["folder"] = os.path.join(root, t)
if _record_stale(rec):
# Verified by older, weaker checks: show it, never install it.
rec["stale"] = True
for f in rec["files"].values():
if isinstance(f, dict):
f.update(ok=False, error="verified by an older version of this page; download it again")
out.append(rec)
out.sort(key=lambda r: r.get("staged") or "", reverse=True)
return out
@@ -751,6 +953,8 @@ def install(tag, names=DEFAULT_INSTALL):
rec = next(iter(staged(tag)), None)
if not rec:
raise ValueError(f"{tag} is not staged")
if rec.get("stale"):
raise ValueError(f"{tag} was verified by an older version of this page; download it again")
names = [n for n in MANAGED if n in names]
if not names:
raise ValueError("nothing selected")
@@ -763,13 +967,25 @@ def install(tag, names=DEFAULT_INSTALL):
def _install(tag, rec, names):
if _record_stale(rec):
raise ValueError(f"{tag} was verified by an older version of this page; download it again")
for n in names:
f = rec["files"].get(n) or {}
if not f.get("ok"):
raise ValueError(f"{n}: {f.get('error') or 'not staged'}")
path = os.path.join(rec["folder"], n)
if not os.path.isfile(path) or uc.file_hash(path) != f["sha256"]:
if not os.path.isfile(path) or uc.file_hash(path) != f.get("sha256"):
raise ValueError(f"{n}: staged copy changed since it was verified; download again")
# Re-read the file itself: the record says what was true at staging.
need = MANAGED[n][1]
if need:
now = _describe(path, os.stat(path)) # uncached: this gates going live
if now.get("sig_error") or not str(now.get("kind") or "").startswith(need):
raise ValueError(f"{n}: expected a signature under {NEED_LABEL[need]}, found "
f"{now.get('sig_error') or now.get('kind')}")
bad = risks(n, now)
if bad:
raise ValueError(f"{n}: {bad[0]}; refusing to install it")
if not any(n in names for n in SHIMS):
# iPXE alone must still be one the installed shim will start.
trusts = vendor_cert_cn(os.path.join(config.TFTP_ROOT, "ipxe-shim.efi"))
@@ -799,14 +1015,19 @@ def backups():
meta = uc.read_json(os.path.join(folder, "backup.json"), {})
meta = meta if isinstance(meta, dict) else {}
files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))]
infos = {f: describe(os.path.join(folder, f)) for f in files}
out.append({"id": b, "reason": meta.get("reason", ""), "at": meta.get("at", ""),
"files": files, "absent": meta.get("absent", []),
"versions": {f: version_of(os.path.join(folder, f)) for f in files}})
"info": infos,
"versions": {f: (infos[f] or {}).get("version", "") for f in files},
"warnings": [w for f in files for w in risks(f, infos[f])]})
return out
def rollback(backup_id):
"""Put the files of one backup back, after backing up what is there now."""
def rollback(backup_id, confirm=False):
"""Put the files of one backup back, after backing up what is there now.
A backup holding a native-USB ipxe.efi or a shim that is not dual-signed
is only restored with confirm=True."""
bid = os.path.basename(str(backup_id or ""))
folder = os.path.join(_dir(), "backups", bid)
if not bid or bid.startswith(".") or not os.path.isdir(folder):
@@ -814,6 +1035,10 @@ def rollback(backup_id):
files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))]
if not files:
raise ValueError("that backup holds no boot files")
warnings = [w for f in files
for w in risks(f, _describe(os.path.join(folder, f), os.stat(os.path.join(folder, f))))]
if warnings and not confirm:
raise ValueError("; ".join(warnings) + ". Tick the confirmation to restore it anyway")
with _Lock():
_backup(files, f"before rolling back to {bid}")
_place_all([(os.path.join(folder, f), f, uc.file_hash(os.path.join(folder, f)))

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]}

View File

@@ -60,18 +60,22 @@
{% set track_kind = {'failed': 'fail', 'action': 'act', 'running': 'run', 'done': 'ok'} %}
{% macro floor_row(s) %}
{# Sessions are sanitised on load; these guards keep a row rendering if a
non-string still slips through (a lookup or `in` on a list raises). #}
{% set pctype = s.pctype if s.pctype is string else '' %}
{% set cur_stage = s.current_stage if s.current_stage is string else '' %}
{% set is_inferred = (s.source == 'inferred') %}
{% set is_blancco = (s.source == 'blancco' or s.pctype == 'blancco') %}
{% set is_blancco = (s.source == 'blancco' or pctype == 'blancco') %}
{% set stage_idx = (s.stage_index or 0) | int(0) %}
{% set stage_total = (s.stage_total or 0) | int(0) %}
{% set state = s.floor_state or 'running' %}
{% set state = s.floor_state if s.floor_state in state_chip else 'running' %}
{% set drive_pct = None %}
{% if is_blancco and stage_idx == 4 and s.current_stage and '%]' in s.current_stage %}
{% set drive_pct = s.current_stage.split('[')[-1].split('%')[0] | int(0) %}
{% if is_blancco and stage_idx == 4 and cur_stage and '%]' in cur_stage %}
{% set drive_pct = cur_stage.split('[')[-1].split('%')[0] | int(0) %}
{% endif %}
{% set warn = False %}
{% if is_inferred %}
{% set friendly = inferred_stage_labels.get(s.current_stage, ('Inferred from logs', s.current_stage or '')) %}
{% set friendly = inferred_stage_labels.get(cur_stage, ('Inferred from logs', cur_stage)) %}
{% elif is_blancco %}
{% if s.status == 'failed' %}
{% set friendly = ('Erasure FAILED', 'Blancco reported a failed erasure step. Check the log tail and the report.') %}
@@ -80,18 +84,18 @@
{% endif %}
{% if stage_idx == 4 and s.status != 'failed' %}{% set warn = True %}{% endif %}
{% else %}
{% set friendly = (desk_stage_labels if s.pctype in desk_kind else stage_labels).get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
{% if stage_idx == 1 and s.current_stage and 'bios update staged' in s.current_stage|lower %}
{% set friendly = (desk_stage_labels if pctype in desk_kind else stage_labels).get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
{% if stage_idx == 1 and cur_stage and 'bios update staged' in cur_stage|lower %}
{% set friendly = ('Applying image - BIOS update staged',
'WinPE staged a firmware update for this model. It flashes during the reboot after the image is applied: do NOT power off then.') %}
{% set warn = True %}
{% elif stage_idx == 1 and s.current_stage and 'bios' in s.current_stage|lower %}
{% elif stage_idx == 1 and cur_stage and 'bios' in cur_stage|lower %}
{% set friendly = ('Updating BIOS: do not power off',
'WinPE detected a firmware update for this model. Do NOT power off until the next reboot. Imaging continues afterward.') %}
{% set warn = True %}
{% endif %}
{% if stage_idx == 7 and s.current_stage %}
{% set _cs = s.current_stage|lower %}
{% if stage_idx == 7 and cur_stage %}
{% set _cs = cur_stage|lower %}
{% if 'ready for lockdown' in _cs or 'request lockdown' in _cs %}
{% set friendly = ('Ready - request lockdown',
'Phase 1-4 all complete (Registration, Device Config, Software Deploy, Credentials). Click "ARTS request" to initiate the lockdown workflow.') %}
@@ -112,11 +116,11 @@
{% if s.hostname_target and s.serial %}{% set _ = ident.append(s.hostname_target) %}{% endif %}
{% if s.ip %}{% set _ = ident.append(s.ip) %}{% endif %}
{% set title = s.serial or s.hostname_target or s.mac or s.ip or '(unknown bay)' %}
{% set kind = 'Blancco wipe' if is_blancco else desk_kind.get(s.pctype, s.pctype or '') %}
{% set kind = 'Blancco wipe' if is_blancco else desk_kind.get(pctype, pctype) %}
<div class="ui-row floor-row imaging-card" data-state="{{ state }}"
data-serial="{{ s.serial or '' }}" data-key="{{ card_key }}" data-mac="{{ s.mac or '' }}"
data-ip="{{ s.ip or '' }}" data-source="{{ s.source or 'client' }}"
data-filter="{{ (s.serial or '')|lower }} {{ (s.hostname_target or '')|lower }} {{ (s.pctype or '')|lower }} {{ (s.machinenumber or '')|lower }} {{ (s.intune_device_id or '')|lower }} {{ friendly[0]|lower }} stage-{{ stage_idx }} {{ (s.status or 'in_progress')|lower }} {{ (s.mac or '')|lower }} {{ (s.ip or '') }} {{ s.source or 'client' }}">
data-filter="{{ (s.serial or '')|lower }} {{ (s.hostname_target or '')|lower }} {{ pctype|lower }} {{ (s.machinenumber or '')|lower }} {{ (s.intune_device_id or '')|lower }} {{ friendly[0]|lower }} stage-{{ stage_idx }} {{ (s.status or 'in_progress')|lower }} {{ (s.mac or '')|lower }} {{ (s.ip or '') }} {{ s.source or 'client' }}">
<details class="imaging-toggle">
<summary class="frow">
<span class="g-st">{{ ui.chip(state_chip[state][0], state_chip[state][1]) }}</span>

View File

@@ -0,0 +1,50 @@
{# Overview network pill and its problem notice. Rendered on its own by
the dashboard view (part = 'pill' or 'notice') inside a try, so a broken
status file can never take the Overview down. #}
{% import '_ui.html' as ui %}
{% if part == 'pill' %}
{% set nic_dot = {'ok': 'var(--ok)', 'warn': 'var(--act)', 'bad': 'var(--fail)'}.get(nic.state, 'var(--off-edge)') %}
{% set role_name = {'imaging': 'Imaging', 'ge': 'GE network', 'other': 'Other'} %}
{% set verdict_chip = {'isolated': ('Isolated', 'ok'), 'ge': ('GE network', 'neutral'), 'foreign-dhcp': ('Other DHCP server', 'err'),
'no-link': ('No link', 'off'), 'error': ('Check failed', 'warn')} %}
<details class="more">
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
<span class="dot" style="background: {{ nic_dot }}"></span>
{{ {'ok': 'Network OK', 'warn': 'Network: check', 'bad': 'Network: DANGER'}.get(nic.state, 'Network: unknown') }}
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="menu" style="width: min(460px, calc(100vw - 32px)); padding: 12px 14px; gap: 10px; font-size: 13px">
{% for i in nic.interfaces %}
{% set bad_here = (i.role == 'imaging' and i.verdict in ('ge', 'foreign-dhcp')) or (i.role != 'imaging' and i.dnsmasq_bound) %}
<div style="display: flex; flex-direction: column; gap: 4px">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<span class="dot" style="background: {{ 'var(--fail)' if bad_here else ('var(--ok)' if i.link == 'up' else 'var(--act)') }}"></span>
<strong>{{ role_name.get(i.role, i.role) }}</strong>
<span class="mono muted">{{ i.iface }}</span>
{% if i.verdict and i.verdict in verdict_chip %}{% set vc = verdict_chip[i.verdict] %}{{ ui.chip(vc[0], 'err' if bad_here else vc[1]) }}{% endif %}
</div>
<div class="muted" style="padding-left: 18px; white-space: normal">
{{ 'Up' if i.link == 'up' else 'No link' }}{% if i.speed_mbps is number and i.speed_mbps > 0 %}, {% if i.speed_mbps >= 1000 %}{{ (i.speed_mbps // 1000) if i.speed_mbps % 1000 == 0 else (i.speed_mbps / 1000) | round(1) }} Gb/s{% else %}{{ i.speed_mbps }} Mb/s{% endif %}{% endif %}
{% if i.addrs and i.addrs is not string %}<span class="mono">{{ i.addrs | join(', ') }}</span>{% endif %}
{% if i.dnsmasq_bound is not none %}<br>DHCP/PXE {{ 'served here' if i.dnsmasq_bound else 'not served here' }}{% endif %}
{% for e in (i.evidence if i.evidence is iterable and i.evidence is not string else []) %}<br>{{ e }}{% endfor %}{% for n in (i.notes if i.notes is iterable and i.notes is not string else []) %}<br><span class="sub">{{ n }}</span>{% endfor %}
</div>
</div>
{% else %}
<div class="muted">{{ 'Interfaces unknown.' if nic.source == 'error' else 'No network interfaces found.' }}</div>
{% endfor %}
<div class="sub" style="white-space: normal; border-top: 1px solid var(--line); padding-top: 8px">
{% if nic.source == 'check' and not nic.stale %}Checked {{ nic.checked_at | local_dt }} by pxe-nic-check.
{% elif nic.source == 'check' %}The network check is stale (last {{ nic.checked_at | local_dt }}): showing link and addresses only.
{% elif nic.source == 'error' %}The network status could not be read{% if nic.error %}: <span class="mono">{{ nic.error }}</span>{% endif %}. See the webapp log.
{% else %}Link and addresses only; pxe-nic-check (the GE-network detector) is not installed yet.{% endif %}
</div>
</div>
</details>
{% elif part == 'notice' %}
{% if nic and nic.problems %}
<div class="notice{{ ' bad' if nic.state == 'bad' else '' }}" role="{{ 'alert' if nic.state == 'bad' else 'status' }}">
<span>{% if nic.state == 'bad' %}<strong>Network danger.</strong> {% endif %}{% for level, text in nic.problems %}{{ text }}{% if not loop.last %} {% endif %}{% endfor %}</span>
</div>
{% endif %}
{% endif %}

View File

@@ -5,45 +5,7 @@
{% block content %}
{% set services_up = services | selectattr('active') | list | length %}
{% call ui.head('Overview', 'West Jefferson') %}
{% if nic %}
{% set nic_dot = {'ok': 'var(--ok)', 'warn': 'var(--act)', 'bad': 'var(--fail)'}[nic.state] %}
{% set role_name = {'imaging': 'Imaging', 'ge': 'GE network', 'other': 'Other'} %}
{% set verdict_chip = {'isolated': ('Isolated', 'ok'), 'ge': ('GE network', 'neutral'), 'foreign-dhcp': ('Other DHCP server', 'err'),
'no-link': ('No link', 'off'), 'error': ('Check failed', 'warn')} %}
<details class="more">
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
<span class="dot" style="background: {{ nic_dot }}"></span>
{{ 'Network OK' if nic.state == 'ok' else ('Network: check' if nic.state == 'warn' else 'Network: DANGER') }}
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="menu" style="width: min(460px, calc(100vw - 32px)); padding: 12px 14px; gap: 10px; font-size: 13px">
{% for i in nic.interfaces %}
{% set bad_here = (i.role == 'imaging' and i.verdict in ('ge', 'foreign-dhcp')) or (i.role != 'imaging' and i.dnsmasq_bound) %}
<div style="display: flex; flex-direction: column; gap: 4px">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<span class="dot" style="background: {{ 'var(--fail)' if bad_here else ('var(--ok)' if i.link == 'up' else 'var(--act)') }}"></span>
<strong>{{ role_name.get(i.role, i.role) }}</strong>
<span class="mono muted">{{ i.iface }}</span>
{% if i.verdict and i.verdict in verdict_chip %}{% set vc = verdict_chip[i.verdict] %}{{ ui.chip(vc[0], 'err' if bad_here else vc[1]) }}{% endif %}
</div>
<div class="muted" style="padding-left: 18px; white-space: normal">
{{ 'Up' if i.link == 'up' else 'No link' }}{% if i.speed_mbps %}, {% if i.speed_mbps >= 1000 %}{{ (i.speed_mbps // 1000) if i.speed_mbps % 1000 == 0 else (i.speed_mbps / 1000) | round(1) }} Gb/s{% else %}{{ i.speed_mbps }} Mb/s{% endif %}{% endif %}
{% if i.addrs %}<span class="mono">{{ i.addrs | join(', ') }}</span>{% endif %}
{% if i.dnsmasq_bound is not none %}<br>DHCP/PXE {{ 'served here' if i.dnsmasq_bound else 'not served here' }}{% endif %}
{% for e in i.evidence %}<br>{{ e }}{% endfor %}{% for n in i.notes or [] %}<br><span class="sub">{{ n }}</span>{% endfor %}
</div>
</div>
{% else %}
<div class="muted">No network interfaces found.</div>
{% endfor %}
<div class="sub" style="white-space: normal; border-top: 1px solid var(--line); padding-top: 8px">
{% if nic.source == 'check' and not nic.stale %}Checked {{ nic.checked_at | local_dt }} by pxe-nic-check.
{% elif nic.source == 'check' %}The network check is stale (last {{ nic.checked_at | local_dt }}): showing link and addresses only.
{% else %}Link and addresses only; pxe-nic-check (the GE-network detector) is not installed yet.{% endif %}
</div>
</div>
</details>
{% endif %}
{{ nic_pill }}
<details class="more">
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
<span class="dot" style="background: {{ 'var(--ok)' if services_up == services|length else 'var(--fail)' }}"></span>
@@ -59,11 +21,7 @@
{% endcall %}
<div class="ui-stack">
{% if nic and nic.problems %}
<div class="notice{{ ' bad' if nic.state == 'bad' else '' }}" role="{{ 'alert' if nic.state == 'bad' else 'status' }}">
<span>{% if nic.state == 'bad' %}<strong>Network danger.</strong> {% endif %}{% for level, text in nic.problems %}{{ text }}{% if not loop.last %} {% endif %}{% endfor %}</span>
</div>
{% endif %}
{{ nic_notice }}
{% if blancco_todo.total %}
{% set parts = [] %}
{% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %}

View File

@@ -71,7 +71,7 @@
<label class="sub" style="display: flex; flex-direction: column; gap: 4px">To
<input id="evidenceTo" type="date" name="to" value="{{ pack_to }}" class="field" required></label>
</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap" aria-label="Quick ranges">
<div style="display: flex; gap: 6px; flex-wrap: wrap" role="group" aria-label="Quick ranges">
<button type="button" class="fbtn" data-range="last-month">Last month</button>
<button type="button" class="fbtn" data-range="last-3-months">Last 3 months</button>
<button type="button" class="fbtn" data-range="this-month">This month</button>

View File

@@ -5,7 +5,8 @@
{# Signer as a short chip; the long explanation goes in the row body (it is
a safety warning, so never hover-only). #}
{% macro sig_chip(info) -%}
{% if info.kind == 'microsoft-2011' %}{{ ui.chip('MS UEFI CA 2011 only', 'warn') }}
{% if info.dual %}{{ ui.chip('MS UEFI CA 2011 + 2023', 'ok') }}
{% elif info.kind == 'microsoft-2011' %}{{ ui.chip('MS UEFI CA 2011 only', 'warn') }}
{% elif info.kind == 'microsoft-2023' %}{{ ui.chip('MS UEFI CA 2023', 'ok') }}
{% elif info.kind == 'microsoft' %}{{ ui.chip('Microsoft, other CA', 'neutral') }}
{% elif info.kind == 'ipxe' %}{{ ui.chip('iPXE Secure Boot CA', 'ok') }}
@@ -17,7 +18,8 @@
{% macro sig_text(info, chain_ok=none) -%}
{% if info.kind == 'microsoft-2011' %}<div class="notice">Signed only under Microsoft Corporation UEFI CA 2011. That CA's signing period ended in 2026: machines that trust only the 2023 CA, or a DBX/SBAT revocation update, will refuse this file.</div>
{% elif info.kind == 'microsoft-2023' %}<div class="sub">Signed under Microsoft UEFI CA 2023.</div>
{% elif info.dual %}<div class="sub">Dual-signed: under Microsoft Corporation UEFI CA 2011 and Microsoft UEFI CA 2023.</div>
{% elif info.kind == 'microsoft-2023' %}<div class="notice">Signed under Microsoft UEFI CA 2023 only, not dual-signed: machines that trust only the 2011 CA will refuse it.</div>
{% elif info.kind == 'ipxe' %}<div class="sub">Chains to the iPXE Secure Boot CA, which the iPXE shim trusts.</div>
{% elif info.kind == 'bios' %}<div class="sub">Legacy BIOS has no Secure Boot; the file is unsigned by design.</div>
{% elif info.kind == 'unsigned' %}<div class="notice bad">Unsigned: Secure Boot firmware will refuse it.</div>
@@ -149,7 +151,7 @@
<div class="panel strip" role="status">
<span class="pulse" aria-hidden="true"></span>
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
<div class="bar" aria-hidden="true"><div id="ipxeJobBar" style="width: {{ job.percent or 0 }}%"></div></div>
<div class="bar" aria-hidden="true"><div id="ipxeJobBar" style="width: {{ 100 if job.step in ('verify', 'shim') else (job.percent or 0) }}%"></div></div>
</div>
{% elif job.state == 'error' %}
<div class="errbox"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
@@ -213,8 +215,9 @@
<div class="sec-h"><div><h2 id="mac-h" class="sec">Per-client folders</h2>
<div class="sub">With <span class="mono">tftp-unique-root=mac</span>, a client whose MAC has a folder gets every file from it, with no fallback to the root. Read-only here: installs do not update them.</div></div></div>
{% for m in mac_folders %}
<div class="rel-h"><strong class="mono">{{ m.mac }}</strong><span class="sub mono">/srv/tftp/{{ m.dir }}/</span>
<div class="rel-h"><strong class="mono">{{ m.mac }}</strong><span class="sub mono">{{ tftp_root }}/{{ m.dir }}/</span>
<span class="sub">{{ m.files | length }} file{{ 's' if m.files | length != 1 }}</span></div>
{% for w in m.warnings or [] %}<div class="notice bad" style="margin: 0 24px 8px"><span>{{ w }}</span></div>{% endfor %}
{% for f in m.files %}
{% set i = f.info %}
{% set chain_ok = i.chain_ok if i.chain_ok is defined else none %}
@@ -229,7 +232,7 @@
<div class="ibody">
{{ sig_text(i, chain_ok) }}
<dl class="kv">
<dt>Path</dt><dd class="mono">/srv/tftp/{{ m.dir }}/{{ f.name }}</dd>
<dt>Path</dt><dd class="mono">{{ tftp_root }}/{{ m.dir }}/{{ f.name }}</dd>
<dt>Size</dt><dd>{{ "{:,}".format(i.size) }} B</dd>
<dt>Modified</dt><dd class="mono">{{ i.modified | timestamp_fmt }}</dd>
<dt>sha256</dt><dd class="mono">{{ i.sha256 }}</dd>
@@ -289,6 +292,7 @@
<div class="rel-h">
<strong class="mono">{{ s.tag }}</strong>
<span class="sub">verified {{ s.staged | local_dt }}</span>
{% if s.stale %}{{ ui.chip('Stale: download again', 'warn') }}{% endif %}
<span class="tip inl" data-tip="{{ s.tarball_sha256 }}"><span class="sub">tarball sha256 <span class="mono">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span></span>
</div>
{% for name, f in s.files.items() %}
@@ -380,7 +384,8 @@
</summary>
<div class="ibody">
{% if b.reason %}<div>{{ b.reason }}</div>{% endif %}
{% for f in b.files %}<div><span class="mono">{{ f }}</span> <span class="muted">{{ b.versions[f] or '' }}</span></div>{% else %}<div class="muted">Empty backup.</div>{% endfor %}
{% for f in b.files %}<div><span class="mono">{{ f }}</span> <span class="muted">{{ b.versions[f] or '' }}</span>{% if b.info and b.info[f] %} {{ sig_chip(b.info[f]) }}{% endif %}</div>{% else %}<div class="muted">Empty backup.</div>{% endfor %}
{% for w in b.warnings or [] %}<div class="notice bad"><span>{{ w }}</span></div>{% endfor %}
</div>
</details>
{% if b.files %}
@@ -392,6 +397,8 @@
onsubmit="return confirm(this.dataset.confirm);">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="backup" value="{{ b.id }}">
{% if b.warnings %}<label class="sub" style="display: flex; gap: 6px; align-items: flex-start; white-space: normal; padding: 6px 10px">
<input type="checkbox" name="confirm" value="1" required> Restore anyway: {{ b.warnings | join('; ') }}</label>{% endif %}
<button type="submit">Restore these files</button>
</form>
{% endcall %}
@@ -437,8 +444,8 @@ ls -l ipxeboot/x86_64-sb/</pre></li>
<pre class="cmd mono">sbverify --list ipxe-shimx64.efi
sbverify --list ipxeboot/x86_64-sb/ipxe.efi
sbverify --list ipxeboot/x86_64-sb/snponly.efi</pre>
The dual-signed shim shows <span class="mono">CN=Microsoft UEFI CA 2023</span> (sbverify lists the first signature only;
the 2011 one is nested inside it). Fall back to the tarball's <span class="mono">shimx64.efi</span>
The dual-signed shim shows <span class="mono">CN=Microsoft UEFI CA 2023</span> and <span class="mono">CN=Microsoft Corporation UEFI CA 2011</span>
(depending on the sbverify version it lists both signatures or only the outer one; this page reads every signature, nested ones too). Fall back to the tarball's <span class="mono">shimx64.efi</span>
(<span class="mono">CN=Microsoft Corporation UEFI CA 2011</span>) only if the dual-signed one is unavailable.
<span class="mono">ipxe.efi</span> must show <span class="mono">CN=iPXE Secure Boot Intermediate ...</span> issued by
<span class="mono">CN=iPXE Secure Boot CA</span>. The shim's <span class="mono">warning: data remaining ... gaps between PE/COFF sections?</span>
@@ -484,7 +491,7 @@ install -m 0644 ipxe-shimx64.efi /srv/tftp/.ipxe-shim.efi.new &amp
if (j.state !== 'queued' && j.state !== 'running') { location.reload(); return; }
var step = j.step === 'download' ? 'Downloading' : j.step === 'verify' ? 'Verifying' : j.step === 'shim' ? 'Fetching the dual-signed shim for' : 'Starting';
if (text) text.textContent = step + ' ' + (j.tag || '') + (j.step === 'download' && j.percent != null ? ' (' + j.percent + '%)' : '') + '...';
if (bar) bar.style.width = (j.step === 'verify' ? 100 : (j.percent || 0)) + '%';
if (bar) bar.style.width = ((j.step === 'verify' || j.step === 'shim') ? 100 : (j.percent || 0)) + '%';
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });

View File

@@ -0,0 +1,452 @@
"""Regression tests for the 2026-10-15 webapp review findings.
Run from the webapp folder:
PYTHONPATH=.:<deps> python -m unittest discover -s tests -v
Every config path points into a temp folder (config.py reads env vars), so
nothing on the host is read or written. The shim tests use the real boot
files in the sibling pxe checkout and skip when they are not there.
"""
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest import mock
T = tempfile.mkdtemp(prefix="pxe-webapp-test-")
for _k, _sub in {"SAMBA_SHARE": "samba", "CLONEZILLA_SHARE": "cz", "BLANCCO_REPORTS": "br",
"ENROLLMENT_SHARE": "enr", "WEB_ROOT": "www", "IMAGING_DIR": "img", "UPDATES_DIR": "upd",
"TFTP_ROOT": "tftp", "BIOS_DIR": "bios", "UPLOAD_DIR": "upl", "SAMBA_LOG_DIR": "smblog"}.items():
os.environ[_k] = os.path.join(T, _sub)
os.makedirs(os.environ[_k], exist_ok=True)
for _k in ("AUDIT_LOG", "HARDWARE_STATE", "BLANCCO_STATE", "DNSMASQ_LEASES", "APACHE_ACCESS_LOG",
"DNSMASQ_SYSLOG", "NIC_STATUS_FILE", "NIC_CONF", "DNSMASQ_NIC_CONF", "DNSMASQ_CONF"):
os.environ[_k] = os.path.join(T, _k.lower())
os.makedirs(os.path.join(T, "enr", "ppkgs"), exist_ok=True)
WEBAPP = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, WEBAPP)
os.chdir(WEBAPP)
import config # noqa: E402
import app as A # noqa: E402
from services import imaging_status, ipxe_update, nic_status # noqa: E402
BOOT_FILES = os.environ.get("PXE_BOOT_FILES", "/home/camp/projects/pxe/boot-files")
DUAL_SHIM = os.path.join(BOOT_FILES, "ipxe-shimx64.efi")
TARBALL = os.path.join(BOOT_FILES, "ipxeboot.tar.gz")
HAVE_TOOLS = bool(shutil.which("openssl")) and bool(shutil.which("sbverify"))
def _extract(member, dest):
"""One file from the real iPXE tarball (symlinks followed), or None."""
if not os.path.isfile(TARBALL):
return None
with tarfile.open(TARBALL) as tar:
members = {m.name[2:] if m.name.startswith("./") else m.name: m for m in tar.getmembers()}
m = ipxe_update._resolve(members, member)
if m is None:
return None
with tar.extractfile(m) as src, open(dest, "wb") as out:
shutil.copyfileobj(src, out)
return dest
FILES = os.path.join(T, "realfiles")
os.makedirs(FILES, exist_ok=True)
SHIM_2011 = _extract("ipxeboot/x86_64-sb/ipxe-shim.efi", os.path.join(FILES, "shim2011.efi"))
SNPONLY = _extract("ipxeboot/x86_64-sb/snponly.efi", os.path.join(FILES, "snponly.efi"))
NATIVE = _extract("ipxeboot/x86_64-sb/ipxe.efi", os.path.join(FILES, "ipxe-native.efi"))
def _client():
c = A.app.test_client()
with c.session_transaction() as s:
s["_csrf_token"] = "t"
return c
def _write(path, text):
with open(path, "w") as f:
f.write(text)
def _iso(delta):
return (datetime.now(timezone.utc) + delta).isoformat(timespec="seconds")
# --- 1: imaging payload types ------------------------------------------------
class ImagingPayloadTypes(unittest.TestCase):
def setUp(self):
for f in os.listdir(config.IMAGING_DIR):
os.remove(os.path.join(config.IMAGING_DIR, f))
self.c = _client()
def test_list_pctype_post_does_not_500_pages(self):
r = self.c.post("/imaging/status", json={
"serial": "LISTPC", "pctype": ["gea-standard"], "current_stage": {"x": 1},
"status": ["failed"], "stage_index": "3", "stage_total": [5], "mac": None,
"log_lines": ["one", {"bad": 1}, 2], "hostname_target": 12})
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
state = imaging_status.get_session("LISTPC")
self.assertNotIn("pctype", state)
self.assertNotIn("current_stage", state)
self.assertEqual(state["stage_index"], 3)
self.assertNotIn("stage_total", state)
self.assertEqual(state["hostname_target"], "12")
self.assertEqual(state["log_tail"], ["one", "2"])
for url in ("/", "/imaging", "/imaging/tiles"):
self.assertEqual(self.c.get(url).status_code, 200, url)
def test_bad_serial_and_body_are_400(self):
self.assertEqual(self.c.post("/imaging/status", json={"serial": ["x"]}).status_code, 400)
self.assertEqual(self.c.post("/imaging/status", json=[1, 2]).status_code, 400)
def test_bad_json_on_disk_does_not_500(self):
_write(os.path.join(config.IMAGING_DIR, "DISKBAD.json"), json.dumps({
"serial": "DISKBAD", "pctype": ["gea-engineer"], "current_stage": ["x"],
"stage_index": {"a": 1}, "log_tail": "not a list", "stage_history": [1, {"stage_index": "x"}],
"last_updated": _iso(timedelta(0)), "status": "in_progress"}))
_write(os.path.join(config.IMAGING_DIR, "LISTFILE.json"), "[1, 2, 3]")
sessions = imaging_status.list_sessions(include_inferred=False)
self.assertEqual([s["serial"] for s in sessions], ["DISKBAD"])
s = sessions[0]
self.assertNotIn("pctype", s)
self.assertEqual(s["log_tail"], ["not a list"])
for url in ("/", "/imaging", "/imaging/session/DISKBAD", "/imaging/DISKBAD.json"):
self.assertEqual(self.c.get(url).status_code, 200, url)
def test_template_guards_unsanitised_session(self):
with A.app.test_request_context("/"):
html = A.render_template("_imaging_tiles.html", sessions=[{
"serial": "RAW", "pctype": ["gea-standard"], "current_stage": {"a": 1},
"stage_index": 1, "floor_state": "running"}])
self.assertIn("RAW", html)
# --- 4..8: network status ----------------------------------------------------
def _kernel(*ifaces):
return (list(ifaces), True)
def _kif(name, role, bound=None, link="up", speed=1000, addrs=None):
return {"iface": name, "mac": "", "role": role, "link": link, "speed_mbps": speed,
"addrs": addrs or [], "verdict": None, "evidence": [], "notes": [], "dnsmasq_bound": bound}
class NicStatus(unittest.TestCase):
def setUp(self):
for p in (config.NIC_STATUS_FILE, config.NIC_CONF, config.DNSMASQ_NIC_CONF, config.DNSMASQ_CONF):
if os.path.exists(p):
os.remove(p)
def _check(self, interfaces, checked_at=None, guard=None):
_write(config.NIC_STATUS_FILE, json.dumps({
"checked_at": checked_at or _iso(timedelta(0)), "interfaces": interfaces,
"dhcp_guard": guard or {"state": "ok", "reason": ""}}))
def test_malformed_file_renders(self):
self._check([
{"iface": "enp1", "role": "imaging", "evidence": None, "speed_mbps": "1000", "addrs": "x",
"notes": 5, "dnsmasq_bound": "yes", "verdict": ["ge"], "link": "up"},
"not a dict", None, 7,
{"iface": ["x"], "role": {"a": 1}, "mac": 5},
])
with mock.patch.object(nic_status, "_kernel_view", return_value=([], False)):
st = nic_status.status()
for i in st["interfaces"]:
self.assertIsInstance(i["evidence"], list)
self.assertIsInstance(i["notes"], list)
self.assertIsInstance(i["addrs"], list)
self.assertTrue(i["speed_mbps"] is None or isinstance(i["speed_mbps"], int))
self.assertIn(i["dnsmasq_bound"], (True, False, None))
for k in ("role", "iface", "mac", "link"):
self.assertIsInstance(i[k], str)
self.assertEqual(st["interfaces"][0]["speed_mbps"], 1000)
self.assertEqual(_client().get("/").status_code, 200)
for junk in ("[1,2]", "{\"interfaces\": \"x\"}", "garbage", "{\"interfaces\": [], \"dhcp_guard\": 5}"):
_write(config.NIC_STATUS_FILE, junk)
with mock.patch.object(nic_status, "_kernel_view", return_value=([], False)):
self.assertEqual(_client().get("/").status_code, 200, junk)
def test_status_raising_shows_unknown_pill(self):
with mock.patch.object(nic_status, "status", side_effect=RuntimeError("boom")):
r = _client().get("/")
self.assertEqual(r.status_code, 200)
self.assertIn("Network: unknown", r.get_data(as_text=True))
def test_roles_with_headered_conf(self):
_write(config.NIC_CONF, "# managed\n[nic]\nimaging_mac = AA-BB-CC-DD-EE-01 # pxe\n"
"ge_mac = aa:bb:cc:dd:ee:02 ; corp\n")
self.assertEqual(nic_status._roles(), {"aa:bb:cc:dd:ee:01": "imaging", "aa:bb:cc:dd:ee:02": "ge"})
_write(config.NIC_CONF, "imaging_iface = enp1\n") # no header: still read
self.assertEqual(nic_status._roles(), {"enp1": "imaging"})
def test_roles_from_dnsmasq_pin_split_commas(self):
_write(config.DNSMASQ_NIC_CONF, "interface=enp1,br0\nexcept-interface=enp2\n")
self.assertEqual(nic_status._roles(), {"enp1": "imaging", "br0": "imaging", "enp2": "ge"})
def test_wildcard_binding_not_flagged(self):
_write(config.DNSMASQ_NIC_CONF, "interface=enp1\nexcept-interface=enp2\nno-dhcp-interface=enp2\n")
rules = nic_status._dnsmasq_rules()
socks = [("0.0.0.0", None)]
self.assertFalse(nic_status._bound("enp2", ["10.1.2.3"], socks, rules))
self.assertTrue(nic_status._bound("enp1", ["172.16.9.1"], socks, rules))
self.assertIsNone(nic_status._bound("enp2", ["10.1.2.3"], socks, None))
self.assertTrue(nic_status._bound("enp2", [], [("0.0.0.0", "enp2")], rules))
def test_fresh_check_keeps_its_binding(self):
self._check([{"iface": "enp1", "role": "imaging", "verdict": "isolated", "dnsmasq_bound": True},
{"iface": "enp2", "role": "ge", "verdict": "ge", "dnsmasq_bound": False}])
k = _kernel(_kif("enp1", "imaging", True), _kif("enp2", "ge", True))
with mock.patch.object(nic_status, "_kernel_view", return_value=k):
st = nic_status.status()
self.assertEqual(st["state"], "ok", st["problems"])
self.assertFalse(st["interfaces"][1]["dnsmasq_bound"])
def test_stale_file_downgrades_and_clears(self):
guard = {"state": "blocked", "reason": "foreign DHCP server seen"}
ifs = [{"iface": "enp1", "role": "imaging", "verdict": "ge", "evidence": ["LLDP from corp switch"]}]
k = _kernel(_kif("enp1", "imaging", True, addrs=["172.16.9.1/24"]))
for stamp in (_iso(-timedelta(hours=2)), _iso(timedelta(hours=1)), "not a date"):
self._check(ifs, checked_at=stamp, guard=guard)
with mock.patch.object(nic_status, "_kernel_view", return_value=k):
st = nic_status.status()
self.assertTrue(st["stale"], stamp)
self.assertNotEqual(st["state"], "bad", (stamp, st["problems"]))
self.assertTrue(any("last network check" in t.lower() for _, t in st["problems"]))
self.assertEqual(st["interfaces"][0]["evidence"], [])
self.assertIsNone(st["interfaces"][0]["verdict"])
# Fresh, the same file is a danger.
self._check(ifs, guard=guard)
with mock.patch.object(nic_status, "_kernel_view", return_value=k):
self.assertEqual(nic_status.status()["state"], "bad")
def test_interface_gone_from_kernel(self):
self._check([{"iface": "enx1", "role": "imaging", "link": "up", "speed_mbps": 5000,
"addrs": ["172.16.9.1/24"], "dnsmasq_bound": True}])
with mock.patch.object(nic_status, "_kernel_view", return_value=_kernel(_kif("enp2", "ge"))):
st = nic_status.status()
i = st["interfaces"][0]
self.assertEqual((i["link"], i["speed_mbps"], i["addrs"]), ("down", None, []))
self.assertIn("not present (unplugged?)", i["notes"])
def test_kernel_view_includes_role_bridge(self):
sysnet = os.path.join(T, "sysnet")
shutil.rmtree(sysnet, ignore_errors=True)
for name, mac in (("br0", "aa:bb:cc:dd:ee:01"), ("enp1", "aa:bb:cc:dd:ee:01"), ("enp2", "aa:bb:cc:dd:ee:02")):
os.makedirs(os.path.join(sysnet, name))
_write(os.path.join(sysnet, name, "address"), mac + "\n")
_write(os.path.join(sysnet, name, "operstate"), "up\n")
os.makedirs(os.path.join(sysnet, "br0", "bridge"))
os.makedirs(os.path.join(sysnet, "br0", "brif", "enp1"))
os.makedirs(os.path.join(sysnet, "enp1", "device"))
os.makedirs(os.path.join(sysnet, "enp2", "device"))
_write(config.NIC_CONF, "[nic]\nimaging_mac = aa:bb:cc:dd:ee:01\nge_mac = aa:bb:cc:dd:ee:02\n")
with mock.patch.object(nic_status, "SYS_NET", sysnet), \
mock.patch.object(nic_status, "_addrs", return_value={"br0": ["172.16.9.1/24"]}), \
mock.patch.object(nic_status, "_dhcp_sockets", return_value=[("172.16.9.1", None)]):
view, ok = nic_status._kernel_view()
by = {i["iface"]: i for i in view}
self.assertTrue(ok)
self.assertEqual(by["br0"]["role"], "imaging")
self.assertTrue(by["br0"]["dnsmasq_bound"])
self.assertEqual(by["enp1"]["role"], "other")
self.assertEqual(by["enp2"]["role"], "ge")
self.assertFalse(by["enp2"]["dnsmasq_bound"])
# --- 2, 3, 9, 10, 11: iPXE ---------------------------------------------------
@unittest.skipUnless(HAVE_TOOLS and os.path.isfile(DUAL_SHIM) and SHIM_2011 and SNPONLY and NATIVE,
"real boot files, openssl or sbverify missing")
class IpxeRealFiles(unittest.TestCase):
def setUp(self):
ipxe_update._DESCRIBE_CACHE.clear()
for d in (os.path.join(config.UPDATES_DIR, "ipxe"), config.TFTP_ROOT):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(config.TFTP_ROOT)
def test_dual_shim_accepted(self):
self.assertEqual(ipxe_update.microsoft_cas(DUAL_SHIM), [ipxe_update.MS_CA_2011, ipxe_update.MS_CA_2023])
d = ipxe_update.describe(DUAL_SHIM)
self.assertTrue(d["dual"])
self.assertEqual(ipxe_update.risks("ipxe-shim.efi", d), [])
def test_2011_only_shim_refused(self):
self.assertEqual(ipxe_update.microsoft_cas(SHIM_2011), [ipxe_update.MS_CA_2011])
d = ipxe_update.describe(SHIM_2011)
self.assertFalse(d["dual"])
self.assertTrue(ipxe_update.risks("ipxe-shim.efi", d))
def test_shim_swap_only_for_dual(self):
folder = tempfile.mkdtemp(dir=T)
for src, expect_swap in ((DUAL_SHIM, True), (SHIM_2011, False)):
files = {"ipxe-shim.efi": dict(ipxe_update.describe(SHIM_2011), ok=True, error="", source="tar")}
rel = {"tag_name": "t1", "assets": [{"name": ipxe_update.SHIM_ASSET, "browser_download_url": "u",
"digest": "sha256:" + ipxe_update.uc.file_hash(src)}]}
dest = os.path.join(folder, ipxe_update.SHIM_ASSET)
shutil.copy(src, dest)
with mock.patch.object(ipxe_update.uc, "fetch_text", return_value=json.dumps(rel)):
ipxe_update._use_dual_signed_shim(folder, files)
f = files["ipxe-shim.efi"]
self.assertEqual(bool(f.get("dual")), expect_swap, f.get("note"))
if not expect_swap:
self.assertIn("not signed under both", f["note"])
def _stage_real(self, shim_src):
tag = "vreal"
folder = os.path.join(ipxe_update._dir(), "staged", tag)
os.makedirs(folder, exist_ok=True)
shutil.copy(TARBALL, os.path.join(folder, ipxe_update.TARBALL))
shutil.copy(shim_src, os.path.join(folder, ipxe_update.SHIM_ASSET))
info = {"tag": tag, "sha256": ipxe_update.uc.file_hash(TARBALL), "url": "unused",
"size": os.path.getsize(TARBALL)}
rel = {"tag_name": "s1", "assets": [{"name": ipxe_update.SHIM_ASSET, "browser_download_url": "unused",
"digest": "sha256:" + ipxe_update.uc.file_hash(shim_src)}]}
with mock.patch.object(ipxe_update.uc, "fetch_text", return_value=json.dumps(rel)), \
mock.patch.object(ipxe_update.uc, "download", side_effect=AssertionError("no network")):
return ipxe_update.stage(info)
def test_stage_real_tarball_dual_shim(self):
rec = self._stage_real(DUAL_SHIM)
self.assertEqual(rec["schema"], ipxe_update.STAGED_SCHEMA)
self.assertTrue(rec["files"]["ipxe-shim.efi"]["ok"], rec["files"]["ipxe-shim.efi"])
self.assertTrue(rec["files"]["ipxe-shim.efi"]["dual"])
self.assertTrue(rec["files"]["ipxe.efi"]["ok"], rec["files"]["ipxe.efi"])
self.assertEqual(rec["files"]["ipxe.efi"]["drivers"], "firmware")
ipxe_update._install("vreal", ipxe_update.staged("vreal")[0], ["ipxe-shim.efi", "ipxe.efi"])
def test_stage_real_tarball_2011_shim_kept_not_installable(self):
rec = self._stage_real(SHIM_2011) # the "dual" release asset is really 2011-only
f = rec["files"]["ipxe-shim.efi"]
self.assertFalse(f["ok"])
self.assertIn("not dual-signed", f["error"])
self.assertIn("dual-signed shim not used", f["note"])
self.assertTrue(rec["files"]["ipxe.efi"]["ok"])
def test_drivers_of(self):
self.assertEqual(ipxe_update.drivers_of(SNPONLY), "firmware")
self.assertEqual(ipxe_update.drivers_of(NATIVE), "native")
self.assertEqual(ipxe_update.drivers_of(DUAL_SHIM), "")
def _stage(self, tag, files, schema=ipxe_update.STAGED_SCHEMA):
folder = os.path.join(ipxe_update._dir(), "staged", tag)
os.makedirs(folder, exist_ok=True)
recs = {}
for name, (src, lie) in files.items():
shutil.copy(src, os.path.join(folder, name))
d = ipxe_update.describe(os.path.join(folder, name))
d.update(lie)
recs[name] = dict(d, ok=True, error="", source="test")
rec = {"tag": tag, "folder": folder, "files": recs, "staged": "now"}
if schema is not None:
rec["schema"] = schema
ipxe_update.uc.write_json(os.path.join(folder, "staged.json"), rec)
return rec
def test_install_refuses_native_ipxe(self):
# The record claims snponly; the file on disk is the native-USB build.
self._stage("v9", {"ipxe-shim.efi": (DUAL_SHIM, {}), "ipxe.efi": (NATIVE, {"drivers": "firmware"})})
rec = ipxe_update.staged("v9")[0]
with self.assertRaisesRegex(ValueError, "drives USB itself"):
ipxe_update._install("v9", rec, ["ipxe-shim.efi", "ipxe.efi"])
self.assertEqual(os.listdir(config.TFTP_ROOT), [])
def test_install_refuses_2011_shim(self):
self._stage("v8", {"ipxe-shim.efi": (SHIM_2011, {"dual": True}), "ipxe.efi": (SNPONLY, {})})
rec = ipxe_update.staged("v8")[0]
with self.assertRaisesRegex(ValueError, "not dual-signed"):
ipxe_update._install("v8", rec, ["ipxe-shim.efi", "ipxe.efi"])
def test_install_good_set(self):
self._stage("v7", {"ipxe-shim.efi": (DUAL_SHIM, {}), "ipxe.efi": (SNPONLY, {})})
rec = ipxe_update.staged("v7")[0]
ipxe_update._install("v7", rec, ["ipxe-shim.efi", "ipxe.efi"])
self.assertTrue(os.path.isfile(os.path.join(config.TFTP_ROOT, "ipxe.efi")))
def test_old_schema_record_is_stale(self):
self._stage("v6", {"ipxe-shim.efi": (DUAL_SHIM, {}), "ipxe.efi": (SNPONLY, {})}, schema=None)
rec = ipxe_update.staged("v6")[0]
self.assertTrue(rec["stale"])
self.assertFalse(rec["files"]["ipxe.efi"]["ok"])
with mock.patch.object(ipxe_update.uc, "job_running", return_value=False):
with self.assertRaisesRegex(ValueError, "older version"):
ipxe_update.install("v6", ["ipxe-shim.efi", "ipxe.efi"])
with self.assertRaisesRegex(ValueError, "older version"):
ipxe_update._install("v6", rec, ["ipxe.efi"])
with mock.patch.object(ipxe_update.uc, "job_running", return_value=False):
r = _client().get("/updates/ipxe/")
self.assertEqual(r.status_code, 200)
self.assertIn("Stale: download again", r.get_data(as_text=True))
def test_rollback_needs_confirm_for_risky_backup(self):
folder = os.path.join(ipxe_update._dir(), "backups", "20260101-000000")
os.makedirs(folder)
shutil.copy(NATIVE, os.path.join(folder, "ipxe.efi"))
shutil.copy(SHIM_2011, os.path.join(folder, "ipxe-shim.efi"))
b = ipxe_update.backups()[0]
self.assertEqual(len(b["warnings"]), 2)
with mock.patch.object(ipxe_update.uc, "job_running", return_value=False):
html = _client().get("/updates/ipxe/").get_data(as_text=True)
self.assertIn('name="confirm"', html)
self.assertIn("Restore anyway", html)
r = _client().post("/updates/ipxe/rollback", data={"_csrf_token": "t", "backup": "20260101-000000"})
self.assertEqual(r.status_code, 302)
self.assertFalse(os.path.exists(os.path.join(config.TFTP_ROOT, "ipxe.efi")))
with self.assertRaisesRegex(ValueError, "Tick the confirmation"):
ipxe_update.rollback("20260101-000000")
self.assertEqual(sorted(ipxe_update.rollback("20260101-000000", confirm=True)),
["ipxe-shim.efi", "ipxe.efi"])
def test_mac_folder_shim_without_ipxe_warns(self):
d = os.path.join(config.TFTP_ROOT, "aa-bb-cc-dd-ee-ff")
os.makedirs(d)
shutil.copy(DUAL_SHIM, os.path.join(d, "ipxe-shim.efi"))
m = ipxe_update.mac_folders()[0]
self.assertTrue(any("no ipxe.efi" in w for w in m["warnings"]))
with mock.patch.object(ipxe_update.uc, "job_running", return_value=False):
html = _client().get("/updates/ipxe/").get_data(as_text=True)
self.assertIn(config.TFTP_ROOT + "/aa-bb-cc-dd-ee-ff/", html)
self.assertNotIn("/srv/tftp/aa-bb", html)
def test_describe_is_cached(self):
with mock.patch.object(ipxe_update, "signers", wraps=ipxe_update.signers) as sp:
ipxe_update.describe(SNPONLY)
ipxe_update.describe(SNPONLY)
self.assertEqual(sp.call_count, 1)
class NestedSignatureParse(unittest.TestCase):
def test_finds_nested_contentinfo(self):
inner = bytes([0x30, 0x03, 0x02, 0x01, 0x05]) # SEQUENCE { INTEGER 5 }
outer = b"\x30\x00junk" + ipxe_update.NESTED_SIG_OID + bytes([0x31, len(inner)]) + inner + b"tail"
self.assertEqual(ipxe_update._nested_signatures(outer), [inner])
self.assertEqual(ipxe_update._nested_signatures(b"no oid here"), [])
self.assertEqual(ipxe_update._nested_signatures(ipxe_update.NESTED_SIG_OID + b"\x31\x7f"), [])
# --- 12: evidence pack dates -------------------------------------------------
class EvidenceDates(unittest.TestCase):
def test_far_future_dates_do_not_500(self):
r = _client().get("/reports/evidence?from=9999-12-01&to=9999-12-31")
self.assertEqual(r.status_code, 302)
def test_400_days_inclusive(self):
c = _client()
ok = c.get("/reports/evidence?from=2025-01-01&to=2026-02-04") # 400 days with both ends
self.assertEqual(ok.status_code, 200)
self.assertEqual(ok.mimetype, "application/zip")
self.assertEqual(c.get("/reports/evidence?from=2025-01-01&to=2026-02-05").status_code, 302)
if __name__ == "__main__":
unittest.main()

View File

@@ -9,6 +9,7 @@ services/ipxe_update.py; this module is the route surface.
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
import config
from services import ipxe_update, updates_common
from services.audit import audit
from views import save_speed_cap
@@ -33,6 +34,7 @@ def page():
installed_sha={n: (i or {}).get("sha256") for n, i in by_name.items()},
routing=ipxe_update.dnsmasq_routing(),
mac_folders=ipxe_update.mac_folders(),
tftp_root=config.TFTP_ROOT.rstrip("/") or "/",
latest=latest,
status=status,
job=ipxe_update.job_state(),
@@ -99,7 +101,7 @@ def rollback():
# rollback() looks the id up by its last path part only; log that.
backup_id = request.form.get("backup", "").replace("\\", "/").rsplit("/", 1)[-1]
try:
files = ipxe_update.rollback(backup_id)
files = ipxe_update.rollback(backup_id, confirm=request.form.get("confirm") == "1")
except Exception as ex:
flash(f"Rollback failed: {ex}", "danger")
return _back()