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:
co-authored by
Claude Opus 5.5
parent
fb299008f8
commit
7fa50f24d2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user