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
+83 -2
View File
@@ -17,11 +17,13 @@ Auth is the token headers checked inline below, not the app-wide
Firebase/service-key dependency the rest of routers/nodes.py uses.
"""
import hashlib
import re
import secrets
import time
from typing import Optional
from datetime import datetime, timezone
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Header, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.config import settings
from app.internal import firestore as fstore
from app.internal.logger import logger
@@ -216,3 +218,82 @@ async def get_node_credentials(node_id: str, x_pickup_secret: Optional[str] = He
return CredentialsResponse(approval_status=approval_status)
return CredentialsResponse(approval_status=approval_status, api_key=key_doc["api_key"])
# ---------------------------------------------------------------------------
# Host updater check-in (node-26#15)
#
# A node's host-level updater (Client/scripts/drb-update.sh, a systemd timer
# installed by install.sh) calls this over HTTPS on a timer. It is the
# out-of-band channel: it works for a node that is offline from MQTT or
# locked out of the broker entirely, which is exactly when an update or a
# credential fix is needed most.
#
# Auth, checked inline for THIS node_id only (never a fleet credential):
# Authorization: Bearer <api_key> -> must equal node_keys/{id}.api_key
# X-Pickup-Secret: <secret> -> must hash to nodes/{id}.pickup_secret_hash
# The pickup secret is accepted so a node whose api_key is missing/stale can
# still be told to update — it is the same credential that already returns
# the api_key itself, so it grants nothing new.
#
# The response is a single timestamp. The node compares it with the last
# request it applied and, if newer, pulls the repo/registry/ref IT was
# installed with. C2 cannot name a ref, an image or a command here, so a
# compromised C2 or a stolen node credential can at most make a node
# re-pull its own pinned software.
# ---------------------------------------------------------------------------
_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$")
class UpdateCheckRequest(BaseModel):
sha: Optional[str] = Field(None, max_length=40)
applied_request_at: Optional[str] = Field(None, max_length=64)
result: Optional[Literal["ok", "failed"]] = None
class UpdateCheckResponse(BaseModel):
update_requested_at: Optional[str] = None
async def _authenticate_node(
node_id: str, authorization: Optional[str], x_pickup_secret: Optional[str],
) -> dict:
node = await fstore.doc_get("nodes", node_id)
if not node:
# 401, not 404: this route must not double as a node_id oracle.
raise HTTPException(401, "Invalid node credentials")
if authorization and authorization.lower().startswith("bearer "):
presented = authorization[7:].strip()
key_doc = await fstore.doc_get("node_keys", node_id)
stored = (key_doc or {}).get("api_key") or ""
if stored and presented and secrets.compare_digest(stored, presented):
return node
if x_pickup_secret and node.get("pickup_secret_hash"):
if secrets.compare_digest(_hash_secret(x_pickup_secret), node["pickup_secret_hash"]):
return node
raise HTTPException(401, "Invalid node credentials")
@router.post("/{node_id}/update-check", response_model=UpdateCheckResponse)
async def node_update_check(
node_id: str,
body: UpdateCheckRequest,
authorization: Optional[str] = Header(None),
x_pickup_secret: Optional[str] = Header(None),
):
node = await _authenticate_node(node_id, authorization, x_pickup_secret)
now = datetime.now(timezone.utc).isoformat()
updates: dict = {"updater_last_check_at": now}
if body.sha and _SHA_RE.match(body.sha):
updates["updater_sha"] = body.sha
requested = node.get("update_requested_at")
if body.applied_request_at and requested and body.applied_request_at == requested and body.result:
updates["update_applied_at"] = now
updates["update_result"] = body.result
try:
await fstore.doc_update("nodes", node_id, updates)
except Exception as e: # bookkeeping only — never fail the node's check on it
logger.warning(f"update-check: could not record check-in for {node_id!r}: {e}")
return UpdateCheckResponse(update_requested_at=requested)