The Cert Broker
The cert broker is the concrete implementation of Citinet’s “centralize only the on-ramp” principle (see Project Overview): a hub creator never sees Cloudflare, DNS, or an API token. They pick a hub name; a real certificate for <slug>.hub.citinet.cloud appears automatically.
Wizard (identity step) Hub's own citinet-api container │ │ │ GET /api/cert-broker?slug=X │ │◄──── available? ──────────────│ │ │ │ POST /api/cert-broker │ │ {op:'claim', slug} │ │◄──── { secret } ──────────────│ (baked into .env as HUB_CERT_SECRET, │ │ same pattern as JWT_SECRET/DB_PASSWORD) ▼ ▼ api/cert-broker.js api/certAgent.js (on startup, and daily): (Vercel) POST /api/cert-broker {op:'issue', slug, secret, lanIp} │ ├─ verify secret against its stored hash ├─ ACME DNS-01 via Cloudflare (dedicated service token, │ never given to any hub) ├─ upsert an A record <slug>.hub.citinet.cloud → lanIp └─ return { cert, key } (never persisted server-side)The broker is stateless with respect to certificate material: it never stores a cert or key. The only thing it persists is slug → hashed secret claims, in a JSON file in the citinet-registry repo, using the same GitHub-Contents-API read/modify/write pattern the hub registry itself uses.
Why the A record points at a private IP
Section titled “Why the A record points at a private IP”The issued certificate is for <slug>.hub.citinet.cloud, and the DNS A record for that name points directly at the hub’s own LAN IP, not a public one. That’s deliberate: anyone already on the hub’s network can just use the hostname directly, with zero extra setup, because a private IP simply isn’t routable from outside that network. This is also what Guest Wi-Fi Access builds on for guests with no connectivity of their own.
The hub side: certAgent.js
Section titled “The hub side: certAgent.js”Each hub’s citinet-api container runs a small always-on agent (api/certAgent.js) that:
- On first boot, before the server reports healthy, writes a short-lived self-signed placeholder certificate to the shared cert volume. This exists purely so Caddy (which is gated on
citinet-api’s healthcheck viadepends_on) has valid files to load the instant it starts, since the real cert can take up to a minute or two to obtain (DNS-01 propagation). - Calls the broker’s
issueendpoint immediately after, and again once a day, renewing only within 30 days of expiry so routine checks don’t churn Let’s Encrypt’s rate limits. - Writes the real cert/key to the shared volume and triggers Caddy to reload via its local admin API (
http://citinet-caddy:2019/load). No Docker-socket access needed.
Operational notes for maintainers
Section titled “Operational notes for maintainers”A few non-obvious things, found through direct testing rather than documentation, worth knowing if you’re touching this code:
- Node 18’s
fetch()can fail POSTing to Vercel.certAgent.jsdeliberately uses Node’s rawhttpsmodule instead offetch()for its broker call:fetch()’s bundled undici threwRequestContentLengthMismatchErrorconsistently on HTTPS POSTs through Vercel’s edge in this Node version, while GETs and plain-HTTP POSTs were unaffected. - Caddy’s admin API checks the
Originheader independently ofenforce_origin. The Caddyfile’s admin block setsorigins citinet-caddy:2019explicitly, andcertAgent.jssends a matchingOriginheader on its reload call; without both, Caddy’s admin API returns a 403. - Caddy silently no-ops an unchanged reload. Resubmitting an identical Caddyfile to
/loadafter a certificate file changes on disk does not make Caddy re-read it: Caddy diffs the adapted config, decides nothing changed, and skips.certAgent.jsworks around this by including a live timestamp in a response header on every reload payload, forcing a real, detectable config diff.
