Add wimtools and startnet.cmd editor for boot.wim modification

- Added wimtools to offline packages and playbook verification
- Webapp startnet.cmd editor: extract, view, edit, save back to boot.wim
- Uses wimextract/wimupdate for in-place WIM modification
- Dark-themed code editor with tab support and common command reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-06 16:23:22 -05:00
parent e7313c2ca3
commit 89b58347d9
5 changed files with 250 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ PLAYBOOK_PACKAGES=(
unzip unzip
ufw ufw
cron cron
wimtools
) )
# Packages installed during autoinstall late-commands (NetworkManager, WiFi, etc.) # Packages installed during autoinstall late-commands (NetworkManager, WiFi, etc.)

View File

@@ -16,6 +16,7 @@
- ufw - ufw
- cron - cron
- ansible - ansible
- wimtools
register: pkg_check register: pkg_check
failed_when: false failed_when: false
changed_when: false changed_when: false
@@ -374,6 +375,7 @@
WorkingDirectory=/opt/pxe-webapp WorkingDirectory=/opt/pxe-webapp
Environment=SAMBA_SHARE={{ samba_share }} Environment=SAMBA_SHARE={{ samba_share }}
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
Environment=WEB_ROOT={{ web_root }}
ExecStart=/opt/pxe-webapp/venv/bin/python app.py ExecStart=/opt/pxe-webapp/venv/bin/python app.py
Restart=always Restart=always
RestartSec=5 RestartSec=5

View File

@@ -29,6 +29,8 @@ app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 * 1024 # 16 GB max upload
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SAMBA_SHARE = os.environ.get("SAMBA_SHARE", "/srv/samba/winpeapps") SAMBA_SHARE = os.environ.get("SAMBA_SHARE", "/srv/samba/winpeapps")
CLONEZILLA_SHARE = os.environ.get("CLONEZILLA_SHARE", "/srv/samba/clonezilla") CLONEZILLA_SHARE = os.environ.get("CLONEZILLA_SHARE", "/srv/samba/clonezilla")
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
IMAGE_TYPES = [ IMAGE_TYPES = [
"gea-standard", "gea-standard",
@@ -638,6 +640,118 @@ def clonezilla_delete(filename):
return redirect(url_for("clonezilla_backups")) return redirect(url_for("clonezilla_backups"))
# ---------------------------------------------------------------------------
# Routes — startnet.cmd Editor (WIM)
# ---------------------------------------------------------------------------
def _wim_extract_startnet(wim_path):
"""Extract startnet.cmd from a WIM file using wimextract."""
import tempfile
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):
content = open(startnet_path, "r", encoding="utf-8", errors="replace").read()
return content
return None
except Exception:
return None
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def _wim_update_startnet(wim_path, content):
"""Update startnet.cmd inside a WIM file using wimupdate."""
import tempfile
tmpdir = tempfile.mkdtemp()
try:
startnet_path = os.path.join(tmpdir, "startnet.cmd")
with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh:
fh.write(content)
# wimupdate reads commands from stdin
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 _wim_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 []
@app.route("/startnet")
def startnet_editor():
wim_exists = os.path.isfile(BOOT_WIM)
content = ""
wim_info = {}
if wim_exists:
content = _wim_extract_startnet(BOOT_WIM) or ""
# Get WIM info
try:
result = subprocess.run(
["wiminfo", 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
return render_template(
"startnet_editor.html",
wim_exists=wim_exists,
wim_path=BOOT_WIM,
content=content,
wim_info=wim_info,
image_types=IMAGE_TYPES,
friendly_names=FRIENDLY_NAMES,
)
@app.route("/startnet/save", methods=["POST"])
def startnet_save():
if not os.path.isfile(BOOT_WIM):
flash("boot.wim not found.", "danger")
return redirect(url_for("startnet_editor"))
content = request.form.get("content", "")
ok, err = _wim_update_startnet(BOOT_WIM, content)
if ok:
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"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes — API # Routes — API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -137,6 +137,12 @@
<div class="nav-section-divider"></div> <div class="nav-section-divider"></div>
<div class="sidebar-heading">Tools</div> <div class="sidebar-heading">Tools</div>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'startnet_editor' %}active{% endif %}"
href="{{ url_for('startnet_editor') }}">
<i class="bi bi-terminal"></i> startnet.cmd
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
href="{{ url_for('clonezilla_backups') }}"> href="{{ url_for('clonezilla_backups') }}">

View File

@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
{% block extra_head %}
<style>
.cmd-editor {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.9rem;
min-height: 400px;
background-color: #1e1e1e;
color: #d4d4d4;
border: 1px solid #333;
padding: 1rem;
tab-size: 4;
white-space: pre;
line-height: 1.5;
}
.cmd-editor:focus {
outline: none;
border-color: #0d6efd;
box-shadow: 0 0 0 0.15rem rgba(13,110,253,.25);
}
.wim-info dt {
font-weight: 600;
color: #6c757d;
}
.wim-info dd {
margin-bottom: 0.3rem;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-terminal me-2"></i>startnet.cmd Editor</h2>
</div>
{% if not wim_exists %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
Run the PXE server setup playbook and import WinPE boot files first.
</div>
{% else %}
<div class="row">
<div class="col-lg-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-file-earmark-code me-2"></i>Windows\System32\startnet.cmd</span>
<span class="badge bg-secondary">boot.wim</span>
</div>
<div class="card-body p-0">
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
<textarea name="content" class="form-control cmd-editor" id="cmdEditor"
spellcheck="false">{{ content }}</textarea>
</form>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted">
Editing the startnet.cmd inside <code>{{ wim_path }}</code>
</small>
<button type="submit" form="startnetForm" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i> Save to boot.wim
</button>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-info-circle me-1"></i> Common startnet.cmd Commands</h6>
<div class="row">
<div class="col-md-6">
<code class="d-block mb-1">wpeinit</code>
<small class="text-muted d-block mb-2">Initialize WinPE networking</small>
<code class="d-block mb-1">net use Z: \\10.9.100.1\winpeapps</code>
<small class="text-muted d-block mb-2">Map Samba share for deployment</small>
</div>
<div class="col-md-6">
<code class="d-block mb-1">wpeutil WaitForNetwork</code>
<small class="text-muted d-block mb-2">Wait for network to be ready</small>
<code class="d-block mb-1">Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
<small class="text-muted d-block mb-2">Launch deployment script</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3">
<div class="card">
<div class="card-header">
<i class="bi bi-info-square me-2"></i>WIM Info
</div>
<div class="card-body wim-info">
<dl class="mb-0">
{% for key, val in wim_info.items() %}
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
<dt>{{ key }}</dt>
<dd>{{ val }}</dd>
{% endif %}
{% endfor %}
{% if not wim_info %}
<p class="text-muted mb-0">Could not read WIM info.</p>
{% endif %}
</dl>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
<script>
// Tab key inserts a tab in the editor instead of moving focus
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 1;
}
});
</script>
{% endblock %}