Node self-heals its API key; one-stop install.sh; real host-level OTA updater (#14, #15)

install.sh (#14): after enrolling it now BLOCKS until an admin approves,
printing "Please have an admin approve node '<id>' at <app>/settings/nodes
to continue", polling GET /nodes/{id}/credentials with the pickup secret
(no default timeout; Ctrl-C safe; re-run resumes from configs/pickup_secret).
A re-run with a key on disk re-checks it against C2 and replaces a stale
(reissued) key. credentials.json is merged atomically so the dashboard auth
salt/secret survive. edge-node is ALWAYS force-recreated at the end, so a key
written after the container started is loaded. --wait-approval N caps the
wait; 0 = don't wait. git fetch uses --force so a re-cut v1 tag is accepted.

edge-node (#14): with no key at startup, or on a broker auth refusal
(CONNACK 4/5/134/135), mqtt_manager re-reads credentials.json, else fetches
the key over HTTPS (key_pickup.py, pickup secret from /configs), persists it
and rebuilds the paho client in place. Single-flight, 30s..15min backoff.
A key pushed over MQTT while connected is applied for the next reconnect.

OTA (#15): node_update no longer os._exit()s. It drops
configs/update-requested; install.sh installs scripts/install-updater.sh's
drb-update.{service,timer,path}. The path unit fires on the sentinel, the
timer (~15 min) does an HTTPS check-in (POST /nodes/{id}/update-check,
server-26 PR) so offline/locked-out nodes still update. drb-update.sh only
installs the repo/ref/registry in root-owned /etc/drb/updater.conf; C2's
answer is just a timestamp; origin and registry mismatches abort; creds go
to curl via a 0600 header file.

Tests: test_credential_recovery.py, test_host_updater.py (runs the real
updater script with stub git/docker/curl).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 01:09:39 -04:00
co-authored by Claude Opus 5.5
parent 6bf2c9dd93
commit c2c349043b
10 changed files with 1057 additions and 74 deletions
+46 -3
View File
@@ -3,8 +3,12 @@ Manages the persisted node API key, plus the local-auth signing material used
by app/internal/auth.py.
The API key is provisioned by the C2 server after an admin approves the node.
It arrives via MQTT and is saved to /configs/credentials.json so it survives
container restarts.
install.sh fetches it over HTTPS (GET /nodes/{id}/credentials + the pickup
secret it saved to /configs/pickup_secret) and writes /configs/credentials.json;
the running app can also fetch it itself (app/internal/key_pickup.py) and
re-read the file at any time (reload_api_key), so a key written after startup
is picked up without a container restart (node-26#14). The legacy MQTT
delivery (nodes/{id}/api_key) still saves here too.
The scrypt salt and session-signing secret are generated locally on first boot
(never provisioned externally) and persisted the same way, so dashboard
@@ -12,12 +16,14 @@ sessions survive a container restart instead of forcing every operator to
re-login whenever the node restarts.
"""
import json
import os
import secrets
from pathlib import Path
from app.config import settings
from app.internal.logger import logger
_CREDS_FILE = Path(settings.config_path) / "credentials.json"
_PICKUP_FILE = Path(settings.config_path) / "pickup_secret"
_api_key: str | None = None
_auth_salt: bytes | None = None
_session_secret: bytes | None = None
@@ -74,6 +80,37 @@ def get_api_key() -> str | None:
return _api_key
def reload_api_key() -> str | None:
"""Re-read the api_key from disk (install.sh, or an operator, may have
written it after startup). Returns the key now in effect. Only the api_key
is taken from the file — the auth material already in memory stays."""
global _api_key
try:
data = json.loads(_CREDS_FILE.read_text())
except FileNotFoundError:
return _api_key
except Exception as e:
logger.warning(f"Could not re-read credentials file: {e}")
return _api_key
key = data.get("api_key")
if key and key != _api_key:
_api_key = key
logger.info("Node API key re-read from disk (changed since it was last loaded).")
return _api_key
def get_pickup_secret() -> str | None:
"""The one-time pickup secret install.sh saved at enrollment, if any."""
try:
secret = _PICKUP_FILE.read_text().strip()
except FileNotFoundError:
return None
except Exception as e:
logger.warning(f"Could not read pickup secret: {e}")
return None
return secret or None
def save_api_key(key: str) -> None:
global _api_key
_api_key = key
@@ -88,4 +125,10 @@ def _write() -> None:
data["auth_salt"] = _auth_salt.hex()
if _session_secret is not None:
data["session_secret"] = _session_secret.hex()
_CREDS_FILE.write_text(json.dumps(data))
# Atomic replace with 0600: install.sh on the host writes this same file,
# and a torn half-written JSON would lose the key AND the auth material.
tmp = _CREDS_FILE.with_name(_CREDS_FILE.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(json.dumps(data))
os.replace(tmp, _CREDS_FILE)
+73
View File
@@ -0,0 +1,73 @@
"""
HTTPS api_key pickup — the node's own half of C2's enrollment flow
(Server/drb-c2-core/app/routers/enrollment.py, GET /nodes/{id}/credentials).
Why this exists (node-26#14): key delivery used to be MQTT-only
(nodes/{id}/api_key), and a dynsec broker refuses a node that has no key — or
a stale one — so a locked-out node could never receive the key that would let
it in. install.sh already did this pickup once at install time; doing it from
the running app too means a node approved after install, or whose key was
reissued, recovers on its own with no SSH and no container restart.
Auth is the pickup secret install.sh saved to /configs/pickup_secret at
enrollment (the same file, bind-mounted). Only ever sent to settings.c2_url,
over whatever scheme the operator configured (https in production).
"""
from typing import Optional
import httpx
from app.config import settings
from app.internal import credentials
from app.internal.logger import logger
_TIMEOUT = 15
async def fetch_api_key() -> Optional[str]:
"""Ask C2 for this node's api_key. Returns it, or None when there isn't one
to be had yet (not approved, no pickup secret, C2 unreachable, rejected).
Never raises — the caller retries on its own backoff."""
if not settings.c2_url:
logger.warning("Key pickup skipped: C2_URL is not configured.")
return None
secret = credentials.get_pickup_secret()
if not secret:
logger.warning(
f"Key pickup skipped: no {settings.config_path}/pickup_secret. This node was not enrolled by "
"install.sh on this machine — re-run install.sh, or have an admin Reissue key and write it in."
)
return None
url = f"{settings.c2_url.rstrip('/')}/nodes/{settings.node_id}/credentials"
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(url, headers={"X-Pickup-Secret": secret})
except Exception as e:
logger.warning(f"Key pickup: could not reach C2 ({e}).")
return None
if r.status_code == 401:
logger.error(
"Key pickup: C2 rejected the saved pickup secret (401) — it was rotated by a re-enroll elsewhere. "
"Re-run install.sh with --token to recover."
)
return None
if r.status_code == 404:
logger.error("Key pickup: C2 does not know this node (404) — it was deleted server-side. Re-run install.sh.")
return None
if r.status_code != 200:
logger.warning(f"Key pickup: unexpected HTTP {r.status_code} from C2.")
return None
try:
body = r.json()
except ValueError:
logger.warning("Key pickup: C2 returned a non-JSON body.")
return None
key = body.get("api_key") if isinstance(body, dict) else None
if not key or not isinstance(key, str):
status = body.get("approval_status") if isinstance(body, dict) else None
logger.info(f"Key pickup: no api_key yet (approval_status={status!r}) — waiting for an admin to approve.")
return None
return key
+103 -11
View File
@@ -6,12 +6,30 @@ from typing import Optional, Callable, Awaitable, Dict, Any
import paho.mqtt.client as mqtt
from app.config import settings
from app.internal.logger import logger
from app.internal import credentials
from app.internal import credentials, key_pickup
CommandCallback = Callable[[Dict[str, Any]], Awaitable[None]]
ConfigCallback = Callable[[Dict[str, Any]], Awaitable[None]]
ApiKeyCallback = Callable[[Dict[str, Any]], Awaitable[None]]
# CONNACK codes meaning "your credentials are wrong", as paho reports them:
# MQTT 3.1.1 return codes 4/5, and the MQTT 5 reason codes paho maps them to
# (134 Bad user name or password, 135 Not authorized).
_AUTH_FAILURE_CODES = {4, 5, 134, 135}
# Credential-recovery backoff (node-26#14). The pickup endpoint is cheap, but a
# node that is simply not approved yet must not hammer C2 for days.
RECOVERY_MIN_DELAY = 30
RECOVERY_MAX_DELAY = 900
def _is_auth_failure(reason_code) -> bool:
value = getattr(reason_code, "value", reason_code)
if isinstance(value, int) and value in _AUTH_FAILURE_CODES:
return True
text = str(reason_code).lower()
return "not authori" in text or "bad user name or password" in text
class MQTTManager:
def __init__(self):
@@ -19,6 +37,11 @@ class MQTTManager:
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._connected = False
self._connect_task: Optional[asyncio.Task] = None
# The api_key the CURRENT paho client was built with — not necessarily
# credentials.get_api_key(), which can move ahead of it (MQTT key push,
# install.sh writing the file). Recovery compares against this.
self._client_key: Optional[str] = None
self._recovery_task: Optional[asyncio.Task] = None
self.on_command: Optional[CommandCallback] = None
self.on_config_push: Optional[ConfigCallback] = None
@@ -53,6 +76,7 @@ class MQTTManager:
)
api_key = credentials.get_api_key()
self._client_key = api_key
if api_key:
# Post-cutover auth: broker's dynsec plugin authenticates this
# exact (username, password) pair as this node's own client — see
@@ -70,18 +94,14 @@ class MQTTManager:
client.username_pw_set(settings.mqtt_user, settings.mqtt_pass)
else:
# No api_key on disk and no legacy shared login configured. A
# dynsec broker (allow_anonymous false) refuses this outright —
# expected, not a bug to route around here: this node hasn't been
# enrolled/approved yet, and the enrollment flow that would fix
# that client-side is a later, separate pass (out of scope here;
# see MQTT-PUBLIC-AUTH-PLAN.md). paho's reconnect_delay_set()
# below bounds the retry rate (2..60s exponential backoff), so
# this degrades to a slow, clearly-logged refusal loop via
# _on_connect's "MQTT connect refused" line — not a hot spin.
# dynsec broker (allow_anonymous false) refuses this outright.
# connect() starts _recover_credentials() in this case, which
# fetches the key over HTTPS (key_pickup.py) and rebuilds this
# client with it — node-26#14. paho's reconnect_delay_set() below
# bounds the refusal loop meanwhile (2..60s backoff).
logger.warning(
"No API key on disk and no legacy MQTT_USER configured — "
"connecting without credentials; the broker is expected to "
"refuse this until the node is enrolled/approved."
"connecting without credentials while the key is fetched from C2."
)
if settings.mqtt_tls:
@@ -120,6 +140,10 @@ class MQTTManager:
asyncio.run_coroutine_threadsafe(self._flush_offline_buffer(), self._loop)
else:
logger.error(f"MQTT connect refused: {reason_code}")
if _is_auth_failure(reason_code) and self._loop is not None:
# paho thread -> event loop. A refused login never fixes itself
# by retrying the same password, so go get the right one.
self._loop.call_soon_threadsafe(self._start_recovery, f"broker refused login ({reason_code})")
def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
self._connected = False
@@ -144,6 +168,72 @@ class MQTTManager:
self._loop = asyncio.get_event_loop()
self._client = self._build_client()
self._connect_task = asyncio.create_task(self._connect_with_retry())
if not self._client_key and not settings.mqtt_user:
self._start_recovery("no API key at startup")
# ------------------------------------------------------------------
# Credential self-heal (node-26#14)
#
# A node with no key, or a stale one, used to sit in an MQTT refusal loop
# forever: key delivery was MQTT-only, and credentials were read once at
# startup. Now: re-read credentials.json (install.sh may have written it),
# else fetch from C2 over HTTPS with the pickup secret; if that yields a
# key different from the one the client is using, rebuild the client with
# it in place. No container restart.
# ------------------------------------------------------------------
def _start_recovery(self, why: str) -> None:
if self._recovery_task and not self._recovery_task.done():
return
logger.warning(f"MQTT credential recovery started: {why}")
self._recovery_task = asyncio.create_task(self._recover_credentials())
async def _recover_credentials(self) -> None:
delay = RECOVERY_MIN_DELAY
while True:
tried = self._client_key
key = credentials.reload_api_key()
if not key or key == tried:
fetched = await key_pickup.fetch_api_key()
if fetched and fetched != tried:
credentials.save_api_key(fetched)
key = fetched
if key and key != tried:
logger.info("MQTT credential recovery: new API key in hand — reconnecting with it.")
await self._rebuild_client()
return
if self._connected:
return
if key and key == tried:
logger.error(
"MQTT credential recovery: C2 returns the same key the broker refuses. The broker's login for "
"this node is out of sync with C2 (dynsec) — an admin re-approve re-syncs it without rotating."
)
logger.info(f"MQTT credential recovery: retrying in {delay}s.")
await asyncio.sleep(delay)
delay = min(delay * 2, RECOVERY_MAX_DELAY)
async def _rebuild_client(self) -> None:
"""Swap the paho client for one built from the current credentials."""
old = self._client
if self._connect_task and not self._connect_task.done():
self._connect_task.cancel()
if old is not None:
# loop_stop() joins paho's thread, which can be mid-backoff — off the event loop.
await asyncio.to_thread(old.loop_stop)
try:
old.disconnect()
except Exception:
pass
self._connected = False
self._client = self._build_client()
self._connect_task = asyncio.create_task(self._connect_with_retry())
def update_api_key(self, key: str) -> None:
"""A key arrived while connected (legacy MQTT push / reissue): use it on
the next reconnect without dropping the current, working session."""
if self._client is not None and key != self._client_key:
self._client.username_pw_set(settings.node_id, key)
self._client_key = key
async def _connect_with_retry(self):
"""Attempt the initial TCP connect, retrying with backoff until it succeeds."""
@@ -161,6 +251,8 @@ class MQTTManager:
delay = min(delay * 2, 60)
async def disconnect(self):
if self._recovery_task:
self._recovery_task.cancel()
if self._connect_task:
self._connect_task.cancel()
if self._client:
@@ -0,0 +1,41 @@
"""
Hand an MQTT `node_update` command to the host-level updater (node-26#15).
The edge-node container has no business updating its own host: it writes a
sentinel file into /configs (bind-mounted from <install dir>/configs), and the
host's systemd drb-update.path unit — installed by install.sh — triggers
drb-update.service, which runs Client/scripts/drb-update.sh. The host treats
the file as a bare "please update" flag and never reads its content as input,
so nothing C2 sends can pick the ref, image or command that runs.
"""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from app.config import settings
from app.internal.logger import logger
SENTINEL_NAME = "update-requested"
def sentinel_path() -> Path:
return Path(settings.config_path) / SENTINEL_NAME
def request_host_update() -> bool:
"""Drop the sentinel. Returns False if it could not be written."""
path = sentinel_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(SENTINEL_NAME + ".tmp")
tmp.write_text(json.dumps({"requested_at": datetime.now(timezone.utc).isoformat()}))
os.replace(tmp, path)
except OSError as e:
logger.error(f"Node update requested but the sentinel could not be written ({e}).")
return False
logger.info(
f"Node update requested — handed to the host updater via {path}. If nothing happens within a minute, "
"this node predates the updater: re-run install.sh on it once."
)
return True