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") 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: def get_role(decoded: dict) -> str:
"""Extract the effective role from a decoded Firebase token. """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.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings 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 nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment from app.routers import enrollment
from app.internal import dynsec 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(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(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.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)]) 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 # 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. # 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 - name: Create mosquitto certs directory
file: file:
path: /opt/drb/mosquitto-certs path: /opt/drb/mosquitto-certs
state: directory state: directory
owner: root owner: root
group: root group: "1883"
mode: "0700" mode: "0750"
# dynamic-security.json (node credentials — see app/internal/dynsec.py) # dynamic-security.json (node credentials — see app/internal/dynsec.py)
# lives here. Root-owned is fine: the mosquitto container itself runs as # lives here, and mosquitto WRITES it, so this must be owned by the uid the
# root (no `user` directive in mosquitto.conf, matching the pre-existing # broker actually runs as (1883), not root. The earlier assumption that the
# setup this project already ran before the dynsec change), so it can # container runs as root was wrong — the image's entrypoint drops privileges
# read/write this directory directly without any host-side chown dance. # 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 - name: Create mosquitto data directory
file: file:
path: /opt/drb/mosquitto-data path: /opt/drb/mosquitto-data
state: directory state: directory
owner: root owner: "1883"
group: root group: "1883"
mode: "0700" 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 - name: Deploy MQTT cert-sync script
template: template:
@@ -10,12 +10,9 @@
# source cert file for changes — a path unit rather than cron so this fires # source cert file for changes — a path unit rather than cron so this fires
# on the actual write instead of racing a polling interval. # on the actual write instead of racing a polling interval.
# #
# UNVERIFIED: the exact source path below assumes Caddy's default file # CONFIRMED 2026-08-16 against a real issuance on drb-server: this path is
# storage layout and Let's Encrypt's production ACME directory name. This # correct, and the copied cert came out as CN=mqtt.drb.cusano.net issued by
# has not been confirmed against a real Caddy cert issuance for this # Let's Encrypt. Was previously flagged unverified.
# 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.
# #
# UNVERIFIED: mosquitto 2.x reloading TLS certs on SIGHUP without dropping # UNVERIFIED: mosquitto 2.x reloading TLS certs on SIGHUP without dropping
# connections is documented upstream but untested here. If listener 8883 # 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. # user) needs its own readable copy, not a pointer to an unreadable file.
cp "$SRC_CERT" "$DEST_DIR/mqtt.crt" cp "$SRC_CERT" "$DEST_DIR/mqtt.crt"
cp "$SRC_KEY" "$DEST_DIR/mqtt.key" cp "$SRC_KEY" "$DEST_DIR/mqtt.key"
chmod 600 "$DEST_DIR/mqtt.crt" "$DEST_DIR/mqtt.key" # Ownership matters: the stock eclipse-mosquitto entrypoint drops privileges
chown root:root "$DEST_DIR/mqtt.crt" "$DEST_DIR/mqtt.key" # 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" cd "$APP_DIR"
docker compose -f docker-compose.yml -f docker-compose.prod.yml kill -s HUP mosquitto docker compose -f docker-compose.yml -f docker-compose.prod.yml kill -s HUP mosquitto