From 90296498f55d0f0e54027e06ef49b304d0be7ac3 Mon Sep 17 00:00:00 2001 From: nearxos Date: Fri, 20 Feb 2026 17:30:23 +0200 Subject: [PATCH] Add DHCP leases management to dashboard and UI Implement a new API endpoint to retrieve current DHCP leases from dnsmasq, enhancing the dashboard's functionality for monitoring network devices. Update the home.html template to display DHCP lease information in a structured table format, including IP, MAC, hostname, and expiry details. Introduce buttons for enabling and disabling DHCP network boot, improving user interaction. Enhance JavaScript to fetch and display lease data dynamically, ensuring users have real-time visibility of network activity. --- emmc-provisioning/dashboard/app.py | 36 +++++++++++- .../dashboard/templates/home.html | 55 +++++++++++++++++-- emmc-provisioning/docs/NETWORK-BOOT-LXC.md | 11 ++++ .../host/flash-emmc-on-connect.sh | 3 + .../scripts/check-network-boot-priority.sh | 40 ++++++++++++++ .../scripts/setup-network-boot-on-lxc.sh | 20 ++++++- 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 emmc-provisioning/scripts/check-network-boot-priority.sh 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)"