diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py index ea2439b..5173953 100644 --- a/emmc-provisioning/dashboard/app.py +++ b/emmc-provisioning/dashboard/app.py @@ -10,6 +10,7 @@ import os import re import shutil import sqlite3 +import subprocess import time import urllib.request from functools import wraps @@ -40,6 +41,7 @@ SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json"))) PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", str(BASE_DIR / "portal_descriptions.json"))) 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") # --- Database (admin users + activity logs) --- @@ -489,8 +491,10 @@ def api_device_action(): 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"): - return jsonify({"ok": False, "error": "action must be 'backup' or 'deploy'"}), 400 + if action not in ("backup", "deploy", "reboot"): + return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', or 'reboot'"}), 400 + if action == "reboot" and source != "network": + return jsonify({"ok": False, "error": "'reboot' is only for network devices"}), 400 if source == "usb": try: os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) @@ -543,6 +547,59 @@ def api_register_device(): return jsonify({"ok": True, "message": "registered"}) +def _dhcp_network_boot_run(cmd): + """Run toggle script with enable|disable|status. Returns (ok, output_or_error).""" + if not os.path.isfile(TOGGLE_NETWORK_BOOT_SCRIPT) or not os.access(TOGGLE_NETWORK_BOOT_SCRIPT, os.X_OK): + return False, "Toggle script not installed" + try: + out = subprocess.run( + [TOGGLE_NETWORK_BOOT_SCRIPT, cmd], + capture_output=True, + text=True, + timeout=10, + ) + if out.returncode != 0: + return False, (out.stderr or out.stdout or "script failed").strip() + return True, (out.stdout or "").strip() + except subprocess.TimeoutExpired: + return False, "Timeout" + except Exception as e: + return False, str(e) + + +@app.route("/api/dhcp-network-boot", methods=["GET"]) +def api_dhcp_network_boot_get(): + """Return whether DHCP network-boot options (66/67) are enabled.""" + ok, out = _dhcp_network_boot_run("status") + if not ok: + return jsonify({"enabled": None, "error": out}), 200 + return jsonify({"enabled": out.strip().lower() == "enabled"}) + + +@app.route("/api/dhcp-network-boot", methods=["POST"]) +def api_dhcp_network_boot_post(): + """Enable or disable DHCP network-boot options (DHCP server keeps running). Body: { \"enabled\": true|false }.""" + body = request.get_json(force=True, silent=True) or {} + enabled = body.get("enabled") + if enabled is None: + return jsonify({"ok": False, "error": "enabled required (true|false)"}), 400 + cmd = "enable" if enabled else "disable" + ok, out = _dhcp_network_boot_run(cmd) + if not ok: + return jsonify({"ok": False, "error": out}), 500 + return jsonify({"ok": True, "enabled": enabled}) + + +@app.route("/api/action-done", methods=["POST"]) +def api_action_done(): + """Called by a device when deploy or backup has completed. Disables DHCP network-boot so the device boots from eMMC next time.""" + mac = (request.args.get("mac") or request.get_json(silent=True) or {}).get("mac", "") + ok, _ = _dhcp_network_boot_run("disable") + if not ok: + return jsonify({"ok": False, "error": "Could not disable DHCP network boot"}), 500 + return jsonify({"ok": True, "message": "Network boot disabled; device will boot from eMMC on next boot"}) + + @app.route("/api/device-action-poll") def api_device_action_poll(): """Network device polls this to get its assigned action (deploy/backup) and URL.""" @@ -558,6 +615,8 @@ def api_device_action_poll(): return jsonify({"action": "deploy", "url": f"{base}/api/golden-image"}) if action == "backup": return jsonify({"action": "backup", "upload_url": f"{base}/api/backup-upload?mac={mac}"}) + if action == "reboot": + return jsonify({"action": "reboot"}) return jsonify({"action": "wait"}) return jsonify({"action": "wait"}) diff --git a/emmc-provisioning/dashboard/templates/home.html b/emmc-provisioning/dashboard/templates/home.html index db49b20..8f009b8 100644 --- a/emmc-provisioning/dashboard/templates/home.html +++ b/emmc-provisioning/dashboard/templates/home.html @@ -104,6 +104,7 @@

