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 = '
USB boot
Device connected — choose Backup or Deploy
'; + el.innerHTML = '
USB boot
Device connected — choose Backup, Deploy, or Update EEPROM
'; container.appendChild(el); } else { if (shrinkWrap) shrinkWrap.style.display = 'none'; @@ -581,6 +582,10 @@ const mac = btn.getAttribute('data-mac'); const body = { source: source, action: action }; if (mac) body.mac = mac; + if (action === 'eeprom_update' && source === 'usb') { + const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset'); + body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0xf21'; + } const shrinkCb = document.getElementById('shrinkAfterBackup'); if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true; fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) diff --git a/emmc-provisioning/host/flash-emmc-on-connect.sh b/emmc-provisioning/host/flash-emmc-on-connect.sh index 20ae994..e608b3b 100644 --- a/emmc-provisioning/host/flash-emmc-on-connect.sh +++ b/emmc-provisioning/host/flash-emmc-on-connect.sh @@ -152,10 +152,10 @@ if [[ -z "$target_dev" ]]; then fi # Ask user (dashboard): Backup or Deploy? -write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup or Deploy in the dashboard." "null" +write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup, Deploy, or Update EEPROM in the dashboard." "null" echo "usb" > "$DEVICE_SOURCE_FILE" 2>/dev/null || true echo "$target_dev" > "$CURRENT_DEVICE_FILE" 2>/dev/null || true -log "Waiting for user choice (Backup or Deploy) in dashboard; timeout ${WAIT_TIMEOUT}s..." +log "Waiting for user choice (Backup, Deploy, or Update EEPROM) in dashboard; timeout ${WAIT_TIMEOUT}s..." for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do sleep 2 if [[ -f "$ACTION_REQUEST_FILE" ]]; then @@ -225,8 +225,48 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do else write_status "error" "Flash failed" "null" "dd failed" fi + elif [[ "$action" == "eeprom_update" ]]; then + # Dashboard has written pieeprom.upd and pieeprom.sig to BASE_DIR; copy to eMMC boot partition + PROV_DIR="$(dirname "$STATUS_FILE")" + EEPROM_UPD="$PROV_DIR/pieeprom.upd" + EEPROM_SIG="$PROV_DIR/pieeprom.sig" + if [[ ! -f "$EEPROM_UPD" || ! -f "$EEPROM_SIG" ]]; then + log "EEPROM update files not found: $EEPROM_UPD / $EEPROM_SIG" + write_status "error" "EEPROM update failed" "null" "pieeprom.upd or pieeprom.sig not found in $PROV_DIR" + exit 1 + fi + boot_part="" + for p in "${target_dev}1" "${target_dev}p1"; do + if [[ -b "$p" ]]; then + boot_part="$p" + break + fi + done + if [[ -z "$boot_part" ]]; then + log "No boot partition found for $target_dev (tried ...1 and ...p1)" + write_status "error" "EEPROM update failed" "null" "Boot partition not found" + exit 1 + fi + write_status "eeprom_update" "Writing EEPROM update to boot partition…" "null" + log "Mounting $boot_part and copying EEPROM update..." + mnt=$(mktemp -d) + if mount "$boot_part" "$mnt" 2>/dev/null; then + if cp "$EEPROM_UPD" "$mnt/pieeprom.upd" && cp "$EEPROM_SIG" "$mnt/pieeprom.sig"; then + sync + log "EEPROM update written. Remove eMMC disable jumper and power cycle to apply." + write_status "done" "EEPROM update written to boot partition. Remove eMMC disable jumper and power cycle the reTerminal to apply." "100" + rm -f "$EEPROM_UPD" "$EEPROM_SIG" + ( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) & + else + write_status "error" "EEPROM update failed" "null" "Failed to copy pieeprom.upd/sig" + fi + umount "$mnt" 2>/dev/null || true + else + write_status "error" "EEPROM update failed" "null" "Could not mount boot partition" + fi + rm -rf "$mnt" else - write_status "error" "Unknown action" "null" "action_request must be 'backup' or 'deploy'" + write_status "error" "Unknown action" "null" "action_request must be 'backup', 'deploy', or 'eeprom_update'" fi exit 0 fi diff --git a/emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh b/emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh new file mode 100755 index 0000000..eb339d4 --- /dev/null +++ b/emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Install rpi-eeprom-config and pieeprom.bin on the LXC (or host) where the dashboard runs. +# Required for "Update EEPROM" from the dashboard when a device is connected via USB boot. +# Run on the LXC: bash -s < scripts/install-eeprom-tools-on-lxc.sh +# Or: ssh root@ 'bash -s' < emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh + +set -e +RPI_EEPROM_RAW="https://raw.githubusercontent.com/raspberrypi/rpi-eeprom/master" +EEPROM_DIR="${EEPROM_DIR:-/opt/cm4-provisioning/eeprom}" + +echo "Installing EEPROM tools to $EEPROM_DIR" + +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: Python 3 is required to run rpi-eeprom-config. Install it (e.g. apt install python3) and re-run." + exit 1 +fi + +if command -v curl >/dev/null 2>&1; then + GET="curl -sL" + GET_O="curl -sL -o" +elif command -v wget >/dev/null 2>&1; then + GET="wget -q -O -" + GET_O="wget -q -O" +else + echo "ERROR: curl or wget required" + exit 1 +fi + +mkdir -p "$EEPROM_DIR" +cd "$EEPROM_DIR" + +echo "Downloading rpi-eeprom-config..." +$GET_O "$EEPROM_DIR/rpi-eeprom-config" "$RPI_EEPROM_RAW/rpi-eeprom-config" +$GET_O "$EEPROM_DIR/rpi-eeprom-digest" "$RPI_EEPROM_RAW/rpi-eeprom-digest" +chmod +x "$EEPROM_DIR/rpi-eeprom-config" "$EEPROM_DIR/rpi-eeprom-digest" + +echo "Finding latest BCM2711 EEPROM firmware..." +LATEST_FW=$($GET "https://api.github.com/repos/raspberrypi/rpi-eeprom/contents/firmware-2711/default" \ + | grep -o '"name" *: *"pieeprom-[^"]*\.bin"' | sed 's/"name" *: *"//;s/"//' | sort | tail -1) + +if [ -z "$LATEST_FW" ]; then + echo "WARNING: Could not determine latest firmware from GitHub API. Try again or download pieeprom.bin manually." + exit 1 +fi + +echo "Downloading $LATEST_FW..." +$GET_O "$EEPROM_DIR/pieeprom.bin" "$RPI_EEPROM_RAW/firmware-2711/default/$LATEST_FW" + +if [ ! -s "$EEPROM_DIR/pieeprom.bin" ]; then + echo "ERROR: pieeprom.bin is missing or empty" + exit 1 +fi + +echo "Verifying rpi-eeprom-config..." +if ! python3 "$EEPROM_DIR/rpi-eeprom-config" "$EEPROM_DIR/pieeprom.bin" >/dev/null 2>&1; then + echo "WARNING: rpi-eeprom-config could not read pieeprom.bin (non-fatal)" +fi + +echo "Done. EEPROM tools installed:" +ls -la "$EEPROM_DIR"