From cc391529bd6b8506cfb55401b54a51af79394bde Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 23 Jul 2026 11:37:24 -0400 Subject: [PATCH] startnet editor v2: settings/menu forms, syntax-highlight+lint, backup/restore+diff Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs: - Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens. - Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router. - Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current. - History: timestamped snapshots on every save, per-row diff + restore. New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups. Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN). --- webapp/app.py | 244 +++++++- webapp/services/wim.py | 665 ++++++++++++++++++++- webapp/templates/startnet_editor.html | 793 ++++++++++++++++++++++---- 3 files changed, 1581 insertions(+), 121 deletions(-) diff --git a/webapp/app.py b/webapp/app.py index b12f13b..25a6bb3 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -932,29 +932,89 @@ def enrollment_delete(filename): # --------------------------------------------------------------------------- # Routes - startnet.cmd Editor (boot.wim) +# +# The raw full-text editor (POST /startnet/save) is the source of truth and the +# always-available fallback. The Settings and Boot-menu tabs are conveniences +# that parse the CURRENT extracted content to prefill, then round-trip an +# apply_* result back through the SAME wim.update_startnet save path. Every save +# path snapshots the prior startnet.cmd first (feature 4) so a bad edit can be +# rolled back from the Snapshots tab. # --------------------------------------------------------------------------- +def _startnet_wiminfo(): + """Parse `wiminfo boot.wim` into a flat dict (empty on any failure).""" + import subprocess + info = {} + try: + result = subprocess.run( + ["wiminfo", config.BOOT_WIM], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + if ":" in line: + key, _, val = line.partition(":") + info[key.strip()] = val.strip() + except Exception: + pass + return info + + +def _startnet_current(): + """Extracted startnet.cmd text from boot.wim, or '' if unavailable.""" + if not os.path.isfile(config.BOOT_WIM): + return "" + return wim.extract_startnet(config.BOOT_WIM) or "" + + +def _startnet_save_content(new_content, prior, action, detail): + """Snapshot `prior` then write `new_content` to boot.wim via wimtools. + + Central save path shared by the raw editor, the settings tab, the menu tab, + and snapshot-restore so EVERY write snapshots first. Flashes success/failure + and returns True on success. Lint warnings are surfaced but never block. + """ + try: + wim.save_snapshot(prior, note=action) + except Exception as exc: + # A snapshot failure should not silently drop the safety net; warn but + # still let the operator save (the raw editor is the source of truth). + flash(f"Warning: could not snapshot prior startnet.cmd: {exc}", "warning") + + ok, err = wim.update_startnet(config.BOOT_WIM, new_content) + if not ok: + flash(f"Failed to update boot.wim: {err}", "danger") + return False + + lint = wim.lint_startnet(new_content) + for e in lint.get("errors", []): + loc = f"line {e['line']}: " if e.get("line") else "" + flash(f"Lint error - {loc}{e['message']}", "warning") + for w in lint.get("warnings", []): + loc = f"line {w['line']}: " if w.get("line") else "" + flash(f"Lint warning - {loc}{w['message']}", "warning") + + audit(action, detail) + return True + + @app.route("/startnet") def startnet_editor(): - import subprocess wim_exists = os.path.isfile(config.BOOT_WIM) content = "" wim_info = {} + settings = {"server_ip": None, "menu_timeout": None, "menu_default": None} + boot_menu = [] + lint = {"errors": [], "warnings": []} if wim_exists: - content = wim.extract_startnet(config.BOOT_WIM) or "" - try: - result = subprocess.run( - ["wiminfo", config.BOOT_WIM], - capture_output=True, text=True, timeout=15, - ) - if result.returncode == 0: - for line in result.stdout.splitlines(): - if ":" in line: - key, _, val = line.partition(":") - wim_info[key.strip()] = val.strip() - except Exception: - pass + content = _startnet_current() + wim_info = _startnet_wiminfo() + settings = wim.parse_settings(content) + boot_menu = wim.parse_boot_menu(content) + lint = wim.lint_startnet(content) + + snapshots = wim.list_snapshots() return render_template( "startnet_editor.html", @@ -962,6 +1022,10 @@ def startnet_editor(): wim_path=config.BOOT_WIM, content=content, wim_info=wim_info, + settings=settings, + boot_menu=boot_menu, + lint=lint, + snapshots=snapshots, image_types=config.IMAGE_TYPES, friendly_names=config.FRIENDLY_NAMES, ) @@ -974,15 +1038,157 @@ def startnet_save(): return redirect(url_for("startnet_editor")) content = request.form.get("content", "") - ok, err = wim.update_startnet(config.BOOT_WIM, content) - if ok: - audit("STARTNET_SAVE", "boot.wim updated") + prior = _startnet_current() + if _startnet_save_content(content, prior, "STARTNET_SAVE", "boot.wim updated (raw)"): flash("startnet.cmd updated successfully in boot.wim.", "success") - else: - flash(f"Failed to update boot.wim: {err}", "danger") return redirect(url_for("startnet_editor")) +@app.route("/startnet/settings", methods=["POST"]) +def startnet_save_settings(): + """Apply the Settings tab into the CURRENT extracted content, then save. + + Only server_ip / menu_timeout / menu_default are touched, via targeted + replacement (apply_settings never rebuilds the file). Blank fields are left + alone. The raw text stays the source of truth: we apply into what is on + boot.wim right now, not a rebuilt file. + """ + if not os.path.isfile(config.BOOT_WIM): + flash("boot.wim not found.", "danger") + return redirect(url_for("startnet_editor")) + + prior = _startnet_current() + settings = {} + + ip = (request.form.get("server_ip") or "").strip() + if ip: + settings["server_ip"] = ip + + to = (request.form.get("menu_timeout") or "").strip() + if to: + try: + settings["menu_timeout"] = int(to) + except ValueError: + flash("Menu timeout must be a whole number of seconds.", "danger") + return redirect(url_for("startnet_editor")) + + md = (request.form.get("menu_default") or "").strip() + if md: + settings["menu_default"] = md + + new_content = wim.apply_settings(prior, settings) + if new_content == prior: + flash("No settings changed.", "info") + return redirect(url_for("startnet_editor")) + + if _startnet_save_content(new_content, prior, "STARTNET_SETTINGS", + f"settings applied: {settings}"): + flash("Startnet settings applied and saved to boot.wim.", "success") + return redirect(url_for("startnet_editor")) + + +@app.route("/startnet/menu", methods=["POST"]) +def startnet_save_menu(): + """Apply the Boot-menu tab into the CURRENT extracted content, then save. + + The tab posts an `items` JSON array of {label, target, target_image}; list + ORDER defines the new 1..N numbering (any 'num' is ignored). apply_boot_menu + regenerates only the echo lines, choice count, dispatch, and action blocks; + existing block bodies are byte-for-byte preserved. It RAISES ValueError when + the structure is ambiguous or a renumber would desync the enrollment + %choice% router (e.g. reorder/remove of a cross-referenced image); we catch + it, flash the reason, and leave the raw text untouched. + """ + if not os.path.isfile(config.BOOT_WIM): + flash("boot.wim not found.", "danger") + return redirect(url_for("startnet_editor")) + + payload = request.form.get("items", "[]") + try: + items = json.loads(payload) + except json.JSONDecodeError: + flash("Invalid boot-menu payload.", "danger") + return redirect(url_for("startnet_editor")) + if not isinstance(items, list): + flash("Boot-menu payload must be a list of items.", "danger") + return redirect(url_for("startnet_editor")) + for it in items: + if not isinstance(it, dict) or not (it.get("target") or "").strip(): + flash("Every boot-menu entry needs a non-empty Target.", "danger") + return redirect(url_for("startnet_editor")) + + prior = _startnet_current() + try: + new_content = wim.apply_boot_menu(prior, items) + except ValueError as exc: + flash(f"Boot menu not rewritten (kept raw text): {exc}", "danger") + return redirect(url_for("startnet_editor")) + + if new_content == prior: + flash("No boot-menu changes to save.", "info") + return redirect(url_for("startnet_editor")) + + if _startnet_save_content(new_content, prior, "STARTNET_MENU", + f"boot menu rebuilt ({len(items)} item(s))"): + flash("Boot menu rebuilt and saved to boot.wim.", "success") + return redirect(url_for("startnet_editor")) + + +@app.route("/startnet/restore", methods=["POST"]) +def startnet_restore_snapshot(): + """Restore a snapshot back through the save path. + + Reads the snapshot (path-traversal safe), snapshots the ABOUT-TO-BE-REPLACED + current startnet first, then writes the restored bytes to boot.wim. + """ + if not os.path.isfile(config.BOOT_WIM): + flash("boot.wim not found.", "danger") + return redirect(url_for("startnet_editor")) + + snapshot_id = (request.form.get("snapshot_id") or "").strip() + restored = wim.read_snapshot(snapshot_id) + if restored is None: + flash(f"Snapshot not found: {snapshot_id}", "danger") + return redirect(url_for("startnet_editor")) + + prior = _startnet_current() + if _startnet_save_content(restored, prior, "STARTNET_RESTORE", + f"restored snapshot {snapshot_id}"): + flash(f"Restored startnet.cmd from {snapshot_id}.", "success") + return redirect(url_for("startnet_editor")) + + +@app.route("/startnet/diff", methods=["POST"]) +def startnet_diff(): + """Return a unified diff as JSON. Two modes (body may be form or JSON): + + - snapshot_id -> diff that snapshot (fromfile) vs current boot.wim (tofile) + - content -> diff current boot.wim (fromfile) vs pending edit (tofile) + + JSON callers must send the CSRF token in the X-CSRF-Token header. + """ + body = request.get_json(silent=True) or {} + snapshot_id = (request.form.get("snapshot_id") or body.get("snapshot_id") or "").strip() + pending = request.form.get("content") + if pending is None: + pending = body.get("content") + + current = _startnet_current() + + if snapshot_id: + snap = wim.read_snapshot(snapshot_id) + if snap is None: + return jsonify({"error": f"snapshot not found: {snapshot_id}"}), 404 + diff = wim.unified_diff(snap, current, fromfile=snapshot_id, tofile="current") + return jsonify({"diff": diff, "mode": "snapshot", "snapshot_id": snapshot_id}) + + if pending is not None: + diff = wim.unified_diff(current, pending, fromfile="current", tofile="pending") + return jsonify({"diff": diff, "mode": "pending"}) + + return jsonify({"error": "provide snapshot_id or content"}), 400 + + # --------------------------------------------------------------------------- # Routes - Audit Log # --------------------------------------------------------------------------- diff --git a/webapp/services/wim.py b/webapp/services/wim.py index 9556f42..15c1713 100644 --- a/webapp/services/wim.py +++ b/webapp/services/wim.py @@ -1,13 +1,39 @@ -"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir). +"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir) +plus pure-Python helpers for the startnet.cmd editor. -Used by the startnet.cmd editor to extract + update the boot script -that runs when WinPE boots from PXE. +Two layers live here: + +1. wimtools wrappers (extract_startnet / update_startnet / list_files) that + read + write startnet.cmd inside boot.wim. update_startnet writes CRLF. + +2. Framework-free text helpers used by the rebuilt startnet.cmd editor: + settings-as-forms (parse_settings / apply_settings), the boot-menu builder + (parse_boot_menu / apply_boot_menu), lint_startnet, timestamped snapshots + (save_snapshot / list_snapshots / read_snapshot) and unified_diff. + +The helpers hold NO Flask imports on purpose; the routes stage wires them up. +startnet.cmd is a WinPE BATCH script with CRLF endings. Every regen helper +preserves the file's existing line ending and only rewrites the exact lines it +targets, leaving everything else byte-for-byte. When a structure cannot be +located unambiguously the regen helpers raise ValueError so the caller can fall +back to the raw full-text editor (the source of truth). """ +import datetime +import difflib +import glob import os +import re import shutil import subprocess import tempfile +from collections import Counter + +# Root-writable dir for timestamped startnet.cmd snapshots. Overridable by env +# so tests / alt deploys can redirect it. save_snapshot() creates it on demand. +BACKUPS_DIR = os.environ.get( + "STARTNET_BACKUPS_DIR", "/var/lib/pxe-webapp/startnet-backups" +) def extract_startnet(wim_path): @@ -22,7 +48,11 @@ def extract_startnet(wim_path): ) startnet_path = os.path.join(tmpdir, "startnet.cmd") if result.returncode == 0 and os.path.isfile(startnet_path): - with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh: + # newline="" keeps the file's CRLF bytes verbatim; default universal + # newlines would fold CRLF to LF, breaking the lint CRLF check and + # storing LF-only snapshots. + with open(startnet_path, "r", encoding="utf-8", errors="replace", + newline="") as fh: return fh.read() return None except Exception: @@ -38,6 +68,10 @@ def update_startnet(wim_path, content): """ tmpdir = tempfile.mkdtemp() try: + # Normalize to LF first: newline="\r\n" translates each bare \n, so + # CRLF input (browser textareas post CRLF) would otherwise become + # \r\r\n. After normalizing, output is always clean CRLF. + content = content.replace("\r\n", "\n").replace("\r", "\n") startnet_path = os.path.join(tmpdir, "startnet.cmd") with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh: fh.write(content) @@ -68,3 +102,626 @@ def list_files(wim_path, path="/"): return [] except Exception: return [] + + +# =========================================================================== +# Pure text helpers for the startnet.cmd editor (no Flask, no wimtools). +# All operate on the startnet.cmd text. Regen helpers preserve the file's +# existing line ending and only touch their target lines. +# =========================================================================== + +def _detect_eol(content): + """Return the dominant line ending, defaulting to CRLF for WinPE batch.""" + if "\r\n" in content: + return "\r\n" + if "\n" in content: + return "\n" + return "\r\n" + + +def _split_lines(content): + """Split into lines WITHOUT terminators, tolerant of CRLF/LF/CR. + + Rejoining with eol.join(...) reproduces a trailing newline because a + trailing terminator yields a final empty element. + """ + return content.replace("\r\n", "\n").replace("\r", "\n").split("\n") + + +# --- Shared regexes --------------------------------------------------------- +_LABEL_RE = re.compile(r"^\s*:([A-Za-z0-9_][A-Za-z0-9_\-]*)\s*$") +_NETUSE_IMG_RE = re.compile( + r"net use\s+\w+:\s+\\\\[0-9.]+\\winpeapps\\([A-Za-z0-9_\-]+)", re.IGNORECASE +) +_GOTO_END_RE = re.compile(r"^\s*goto\s+end\s*$", re.IGNORECASE) +_DISPATCH_RE = re.compile( + r'^(\s*)if\s+"%choice%"=="(\d+)"\s+goto\s+([A-Za-z0-9_\-]+)\s*$', re.IGNORECASE +) +_ECHO_NUM_RE = re.compile(r"^(\s*)echo\s+(\d+)\.\s?(.*?)\s*$", re.IGNORECASE) +_CHOICE_PROMPT_RE = re.compile( + r"^(\s*set\s+/p\s+choice=.*\(1-)(\d+)(\).*)$", re.IGNORECASE +) + + +# --------------------------------------------------------------------------- +# Feature 1: settings as forms +# --------------------------------------------------------------------------- + +def _detect_server_ip(content): + """Best-effort PXE server IP: the IP most used in \\\\IP\\ UNC paths. + + Falls back to a `ping ` target. Returns the dotted-quad or None. + """ + ips = re.findall(r"\\\\(\d{1,3}(?:\.\d{1,3}){3})\\", content) + if not ips: + ips = re.findall( + r"ping\b[^\r\n]*?\b(\d{1,3}(?:\.\d{1,3}){3})\b", content, re.IGNORECASE + ) + if not ips: + return None + return Counter(ips).most_common(1)[0][0] + + +def _replace_server_ip(content, old, new): + """Replace old IP with new everywhere it stands as a whole IPv4 token. + + The negative look-around on [\\d.] keeps it from matching a fragment of a + longer dotted number, and only the detected server IP is ever passed in, so + unrelated addresses (e.g. 8.8.8.8 in the enroll.cmd here-doc) are untouched. + This covers UNC (\\\\IP\\...), the ping wait-for-network line, and any URL. + """ + pat = re.compile(r"(? settings dict of safe, common knobs. + + Keys: + server_ip str|None PXE server IP used in UNC/ping paths + menu_timeout int|None seconds, from a `choice /t` construct if present + menu_default str|None default choice key, from `choice /d` if present + """ + ip = _detect_server_ip(content) + timeout, default = _detect_menu_timeout_default(content) + return {"server_ip": ip, "menu_timeout": timeout, "menu_default": default} + + +def apply_settings(content, settings): + """settings dict -> content, via targeted replacement only. + + - server_ip: if given and different from the current detected IP, rewrites + that IP everywhere it appears as a whole IPv4 token (UNC, ping, URL). + - menu_timeout / menu_default: updated ONLY if a `choice /t../d..` construct + already exists; never injected (conservative - avoids risky rewrites). + Unknown/None keys are left alone. Line endings are preserved. + """ + out = content + new_ip = settings.get("server_ip") + if new_ip: + cur = _detect_server_ip(content) + if cur and cur != new_ip: + out = _replace_server_ip(out, cur, new_ip) + + if settings.get("menu_timeout") is not None or settings.get("menu_default") is not None: + out = _apply_menu_timeout_default( + out, settings.get("menu_timeout"), settings.get("menu_default") + ) + return out + + +def _apply_menu_timeout_default(content, timeout, default): + """Rewrite /t and /d on an existing `choice` line only. No-op otherwise.""" + eol = _detect_eol(content) + lines = _split_lines(content) + for i, line in enumerate(lines): + if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower(): + if timeout is not None: + line = re.sub( + r"(/t\s*:?\s*)\d+", r"\g<1>" + str(timeout), line, flags=re.IGNORECASE + ) + if default is not None: + line = re.sub( + r"(/d\s*:?\s*)[A-Za-z0-9]+", + r"\g<1>" + str(default), + line, + flags=re.IGNORECASE, + ) + lines[i] = line + break + return eol.join(lines) + + +# --------------------------------------------------------------------------- +# Feature 2: boot-menu builder +# --------------------------------------------------------------------------- + +def _find_label_line(lines, name): + name = name.lower() + for i, line in enumerate(lines): + m = _LABEL_RE.match(line) + if m and m.group(1).lower() == name: + return i + return None + + +def _find_image_blocks(lines): + """Find per-image action blocks: a ':' label whose body maps + Z: to \\\\IP\\winpeapps\\ and ends with 'goto end'. + + Returns list of dicts: {target, image, start, end} where start is the label + line index and end is the 'goto end' line index (inclusive block span). + """ + label_idxs = [i for i, l in enumerate(lines) if _LABEL_RE.match(l)] + label_set = set(label_idxs) + blocks = [] + for i in label_idxs: + target = _LABEL_RE.match(lines[i]).group(1) + # body runs to the next label or EOF + end_bound = len(lines) + for j in range(i + 1, len(lines)): + if j in label_set: + end_bound = j + break + image = None + goto_end_idx = None + for j in range(i + 1, end_bound): + if image is None: + mi = _NETUSE_IMG_RE.search(lines[j]) + if mi: + image = mi.group(1) + if _GOTO_END_RE.match(lines[j]): + goto_end_idx = j + if image is not None and goto_end_idx is not None: + blocks.append( + {"target": target, "image": image, "start": i, "end": goto_end_idx} + ) + return blocks + + +def _find_dispatch_lines(lines): + """All `if "%choice%"=="N" goto TARGET` lines as dicts {idx,num,target,indent}.""" + out = [] + for i, line in enumerate(lines): + m = _DISPATCH_RE.match(line) + if m: + out.append( + { + "idx": i, + "indent": m.group(1), + "num": int(m.group(2)), + "target": m.group(3), + } + ) + return out + + +def _find_choice_prompt(lines, start): + for i in range(start, len(lines)): + if _CHOICE_PROMPT_RE.match(lines[i]): + return i + return None + + +def _find_numbered_echo_run(lines, lo, hi): + """Contiguous run of numbered 'echo N. Label' lines within [lo, hi). + + Returns (indices, indent, labels_by_num) or raises ValueError if the numbered + echo lines are not on consecutive source lines (ambiguous - refuse to edit). + """ + matched = [] + for i in range(lo, hi): + m = _ECHO_NUM_RE.match(lines[i]) + if m: + matched.append((i, m)) + if not matched: + raise ValueError("no numbered 'echo N. ...' menu lines found") + idxs = [i for i, _ in matched] + if idxs != list(range(idxs[0], idxs[0] + len(idxs))): + raise ValueError("menu echo lines are not contiguous") + indent = matched[0][1].group(1) + labels = {int(m.group(2)): m.group(3).rstrip() for _, m in matched} + return idxs, indent, labels + + +def _analyze_boot_menu(lines): + """Locate every editable region of the image menu. Raises ValueError if the + clearly-delimited structure is missing/ambiguous. Returns a dict of parts.""" + menu_idx = _find_label_line(lines, "menu") + if menu_idx is None: + raise ValueError("no :menu label found") + choice_idx = _find_choice_prompt(lines, menu_idx) + if choice_idx is None: + raise ValueError("no 'set /p choice=' prompt found after :menu") + + echo_idxs, echo_indent, echo_labels = _find_numbered_echo_run( + lines, menu_idx, choice_idx + ) + + image_blocks = _find_image_blocks(lines) + if not image_blocks: + raise ValueError("no image action blocks (net use winpeapps ... goto end)") + img_targets = {b["target"] for b in image_blocks} + + all_dispatch = _find_dispatch_lines(lines) + image_dispatch = [d for d in all_dispatch if d["target"] in img_targets] + if not image_dispatch: + raise ValueError("no image %choice% dispatch lines found") + d_idxs = [d["idx"] for d in image_dispatch] + if d_idxs != list(range(d_idxs[0], d_idxs[-1] + 1)): + raise ValueError("image %choice% dispatch lines are not contiguous") + + blocks_sorted = sorted(image_blocks, key=lambda b: b["start"]) + region_start = blocks_sorted[0]["start"] + region_end = blocks_sorted[-1]["end"] + # every line inside the block region must belong to a block span or be blank + covered = set() + for b in blocks_sorted: + covered.update(range(b["start"], b["end"] + 1)) + for j in range(region_start, region_end + 1): + if j not in covered and lines[j].strip() != "": + raise ValueError( + "image action blocks are not contiguous (foreign content at " + "line %d)" % (j + 1) + ) + + return { + "menu_idx": menu_idx, + "choice_idx": choice_idx, + "echo_idxs": echo_idxs, + "echo_indent": echo_indent, + "echo_labels": echo_labels, + "image_blocks": blocks_sorted, + "image_dispatch": image_dispatch, + "all_dispatch": all_dispatch, + "region_start": region_start, + "region_end": region_end, + "dispatch_start": d_idxs[0], + "dispatch_end": d_idxs[-1], + } + + +def parse_boot_menu(content): + """content -> list of image-menu items, ordered by menu number. + + Each item dict: + num int choice number that routes to this image + label str echo text after 'N. ' (kept verbatim, trailing ws only stripped) + target str goto label of the action block (the ':') + target_image str winpeapps\\ the block maps Z: to + + Returns [] if the delimited menu region cannot be found (caller uses raw text). + """ + lines = _split_lines(content) + try: + a = _analyze_boot_menu(lines) + except ValueError: + return [] + by_target = {b["target"]: b for b in a["image_blocks"]} + items = [] + for d in a["image_dispatch"]: + blk = by_target[d["target"]] + items.append( + { + "num": d["num"], + "label": a["echo_labels"].get(d["num"], ""), + "target": d["target"], + "target_image": blk["image"], + } + ) + items.sort(key=lambda it: it["num"]) + return items + + +def _synth_block(item, ip, eol_indent=""): + """Build a fresh per-image action block for a newly added menu item.""" + target = item["target"] + image = item.get("target_image") or target + label = item.get("label") or target + return [ + ":%s" % target, + "echo.", + "echo Starting %s setup..." % label, + r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe', + r"for /l %%i in (1,1,2000000) do rem", + r"net use Z: \\%s\winpeapps\%s /user:pxe-upload pxe /persistent:no" + % (ip, image), + "goto end", + ] + + +def apply_boot_menu(content, items): + """items -> content: regenerate ONLY the menu echo lines, the choice prompt + count, the image %choice% dispatch (renumbered 1..N), and reorder/add/remove + the per-image action blocks. Existing block BODIES are kept byte-for-byte; + only new blocks are synthesised. Everything else is untouched. + + items: list of {label, target, target_image} (a 'num' key, if present, is + ignored - order in the list defines the new numbering). + + Raises ValueError when the structure is ambiguous OR when renumbering would + desync a secondary `%choice%` router (e.g. the enrollment routing that also + keys off choice numbers). The caller then falls back to the raw editor. + """ + if not items: + raise ValueError("boot menu must have at least one item") + + eol = _detect_eol(content) + lines = _split_lines(content) + ip = _detect_server_ip(content) or "172.16.9.1" + a = _analyze_boot_menu(lines) + + old_num_by_target = {d["target"]: d["num"] for d in a["image_dispatch"]} + old_target_by_num = {d["num"]: d["target"] for d in a["image_dispatch"]} + new_num_by_target = {it["target"]: i + 1 for i, it in enumerate(items)} + + # Safety: a secondary %choice% router (not one of the image blocks) that + # references an image number whose meaning is about to change is a desync. + image_dispatch_idxs = {d["idx"] for d in a["image_dispatch"]} + for d in a["all_dispatch"]: + if d["idx"] in image_dispatch_idxs: + continue + n = d["num"] + if n in old_target_by_num: # this number currently selects an image + tgt = old_target_by_num[n] + if tgt not in new_num_by_target or new_num_by_target[tgt] != n: + raise ValueError( + "refusing to renumber: secondary %%choice%% routing at line " + "%d references image choice %d whose number would change" + % (d["idx"] + 1, n) + ) + + blocks_by_target = { + b["target"]: lines[b["start"]:b["end"] + 1] for b in a["image_blocks"] + } + + # --- build replacement lines for each region --- + new_echo = [ + "%secho %d. %s" % (a["echo_indent"], i + 1, it.get("label") or it["target"]) + for i, it in enumerate(items) + ] + disp_indent = a["image_dispatch"][0]["indent"] + new_dispatch = [ + '%sif "%%choice%%"=="%d" goto %s' % (disp_indent, i + 1, it["target"]) + for i, it in enumerate(items) + ] + cm = _CHOICE_PROMPT_RE.match(lines[a["choice_idx"]]) + new_choice = "%s%d%s" % (cm.group(1), len(items), cm.group(3)) + + new_region = [] + for i, it in enumerate(items): + if i > 0: + new_region.append("") # blank line between blocks, matching style + if it["target"] in blocks_by_target: + new_region.extend(blocks_by_target[it["target"]]) + else: + new_region.extend(_synth_block(it, ip)) + + # Apply splices high-index-first so earlier indices stay valid. + splices = [ + (a["region_start"], a["region_end"], new_region), + (a["dispatch_start"], a["dispatch_end"], new_dispatch), + (a["choice_idx"], a["choice_idx"], [new_choice]), + (a["echo_idxs"][0], a["echo_idxs"][-1], new_echo), + ] + for start, end, repl in sorted(splices, key=lambda s: s[0], reverse=True): + lines[start:end + 1] = repl + + return eol.join(lines) + + +# --------------------------------------------------------------------------- +# Feature 3: lint +# --------------------------------------------------------------------------- + +def _strip_for_parens(line): + """Neutralise a line for paren counting: drop comments, quoted spans, and + caret-escaped chars (so `^>NUL`, `echo ... ^(` etc. do not count).""" + s = line.lstrip() + low = s.lower() + if low.startswith("rem ") or low == "rem" or s.startswith("::"): + return "" + # remove escaped pairs first, then any caret-escaped char + s = s.replace("^^", "") + s = re.sub(r"\^.", "", s) + # drop double-quoted spans + s = re.sub(r'"[^"]*"', "", s) + return s + + +def lint_startnet(content): + """Static checks over startnet.cmd. Advisory - never blocks a save. + + Returns {"errors": [...], "warnings": [...]} where each entry is + {"line": int|None, "message": str}. Errors: goto with no matching label. + Warnings: lone-LF (non-CRLF) lines, unbalanced parentheses. + """ + errors = [] + warnings = [] + lines = _split_lines(content) + + labels = set() + for line in lines: + m = _LABEL_RE.match(line) + if m: + labels.add(m.group(1).lower()) + + for i, line in enumerate(lines): + # A `goto` inside an `echo` line is emitted text (e.g. the enroll.cmd + # here-doc), not a jump - skip it to avoid false positives. + if line.lstrip().lower().startswith("echo"): + continue + for m in re.finditer( + r"goto\s+:?([A-Za-z0-9_][A-Za-z0-9_\-]*)", line, re.IGNORECASE + ): + target = m.group(1).lower() + if target == "eof": + continue # goto :eof is a cmd.exe builtin + if target not in labels: + errors.append( + { + "line": i + 1, + "message": "goto %s has no matching :%s label" + % (m.group(1), m.group(1)), + } + ) + + # CRLF check: count lone LFs (a \n not preceded by \r) + crlf = content.count("\r\n") + lf = content.count("\n") + lone = lf - crlf + if lone > 0: + first = None + idx = content.find("\n") + ln = 1 + while idx != -1: + if idx == 0 or content[idx - 1] != "\r": + first = ln + break + idx = content.find("\n", idx + 1) + ln += 1 + warnings.append( + { + "line": first, + "message": "%d line(s) are not CRLF-terminated; WinPE batch " + "expects CRLF" % lone, + } + ) + + # Parenthesis balance (best-effort, comments/quotes/carets removed) + delta = 0 + for line in lines: + cleaned = _strip_for_parens(line) + delta += cleaned.count("(") - cleaned.count(")") + if delta != 0: + warnings.append( + { + "line": None, + "message": "parentheses look unbalanced (delta %+d) - check " + "if(...) blocks" % delta, + } + ) + + return {"errors": errors, "warnings": warnings} + + +# --------------------------------------------------------------------------- +# Feature 4: snapshots + diff +# --------------------------------------------------------------------------- + +def _ensure_backups_dir(): + os.makedirs(BACKUPS_DIR, exist_ok=True) + + +def _snapshot_meta(path): + st = os.stat(path) + base = os.path.basename(path) + note = "" + note_path = path + ".note" + if os.path.isfile(note_path): + try: + with open(note_path, "r", encoding="utf-8", errors="replace") as fh: + note = fh.read().strip() + except Exception: + note = "" + return { + "id": base, + "path": path, + "mtime": st.st_mtime, + "timestamp": datetime.datetime.fromtimestamp(st.st_mtime).strftime( + "%Y-%m-%d %H:%M:%S" + ), + "size": st.st_size, + "note": note, + } + + +def save_snapshot(content, note=""): + """Write content verbatim to a timestamped file under BACKUPS_DIR. + + Snapshots the bytes exactly (no line-ending translation) so a restore + round-trips faithfully. Returns the snapshot metadata dict (see + _snapshot_meta). Call this with the PRIOR startnet before overwriting it. + """ + _ensure_backups_dir() + ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + base = "startnet-%s.cmd" % ts + path = os.path.join(BACKUPS_DIR, base) + n = 1 + while os.path.exists(path): + base = "startnet-%s-%d.cmd" % (ts, n) + path = os.path.join(BACKUPS_DIR, base) + n += 1 + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(content) + if note: + with open(path + ".note", "w", encoding="utf-8", newline="") as fh: + fh.write(note) + return _snapshot_meta(path) + + +def list_snapshots(): + """Return snapshot metadata dicts, newest first.""" + _ensure_backups_dir() + metas = [] + for path in glob.glob(os.path.join(BACKUPS_DIR, "startnet-*.cmd")): + if path.endswith(".note"): + continue + try: + metas.append(_snapshot_meta(path)) + except OSError: + continue + metas.sort(key=lambda m: m["mtime"], reverse=True) + return metas + + +def read_snapshot(snapshot_id): + """Return a snapshot's text by id (its filename), or None if not found. + + Rejects path traversal: the id must be a bare basename under BACKUPS_DIR. + """ + if not snapshot_id or os.path.basename(snapshot_id) != snapshot_id: + return None + if ".." in snapshot_id or "/" in snapshot_id or "\\" in snapshot_id: + return None + path = os.path.join(BACKUPS_DIR, snapshot_id) + if not os.path.isfile(path): + return None + # newline="" so the stored CRLF bytes come back verbatim (faithful restore). + with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh: + return fh.read() + + +def unified_diff(a, b, fromfile="current", tofile="new"): + """Unified diff between two startnet texts, as a single string ('' if equal). + + Line endings are normalised for comparison so a pure CRLF/LF difference does + not produce a wall of noise; content differences are what surface. + """ + a_lines = _split_lines(a) + b_lines = _split_lines(b) + return "\n".join( + difflib.unified_diff( + a_lines, b_lines, fromfile=fromfile, tofile=tofile, lineterm="" + ) + ) diff --git a/webapp/templates/startnet_editor.html b/webapp/templates/startnet_editor.html index a5c86d3..fb7cfc6 100644 --- a/webapp/templates/startnet_editor.html +++ b/webapp/templates/startnet_editor.html @@ -3,47 +3,140 @@ {% block extra_head %} {% endblock %} @@ -52,9 +145,7 @@

