Compare commits

...
2 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5 a195563da6 Let edge nodes read /systems with their own api_key
Build & Deploy / Build & push images (push) Successful in 4m26s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
The node builds its OP25 config from GET /systems, but that router only
accepted a Firebase token or the shared service key — a node holds neither.
Every fetch returned 401 and the node fell back to its stale offline cache,
so a system edited in the UI never reached the field. Confirmed on node-002
against the live server: "Failed to fetch systems from C2: 401 Unauthorized
... Offline cache will be used."

The node sends no node_id with the request, only the bearer token, so the
key is matched by querying node_keys for the value instead of fetching a
known document the way /upload does.

Read access only: the mutating routes in this router each carry their own
require_admin_token, so widening the router-level gate doesn't let a node
create, edit or delete a system.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:19:04 -04:00
Logan CusanoandClaude Opus 5 55cd1110df Give mosquitto's bind-mounted dirs to uid 1883, not root
The broker crash-looped on every deploy: "Unable to load server certificate
/mosquitto/certs/mqtt.crt ... Permission denied". The cert-sync script wrote
600 root:root into a 0700 root:root directory, on the assumption that
mosquitto runs as root inside its container. It does not — the stock
eclipse-mosquitto entrypoint drops privileges to the in-image mosquitto
user, confirmed on the server as uid=1883(mosquitto) gid=1883(mosquitto),
and the broker's own log says so on every start.

Certs dir is now root:1883 0750 with the cert 0644 and the key 0640, and
the data dir is 1883:1883 recursively — recursively because mosquitto
WRITES dynamic-security.json there, and a root-owned file left by an
earlier deploy would still be unwritable after a directory-only chown.

