boma/roles/reverse_proxy/README.md
sjat 6e38693499 feat(reverse_proxy): optional ACME DNS-01 via Gandi (wildcard / LAN-only)
Adds a per-instance DNS-01 mode to the Caddy role for mesh/LAN-only hosts that
cannot satisfy HTTP-01. Default behaviour (vanilla caddy:2 + HTTP-01, what askari
runs) is unchanged.

  - reverse_proxy__acme_dns_provider: "" (HTTP-01) | "gandi" (DNS-01)
  - reverse_proxy__image: override to the custom caddy-gandi image for DNS-01
  - Caddyfile gains a global `acme_dns gandi {env.GANDI_BEARER_TOKEN}` block
  - the PAT (vault.gandi.pat) renders into a host-only 0600 env file (no_log),
    loaded by compose only when DNS-01 is enabled

Verified: the custom image issues a real wildcard cert (*.dns01test.wingu.me)
end-to-end against LE staging via Gandi DNS-01; `caddy validate` accepts
`acme_dns gandi` on the custom image and rejects it on vanilla caddy:2. Molecule
(HTTP-01 default path) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:57:47 +02:00

83 lines
3.6 KiB
Markdown

# reverse_proxy
Boma's standard Caddy reverse proxy (ADR-024). Runs on `askari` (the off-site
Hetzner host) and terminates TLS for services. It supports **two ACME challenge
types**, chosen per proxy instance by exposure:
- **HTTP-01 (default)** — public hosts with an A-record (askari). Official `caddy:2`
image; no plugin, no token.
- **DNS-01 via Gandi** — mesh/LAN-only hosts with **no** public A-record (the cluster),
where HTTP-01 is impossible. Needs the custom `caddy-gandi` image and the Gandi PAT.
## How TLS works
**HTTP-01 (default).** Caddy obtains per-hostname certificates using the ACME HTTP-01
challenge. Port 80 must be reachable from the internet. Each `host` in
`reverse_proxy__routes` gets its own certificate automatically.
**DNS-01 (Gandi).** Set `reverse_proxy__acme_dns_provider: gandi` and point
`reverse_proxy__image` at the custom Caddy image (`make caddy-image`, built on ubongo
and pushed to the Forgejo registry — see `.docker/caddy-gandi/`). Caddy then proves
domain control by writing ACME TXT records through the Gandi LiveDNS API, so it can
issue certs — **including wildcards** — for hosts that are never publicly reachable.
The token (`vault.gandi.pat`) is injected as `GANDI_BEARER_TOKEN` via a host-only
`env` file (mode 0600) and sent as a **Bearer PAT** (the legacy Apikey scheme is gone).
> **Verified (2026-06-15):** the custom image issues a real wildcard cert
> (`*.dns01test.wingu.me`) end-to-end against Let's Encrypt staging via Gandi DNS-01;
> `caddy validate` accepts the `acme_dns gandi` directive on the custom image and
> rejects it on vanilla `caddy:2` (`module not registered: dns.providers.gandi`). The
> original M4a failure was version skew (a pre-Bearer `libdns/gandi` that sent the
> deprecated Apikey header) plus building the image on a Hetzner IP (Go proxy 403).
## Route catalog — `reverse_proxy__routes`
Services register themselves as routes by appending an entry to
`reverse_proxy__routes` in `group_vars/all/reverse_proxy.yml`:
```yaml
reverse_proxy__routes:
- {host: app.askari.wingu.me, upstream: "app:8080"}
- {host: health.askari.wingu.me, respond: "ok"}
```
Each entry renders a separate server block in the Caddyfile:
```
app.askari.wingu.me {
reverse_proxy app:8080
}
health.askari.wingu.me {
respond "ok" 200
}
```
Use `upstream` to proxy to a Docker service, or `respond` to return a static string.
## Variables
| Variable | Default | Description |
|---|---|---|
| `reverse_proxy__base_dir` | `/opt/services/reverse_proxy` | Working directory for Compose project |
| `reverse_proxy__acme_email` | `admin@example.test` | ACME registration email |
| `reverse_proxy__routes` | `[]` | List of `{host, upstream}` or `{host, respond}` entries |
| `reverse_proxy__image` | `caddy:2` | Container image. DNS-01 hosts override to the custom `caddy-gandi` image |
| `reverse_proxy__acme_dns_provider` | `""` | `""` = HTTP-01; `"gandi"` = ACME DNS-01 via the Gandi PAT |
| `reverse_proxy__manage` | `true` | Set `false` in Molecule to skip Docker tasks |
Production overrides live in
`inventories/production/group_vars/all/reverse_proxy.yml`.
## `reverse_proxy__manage` toggle
Docker operations (`docker compose up`) are gated on `reverse_proxy__manage | bool`.
Set it to `false` in Molecule so the role can be tested (template rendering, directory
creation) without a Docker daemon.
## Secrets
- **HTTP-01 (default):** none — the challenge requires no credentials.
- **DNS-01 (`reverse_proxy__acme_dns_provider: gandi`):** the Gandi PAT
(`vault.gandi.pat`, the same token `public_dns` uses). Rendered host-side into
`{{ reverse_proxy__base_dir }}/env` (mode 0600, `no_log`); never committed.