Files
server-26/docker-compose.prod.yml
T
Logan CusanoandClaude Opus 5 ee633cbe46
Build & Deploy / Build & push images (push) Failing after 42s
Build & Deploy / Deploy to VM (push) Has been skipped
Secure the broker for public exposure: TLS and per-node credentials
Edge nodes are deployed to arbitrary locations by arbitrary people, so the
broker has to be reachable from the internet and secured on its own merits
rather than by a VPN.

Three defects made that impossible. The broker only had a plaintext 1883
listener; every node shared one drb-node password; and the ACL pattern used
%c, the client-supplied client id, so any holder of that shared password
could set client_id to another node and take over its namespace. The comment
claiming this cryptographically prevented cross-node access was wrong and is
gone.

Authentication now uses mosquitto 2.x's built-in dynamic-security plugin on
the stock eclipse-mosquitto image. c2-core administers it over the control
topic, creating each node's client on approval with username=<node_id> and
password=<its node_keys api_key>, attached to a role whose ACL is nodes/%u/#
against the authenticated username. One credential, one revocation point.
An HTTP-callback plugin was implemented first and rejected: that project is
archived upstream, which is not an acceptable dependency on an
internet-facing broker.

Because dynsec state is a second source of truth alongside Firestore,
approve/reissue/delete now write to the broker first and surface a 502
rather than drifting, and c2-core reconciles every approved node into dynsec
on startup.

Adds node self-enrollment (POST /nodes/enroll, GET /nodes/{id}/credentials)
so a new node can obtain its key over HTTPS without an operator handling
secrets by hand. Enrolling an already-approved node_id is refused on the
fleet token alone — otherwise a leaked token plus a guessable id would let
an attacker steal a live node's key before the real node asked for it.
Pickup secrets are stored hashed and returned once, and the endpoint is rate
limited per source IP.

Infrastructure: an 8883 TLS listener fed by Caddy's certificate via a
systemd path unit, a firewall rule for it, and Caddy now 404s /internal/*
so the api vhost cannot proxy internal routes.

Also fixes CORS, which allowed https://app.<domain> while the frontend is
served on the bare domain — every call from the portal would have failed —
and widens the vault gitignore to a glob, since ansible-vault leaves
backup siblings that the exact-name rule left committable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:34:44 -04:00

50 lines
2.4 KiB
YAML

# Production overrides — used on the VM.
# Run with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Differences from dev:
# - MQTT port 1883 is NOT published to the host (stays on the Docker bridge).
# Edge nodes reach it via WireGuard tunnel to the Docker bridge IP.
# - c2-core and frontend ports are only bound to localhost (Caddy proxies them).
# - restart: always (instead of unless-stopped) for hard reboots.
services:
# ports AND volumes both need !override here, not !reset/a plain list —
# compose merges list-type fields by APPENDING across -f files. A plain
# list (or !reset on volumes) would leave dev's mosquitto_certs named
# volume mounted at /mosquitto/certs alongside this bind mount, and two
# mounts targeting the same path is exactly the "address already in use"-
# style footgun the c2-core override below already hit once with ports.
# mosquitto-data is now a host bind mount too (not just certs) — it holds
# dynamic-security.json, the broker's only record of node credentials
# (see app/internal/dynsec.py "TWO-SOURCES-OF-TRUTH"). A named Docker
# volume already survives normal redeploys (git pull && compose pull &&
# up -d never passes -v), but the bind mount makes it inspectable/
# backupable the same way the cert directory already is. NOT read-only —
# mosquitto writes dynamic-security.json here.
mosquitto:
restart: always
ports: !override
- "8883:8883" # TLS only, published. 1883 stays internal (docker bridge, c2-core's own login).
volumes: !override
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- /opt/drb/mosquitto-data:/mosquitto/data
- /opt/drb/mosquitto-certs:/mosquitto/certs:ro # fed by the cert-sync systemd unit, see infra/ansible
# !override, not a plain list: compose MERGES `ports` by appending, so a plain
# list leaves the base file's "8888:8000" in place alongside this one. The
# container then tries to bind 8888 twice — 0.0.0.0 and 127.0.0.1 — and the
# second bind fails with "address already in use". It also silently defeated
# the whole point of this override, publishing the port on every interface.
c2-core:
restart: always
ports: !override
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
discord-bot:
restart: always
frontend:
restart: always
ports: !override
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly