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>
42 lines
1.9 KiB
Caddyfile
42 lines
1.9 KiB
Caddyfile
# Managed by Ansible — do not edit manually.
|
|
|
|
api.{{ domain }} {
|
|
# MQTT auth is no longer an HTTP backend c2-core exposes (it moved to
|
|
# mosquitto's own built-in dynamic-security plugin, administered over MQTT
|
|
# control topics — see app/internal/dynsec.py) — there is currently no
|
|
# /internal/* route in c2-core at all. This block stays anyway as defence
|
|
# in depth: c2-core's app-wide reverse_proxy below forwards every path by
|
|
# default, so this guarantees any FUTURE /internal/* route (or a
|
|
# regression that reintroduces one) is still unreachable from the public
|
|
# internet unless someone also deliberately deletes this block. `route`
|
|
# forces top-to-bottom evaluation instead of Caddy's automatic directive
|
|
# sorting, so this is guaranteed to run before reverse_proxy.
|
|
route {
|
|
respond /internal/* 404
|
|
reverse_proxy localhost:8888 {
|
|
header_up X-Forwarded-For {remote_host}
|
|
}
|
|
}
|
|
}
|
|
|
|
# mqtt.{{ domain }} has no application behind it — mosquitto's TLS listener
|
|
# (8883) is a raw MQTT socket, not HTTP, so Caddy can't reverse_proxy to it.
|
|
# This block's only job is to make Caddy request+manage a Let's Encrypt cert
|
|
# for the name via ACME HTTP-01, which infra/ansible's cert-sync unit then
|
|
# copies out to mosquitto. The DNS A record for mqtt.{{ domain }} must exist
|
|
# before this runs, or ACME issuance fails (see MQTT-PUBLIC-AUTH-PLAN.md).
|
|
mqtt.{{ domain }} {
|
|
respond 404
|
|
}
|
|
|
|
# Frontend is served on the bare domain, not app.{{ domain }}: only drb and api
|
|
# have public DNS records. A vhost for a name with no A record still starts,
|
|
# but Caddy retries ACME against it forever and logs a failure each time.
|
|
# To move it to app.{{ domain }}, create the A record first, then change this
|
|
# line — the reverse_proxy target stays the same either way.
|
|
{{ domain }} {
|
|
reverse_proxy localhost:3000 {
|
|
header_up X-Forwarded-For {remote_host}
|
|
}
|
|
}
|