startnet.cmd Editor

{% if wim_exists %}
- + boot.wim
{% endif %} @@ -64,88 +155,594 @@ boot.wim not found at {{ wim_path }}. Run the PXE server setup playbook and import WinPE boot files first. +{% if snapshots %} +
+
Snapshots
+

boot.wim is missing, but prior startnet.cmd snapshots are still on disk. Restore is unavailable until the WIM is present.

+
+{% endif %} {% else %} -
-
-
-
- Windows\System32\startnet.cmd - boot.wim -
-
- - -
-
- - Editing the startnet.cmd inside {{ wim_path }} - - -
-
+ -
-
Common startnet.cmd Commands
-
-
-
- wpeinit - Initialize WinPE networking -
-
- net use Z: \\172.16.9.1\winpeapps - Map Samba share for deployment -
+
+ + +
+
+
+
+

Boot Settings

+

+ These edit only their targeted lines in startnet.cmd. Leave a field + blank to keep the current value. Everything round-trips + through the same boot.wim as the Raw tab. +

+
+ + +
+ + +
Address startnet.cmd maps the winpeapps share from.
+
+ +
+ + +
Whole seconds before the default menu entry auto-selects.
+
+ +
+ + +
The entry chosen when the timeout elapses.
+
+ + +
-
-
- wpeutil WaitForNetwork - Wait for network to be ready -
-
- Z:\gea-standard\Deploy\Tools\deploy.cmd - Launch deployment script -
+
+
+
+
WIM Info
+
+ {% for key, val in wim_info.items() %} + {% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %} +
{{ key }}
+
{{ val }}
+ {% endif %} + {% endfor %} + {% if not wim_info %} +

