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
+41 -3
View File
@@ -1,4 +1,5 @@
import secrets
from datetime import datetime, timezone
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
@@ -43,7 +44,17 @@ async def approve_node(node_id: str, _: dict = Depends(require_admin_token)):
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
api_key = secrets.token_hex(32)
# node-26#14: approve must NEVER rotate a key that already exists. The old
# behaviour minted a fresh key on every click and overwrote node_keys, so
# re-clicking Approve on a live node (or approving after a re-run) silently
# invalidated the key already on the device and locked it out of MQTT with
# no remote recovery. Rotation is an explicit, separate action
# (POST /nodes/{id}/reissue-key). An existing key is reused as-is; the
# dynsec upsert below still runs, so a second approve doubles as a safe
# "re-sync this node's broker login" without changing the credential.
key_doc = await fstore.doc_get("node_keys", node_id)
existing_key = (key_doc or {}).get("api_key")
api_key = existing_key or secrets.token_hex(32)
# dynsec FIRST, Firestore second: if the broker rejects/never confirms
# the new client, we must not tell Firestore (and the admin UI) the
@@ -56,13 +67,40 @@ async def approve_node(node_id: str, _: dict = Depends(require_admin_token)):
logger.error(f"Approve {node_id!r}: dynsec upsert failed, NOT writing Firestore: {e}")
raise HTTPException(502, f"Could not provision MQTT credentials for node: {e}")
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
if not existing_key:
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
await fstore.doc_update("nodes", node_id, {"approval_status": "approved"})
# TODO(mqtt-cutover): drop this MQTT push once nodes pull their key via
# GET /nodes/{id}/credentials (routers/enrollment.py) exclusively — see
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6. Kept for node-26.
mqtt_handler.publish_node_key(node_id, api_key)
return {"ok": True}
if existing_key:
logger.info(f"Approve {node_id!r}: key already exists — reused, not rotated")
return {"ok": True, "key_rotated": False, "already_had_key": bool(existing_key)}
@router.post("/{node_id}/request-update")
async def request_node_update(node_id: str, _: dict = Depends(require_admin_token)):
"""Ask a node to run its host-level updater (node-26#15).
Two channels, so a node that is offline or locked out of MQTT still gets
it: (1) ``update_requested_at`` on the node doc, which the node's host
updater reads over HTTPS on its timer (POST /nodes/{id}/update-check in
routers/enrollment.py); (2) the MQTT ``node_update`` command, best-effort,
which makes an online node's updater run within seconds.
Deliberately carries NO payload the node acts on beyond "update now": no
ref, no image name, no command. The node pulls only the repo/registry and
ref it was installed with — C2 cannot steer what code a node runs.
"""
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
requested_at = datetime.now(timezone.utc).isoformat()
await fstore.doc_update("nodes", node_id, {"update_requested_at": requested_at})
mqtt_sent = mqtt_handler.send_command(node_id, {"action": "node_update"})
logger.info(f"Update requested for {node_id!r} at {requested_at} (mqtt_sent={mqtt_sent})")
return {"ok": True, "update_requested_at": requested_at, "mqtt_sent": mqtt_sent}
@router.delete("/{node_id}", status_code=204)