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>
27 lines
1.4 KiB
Bash
27 lines
1.4 KiB
Bash
# Top-level docker-compose environment — MQTT credentials and registry prefix.
|
|
# Managed by Ansible. Do not edit manually.
|
|
#
|
|
# The passwords are $-escaped ($ -> $$). Compose INTERPOLATES this file, so a
|
|
# raw "$fP" in a password is read as the variable $fP, warned about, and
|
|
# replaced with an empty string. The env_file templates (c2-core.env.j2 etc.)
|
|
# are NOT interpolated, so they keep the literal value — which means an
|
|
# unescaped $ here silently gives mosquitto and c2-core two different
|
|
# passwords and MQTT auth fails. Compose collapses $$ back to a single $, so
|
|
# both sides end up with the real password.
|
|
# Do not add the same escaping to the env_file templates; it would be literal.
|
|
|
|
MQTT_C2_USER={{ vault_mqtt_c2_user }}
|
|
MQTT_C2_PASS={{ vault_mqtt_c2_pass | replace('$', '$$') }}
|
|
|
|
# Seeds mosquitto's built-in dynamic-security plugin's one-time "admin"
|
|
# bootstrap client on first boot (read directly by the plugin's C code via
|
|
# getenv — see app/internal/dynsec.py). Must be >=12 chars (plugin-enforced
|
|
# minimum). c2-core needs this SAME value as MQTT_DYNSEC_ADMIN_PASS in its
|
|
# own env (c2-core.env.j2) to log in as "admin" and administer node
|
|
# credentials — kept as one vault var (vault_mqtt_dynsec_admin_pass) so the
|
|
# two can't drift.
|
|
MOSQUITTO_DYNSEC_PASSWORD={{ vault_mqtt_dynsec_admin_pass | replace('$', '$$') }}
|
|
|
|
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
|
|
REGISTRY={{ vault_registry }}
|