Files
mesh-catalog/modules/route-proxy
jschoubben f0665ba956 ADR 0056: selectable ACME issuer, step-ca init from operator root, route labels
Piece A + B of ADR 0056, completing the internal-CA work in ff01ada.

Selectable issuer. acme-ca is now a role two providers can satisfy: step-ca
(internal CA) or the new public-acme (a fact-only module, no container/listen)
that serves Let's Encrypt production. A mesh assigns one or the other to satisfy
route-proxy's `requires: acme-ca`.

One directory shape for both. A provider serves the ACME directory's parts the
way the mesh already models any reachable service -- an address (`at`), a `port`
and a `path` -- and route-proxy composes `https://<at>:<port><path>`. step-ca
lets the mesh fill `at` (its node) and `port` (its single listen) and serves only
`path`; public-acme, not being a mesh service, serves all three (overriding `at`
with the public host). Same composition either way.

Empty root means the system trust store. Both providers serve `root`: step-ca
the operator root PEM (settled per mesh), public-acme an empty string. route-proxy
writes it to the CA bundle file unconditionally; the binary now reads an empty
bundle as "the root is already trusted by the OS" and falls back to system roots
(examples/route-proxy/main.go, committed on the mesh-control ADR-0056 branch).

step-ca inits from the operator's root. The operator's root cert, root key and
root-key password are mounted at the smallstep entrypoint's default init paths
(/run/secrets/root_ca.crt, root_ca_key, root_ca_key_password) with the matching
DOCKER_STEPCA_INIT_*_FILE vars, so `step ca init` adopts the operator's root
instead of self-generating one -- the CA that signs is the CA route-proxy trusts.

Route names are labels, not FQDNs. Every routed module now contributes a `label`
(the leftmost subdomain) instead of a full public hostname; the node's public
domain composes the name. Apex (novox.be) is left as a full name -- composeName
has no empty-label/apex convention yet (mesh-control follow-up).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 00:21:42 +02:00
..

route-proxy — the mesh's public ingress

The shipping form of the reference reverse proxy (novox/hq ADR 0007, novox/hq 08-connectivity §3 "Exposure"). It is the traefik replacement: the module that provides route.

What it is

A route is a grant. A module that must be reachable declares requires: ["route"] and contributes the name it wants and the port it listens on; this module provides route, receives every consumer that asked as a file, and forwards by the Host header of each request to http://<at>:<port> — where at is where the mesh says that consumer's machine is (empty means this machine, reached over loopback).

The control plane does not write the proxy's configuration. It delivers the facts — the contributions file at receives.route — and the proxy turns them into whatever it runs. That boundary is why swapping this proxy for another costs nothing to the modules that publish through it.

field value why
provides route (scope mesh) a module on any node may require it
serves.route {} a route hands back a name, not a credential — the consumer already knows the name it asked for
receives.route …/routes/mesh.json where the mesh writes every contribution; the proxy reads it as $ROUTES and re-reads on change
listens 80 and 443, both from: anywhere the one machine with a public opening; the firewall opens these for free (ADR 0045)

No broker account, no own-secrets, no provisioner: the proxy neither mints a credential nor emits an event. It only reads the file the mesh writes. (Contrast redis, which mints passwords, and cloudflare-dns, which emits record events.)

How it ships the Go proxy

The proxy is a Go program, unlike the TypeScript tool-runtime modules. The canonical source is not vendored here — it lives in the mesh-control repository at examples/route-proxy, the contract written as something that runs. This module ships only the packaging: a multi-stage Dockerfile whose build context is the mesh-control repository root and which compiles ./examples/route-proxy into mesh-route-proxy. The committed module.json carries the placeholder digest every mesh-built image does (@sha256:0000…); the mesh pins the real digest at publish.

In the lab, mesh-lab/scripts/build-route-proxy-image.sh builds this into the local daemon as mesh-route-proxy:development, and a scenario stocks it and serves it by digest.

Certificates, and the endpoint (resolves 04-ISSUES/004)

TLS is opt-in and terminates here for public names, via ACME (autocert). A proxy with no TLS_LISTEN serves plain HTTP; with it, it obtains a publicly-trusted certificate for exactly the names the mesh routes here (HostPolicy = only routed names, so a scan cannot spend the account's issuance quota) and answers the HTTP-01 challenge on :80 at the name being certified.

ACME_DIRECTORY defaults to Let's Encrypt staging, not production. This closes novox/hq 04-ISSUES/004 (certificate issuance targets production): the issuer is a per-node choice, and the safe endpoint is the default. Production issuance is rate-limited per domain and per account and does not replenish quickly; defaulting to production would leave the safe path depending on somebody remembering to opt out of it, on exactly the work most likely to iterate. A staging certificate is trusted by no browser, so the mistake announces itself on the first request rather than a fortnight later at the rate limit.

A node that serves real public traffic states so, by overriding ACME_DIRECTORY to the production directory https://acme-v02.api.letsencrypt.org/directory for this module on that node. It is a node property, and the node facing the public internet is the one that opts in.

env default meaning
ROUTES /routes/mesh.json the contributions file the mesh writes (receives.route)
LISTEN :80 HTTP, and the ACME HTTP-01 challenge
TLS_LISTEN :443 HTTPS; unset to serve plain HTTP only
ACME_CACHE /acme where issued certificates persist; required when TLS_LISTEN is set, so a restart does not re-order
ACME_DIRECTORY Let's Encrypt staging the issuer; a public-serving node overrides it to production
ACME_CA_BUNDLE (unset) a file of roots to trust for the issuer's own API — set only for a private/lab authority whose API certificate the world does not yet trust