Could not read WIM info.

+ {% endif %} +
-
-
-
WIM Info
-
- {% for key, val in wim_info.items() %} - {% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %} -
{{ key }}
-
{{ val }}
- {% endif %} - {% endfor %} - {% if not wim_info %} -

Could not read WIM info.

- {% endif %} -
+ +
+
+
+

Boot Menu Entries

+ +
+
+ + Appending a new image at the end always works. Reordering or + removing an entry whose number the enrollment %choice% router + cross-references may be refused by the server to keep numbering + in sync - if so, the raw text is left untouched and the reason is flashed. +
+ + +
+
+ + +
+
+
+
+
+ Windows\System32\startnet.cmd +
+ + +
+
+ +
+ +
+ +
+ + +
+
+
+ +
+ Editing startnet.cmd inside {{ wim_path }} + +
+
+ +
+
Common startnet.cmd Commands
+
+
+
+ wpeinitInitialize WinPE networking +
+
+ net use Z: \\172.16.9.1\winpeappsMap Samba share for deployment +
+
+
+
+ wpeutil WaitForNetworkWait for network to be ready +
+
+ Z:\gea-standard\Deploy\Tools\deploy.cmdLaunch deployment script +
+
+
+
+
+ +
+
+
+ Lint + {% if lint.errors %}{{ lint.errors|length }} err{% endif %} + {% if lint.warnings %}{{ lint.warnings|length }} warn{% endif %} +
+ {% if not lint.errors and not lint.warnings %} +

