Add DHCP network boot management to API and UI

Enhance the dashboard API with new endpoints for managing DHCP network boot options, allowing devices to enable or disable network boot via POST requests. Update the device action handling to include a 'reboot' action, specifically for network devices. Modify the home.html template to display the current state of network boot and provide a button for disabling it. Update provisioning scripts to disable network boot after deployment or backup completion, ensuring devices boot from eMMC on the next startup. Improve user feedback and error handling throughout the changes.
This commit is contained in:
nearxos
2026-02-20 17:05:38 +02:00
parent 66ad3b0a39
commit 7e1bf8a4c2
11 changed files with 165 additions and 23 deletions

View File

@@ -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"})

View File

@@ -104,6 +104,7 @@
<div class="card">
<h2 class="card-title">Capture or deploy</h2>
<p id="dhcpNetbootWrap" class="status-row" style="margin-bottom: 0.5rem; font-size: 0.85rem;"><span class="text-dim">Network boot (DHCP):</span> <span id="dhcpNetbootState"></span> <button type="button" id="dhcpNetbootDisableBtn" class="btn btn-outline btn-sm" style="display:none;">Disable network boot</button></p>
<p id="shrinkOptionWrap" style="display:none; margin-bottom: 0.5rem; font-size: 0.8rem;"><label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label></p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Use USB boot mode or register over network.</p>
@@ -182,7 +183,7 @@
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div><div><button type="button" class="btn btn-outline btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></div>';
el.innerHTML = '<div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div><div><button type="button" class="btn btn-outline btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button> <button type="button" class="btn btn-outline btn-sm btn-disable-netboot" title="Stop advertising network boot via DHCP so devices boot from eMMC">Disable network boot</button></div>';
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);
</script>
</body>
</html>