Files
server-26/drb-c2-core/app/routers/enrollment.py
T
Logan CusanoandClaude Opus 5.5 7fa50f24d2 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>
2026-10-04 01:09:52 -04:00

300 lines
13 KiB
Python

"""
Node self-enrollment — the public replacement for the old shared-MQTT-
password flow (see MQTT-PUBLIC-AUTH-PLAN.md "Enrollment flow").
1. POST /nodes/enroll (X-Enrollment-Token: <fleet-wide token>)
First-boot node upserts itself as `approval_status: pending` and gets
back a one-time pickup_secret. Only its hash is persisted.
2. GET /nodes/{id}/credentials (X-Pickup-Secret: <secret from step 1>)
Node polls this with backoff until an admin approves it in the
frontend (existing nodes.py approve_node() flow — unchanged, still
writes node_keys/{id}.api_key) and then reads its api_key back.
These two endpoints are meant to be public (unlike the dynsec control-plane
traffic in app/internal/dynsec.py, which never leaves the docker-internal
MQTT bridge) — that's the whole point of moving off WireGuard-per-node.
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 datetime import datetime, timezone
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Header, Request
from pydantic import BaseModel, Field
from app.config import settings
from app.internal import firestore as fstore
from app.internal.logger import logger
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(prefix="/nodes", tags=["enrollment"])
# ---------------------------------------------------------------------------
# Per-source-IP token bucket for /nodes/enroll.
#
# c2-core has no rate-limiting dependency anywhere today (see
# MQTT-PUBLIC-AUTH-PLAN.md); this is a deliberately small (~30 line)
# in-memory limiter rather than a new library. Known limitations:
# - per-process: with more than one c2-core instance, each has its own
# bucket, so real throughput is (limit x instance count). Fine today —
# there is exactly one instance.
# - resets on every restart/redeploy — not persisted anywhere.
# Good enough to blunt casual guessing of node_ids against the fleet token;
# not a substitute for a real edge/WAF rate limiter if this endpoint is
# ever seriously targeted.
# ---------------------------------------------------------------------------
class _TokenBucket:
def __init__(self, capacity: int, refill_per_sec: float):
self.capacity = capacity
self.refill_per_sec = refill_per_sec
self._buckets: dict[str, tuple[float, float]] = {} # key -> (tokens, last_refill_ts)
def allow(self, key: str) -> bool:
now = time.monotonic()
tokens, last_ts = self._buckets.get(key, (float(self.capacity), now))
tokens = min(self.capacity, tokens + (now - last_ts) * self.refill_per_sec)
if tokens < 1:
self._buckets[key] = (tokens, now)
return False
self._buckets[key] = (tokens - 1, now)
return True
# Burst of 5, refilling 1/minute — enrollment is a first-boot, once-per-node
# event, so a legitimate node never needs more than a handful of attempts.
_enroll_limiter = _TokenBucket(capacity=5, refill_per_sec=1 / 60)
def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode()).hexdigest()
async def _resolve_org_for_token(token: str) -> Optional[str]:
"""
Resolve an X-Enrollment-Token to the org_id it enrolls a node into.
Tries the per-org enrollment_tokens collection first (SAAS_PLAN.md B2b —
minted/listed/revoked via routers/org.py), then falls back to the legacy
fleet-wide settings.enrollment_token so an already-deployed field node's
.env doesn't start failing the day per-org tokens ship. The fallback
always resolves to FOUNDING_ORG_ID — see app/internal/tenancy.py.
"""
token_hash = _hash_secret(token)
doc = await fstore.doc_get("enrollment_tokens", token_hash)
if doc and not doc.get("revoked"):
try:
await fstore.doc_update("enrollment_tokens", token_hash, {"uses": (doc.get("uses") or 0) + 1})
except Exception:
pass # use-counter is informational only — never block enrollment on it
return doc.get("org_id")
if settings.enrollment_token and secrets.compare_digest(token, settings.enrollment_token):
return FOUNDING_ORG_ID
return None
class EnrollRequest(BaseModel):
node_id: str
name: Optional[str] = None
lat: float = 0.0
lon: float = 0.0
class EnrollResponse(BaseModel):
node_id: str
pickup_secret: str
approval_status: str
@router.post("/enroll", response_model=EnrollResponse)
async def enroll_node(
body: EnrollRequest,
request: Request,
x_enrollment_token: Optional[str] = Header(None),
):
client_ip = request.client.host if request.client else "unknown"
if not _enroll_limiter.allow(client_ip):
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
if not x_enrollment_token:
logger.warning(f"Enroll 401: missing X-Enrollment-Token from {client_ip} for node_id={body.node_id!r}")
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
org_id = await _resolve_org_for_token(x_enrollment_token)
if not org_id:
logger.warning(f"Enroll 401: bad enrollment token from {client_ip} for node_id={body.node_id!r}")
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
node_id = body.node_id.strip()
if not node_id:
raise HTTPException(400, "node_id is required")
existing = await fstore.doc_get("nodes", node_id)
# -------------------------------------------------------------------
# CRITICAL GUARD — do not remove or weaken this check.
#
# An already-approved node_id must NEVER get a fresh pickup_secret off
# the fleet-wide enrollment token alone. The fleet token is shared by
# every node (it ships in every node's .env / setup.sh prompt), so it
# is the credential most likely to leak. Without this guard, a leaked
# fleet token plus a guessable node_id (node-001, node-002, ...) would
# let an attacker "re-enroll" a live, already-approved node and race
# the real node to GET /nodes/{id}/credentials — stealing its actual
# api_key before the legitimate device ever asks.
#
# Recovery for an approved node goes through the existing admin-only
# POST /nodes/{id}/reissue-key instead (routers/nodes.py), which
# requires a Firebase admin token, not the fleet token.
# -------------------------------------------------------------------
if existing and existing.get("approval_status") == "approved":
logger.warning(
f"Enroll refused: node_id={node_id!r} is already approved — "
f"refusing to issue a new pickup_secret from the fleet token alone "
f"(source_ip={client_ip})"
)
raise HTTPException(
403,
"Node is already approved. This endpoint cannot re-issue credentials "
"for an approved node from the enrollment token alone — use admin "
"key reissue.",
)
pickup_secret = secrets.token_hex(24)
doc = {
"node_id": node_id,
# A node re-enrolling keeps whatever org_id it already has rather
# than letting a differently-scoped token silently reassign its
# tenancy — only a brand-new node_id (or one that predates tenancy
# entirely) picks up org_id from the token used here.
"org_id": (existing or {}).get("org_id") or org_id,
"name": body.name or (existing or {}).get("name") or node_id,
"lat": body.lat or (existing or {}).get("lat", 0.0),
"lon": body.lon or (existing or {}).get("lon", 0.0),
"approval_status": (existing or {}).get("approval_status", "pending"),
"pickup_secret_hash": _hash_secret(pickup_secret),
}
# approval_status stays "pending" for a brand-new node; if it's an
# existing "pending" or "rejected" node re-enrolling (e.g. lost its
# pickup_secret before an admin ever approved it), leave whatever
# status it already has rather than silently flipping "rejected" back
# to "pending" — that decision belongs to an admin, not this endpoint.
await fstore.doc_set("nodes", node_id, doc, merge=True)
logger.info(f"Node enrolled: {node_id} (status={doc['approval_status']}, source_ip={client_ip})")
return EnrollResponse(node_id=node_id, pickup_secret=pickup_secret, approval_status=doc["approval_status"])
class CredentialsResponse(BaseModel):
approval_status: str
api_key: Optional[str] = None
@router.get("/{node_id}/credentials", response_model=CredentialsResponse)
async def get_node_credentials(node_id: str, x_pickup_secret: Optional[str] = Header(None)):
if not x_pickup_secret:
raise HTTPException(401, "Missing X-Pickup-Secret header")
node = await fstore.doc_get("nodes", node_id)
if not node or not node.get("pickup_secret_hash"):
raise HTTPException(404, "Unknown node, or node was never enrolled via POST /nodes/enroll")
if not secrets.compare_digest(_hash_secret(x_pickup_secret), node["pickup_secret_hash"]):
raise HTTPException(401, "Invalid pickup secret")
approval_status = node.get("approval_status", "pending")
if approval_status != "approved":
return CredentialsResponse(approval_status=approval_status)
key_doc = await fstore.doc_get("node_keys", node_id)
if not key_doc or not key_doc.get("api_key"):
# Approved but no key yet — shouldn't normally happen, approve_node()
# always writes node_keys in the same call that sets approved. Treat
# it as "keep polling" rather than erroring the node's retry loop.
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)