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