diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py index 5173953..a2f1672 100644 --- a/emmc-provisioning/dashboard/app.py +++ b/emmc-provisioning/dashboard/app.py @@ -42,6 +42,7 @@ CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", s 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") +DHCP_LEASES_FILE = os.environ.get("CM4_DHCP_LEASES_FILE", "/var/lib/misc/dnsmasq.leases") # --- Database (admin users + activity logs) --- @@ -593,13 +594,46 @@ def api_dhcp_network_boot_post(): @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", "") + mac = request.args.get("mac") or ((request.get_json(silent=True) or {}).get("mac") or "") 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"}) +def _read_dhcp_leases(): + """Read dnsmasq lease file. Returns (leases_list, error_string). leases_list items: {expiry, mac, ip, hostname}.""" + if not DHCP_LEASES_FILE or not os.path.isfile(DHCP_LEASES_FILE): + return [], None + try: + leases = [] + with open(DHCP_LEASES_FILE, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) >= 4: + leases.append({ + "expiry": int(parts[0]) if parts[0].isdigit() else 0, + "mac": parts[1], + "ip": parts[2], + "hostname": parts[3] if len(parts) > 3 else "", + }) + return leases, None + except (OSError, PermissionError) as e: + return [], str(e) + + +@app.route("/api/dhcp-leases") +def api_dhcp_leases(): + """Return current DHCP leases from dnsmasq (when dashboard runs on LXC with dnsmasq).""" + leases, err = _read_dhcp_leases() + if err: + return jsonify({"leases": [], "error": err}) + return jsonify({"leases": leases, "error": None}) + + @app.route("/api/device-action-poll") def api_device_action_poll(): """Network device polls this to get its assigned action (deploy/backup) and URL.""" diff --git a/emmc-provisioning/dashboard/templates/home.html b/emmc-provisioning/dashboard/templates/home.html index 8f009b8..0e80df0 100644 --- a/emmc-provisioning/dashboard/templates/home.html +++ b/emmc-provisioning/dashboard/templates/home.html @@ -79,6 +79,10 @@ .steps-list .num { flex-shrink: 0; width: 1.2rem; height: 1.2rem; background: var(--bg-secondary); color: var(--text-dim); border-radius: 50%; font-size: 0.7rem; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; } .steps-list strong { color: var(--text); } .help-sub { font-weight: 600; color: var(--text); margin: 0.5rem 0 0.2rem 0; font-size: 0.85rem; } + .leases-table { width: 100%; font-size: 0.8rem; border-collapse: collapse; } + .leases-table th, .leases-table td { padding: 0.35rem 0.5rem; text-align: left; border-bottom: 1px solid var(--border); } + .leases-table th { color: var(--text-dim); font-weight: 600; } + .leases-table .mono { font-family: 'JetBrains Mono', monospace; } @@ -104,7 +108,7 @@

Capture or deploy

-

Network boot (DHCP):

+

Network boot (DHCP):

@@ -123,6 +127,16 @@
+
+ DHCP leases +
+

Provisioning LAN (dnsmasq) — when dashboard runs on LXC.