Also drops the "unverified Caddy cert path" note: a real issuance confirmed
the path, producing CN=mqtt.drb.cusano.net signed by Let's Encrypt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:29:26 -04:00
4 changed files with 76 additions and 18 deletions
+35
View File
@@ -37,6 +37,41 @@ async def require_service_or_firebase_token(
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def require_node_service_or_firebase_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept a node's own API key in addition to a service key / Firebase token.
Edge nodes need to read ``/systems`` to build their OP25 config, but they
hold neither a Firebase token nor the shared service key — only the
per-node api_key that ``/upload`` already trusts. Without this they got a
flat 401 and silently fell back to their stale offline cache, so a system
edited in the UI never reached the node.
Unlike ``/upload``, the node sends no node_id alongside the bearer token,
so the key is matched by querying ``node_keys`` for the value rather than
fetching a known document. Mutating routes are unaffected: they carry
their own ``require_admin_token`` dependency, so widening the router-level
gate grants nodes read access only.
"""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True}
try:
return firebase_auth.verify_id_token(token)
except Exception:
pass
# Deferred import: app.internal.firestore initialises firebase-admin at
# import time, and auth.py is imported from module scope in the routers.
from app.internal import firestore as fstore
matches = await fstore.collection_list("node_keys", api_key=token)
if matches:
return {"node": True, "node_id": matches[0].get("node_id")}
raise HTTPException(status_code=401, detail="Invalid or expired token")
def get_role(decoded: dict) -> str:
"""Extract the effective role from a decoded Firebase token.
+10 -2
View File
@@ -9,7 +9,11 @@ from app.internal.summarizer import summarizer_loop
from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.internal.auth import (
require_firebase_token,
require_service_or_firebase_token,
require_node_service_or_firebase_token,
)
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment
from app.internal import dynsec
@@ -81,7 +85,11 @@ app.add_middleware(
)
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)])
# systems is the one router edge nodes read directly (system_cacher.py builds
# the OP25 config from it), so its gate also accepts a per-node api_key. The
# write routes inside carry their own require_admin_token, so nodes get read
# access only.
app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)])
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
+18 -8
View File
@@ -72,26 +72,36 @@
# read Caddy's own cert storage, so a systemd path unit + oneshot service
# copies a readable copy out and SIGHUPs the broker on every change.
# root:1883 0750, not root:root 0700. The stock eclipse-mosquitto entrypoint
# drops privileges to the in-image `mosquitto` user (uid/gid 1883), so a
# root-only directory makes the broker fail to read its own cert and
# crash-loop: "Unable to load server certificate ... Permission denied".
# The host has no `mosquitto` user, hence the numeric gid.
- name: Create mosquitto certs directory
file:
path: /opt/drb/mosquitto-certs
state: directory
owner: root
group: root
mode: "0700"
group: "1883"
mode: "0750"
# dynamic-security.json (node credentials — see app/internal/dynsec.py)
# lives here. Root-owned is fine: the mosquitto container itself runs as
# root (no `user` directive in mosquitto.conf, matching the pre-existing
# setup this project already ran before the dynsec change), so it can
# read/write this directory directly without any host-side chown dance.
# lives here, and mosquitto WRITES it, so this must be owned by the uid the
# broker actually runs as (1883), not root. The earlier assumption that the
# container runs as root was wrong — the image's entrypoint drops privileges
# to the `mosquitto` user, which a real deploy proved by failing to read a
# root-owned cert. Same numeric-gid reasoning as the certs directory above.
- name: Create mosquitto data directory
file:
path: /opt/drb/mosquitto-data
state: directory
owner: root
group: root
owner: "1883"
group: "1883"
mode: "0700"
# recurse so an existing root-owned dynamic-security.json / mosquitto.db
# left behind by the earlier root-owned deploy gets fixed too — chowning
# only the directory would leave the broker unable to rewrite them.
recurse: true
- name: Deploy MQTT cert-sync script
template:
@@ -10,12 +10,9 @@
# source cert file for changes — a path unit rather than cron so this fires
# on the actual write instead of racing a polling interval.
#
# UNVERIFIED: the exact source path below assumes Caddy's default file
# storage layout and Let's Encrypt's production ACME directory name. This
# has not been confirmed against a real Caddy cert issuance for this
# project — check `caddy storage` / find the actual path under
# /var/lib/caddy the first time this runs, and correct CADDY_CERT_DIR below
# if it doesn't match.
# CONFIRMED 2026-08-16 against a real issuance on drb-server: this path is
# correct, and the copied cert came out as CN=mqtt.drb.cusano.net issued by
# Let's Encrypt. Was previously flagged unverified.
#
# UNVERIFIED: mosquitto 2.x reloading TLS certs on SIGHUP without dropping
# connections is documented upstream but untested here. If listener 8883
@@ -43,8 +40,16 @@ mkdir -p "$DEST_DIR"
# user) needs its own readable copy, not a pointer to an unreadable file.
cp "$SRC_CERT" "$DEST_DIR/mqtt.crt"
cp "$SRC_KEY" "$DEST_DIR/mqtt.key"
chmod 600 "$DEST_DIR/mqtt.crt" "$DEST_DIR/mqtt.key"
chown root:root "$DEST_DIR/mqtt.crt" "$DEST_DIR/mqtt.key"
# Ownership matters: the stock eclipse-mosquitto entrypoint drops privileges
# to the in-image `mosquitto` user (uid/gid 1883) — the broker does NOT run
# as root, despite what an earlier note in DEFERRED.md claimed. Proof from a
# real deploy: "running mosquitto as user: mosquitto", immediately followed
# by "Unable to load server certificate ... Permission denied" on a
# 600 root:root cert. The host has no such user, so use the numeric gid.
# The cert is public material (0644); the key is group-read only (0640).
chown root:1883 "$DEST_DIR/mqtt.crt" "$DEST_DIR/mqtt.key"
chmod 644 "$DEST_DIR/mqtt.crt"
chmod 640 "$DEST_DIR/mqtt.key"
cd "$APP_DIR"
docker compose -f docker-compose.yml -f docker-compose.prod.yml kill -s HUP mosquitto