Files
pxe-server/webapp/views/ipxe.py
cproudlock 81c2d85301 Webapp: iPXE page stages the dual-signed shim, knows snponly and per-MAC folders
Staging fetches ipxe-shimx64.efi from the ipxe/shim release (sha256 from
GitHub), and uses it instead of the tarball's 2011-only shim when it is
signed under the Microsoft UEFI CA 2023 and trusts the same iPXE CA;
otherwise the tarball shim stays, with the reason shown. snponly.efi and
snponly-shim.efi are managed alongside ipxe.efi and ipxe-shim.efi, each
iPXE build says whether it drives USB itself (kills USB keyboards), and
tftp-unique-root per-MAC folders are listed read-only.
2026-10-14 15:37:14 -04:00

115 lines
3.9 KiB
Python

"""Updates > iPXE: the Secure Boot boot files on the TFTP root.
Shows what is installed (version, signer chain, Microsoft CA warning), how
dnsmasq routes clients to those files, and checks GitHub for a newer
upstream release. Download and verify runs as a background job; install
and rollback copy a few MB and run inline. Logic lives in
services/ipxe_update.py; this module is the route surface.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from services import ipxe_update, updates_common
from services.audit import audit
from views import save_speed_cap
bp = Blueprint("ipxe", __name__, url_prefix="/updates/ipxe")
def _back():
return redirect(url_for("ipxe.page"))
@bp.route("/")
def page():
rows = ipxe_update.installed()
by_name = {r["name"]: r["info"] for r in rows}
latest = ipxe_update.last_check()
ipxe_efi = by_name.get("ipxe.efi") or {}
status = ipxe_update.compare(latest["tag"], ipxe_efi.get("version")) if latest else None
return render_template(
"updates_ipxe.html",
rows=rows,
installed_sha={n: (i or {}).get("sha256") for n, i in by_name.items()},
routing=ipxe_update.dnsmasq_routing(),
mac_folders=ipxe_update.mac_folders(),
latest=latest,
status=status,
job=ipxe_update.job_state(),
staged=ipxe_update.staged(),
backups=ipxe_update.backups(),
settings=updates_common.settings(),
default_install=ipxe_update.DEFAULT_INSTALL,
tarball_url=ipxe_update.LATEST_TARBALL_URL,
releases_page=ipxe_update.RELEASES_PAGE,
)
@bp.route("/status.json")
def status():
return jsonify(ipxe_update.job_state())
@bp.route("/check", methods=["POST"])
def check():
try:
info = ipxe_update.check_latest()
except Exception as ex:
flash(f"Could not check GitHub: {ex}", "danger")
return _back()
if not info["sha256"]:
flash(f"Latest release is {info['tag']}, but GitHub lists no sha256 for {ipxe_update.TARBALL}; "
"install it by hand (see below).", "warning")
else:
flash(f"Latest upstream release: {info['tag']}.", "info")
return _back()
@bp.route("/stage", methods=["POST"])
def stage():
try:
ipxe_update.start_stage()
except Exception as ex:
flash(f"Download not started: {ex}", "danger")
return _back()
latest = ipxe_update.last_check() or {}
audit("IPXE_STAGE", f"download and verify {latest.get('tag')}")
flash(f"Downloading {latest.get('tag')}; this page updates when it is verified.", "info")
return _back()
@bp.route("/install", methods=["POST"])
def install():
tag = request.form.get("tag", "")
# Only known names reach the audit log (form values could carry newlines).
names = [n for n in ipxe_update.MANAGED if n in request.form.getlist("names")]
try:
backup = ipxe_update.install(tag, names)
except Exception as ex:
flash(f"Install refused: {ex}", "danger")
return _back()
audit("IPXE_INSTALL", f"{tag}: {', '.join(names)} (backup {backup.rsplit('/', 1)[-1]})")
flash(f"Installed {', '.join(names)} from {tag}. "
"The next client to boot gets them; the old files are in the backups list.", "success")
return _back()
@bp.route("/rollback", methods=["POST"])
def rollback():
# rollback() looks the id up by its last path part only; log that.
backup_id = request.form.get("backup", "").replace("\\", "/").rsplit("/", 1)[-1]
try:
files = ipxe_update.rollback(backup_id)
except Exception as ex:
flash(f"Rollback failed: {ex}", "danger")
return _back()
audit("IPXE_ROLLBACK", f"{backup_id}: {', '.join(files)}")
flash(f"Restored {', '.join(files)} from backup {backup_id}.", "success")
return _back()
@bp.route("/settings", methods=["POST"])
def save_settings():
save_speed_cap() # shared with the other Updates pages (views/__init__.py)
return _back()