Skip to content

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.

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.

Each hub’s citinet-api container runs a small always-on agent (api/certAgent.js) that:

  1. 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 via depends_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).
  2. Calls the broker’s issue endpoint 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.
  3. 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.

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.js deliberately uses Node’s raw https module instead of fetch() for its broker call: fetch()’s bundled undici threw RequestContentLengthMismatchError consistently 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 Origin header independently of enforce_origin. The Caddyfile’s admin block sets origins citinet-caddy:2019 explicitly, and certAgent.js sends a matching Origin header 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 /load after a certificate file changes on disk does not make Caddy re-read it: Caddy diffs the adapted config, decides nothing changed, and skips. certAgent.js works around this by including a live timestamp in a response header on every reload payload, forcing a real, detectable config diff.