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).
This commit is contained in:
244
webapp/app.py
244
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user