"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir) plus pure-Python helpers for the startnet.cmd editor. 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): """Extract startnet.cmd from a WIM file. Returns the contents or None.""" tmpdir = tempfile.mkdtemp() try: result = subprocess.run( ["wimextract", wim_path, "1", "/Windows/System32/startnet.cmd", "--dest-dir", tmpdir], capture_output=True, text=True, timeout=30, ) startnet_path = os.path.join(tmpdir, "startnet.cmd") if result.returncode == 0 and os.path.isfile(startnet_path): # 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: return None finally: shutil.rmtree(tmpdir, ignore_errors=True) def update_startnet(wim_path, content): """Update startnet.cmd inside a WIM file via wimupdate. Returns (ok, error_message). Writes CRLF line endings. """ 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) update_cmd = f"add {startnet_path} /Windows/System32/startnet.cmd\n" result = subprocess.run( ["wimupdate", wim_path, "1"], input=update_cmd, capture_output=True, text=True, timeout=60, ) if result.returncode != 0: return False, result.stderr.strip() return True, "" except Exception as exc: return False, str(exc) finally: shutil.rmtree(tmpdir, ignore_errors=True) def list_files(wim_path, path="/"): """List files inside a WIM at the given path.""" try: result = subprocess.run( ["wimdir", wim_path, "1", path], capture_output=True, text=True, timeout=30, ) if result.returncode == 0: return [l.strip() for l in result.stdout.splitlines() if l.strip()] 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. """ # Collapse any run of CRs before a newline (handles CRLF and a doubled # \r\r\n that a bad CRLF-conversion can leave behind), then lone CR. return re.sub(r"\r+\n", "\n", content).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="" ) )