+
IPMACHostnameExpires
+ + +
+
+
How to connect
@@ -154,6 +168,7 @@ if (data.error) { err.textContent = data.error; err.style.display = 'block'; } else { err.style.display = 'none'; } var clearWrap = document.getElementById('statusClearWrap'); if (clearWrap) clearWrap.style.display = (phase === 'error' ? 'block' : 'none'); + if (phase === 'done') scheduleDoneClear(); const progress = data.progress; const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase); const wrap = document.getElementById('progressWrap'); @@ -225,12 +240,35 @@ 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; } + const disableBtn = document.getElementById('dhcpNetbootDisableBtn'); + const enableBtn = document.getElementById('dhcpNetbootEnableBtn'); + if (d.error) { stateEl.textContent = '—'; if (disableBtn) disableBtn.style.display = 'none'; if (enableBtn) enableBtn.style.display = 'none'; return; } stateEl.textContent = d.enabled ? 'on' : 'off'; - if (btn) btn.style.display = d.enabled ? 'inline-block' : 'none'; + if (disableBtn) disableBtn.style.display = d.enabled ? 'inline-block' : 'none'; + if (enableBtn) enableBtn.style.display = d.enabled ? 'none' : 'inline-block'; }).catch(function(){ document.getElementById('dhcpNetbootState').textContent = '—'; }); } + var doneClearTimer = null; + function scheduleDoneClear() { + if (doneClearTimer) return; + doneClearTimer = setTimeout(function(){ doneClearTimer = null; fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); }, 60000); + } + function fetchDhcpLeases() { + fetch('/api/dhcp-leases').then(function(r){ return r.json(); }).then(function(d){ + const tbody = document.getElementById('leasesBody'); + const errEl = document.getElementById('leasesError'); + const emptyEl = document.getElementById('leasesEmpty'); + errEl.style.display = 'none'; + emptyEl.style.display = 'none'; + if (d.error) { errEl.textContent = d.error; errEl.style.display = 'block'; tbody.innerHTML = ''; return; } + var rows = (d.leases || []).map(function(l){ + var expStr = l.expiry ? (new Date(l.expiry*1000).toLocaleString()) : '—'; + return '' + escapeHtml(l.ip) + '' + escapeHtml(l.mac) + '' + escapeHtml(l.hostname || '') + '' + expStr + ''; + }).join(''); + tbody.innerHTML = rows || ''; + if (!rows) emptyEl.style.display = 'block'; + }).catch(function(){ document.getElementById('leasesError').textContent = 'Could not load leases.'; document.getElementById('leasesError').style.display = 'block'; }); + } 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(); }); }); document.getElementById('dhcpNetbootDisableBtn').addEventListener('click', function(){ fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) }) @@ -238,12 +276,19 @@ .then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); }) .catch(function(){ alert('Request failed'); }); }); - fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); + document.getElementById('dhcpNetbootEnableBtn').addEventListener('click', function(){ + fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }) }) + .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(); fetchDhcpLeases(); setInterval(fetchStatus, 2000); setInterval(fetchLog, 4000); setInterval(fetchPending, 2000); setInterval(fetchGolden, 10000); setInterval(fetchDhcpNetboot, 10000); + setInterval(fetchDhcpLeases, 10000); diff --git a/emmc-provisioning/docs/NETWORK-BOOT-LXC.md b/emmc-provisioning/docs/NETWORK-BOOT-LXC.md index ca08cc5..e03469d 100644 --- a/emmc-provisioning/docs/NETWORK-BOOT-LXC.md +++ b/emmc-provisioning/docs/NETWORK-BOOT-LXC.md @@ -86,6 +86,17 @@ To refresh or populate TFTP without re-running the full setup: The TFTP root contains e.g. `start4cd.elf`, `fixup4cd.dat`, `config.txt`, `cmdline.txt`, `kernel8.img`, and other boot files. For a custom kernel or initramfs (e.g. for provisioning), add or replace files in `/srv/tftpboot` and adjust `config.txt` / `cmdline.txt` as needed. +## DHCP leases + +On the LXC, dnsmasq stores DHCP leases in **`/var/lib/misc/dnsmasq.leases`** (Debian/Ubuntu default). To see which devices got an IP on the provisioning LAN: + +```bash +# On the LXC (or via SSH) +cat /var/lib/misc/dnsmasq.leases +``` + +Each line is: *expiry_epoch MAC IP hostname client_id*. Example: `1734567890 aa:bb:cc:dd:ee:ff 10.20.50.101 reterminal 01:aa:bb:cc:dd:ee:ff` + ## Summary | Component | Where | Purpose | diff --git a/emmc-provisioning/host/flash-emmc-on-connect.sh b/emmc-provisioning/host/flash-emmc-on-connect.sh index b011f37..20ae994 100644 --- a/emmc-provisioning/host/flash-emmc-on-connect.sh +++ b/emmc-provisioning/host/flash-emmc-on-connect.sh @@ -205,6 +205,7 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do fi log "Backup complete: $BACKUPS_DIR/$final_name" write_status "done" "Backup complete: $final_name" "100" + ( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) & else write_status "error" "Backup failed" "null" "dd failed" fi @@ -219,6 +220,8 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do if dd if="$GOLDEN_IMAGE" of="$target_dev" bs=4M status=progress conv=fsync; then log "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal." write_status "done" "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal." "100" + # Auto-reset status to idle after 90s so dashboard does not stay on this message (dashboard also auto-clears after 60s when open) + ( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) & else write_status "error" "Flash failed" "null" "dd failed" fi diff --git a/emmc-provisioning/scripts/check-network-boot-priority.sh b/emmc-provisioning/scripts/check-network-boot-priority.sh new file mode 100644 index 0000000..2138b62 --- /dev/null +++ b/emmc-provisioning/scripts/check-network-boot-priority.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Check if network boot is set as first priority on a Pi 4 / CM4 (reTerminal). +# Run on the device: ./check-network-boot-priority.sh +# Or from your machine: ssh pi@ 'bash -s' < scripts/check-network-boot-priority.sh + +set -e +# BOOT_ORDER: 0x2 = network, 0x1 = SD/eMMC. 0x21 = network first, then local storage. +WANT_BOOT_ORDER="0x21" + +get_config() { + if command -v vcgencmd >/dev/null 2>&1; then + vcgencmd bootloader_config 2>/dev/null || true + fi + if command -v rpi-eeprom-update >/dev/null 2>&1 && command -v rpi-eeprom-config >/dev/null 2>&1; then + PEE="$(rpi-eeprom-update -l 2>/dev/null)" + if [[ -n "$PEE" && -f "$PEE" ]]; then + rpi-eeprom-config "$PEE" 2>/dev/null || true + fi + fi +} + +BOOTCONF="$(get_config)" +BOOT_ORDER="$(echo "$BOOTCONF" | grep -oE 'BOOT_ORDER=[0-9A-Fa-fx]+' | head -1 | cut -d= -f2)" + +if [[ -z "$BOOT_ORDER" ]]; then + echo "Could not read EEPROM config (vcgencmd bootloader_config or rpi-eeprom-config)." + echo "Is this a Raspberry Pi 4 or CM4 with rpi-eeprom / vcgencmd?" + exit 1 +fi + +echo "BOOT_ORDER=$BOOT_ORDER (current)" +echo "Expected for network first: $WANT_BOOT_ORDER (0x2=network, 0x1=SD/eMMC; 0x21 = network then local)" + +if [[ "$(echo "$BOOT_ORDER" | tr '[:upper:]' '[:lower:]')" == "$(echo "$WANT_BOOT_ORDER" | tr '[:upper:]' '[:lower:]')" ]]; then + echo "Result: Network boot is set as first priority." + exit 0 +fi + +echo "Result: Network boot is NOT first (current: $BOOT_ORDER). To set network first, set BOOT_ORDER=0x21 (e.g. via cloud-init first-boot or rpi-eeprom-config --edit)." +exit 2 diff --git a/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh b/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh index 4faa0c3..8f1b8cb 100755 --- a/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh +++ b/emmc-provisioning/scripts/setup-network-boot-on-lxc.sh @@ -12,6 +12,12 @@ if [[ -n "$TARGET" ]]; then REPO_DIR="$(dirname "$SCRIPT_DIR")" echo "Syncing lxc config and script to $TARGET ..." rsync -a "$REPO_DIR/lxc/" "$TARGET:/tmp/cm4-network-boot-lxc/" --exclude='.git' + if [[ -f "$REPO_DIR/network-boot-initramfs/initrd.img" ]]; then + echo "Copying initrd.img to $TARGET ..." + scp "$REPO_DIR/network-boot-initramfs/initrd.img" "$TARGET:/tmp/cm4-network-boot-lxc/initrd.img" + else + echo "Note: network-boot-initramfs/initrd.img not found (run build.sh first); skipping." + fi scp "$SCRIPT_DIR/setup-network-boot-on-lxc.sh" "$TARGET:/tmp/cm4-network-boot-lxc/setup.sh" ssh "$TARGET" "bash /tmp/cm4-network-boot-lxc/setup.sh" echo "Done." @@ -68,6 +74,18 @@ else echo "TFTP root already has boot files (start4cd.elf present), skipping fetch." fi +# 3b) Copy provisioning initrd.img to TFTP root if provided +if [[ -f /tmp/cm4-network-boot-lxc/initrd.img ]]; then + cp /tmp/cm4-network-boot-lxc/initrd.img /srv/tftpboot/initrd.img + echo "Copied initrd.img to /srv/tftpboot" + if [[ -f /srv/tftpboot/config.txt ]] && ! grep -q 'initramfs initrd.img' /srv/tftpboot/config.txt 2>/dev/null; then + echo "" >> /srv/tftpboot/config.txt + echo "# Provisioning initramfs (network-boot-initramfs)" >> /srv/tftpboot/config.txt + echo "initramfs initrd.img followkernel" >> /srv/tftpboot/config.txt + echo "Added initramfs line to config.txt" + fi +fi + # 4) IP forwarding (LAN clients use WAN) echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-cm4-network-boot.conf sysctl -p /etc/sysctl.d/99-cm4-network-boot.conf 2>/dev/null || sysctl -w net.ipv4.ip_forward=1 @@ -105,4 +123,4 @@ systemctl restart dnsmasq echo "Network boot setup done." echo " - DHCP + TFTP on eth1 (10.20.50.1), range 10.20.50.100-200" echo " - NAT: 10.20.50.0/24 -> eth0 (internet)" -echo " - TFTP root: /srv/tftpboot (RPi boot files from GitHub)" +echo " - TFTP root: /srv/tftpboot (RPi boot files; initrd.img if provided)"