Capture or deploy

+

Network boot (DHCP):

@@ -182,7 +183,7 @@ hasAny = true; const el = document.createElement('div'); el.className = 'device-item'; - el.innerHTML = '
' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '
'; + el.innerHTML = '
' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '
'; container.appendChild(el); }); noPending.style.display = hasAny ? 'none' : 'block'; @@ -193,7 +194,15 @@ if (body.action === 'backup' && document.getElementById('shrinkAfterBackup').checked) body.shrink = true; fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) - .then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); } else alert(data.error || 'Failed'); }) + .then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); fetchDhcpNetboot(); } else alert(data.error || 'Failed'); }) + .catch(function() { alert('Request failed'); }); + }; + }); + container.querySelectorAll('button.btn-disable-netboot').forEach(function(btn) { + btn.onclick = function() { + fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) }) + .then(function(r) { return r.json(); }) + .then(function(data) { if (data.ok) { fetchDhcpNetboot(); } else alert(data.error || 'Failed'); }) .catch(function() { alert('Request failed'); }); }; }); @@ -213,12 +222,28 @@ el.innerHTML = t; }).catch(function(){ document.getElementById('goldenInfo').textContent = 'Could not load.'; }); } + function fetchDhcpNetboot() { + fetch('/api/dhcp-network-boot').then(function(r){ return r.json(); }).then(function(d){ + const stateEl = document.getElementById('dhcpNetbootState'); + const btn = document.getElementById('dhcpNetbootDisableBtn'); + if (d.error) { stateEl.textContent = '—'; if (btn) btn.style.display = 'none'; return; } + stateEl.textContent = d.enabled ? 'on' : 'off'; + if (btn) btn.style.display = d.enabled ? 'inline-block' : 'none'; + }).catch(function(){ document.getElementById('dhcpNetbootState').textContent = '—'; }); + } document.getElementById('statusClearBtn').addEventListener('click', function(){ fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); }); - fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); + document.getElementById('dhcpNetbootDisableBtn').addEventListener('click', function(){ + fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) }) + .then(function(r){ return r.json(); }) + .then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); }) + .catch(function(){ alert('Request failed'); }); + }); + fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); setInterval(fetchStatus, 2000); setInterval(fetchLog, 4000); setInterval(fetchPending, 2000); setInterval(fetchGolden, 10000); + setInterval(fetchDhcpNetboot, 10000); diff --git a/emmc-provisioning/docs/NETWORK-BOOT-DEPLOYMENT-FLOW.md b/emmc-provisioning/docs/NETWORK-BOOT-DEPLOYMENT-FLOW.md index 7ec0a63..84fecbe 100644 --- a/emmc-provisioning/docs/NETWORK-BOOT-DEPLOYMENT-FLOW.md +++ b/emmc-provisioning/docs/NETWORK-BOOT-DEPLOYMENT-FLOW.md @@ -67,8 +67,9 @@ So the “netboot environment” is either: - You open the dashboard at `http://10.20.50.1:5000` (or the LXC’s WAN IP if you’re not on the provisioning LAN). - Under **“Device detected (Network)”** you see the device (identified by MAC). -- You click **Deploy** or **Backup**. -- The dashboard sets the **action** (and URL/upload_url) for that MAC; the next **device-action-poll** returns it, and the client runs the corresponding dd + curl. +- You click **Deploy**, **Backup**, or **Disable network boot**. +- **Deploy** / **Backup**: the dashboard sets the action and URL; the client runs dd + curl, then calls **/api/action-done**, which **disables DHCP network-boot options** on the LXC so the device will boot from eMMC on the next reboot. No need to unplug ethernet. +- **Disable network boot**: turns off DHCP options 66/67 (next-server, boot file) on the LXC. The DHCP server keeps running; devices just stop receiving netboot and will boot from local storage (eMMC) next time. Use this when you don't want to deploy or backup; the netbooted device can then reboot and boot from eMMC. --- @@ -83,13 +84,14 @@ So the “netboot environment” is either: | Your choice | You → LXC | In dashboard: click Deploy or Backup for that device. | | Deploy | LXC → device | Client GETs image URL, streams to `dd of=/dev/mmcblk0`. | | Backup | Device → LXC | Client `dd if=/dev/mmcblk0` and POSTs to upload_url. | -| After | reTerminal | Reboot; if you deployed, it can now boot from eMMC. | +| After | Device → LXC | Client calls **POST /api/action-done**; server disables DHCP netboot options. | +| After | reTerminal | Reboot; device boots from eMMC (no netboot advertised). | --- ## What you need in place -- **LXC**: eth1 = 10.20.50.1/24, dnsmasq (DHCP + TFTP on eth1), `/srv/tftpboot` with RPi 4 boot files, NAT for 10.20.50.0/24 via eth0. Dashboard running, `golden.img` present for Deploy. +- **LXC**: eth1 = 10.20.50.1/24, dnsmasq (DHCP + TFTP on eth1; netboot options 66/67 in a separate snippet so they can be toggled), `/srv/tftpboot` with RPi 4 boot files, NAT for 10.20.50.0/24 via eth0. Toggle script **/opt/cm4-provisioning/toggle-network-boot-dhcp.sh** (enable/disable/status). Dashboard running, `golden.img` present for Deploy. See **NETWORK-BOOT-LXC.md** and **setup-network-boot-on-lxc.sh**. - **reTerminal**: EEPROM boot order = network first; Ethernet on 10.20.50.0/24; netboot environment that runs **provisioning-client.sh** with `PROVISIONING_SERVER=http://10.20.50.1:5000`. - **Netboot root**: Must provide network, curl, and the client script (NFS, initramfs, or custom root). diff --git a/emmc-provisioning/lxc/dnsmasq-network-boot-pxe.conf b/emmc-provisioning/lxc/dnsmasq-network-boot-pxe.conf new file mode 100644 index 0000000..b51950a --- /dev/null +++ b/emmc-provisioning/lxc/dnsmasq-network-boot-pxe.conf @@ -0,0 +1,5 @@ +# PXE/network-boot DHCP options (option 66 = next-server, 67 = boot file). +# When this file is present, dnsmasq advertises network boot; when removed, devices get DHCP only and boot from local storage. +# Toggle with: /opt/cm4-provisioning/toggle-network-boot-dhcp.sh enable|disable +dhcp-option=66,10.20.50.1 +dhcp-option=67,start4cd.elf diff --git a/emmc-provisioning/lxc/dnsmasq-network-boot.conf b/emmc-provisioning/lxc/dnsmasq-network-boot.conf index 33a2779..a40c1bd 100644 --- a/emmc-provisioning/lxc/dnsmasq-network-boot.conf +++ b/emmc-provisioning/lxc/dnsmasq-network-boot.conf @@ -12,11 +12,7 @@ dhcp-range=10.20.50.100,10.20.50.200,12h # TFTP for Raspberry Pi / CM4 network boot enable-tftp tftp-root=/srv/tftpboot - -# RPi 4 netboot: next-server is this host; boot filename (Pi firmware uses this) -# Option 66 = next-server (TFTP), 67 = boot filename -dhcp-option=66,10.20.50.1 -dhcp-option=67,start4cd.elf +# PXE options (66/67) are in network-boot-pxe.conf; remove that file to disable netboot advertising # Logging (optional; disable in production if too noisy) log-dhcp diff --git a/emmc-provisioning/lxc/toggle-network-boot-dhcp.sh b/emmc-provisioning/lxc/toggle-network-boot-dhcp.sh new file mode 100644 index 0000000..74967e4 --- /dev/null +++ b/emmc-provisioning/lxc/toggle-network-boot-dhcp.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Enable or disable DHCP network-boot options (option 66/67) on the provisioning LXC. +# Does not stop the DHCP server or TFTP; only stops advertising netboot so devices boot from local storage. +# Usage: toggle-network-boot-dhcp.sh enable | disable +# Run as root (or with sudo). Install to /opt/cm4-provisioning/toggle-network-boot-dhcp.sh + +set -e +PXE_CONF="/etc/dnsmasq.d/network-boot-pxe.conf" +SNIPPET_CONTENT="# PXE options - do not edit; managed by toggle-network-boot-dhcp.sh +dhcp-option=66,10.20.50.1 +dhcp-option=67,start4cd.elf +" + +case "${1:-}" in + enable) + echo "$SNIPPET_CONTENT" > "$PXE_CONF" + systemctl reload dnsmasq 2>/dev/null || service dnsmasq reload 2>/dev/null || true + echo "Network boot (DHCP options) enabled." + ;; + disable) + rm -f "$PXE_CONF" + systemctl reload dnsmasq 2>/dev/null || service dnsmasq reload 2>/dev/null || true + echo "Network boot (DHCP options) disabled. Devices will get DHCP but boot from local storage." + ;; + status) + if [ -f "$PXE_CONF" ]; then + echo "enabled" + else + echo "disabled" + fi + ;; + *) + echo "Usage: $0 enable | disable | status" >&2 + exit 1 + ;; +esac diff --git a/emmc-provisioning/network-boot-initramfs/build.sh b/emmc-provisioning/network-boot-initramfs/build.sh index 69e914c..9bf4305 100755 --- a/emmc-provisioning/network-boot-initramfs/build.sh +++ b/emmc-provisioning/network-boot-initramfs/build.sh @@ -62,7 +62,7 @@ else $CONTAINER_RUNTIME cp "$CNT_NAME:/out/bin/busybox" "$BUILD_DIR/bin/" 2>/dev/null && \ $CONTAINER_RUNTIME cp "$CNT_NAME:/out/usr/bin/curl" "$BUILD_DIR/usr/bin/" 2>/dev/null && \ $CONTAINER_RUNTIME cp "$CNT_NAME:/out/lib/." "$BUILD_DIR/lib/" 2>/dev/null || true - $CONTAINER_RUNTIME rm -f "$CNT_NAME" 2>/dev/null + $CONTAINER_RUNTIME rm -f "$CNT_NAME" >/dev/null 2>&1 if [ -f "$BUILD_DIR/bin/busybox" ] && [ -f "$BUILD_DIR/usr/bin/curl" ]; then CONTAINER_OK=1 echo "Container build succeeded." @@ -147,7 +147,7 @@ chmod +x "$BUILD_DIR/bin/busybox" 2>/dev/null || true cd "$BUILD_DIR/bin" ./busybox --list 2>/dev/null | while read applet; do case "$applet" in - sh|ash|mount|umount|mkdir|cat|ip|udhcpc|sleep|echo|grep|cut|awk|hostname|dd) ln -sf busybox "$applet"; ;; + sh|ash|mount|umount|mkdir|cat|ip|udhcpc|sleep|echo|grep|cut|awk|hostname|dd|reboot) ln -sf busybox "$applet"; ;; esac done [ -e sh ] || ln -sf busybox sh diff --git a/emmc-provisioning/network-boot-initramfs/initrd.img b/emmc-provisioning/network-boot-initramfs/initrd.img new file mode 100644 index 0000000..2ad9845 Binary files /dev/null and b/emmc-provisioning/network-boot-initramfs/initrd.img differ diff --git a/emmc-provisioning/network-boot-initramfs/provisioning-client.sh b/emmc-provisioning/network-boot-initramfs/provisioning-client.sh index d50e793..9dc0a7e 100644 --- a/emmc-provisioning/network-boot-initramfs/provisioning-client.sh +++ b/emmc-provisioning/network-boot-initramfs/provisioning-client.sh @@ -38,7 +38,8 @@ while true; do continue fi curl -sL "$url" | dd of="$EMMC_DEV" bs=4M status=progress conv=fsync - echo "Deploy done. Reboot to run from eMMC." + echo "Deploy done. Disabling network boot on server so device boots from eMMC next time." + curl -s -X POST "$BASE_URL/api/action-done?mac=$MAC" || true exit 0 fi @@ -50,7 +51,14 @@ while true; do continue fi dd if="$EMMC_DEV" bs=4M status=progress 2>/dev/null | curl -s -X POST -T - "$upload_url" - echo "Backup done." + echo "Backup done. Disabling network boot on server." + curl -s -X POST "$BASE_URL/api/action-done?mac=$MAC" || true + exit 0 + fi + + if [ "$action" = "reboot" ]; then + echo "Boot normally: rebooting..." + reboot -f 2>/dev/null || exec reboot 2>/dev/null || true exit 0 fi diff --git a/emmc-provisioning/network-client/provisioning-client.sh b/emmc-provisioning/network-client/provisioning-client.sh index 05ec0dc..d1f544d 100644 --- a/emmc-provisioning/network-client/provisioning-client.sh +++ b/emmc-provisioning/network-client/provisioning-client.sh @@ -29,7 +29,8 @@ while true; do continue fi curl -sL "$url" | dd of="$EMMC_DEV" bs=4M status=progress conv=fsync - echo "Deploy done. Reboot to run from eMMC." + echo "Deploy done. Disabling network boot on server so device boots from eMMC next time." + curl -s -X POST "$BASE_URL/api/action-done?mac=$MAC" || true exit 0 fi @@ -41,9 +42,15 @@ while true; do continue fi dd if="$EMMC_DEV" bs=4M status=progress 2>/dev/null | curl -s -X POST -T - "$upload_url" - echo "Backup done." + echo "Backup done. Disabling network boot on server." + curl -s -X POST "$BASE_URL/api/action-done?mac=$MAC" || true exit 0 fi + if [[ "$action" == "reboot" ]]; then + echo "Boot normally: rebooting..." + reboot -f 2>/dev/null || exec reboot 2>/dev/null || exit 0 + fi + sleep 5 done diff --git a/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh b/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh index bfdea87..4faa0c3 100755 --- a/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh +++ b/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh @@ -26,7 +26,7 @@ if ! command -v dnsmasq >/dev/null 2>&1; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dnsmasq fi -# 2) dnsmasq config for eth1 only (DHCP + TFTP) +# 2) dnsmasq config for eth1 only (DHCP + TFTP); PXE options in network-boot-pxe.conf (toggle with toggle-network-boot-dhcp.sh) mkdir -p /etc/dnsmasq.d cat > /etc/dnsmasq.d/network-boot.conf << 'DNSMASQ' # DHCP + TFTP on eth1 only (provisioning LAN) @@ -35,12 +35,16 @@ bind-interfaces dhcp-range=10.20.50.100,10.20.50.200,12h enable-tftp tftp-root=/srv/tftpboot -dhcp-option=66,10.20.50.1 -dhcp-option=67,start4cd.elf log-dhcp log-queries port=0 DNSMASQ +mkdir -p /opt/cm4-provisioning +if [ -f /tmp/cm4-network-boot-lxc/toggle-network-boot-dhcp.sh ]; then + cp /tmp/cm4-network-boot-lxc/toggle-network-boot-dhcp.sh /opt/cm4-provisioning/ + chmod +x /opt/cm4-provisioning/toggle-network-boot-dhcp.sh + /opt/cm4-provisioning/toggle-network-boot-dhcp.sh enable +fi # 3) TFTP root: fetch Raspberry Pi 4 boot files from GitHub if missing mkdir -p /srv/tftpboot