approve_node never rotates an existing key; node update-check endpoint; real Update Node button (node-26#14, #15)

- approve_node: reuses node_keys/{id}.api_key when one exists (still
  re-syncs dynsec with it, still never overwrites node_keys). Re-clicking
  Approve can no longer lock a live node out. Reissue stays explicit.
- POST /nodes/{id}/request-update (admin): sets update_requested_at on the
  node doc AND sends MQTT node_update best-effort, so offline/locked-out
  nodes still get it.
- POST /nodes/{id}/update-check (node, inline auth: Bearer api_key for THAT
  node or X-Pickup-Secret): host updater's HTTPS check-in. Returns only
  update_requested_at; records updater_last_check_at / sha / applied result.
  Unknown node -> 401 (no node_id oracle). C2 cannot name a ref, image or
  command.
- Frontend: "Update Node Software" (admin only) calls request-update, honest
  confirm text; node page shows updater check-in and last update state.

Tests: tests/test_node_provisioning.py (12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 01:09:52 -04:00
co-authored by Claude Opus 5.5
parent fb299008f8
commit 7fa50f24d2
6 changed files with 352 additions and 16 deletions
+44 -11
View File
@@ -146,6 +146,32 @@ export default function NodeDetailPage() {
}
}
async function handleRequestUpdate() {
if (
!confirm(
"Update this node's software?\n\n" +
"The node pulls the latest release it was installed to track and recreates its containers " +
"(radio capture pauses for about a minute). An online node starts within seconds; an offline " +
"or locked-out node picks it up on its next check-in (up to ~15 minutes). Nodes installed " +
"before the host updater existed need install.sh re-run once.",
)
)
return;
setSending(true);
try {
const r = await c2api.requestNodeUpdate(id);
alert(
r.mqtt_sent
? "Update requested. The node should restart within a minute."
: "Update requested. The node is not reachable over MQTT right now; it will update on its next HTTPS check-in.",
);
} catch (err) {
alert(err instanceof Error ? err.message : "Update request failed.");
} finally {
setSending(false);
}
}
async function handleApprove() {
setApproving(true);
try {
@@ -251,6 +277,15 @@ export default function NodeDetailPage() {
["System", system?.name ?? "Unassigned"],
["Location", `${node.lat}, ${node.lon}`],
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
["Updater Check-in", node.updater_last_check_at ? new Date(node.updater_last_check_at).toLocaleString() : "never"],
...(node.update_requested_at
? [[
"Last Update",
node.update_applied_at && node.update_applied_at >= node.update_requested_at
? `${node.update_result === "failed" ? "FAILED" : "applied"} ${new Date(node.update_applied_at).toLocaleString()}`
: `requested ${new Date(node.update_requested_at).toLocaleString()} — pending`,
]]
: []),
["Configured", node.configured ? "Yes" : "No"],
["Node Type", node.node_type ?? "fixed"],
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
@@ -302,17 +337,15 @@ export default function NodeDetailPage() {
>
Restart OP25
</button>
<button
disabled={sending}
onClick={() => {
if (confirm("Restart the node container to apply any pulled image updates?")) {
sendCommand("node_update");
}
}}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Update Node
</button>
{isAdmin && (
<button
disabled={sending}
onClick={handleRequestUpdate}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Update Node Software
</button>
)}
<button
onClick={() => setShowDiscordJoin(true)}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors"
+6
View File
@@ -65,6 +65,12 @@ export const c2api = {
// Node approval
approveNode: (id: string) =>
request(`/nodes/${id}/approve`, { method: "POST" }),
/** node-26#15: flag the node for its host updater (HTTPS) and nudge it over MQTT. */
requestNodeUpdate: (id: string) =>
request<{ ok: boolean; update_requested_at: string; mqtt_sent: boolean }>(
`/nodes/${id}/request-update`,
{ method: "POST" },
),
rejectNode: (id: string) =>
request(`/nodes/${id}/reject`, { method: "POST" }),
deleteNode: (id: string) =>
+7
View File
@@ -72,6 +72,13 @@ export interface NodeRecord {
is_overridden?: boolean;
override_system_id?: string | null;
override_timeout_at?: string | null;
/** node-26#15 host updater: last "Update Node" request, and what the node did with it. */
update_requested_at?: string | null;
update_applied_at?: string | null;
update_result?: "ok" | "failed" | null;
/** Last HTTPS check-in from the host updater — alive even when MQTT is not. */
updater_last_check_at?: string | null;
updater_sha?: string | null;
}
export interface SdrDevice {