No issues found.

+ {% else %} +
    + {% for e in lint.errors %} +
  • + {{ ('L' ~ e.line) if e.line is not none else '--' }} + {{ e.message }} +
  • + {% endfor %} + {% for w in lint.warnings %} +
  • + {{ ('L' ~ w.line) if w.line is not none else '--' }} + {{ w.message }} +
  • + {% endfor %} +
+ {% endif %} +
+ +
+
WIM Info
+
+ {% for key, val in wim_info.items() %} + {% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %} +
{{ key }}
+
{{ val }}
+ {% endif %} + {% endfor %} + {% if not wim_info %} +

Could not read WIM info.

+ {% endif %} +
+
+
+
+
+ + +
+
+

Snapshots

+

+ Every save snapshots the prior startnet.cmd first. Diff a snapshot against the + current boot.wim, or restore it (restoring also snapshots the current copy first). +

+ {% if not snapshots %} +

No snapshots yet.

+ {% else %} +
+ + + + + + + + + + + {% for s in snapshots %} + + + + + + + {% endfor %} + +
TimestampSizeNoteActions
{{ s.timestamp }}{{ '%.1f'|format(s.size / 1024) }} KB{{ s.note or '-' }} + +
+ + + +
+
+
+ {% endif %} +
+
+ +
+ + + + {% endif %} {% endblock %} {% block extra_scripts %} +{% if wim_exists %} +{% endif %} {% endblock %}