diff --git a/webapp/app.py b/webapp/app.py index 44fbbc4..ad010ea 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -12,15 +12,18 @@ This file is the route surface; most logic lives in ``services/``: services.wim - boot.wim startnet.cmd extract/update via wimtools """ +import hashlib import json import os import shutil import tempfile +import time from datetime import datetime from pathlib import Path from flask import ( Flask, + Response, abort, flash, jsonify, @@ -28,13 +31,24 @@ from flask import ( render_template, request, send_file, + stream_with_context, url_for, ) from lxml import etree from werkzeug.utils import secure_filename import config -from services import blancco_report, deploy, fs, images, imaging_status, system, unattend, wim +from services import ( + blancco_report, + deploy, + fs, + image_registry, + images, + imaging_status, + system, + unattend, + wim, +) from services.audit import audit from services.csrf import init_csrf @@ -44,6 +58,10 @@ app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH init_csrf(app) +# Pull IMAGE_TYPES/FRIENDLY_NAMES from the registry file (created from the +# baked-in config.py defaults on first run). Mutates config.* in place. +image_registry.reload() + # --------------------------------------------------------------------------- # Routes - pages @@ -281,6 +299,171 @@ def image_config_save(image_type): return redirect(url_for("image_config", image_type=image_type)) +# --------------------------------------------------------------------------- +# Routes - Driver/package upload + orphan adoption +# --------------------------------------------------------------------------- + +@app.route("/images//drivers/upload", methods=["POST"]) +def images_drivers_upload(image_type): + if image_type not in config.IMAGE_TYPES: + flash(f"Unknown image type: {image_type}", "danger") + return redirect(url_for("dashboard")) + f = request.files.get("driver_file") + if not f or not f.filename: + flash("No file selected.", "danger") + return redirect(url_for("image_config", image_type=image_type)) + family = (request.form.get("family") or "").strip() + dest = (request.form.get("destination_dir") or "").strip() + overwrite = request.form.get("overwrite") == "1" + try: + rec = images.upload_driver(image_type, f, family=family, + destination_dir=dest, overwrite=overwrite) + audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}") + msg = f"Uploaded {rec['filename']}." + msg += " Registered to family in HardwareDriver.json." if rec["registered"] else " Now in orphans list until adopted." + flash(msg, "success") + except (ValueError, FileExistsError, FileNotFoundError) as ex: + flash(str(ex), "danger") + except Exception as ex: + flash(f"Upload failed: {ex}", "danger") + return redirect(url_for("image_config", image_type=image_type)) + + +@app.route("/images//drivers/adopt", methods=["POST"]) +def images_drivers_adopt(image_type): + if image_type not in config.IMAGE_TYPES: + flash(f"Unknown image type: {image_type}", "danger") + return redirect(url_for("dashboard")) + filename = (request.form.get("filename") or "").strip() + family = (request.form.get("family") or "").strip() + dest = (request.form.get("destination_dir") or "").strip() + try: + rec = images.adopt_orphan(image_type, filename, family, dest) + audit("DRIVER_ADOPT", f"{image_type}/{rec['filename']} family={family}") + if rec.get("already_registered"): + flash(f"{rec['filename']} was already registered.", "info") + else: + flash(f"Adopted {rec['filename']} into HardwareDriver.json.", "success") + except (ValueError, FileNotFoundError) as ex: + flash(str(ex), "danger") + except Exception as ex: + flash(f"Adopt failed: {ex}", "danger") + return redirect(url_for("image_config", image_type=image_type)) + + +@app.route("/images//drivers/orphans/delete", methods=["POST"]) +def images_drivers_orphans_delete(image_type): + if image_type not in config.IMAGE_TYPES: + flash(f"Unknown image type: {image_type}", "danger") + return redirect(url_for("dashboard")) + # Filenames come as repeated form fields (one per checkbox). + filenames = request.form.getlist("filename") + if not filenames: + flash("No files selected for removal.", "warning") + return redirect(url_for("image_config", image_type=image_type)) + rec = images.remove_orphans(image_type, filenames) + audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} missing={len(rec['missing'])}") + parts = [] + if rec["removed"]: + parts.append(f"Removed {len(rec['removed'])} orphan(s).") + if rec["missing"]: + parts.append(f"{len(rec['missing'])} not found / errored.") + flash(" ".join(parts) or "No-op.", "success" if rec["removed"] else "warning") + return redirect(url_for("image_config", image_type=image_type)) + + +@app.route("/images//packages/upload", methods=["POST"]) +def images_packages_upload(image_type): + if image_type not in config.IMAGE_TYPES: + flash(f"Unknown image type: {image_type}", "danger") + return redirect(url_for("dashboard")) + f = request.files.get("package_file") + if not f or not f.filename: + flash("No file selected.", "danger") + return redirect(url_for("image_config", image_type=image_type)) + dest = (request.form.get("destination_dir") or "").strip() + overwrite = request.form.get("overwrite") == "1" + try: + rec = images.upload_package(image_type, f, destination_dir=dest, overwrite=overwrite) + audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}") + msg = f"Uploaded {rec['filename']}." + msg += " Registered in packages.json." if rec["registered"] else " Not registered (no destination_dir provided)." + flash(msg, "success") + except (ValueError, FileExistsError) as ex: + flash(str(ex), "danger") + except Exception as ex: + flash(f"Upload failed: {ex}", "danger") + return redirect(url_for("image_config", image_type=image_type)) + + +# --------------------------------------------------------------------------- +# Routes - Image type CRUD (registry-backed) +# --------------------------------------------------------------------------- + +@app.route("/images/new", methods=["POST"]) +def images_new(): + key = (request.form.get("key") or "").strip().lower() + friendly_name = (request.form.get("friendly_name") or "").strip() + try: + rec = image_registry.create(key, friendly_name) + audit("IMAGE_REGISTRY_CREATE", f"{rec['key']} ({rec['friendly_name']})") + flash(f"Created image type {rec['key']}.", "success") + except image_registry.RegistryError as ex: + flash(str(ex), "danger") + return redirect(url_for("dashboard")) + + +@app.route("/images//clone", methods=["POST"]) +def images_clone(image_type): + if image_type not in config.IMAGE_TYPES: + flash(f"Unknown source image type: {image_type}", "danger") + return redirect(url_for("dashboard")) + dst_key = (request.form.get("dst_key") or "").strip().lower() + friendly_name = (request.form.get("friendly_name") or "").strip() or None + try: + rec = image_registry.clone(image_type, dst_key, friendly_name) + audit("IMAGE_REGISTRY_CLONE", f"{image_type} -> {rec['key']}") + flash(f"Cloned {image_type} -> {rec['key']}.", "success") + except image_registry.RegistryError as ex: + flash(str(ex), "danger") + except Exception as ex: + flash(f"Clone failed: {ex}", "danger") + return redirect(url_for("dashboard")) + + +@app.route("/images//delete", methods=["POST"]) +def images_delete(image_type): + if image_type not in config.IMAGE_TYPES: + flash(f"Unknown image type: {image_type}", "danger") + return redirect(url_for("dashboard")) + delete_content = request.form.get("delete_content") == "1" + try: + rec = image_registry.delete(image_type, delete_content=delete_content) + audit("IMAGE_REGISTRY_DELETE", f"{rec['key']} content={rec['removed_content']}") + msg = f"Removed image type {rec['key']} from registry." + if rec["removed_content"]: + msg += " On-disk content wiped." + flash(msg, "success") + except image_registry.RegistryError as ex: + flash(str(ex), "danger") + return redirect(url_for("dashboard")) + + +@app.route("/images//rename", methods=["POST"]) +def images_rename(image_type): + if image_type not in config.IMAGE_TYPES: + flash(f"Unknown image type: {image_type}", "danger") + return redirect(url_for("dashboard")) + friendly_name = (request.form.get("friendly_name") or "").strip() + try: + rec = image_registry.rename_friendly(image_type, friendly_name) + audit("IMAGE_REGISTRY_RENAME", f"{rec['key']} -> {rec['friendly_name']}") + flash(f"Renamed {rec['key']} to '{rec['friendly_name']}'.", "success") + except image_registry.RegistryError as ex: + flash(str(ex), "danger") + return redirect(url_for("dashboard")) + + # --------------------------------------------------------------------------- # Routes - Clonezilla Backups # --------------------------------------------------------------------------- @@ -462,6 +645,75 @@ def imaging_dashboard(): return render_template("imaging.html", sessions=sessions) +@app.route("/imaging/tiles") +def imaging_tiles_partial(): + """HTML fragment of the per-bay tile loop only, used by the dashboard's + SSE/polling refresh to swap #imaging-tiles innerHTML without a full page + reload.""" + sessions = imaging_status.list_sessions() + return render_template("_imaging_tiles.html", sessions=sessions) + + +def _sessions_hash() -> str: + """Compact fingerprint of the current session list. Used by the SSE + stream to detect changes without sending the full payload. Hashing + (serial-or-key, status, stage_index, current_stage, last_updated) covers + every field the dashboard renders prominently.""" + sessions = imaging_status.list_sessions() + h = hashlib.sha256() + for s in sessions: + key = s.get("serial") or f"{s.get('mac','')}-{s.get('ip','')}" + h.update(repr(( + key, + s.get("source", "client"), + s.get("status", ""), + s.get("stage_index", 0), + s.get("current_stage", ""), + s.get("last_updated", ""), + s.get("laps_password", "") and "1" or "0", + )).encode()) + return h.hexdigest()[:16] + + +@app.route("/imaging/stream") +def imaging_stream(): + """Server-Sent Events stream of session-list change pings. + + Emits one JSON event every SSE_PING_INTERVAL seconds. When the hash + changes from the previously sent value, the client fetches /imaging/tiles + and re-renders. A keepalive heartbeat is sent on the same cadence so + intermediate proxies don't close the connection. + + Single-threaded dev server can only serve one SSE client at a time. The + live PXE box runs gunicorn with multiple workers (see playbook) so this + is fine in production. + """ + SSE_PING_INTERVAL = 5 # seconds between hash checks + SSE_MAX_DURATION = 600 # cap connection length so the worker recycles + + @stream_with_context + def gen(): + start = time.time() + last = None + while time.time() - start < SSE_MAX_DURATION: + try: + cur = _sessions_hash() + except Exception as ex: + yield f": error {ex}\n\n" + cur = last + payload = json.dumps({"hash": cur, "ts": int(time.time())}) + yield f"data: {payload}\n\n" + last = cur + time.sleep(SSE_PING_INTERVAL) + + headers = { + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # disable nginx/apache proxy buffering + "Connection": "keep-alive", + } + return Response(gen(), mimetype="text/event-stream", headers=headers) + + @app.route("/imaging/status", methods=["POST"]) def imaging_status_post(): # CSRF-exempt machine-to-machine endpoint; see services/csrf.py exempt list. @@ -485,6 +737,25 @@ def imaging_session_json(serial): return jsonify(s) +@app.route("/imaging/session/") +def imaging_session_detail(serial): + """Per-bay forensics page: stage timeline, full sidecar log, all session + metadata. Linked from the dashboard tile. Returns 404 if no session JSON + exists for the serial.""" + serial = secure_filename(serial) + s = imaging_status.get_session(serial) + if not s: + flash(f"No session for serial {serial}.", "danger") + return redirect(url_for("imaging_dashboard")) + full_log, truncated = imaging_status.read_full_log(serial) + return render_template( + "imaging_detail.html", + session=s, + full_log=full_log, + full_log_truncated=truncated, + ) + + @app.route("/imaging/delete/", methods=["POST"]) def imaging_delete_session(serial): serial = secure_filename(serial) diff --git a/webapp/config.py b/webapp/config.py index 56189e5..c6328f3 100644 --- a/webapp/config.py +++ b/webapp/config.py @@ -22,6 +22,16 @@ BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim") AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log") IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging") +# Log sources used by services/imaging_log_tail.py to infer client progress +# when no /imaging/status push has arrived yet. Override per-host if any of +# these live elsewhere on the live PXE server. +DNSMASQ_LEASES = os.environ.get("DNSMASQ_LEASES", "/var/lib/misc/dnsmasq.leases") +APACHE_ACCESS_LOG = os.environ.get("APACHE_ACCESS_LOG", "/var/log/apache2/access.log") +SAMBA_LOG_DIR = os.environ.get("SAMBA_LOG_DIR", "/var/log/samba") +# dnsmasq log-dhcp + TFTP requests land in syslog by default. Used to spot +# very-early boot (TFTP bootloader fetch) before Apache sees anything. +DNSMASQ_SYSLOG = os.environ.get("DNSMASQ_SYSLOG", "/var/log/syslog") + # --- Flask ------------------------------------------------------------------- FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod") MAX_CONTENT_LENGTH = 16 * 1024 * 1024 * 1024 # 16 GB max upload diff --git a/webapp/services/image_registry.py b/webapp/services/image_registry.py new file mode 100644 index 0000000..8960fbc --- /dev/null +++ b/webapp/services/image_registry.py @@ -0,0 +1,254 @@ +"""Mutable registry for image types. + +Previously IMAGE_TYPES + FRIENDLY_NAMES were hardcoded in config.py and +adding a new image type required a code edit + Ansible re-run. Now they +live in a JSON file (REGISTRY_PATH) that the webapp can read and write at +runtime. + +On import (called from app.py startup), reload() rewrites +config.IMAGE_TYPES and config.FRIENDLY_NAMES in-place so the rest of the +code (and the base.html sidebar context) sees the live list without +threading the registry through every call. + +Schema: + { + "image_types": [ + {"key": "gea-standard", "friendly_name": "GE Aerospace Standard"}, + ... + ] + } + +The seed comes from whatever IMAGE_TYPES/FRIENDLY_NAMES are baked into +config.py at first run. Once the registry file exists, it wins. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import tempfile +from datetime import datetime +from typing import Optional + +import config + +# Persist alongside the Samba share so an Ansible re-run that recreates +# SAMBA_SHARE keeps the registry. Override via env for tests. +REGISTRY_PATH = os.environ.get( + "IMAGE_REGISTRY_PATH", + os.path.join(config.SAMBA_SHARE, "image-registry.json"), +) + +# image_type keys are used as directory names + URL path components. Match +# the existing baked-in convention (lowercase + hyphens + alnum). +_VALID_KEY = re.compile(r"^[a-z][a-z0-9-]{1,63}$") + + +class RegistryError(Exception): + pass + + +def _read_file() -> dict: + try: + with open(REGISTRY_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +def _write_file(data: dict): + os.makedirs(os.path.dirname(REGISTRY_PATH), exist_ok=True) + fd, tmp = tempfile.mkstemp( + dir=os.path.dirname(REGISTRY_PATH), + prefix=".tmp-registry-", + suffix=".json", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + os.replace(tmp, REGISTRY_PATH) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def _seed_from_config() -> dict: + """First-run bootstrap: build a registry dict from whatever's baked into + config.py. Preserves order so the sidebar layout doesn't shuffle on the + first reload.""" + items = [] + for key in config.IMAGE_TYPES: + items.append({ + "key": key, + "friendly_name": config.FRIENDLY_NAMES.get(key, key), + "created_at": None, + }) + return {"image_types": items} + + +def load_registry() -> dict: + """Return the registry dict. Bootstraps from config.py on first read.""" + data = _read_file() + if not data.get("image_types"): + data = _seed_from_config() + try: + _write_file(data) + except OSError: + # Read-only filesystem / no perms; fall back to in-memory seed. + pass + return data + + +def reload(): + """Refresh config.IMAGE_TYPES + config.FRIENDLY_NAMES from the registry + file. Called at startup and after every CRUD op. Mutates in place so + existing references stay valid.""" + data = load_registry() + keys = [item["key"] for item in data.get("image_types", []) if "key" in item] + friendly = {item["key"]: item.get("friendly_name", item["key"]) + for item in data.get("image_types", [])} + # In-place replace so module-level references still point at the same + # list/dict objects. + config.IMAGE_TYPES[:] = keys + config.FRIENDLY_NAMES.clear() + config.FRIENDLY_NAMES.update(friendly) + + +def _validate_key(key: str): + if not key or not _VALID_KEY.match(key): + raise RegistryError( + f"Invalid image_type key '{key}'. Must be lowercase alphanumeric " + f"+ hyphens, 2-64 chars, starting with a letter." + ) + + +def create(key: str, friendly_name: str) -> dict: + """Add a new image type with no on-disk content. Caller is responsible + for populating Deploy/ via the import flow afterwards.""" + _validate_key(key) + if not friendly_name: + friendly_name = key + data = load_registry() + existing_keys = {item["key"] for item in data["image_types"]} + if key in existing_keys: + raise RegistryError(f"image_type '{key}' already exists") + data["image_types"].append({ + "key": key, + "friendly_name": friendly_name, + "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + }) + _write_file(data) + reload() + return {"key": key, "friendly_name": friendly_name} + + +def clone(src_key: str, dst_key: str, friendly_name: Optional[str] = None) -> dict: + """Duplicate a source image type's on-disk content + register the new + key. Copies Deploy/ tree (which contains Control/ JSON configs + + unattend XML), Tools/ if present, and the top-level Sources/ symlink if + present. Shared dirs (Out-of-box Drivers, Operating Systems, Packages) + are re-symlinked into the new image root, not duplicated.""" + _validate_key(dst_key) + data = load_registry() + keys = {item["key"] for item in data["image_types"]} + if src_key not in keys: + raise RegistryError(f"source image_type '{src_key}' not registered") + if dst_key in keys: + raise RegistryError(f"destination image_type '{dst_key}' already exists") + + from services import fs # local import to avoid circular at module load + src_root = fs.image_root(src_key) + dst_root = fs.image_root(dst_key) + if os.path.exists(dst_root): + raise RegistryError(f"destination path already exists on disk: {dst_root}") + + os.makedirs(dst_root, exist_ok=True) + if os.path.isdir(src_root): + for entry in os.listdir(src_root): + src_item = os.path.join(src_root, entry) + dst_item = os.path.join(dst_root, entry) + if os.path.islink(src_item): + # Preserve symlink (shared dir) instead of dereferencing. + target = os.readlink(src_item) + os.symlink(target, dst_item) + elif os.path.isdir(src_item): + shutil.copytree(src_item, dst_item, symlinks=True) + else: + shutil.copy2(src_item, dst_item) + + if not friendly_name: + src_friendly = next( + (i["friendly_name"] for i in data["image_types"] if i["key"] == src_key), + src_key, + ) + friendly_name = f"{src_friendly} (copy)" + + data["image_types"].append({ + "key": dst_key, + "friendly_name": friendly_name, + "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "cloned_from": src_key, + }) + _write_file(data) + reload() + return {"key": dst_key, "friendly_name": friendly_name, "cloned_from": src_key} + + +def delete(key: str, delete_content: bool = False) -> dict: + """Remove key from the registry. When delete_content is True, also wipes + the on-disk Deploy/Tools/etc tree. Shared symlinked dirs are NOT + followed when deleting content (we unlink the symlink, not its target).""" + data = load_registry() + items = data.get("image_types", []) + found = [i for i in items if i["key"] == key] + if not found: + raise RegistryError(f"image_type '{key}' not registered") + data["image_types"] = [i for i in items if i["key"] != key] + _write_file(data) + reload() + + removed_content = False + if delete_content: + from services import fs + root = fs.image_root(key) + if os.path.isdir(root): + # Walk top-level: unlink symlinks (don't follow into shared + # dirs), rmtree real directories, unlink files. + for entry in os.listdir(root): + p = os.path.join(root, entry) + try: + if os.path.islink(p): + os.unlink(p) + elif os.path.isdir(p): + shutil.rmtree(p) + else: + os.unlink(p) + except OSError: + pass + try: + os.rmdir(root) + except OSError: + pass + removed_content = True + + return {"key": key, "removed_content": removed_content} + + +def rename_friendly(key: str, friendly_name: str) -> dict: + """Update the human-readable name without touching the key or on-disk + content. Sidebar reflects on next request.""" + if not friendly_name: + raise RegistryError("friendly_name required") + data = load_registry() + for item in data.get("image_types", []): + if item["key"] == key: + item["friendly_name"] = friendly_name + _write_file(data) + reload() + return {"key": key, "friendly_name": friendly_name} + raise RegistryError(f"image_type '{key}' not registered") diff --git a/webapp/services/images.py b/webapp/services/images.py index 1fc137f..ca29bc3 100644 --- a/webapp/services/images.py +++ b/webapp/services/images.py @@ -1,11 +1,24 @@ """Per-image-type state probes: status + config (drivers / OS / packages / models).""" import os +import re +import shutil import config from services import fs +# File-name sanitizer for uploads. Strips anything outside [A-Za-z0-9._-] +# so a malicious filename can't break out of the destination directory. +_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]") + + +def _safe_filename(name: str) -> str: + base = os.path.basename(name or "") + cleaned = _SAFE_NAME.sub("_", base) + return cleaned or "upload.bin" + + def image_status(image_type): """Return a dict describing the state of an image type.""" dp = fs.deploy_path(image_type) @@ -121,3 +134,131 @@ def load_image_config(image_type): "orphan_drivers": orphan_drivers, "os_selection": os_selection, } + + +# --------------------------------------------------------------------------- +# Driver / package upload + orphan adoption +# --------------------------------------------------------------------------- + +DRIVERS_SUBDIR = "Out-of-box Drivers" +PACKAGES_SUBDIR = "Packages" + + +def _drivers_dir(image_type: str) -> str: + return os.path.join(fs.deploy_path(image_type), DRIVERS_SUBDIR) + + +def _packages_dir(image_type: str) -> str: + return os.path.join(fs.deploy_path(image_type), PACKAGES_SUBDIR) + + +def upload_driver(image_type: str, uploaded_file, family: str = "", + destination_dir: str = "", overwrite: bool = False) -> dict: + """Save an uploaded driver .zip into the image's Out-of-box Drivers dir. + When family + destination_dir are provided, also append a HardwareDriver + .json entry so the driver is recognized at deploy time. Returns a dict + describing what landed on disk + whether an entry was registered.""" + fname = _safe_filename(uploaded_file.filename) + if not fname.lower().endswith(".zip"): + raise ValueError("Driver upload must be a .zip file") + dst_dir = _drivers_dir(image_type) + os.makedirs(dst_dir, exist_ok=True) + dst_path = os.path.join(dst_dir, fname) + if os.path.exists(dst_path) and not overwrite: + raise FileExistsError(f"{fname} already exists in {DRIVERS_SUBDIR}/") + uploaded_file.save(dst_path) + registered = False + if family and destination_dir: + adopt_orphan(image_type, fname, family, destination_dir) + registered = True + return { + "filename": fname, + "path": dst_path, + "registered": registered, + } + + +def adopt_orphan(image_type: str, filename: str, family: str, + destination_dir: str) -> dict: + """Append a HardwareDriver.json entry for an existing .zip in the image's + Out-of-box Drivers dir, so it stops showing up as an orphan + gets + deployed for the named hardware family. Idempotent: a second adopt with + the same filename is a no-op.""" + safe_name = _safe_filename(filename) + drivers_path = _drivers_dir(image_type) + if not os.path.isfile(os.path.join(drivers_path, safe_name)): + raise FileNotFoundError(f"{safe_name} not found in {DRIVERS_SUBDIR}/") + if not family: + raise ValueError("family is required (matches HardwareModelSelection.Id)") + if not destination_dir: + raise ValueError("destination_dir is required") + + ctrl = fs.control_path(image_type) + hw_file = os.path.join(ctrl, "HardwareDriver.json") + entries = fs.load_json(hw_file) + for e in entries: + if (e.get("FileName") or e.get("fileName") or "").lower() == safe_name.lower(): + return {"filename": safe_name, "already_registered": True} + entries.append({ + "FileName": safe_name, + "DestinationDir": destination_dir, + "family": family, + }) + fs.save_json(hw_file, entries) + return {"filename": safe_name, "already_registered": False} + + +def remove_orphans(image_type: str, filenames: list[str]) -> dict: + """Delete the named files from Out-of-box Drivers/. Caller is + responsible for confirming this is what the user wants (orphan files + have no JSON entry, so deleting them is safe in the sense that nothing + references them; but they may be stash for future adoption).""" + drivers_path = _drivers_dir(image_type) + removed, missing = [], [] + for fn in filenames: + safe = _safe_filename(fn) + path = os.path.join(drivers_path, safe) + if not os.path.isfile(path): + missing.append(safe) + continue + try: + os.unlink(path) + removed.append(safe) + except OSError: + missing.append(safe) + return {"removed": removed, "missing": missing} + + +def upload_package(image_type: str, uploaded_file, destination_dir: str = "", + overwrite: bool = False) -> dict: + """Save an uploaded package (any extension) into the image's Packages + dir, and append an entry to packages.json when destination_dir is set + so it deploys at imaging time.""" + fname = _safe_filename(uploaded_file.filename) + dst_dir = _packages_dir(image_type) + os.makedirs(dst_dir, exist_ok=True) + dst_path = os.path.join(dst_dir, fname) + if os.path.exists(dst_path) and not overwrite: + raise FileExistsError(f"{fname} already exists in {PACKAGES_SUBDIR}/") + uploaded_file.save(dst_path) + registered = False + if destination_dir: + ctrl = fs.control_path(image_type) + pkg_file = os.path.join(ctrl, "packages.json") + entries = fs.load_json(pkg_file) + already = any( + (e.get("fileName") or e.get("FileName") or "").lower() == fname.lower() + for e in entries + ) + if not already: + entries.append({ + "fileName": fname, + "destinationDir": destination_dir, + }) + fs.save_json(pkg_file, entries) + registered = True + return { + "filename": fname, + "path": dst_path, + "registered": registered, + } diff --git a/webapp/services/imaging_log_tail.py b/webapp/services/imaging_log_tail.py new file mode 100644 index 0000000..eda376a --- /dev/null +++ b/webapp/services/imaging_log_tail.py @@ -0,0 +1,376 @@ +"""Infer in-progress imaging sessions from server-side logs. + +The /imaging/status endpoint only sees clients that have reached the WinPE +startnet.cmd push (stage_index 2). A bay that gets stuck earlier (no DHCP, +TFTP fail, boot.wim download stall) is invisible to the operator. + +This module tails dnsmasq leases + Apache access log + per-host Samba logs +and synthesizes a "session" record per active MAC, with a coarse inferred +stage based on which boot assets the client has actually fetched. Output +shape matches services/imaging_status so list_sessions() can merge both. + +Inferred sessions carry source="inferred" and never overwrite a real +client-pushed session for the same serial (correlated by MAC when present). +""" +from __future__ import annotations + +import os +import re +import time +from datetime import datetime, timezone +from typing import Optional + +import config + +# Active window: an inferred session is shown if any evidence is newer than +# this many seconds. Past that we assume the bay is idle / done / off. +INFERRED_ACTIVE_WINDOW_S = 90 * 60 # 90 min + +# Tail size caps so a giant log doesn't pull the whole file into memory on +# each dashboard refresh. +APACHE_TAIL_BYTES = 512 * 1024 # 512 KB +SAMBA_TAIL_BYTES = 64 * 1024 # 64 KB per file +SYSLOG_TAIL_BYTES = 256 * 1024 # 256 KB + +# Path prefixes Apache serves, mapped to coarse imaging stage signals. +# Order matters: we take the highest-numbered match (latest in the boot chain). +_APACHE_STAGE_HITS = [ + ("/menu.ipxe", ("stage_0_menu", 0)), + ("/win11/boot/", ("stage_0_boot_pre", 0)), + ("/win11/efi/", ("stage_0_boot_pre", 0)), + ("/win11/sources/boot.wim", ("stage_1_wim_get", 1)), + ("/win11/sources/", ("stage_1_wim_get", 1)), +] + +# TFTP bootloader fetches arrive via dnsmasq, not Apache. Mapped same way. +_TFTP_STAGE_HITS = [ + ("undionly.kpxe", ("stage_0_tftp_bios", 0)), + ("ipxe.efi", ("stage_0_tftp_uefi", 0)), +] + +# Apache combined log: - - [DD/Mon/YYYY:HH:MM:SS +ZZZZ] "GET /path HTTP/x.y" status bytes "ref" "ua" +_APACHE_RE = re.compile( + r'^(?P\S+)\s+\S+\s+\S+\s+' + r'\[(?P[^\]]+)\]\s+' + r'"(?P\S+)\s+(?P\S+)\s+\S+"\s+' + r'(?P\d+)\s+(?P\S+)' +) +_APACHE_TS_FMT = "%d/%b/%Y:%H:%M:%S %z" + +# dnsmasq syslog: " host dnsmasq-tftp[pid]: sent /tftp/path to ip" +# or "dnsmasq-dhcp[pid]: DHCPACK(...) ip mac hostname" +_SYSLOG_DNSMASQ_RE = re.compile( + r'^(?P\w{3})\s+(?P\d+)\s+(?P