diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py index 69de5bb..6ff9f53 100644 --- a/emmc-provisioning/dashboard/app.py +++ b/emmc-provisioning/dashboard/app.py @@ -5,12 +5,14 @@ Public home: deploy only (status, logs, how to connect). No login. Admin: login required — backups, cloud-init, portal files, set golden, users. """ +import hashlib import json import os import re import shutil import sqlite3 import subprocess +import tempfile import time import urllib.request from functools import wraps @@ -49,6 +51,12 @@ PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", s DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db"))) TOGGLE_NETWORK_BOOT_SCRIPT = os.environ.get("CM4_TOGGLE_NETWORK_BOOT_SCRIPT", "/opt/cm4-provisioning/toggle-network-boot-dhcp.sh") DHCP_LEASES_FILE = os.environ.get("CM4_DHCP_LEASES_FILE", "/var/lib/misc/dnsmasq.leases") +# EEPROM update (USB boot): tools and output files (shared with host script) +EEPROM_DIR = Path(os.environ.get("CM4_EEPROM_DIR", "/opt/cm4-provisioning/eeprom")) +EEPROM_CONFIG_TOOL = EEPROM_DIR / "rpi-eeprom-config" +EEPROM_FW_FILE = EEPROM_DIR / "pieeprom.bin" +EEPROM_UPD_FILE = BASE_DIR / "pieeprom.upd" +EEPROM_SIG_FILE = BASE_DIR / "pieeprom.sig" # --- Database (admin users + activity logs) --- @@ -229,6 +237,72 @@ def _write_status(phase, message, progress=None): pass +def _generate_eeprom_update(boot_order, extra_settings=None): + """Generate pieeprom.upd and pieeprom.sig in BASE_DIR. Returns (True, None) or (False, error_message).""" + extra_settings = extra_settings or {} + if not EEPROM_CONFIG_TOOL.is_file() or not EEPROM_FW_FILE.is_file(): + return False, "EEPROM tools not installed. Run install-eeprom-tools-on-lxc.sh on the LXC." + try: + os.makedirs(BASE_DIR, exist_ok=True) + # Read current config from firmware image + out = subprocess.run( + [str(EEPROM_CONFIG_TOOL), str(EEPROM_FW_FILE)], + capture_output=True, + text=True, + timeout=30, + cwd=str(EEPROM_DIR), + ) + if out.returncode != 0: + return False, (out.stderr or out.stdout or "rpi-eeprom-config failed").strip()[:500] + lines = (out.stdout or "").strip().splitlines() + # Strip lines we replace + skip_keys = {"BOOT_ORDER", "NET_BOOT_MAX_RETRIES", "DHCP_TIMEOUT", "DHCP_REQ_TIMEOUT", "TFTP_IP", "NET_INSTALL_AT_POWER_ON"} + new_lines = [] + for line in lines: + key = line.split("=", 1)[0].strip() if "=" in line else "" + if key in skip_keys: + continue + new_lines.append(line) + # Add our settings + new_lines.append(f"BOOT_ORDER={boot_order}") + new_lines.append("NET_BOOT_MAX_RETRIES=3") + new_lines.append("DHCP_TIMEOUT=1500") + new_lines.append("DHCP_REQ_TIMEOUT=500") + new_lines.append("NET_INSTALL_AT_POWER_ON=0") + for k, v in extra_settings.items(): + new_lines.append(f"{k}={v}") + with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f: + f.write("\n".join(new_lines) + "\n") + conf_path = f.name + try: + out2 = subprocess.run( + [str(EEPROM_CONFIG_TOOL), "--config", conf_path, "--out", str(EEPROM_UPD_FILE), str(EEPROM_FW_FILE)], + capture_output=True, + text=True, + timeout=60, + cwd=str(EEPROM_DIR), + ) + if out2.returncode != 0: + return False, (out2.stderr or out2.stdout or "rpi-eeprom-config --config failed").strip()[:500] + if not EEPROM_UPD_FILE.is_file() or EEPROM_UPD_FILE.stat().st_size == 0: + return False, "Generated pieeprom.upd is missing or empty" + # Write signature (sha256 hex) + h = hashlib.sha256() + with open(EEPROM_UPD_FILE, "rb") as f: + h.update(f.read()) + EEPROM_SIG_FILE.write_text(h.hexdigest()) + return True, None + finally: + try: + os.unlink(conf_path) + except OSError: + pass + except subprocess.TimeoutExpired: + return False, "Timeout running rpi-eeprom-config" + except (PermissionError, OSError) as e: + return False, str(e) + + def read_status(): try: with open(STATUS_FILE, "r") as f: @@ -518,6 +592,17 @@ def api_log(): return jsonify({"log": read_log_tail()}) +@app.route("/api/eeprom-presets") +def api_eeprom_presets(): + """Return available boot order presets for the Update EEPROM dropdown.""" + presets = [ + {"id": "0x1", "label": "eMMC only"}, + {"id": "0xf21", "label": "eMMC first, then network"}, + {"id": "0xf12", "label": "Network first, then eMMC"}, + ] + return jsonify({"presets": presets}) + + @app.route("/api/pending-devices") def api_pending_devices(): """Returns USB (if waiting_choice) and registered network devices so the UI can show Backup/Deploy.""" @@ -532,15 +617,32 @@ def api_pending_devices(): @app.route("/api/device-action", methods=["POST"]) def api_device_action(): - """User chose Backup or Deploy for a device. source=usb | network; for network pass mac=.""" + """User chose Backup, Deploy, or Update EEPROM for a device. source=usb | network; for network pass mac=.""" body = request.get_json(force=True, silent=True) or {} source = (body.get("source") or "").strip().lower() action = (body.get("action") or "").strip().lower() - if action not in ("backup", "deploy", "reboot"): - return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', or 'reboot'"}), 400 + if action not in ("backup", "deploy", "reboot", "eeprom_update"): + return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', 'reboot', or 'eeprom_update'"}), 400 if action == "reboot" and source != "network": return jsonify({"ok": False, "error": "'reboot' is only for network devices"}), 400 + if action == "eeprom_update" and source != "usb": + return jsonify({"ok": False, "error": "'eeprom_update' is only for USB-connected devices"}), 400 if source == "usb": + if action == "eeprom_update": + boot_order = (body.get("boot_order") or "0xf21").strip().lower() + if boot_order not in ("0x1", "0xf21", "0xf12"): + return jsonify({"ok": False, "error": "boot_order must be 0x1, 0xf21, or 0xf12"}), 400 + ok, err = _generate_eeprom_update(boot_order) + if not ok: + return jsonify({"ok": False, "error": err or "Failed to generate EEPROM update"}), 500 + try: + os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) + with open(ACTION_REQUEST_FILE, "w") as f: + f.write("eeprom_update") + _write_status("eeprom_update", "EEPROM update ready; host will write to device boot partition.") + return jsonify({"ok": True}) + except (PermissionError, OSError): + return jsonify({"ok": False, "error": "Could not write action file"}), 500 try: os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) # If user requested "shrink after backup", create flag so host runs PiShrink after dd diff --git a/emmc-provisioning/dashboard/templates/index.html b/emmc-provisioning/dashboard/templates/index.html index 6cc310d..d5c5cb1 100644 --- a/emmc-provisioning/dashboard/templates/index.html +++ b/emmc-provisioning/dashboard/templates/index.html @@ -165,7 +165,8 @@ margin-bottom: 0.2rem; } .device-desc { font-size: 0.9rem; color: var(--text); } - .device-actions { display: flex; gap: 0.5rem; flex-shrink: 0; } + .device-actions { display: flex; gap: 0.5rem; flex-shrink: 0; align-items: center; } + .eeprom-preset { padding: 0.35rem 0.5rem; font-size: 0.85rem; font-family: inherit; background: var(--bg-tertiary); color: var(--text); border: 1px solid var(--border); border-radius: 6px; max-width: 12rem; } .btn { padding: 0.5rem 1rem; font-size: 0.85rem; @@ -557,7 +558,7 @@ if (shrinkWrap) shrinkWrap.style.display = 'block'; const el = document.createElement('div'); el.className = 'device-item'; - el.innerHTML = '