boma/roles/reverse_proxy/SECURITY.md
sjat cb8f924d4b docs(reverse_proxy): service-role SECURITY/VERIFY/ACCESS records (O12)
reverse_proxy is the first built+applied service role; add the per-service
records CLAUDE.md/ADR-002/008/017/021 require. Add access__*/backup__* data to
defaults as the source of truth (ADR-021/022). reverse_proxy is stateless (ACME
certs re-issue via HTTP-01), so it declares backup__state: false with a reason
rather than a BACKUP.md (ADR-022 convention).

The access__*/backup__* cross-role field names intentionally don't carry the
reverse_proxy__ prefix, so each is marked `# noqa: var-naming[no-role-prefix]`
(ansible-lint has no per-prefix allowlist; rule stays enabled elsewhere).

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

3.8 KiB

Security — reverse_proxy (Caddy)

Exposure

  • Published ports: 80/tcp + 443/tcp (HTTP→HTTPS redirect + TLS). Both are declared in the group_vars firewall catalog as the askari public_web opens (ADR-020); the Hetzner Cloud Firewall also opens 80/443 (and 3478 for NetBird). Port 80 must stay open to the internet for the ACME HTTP-01 challenge.
  • Auth surface: none of its own. Caddy is the TLS terminator and router; per-service authentication (Authentik forward_auth) is added at each route in Phase 2 (ADR-024 §4). Today it fronts only a static respond test vhost and (M4b) the NetBird stack, which carries its own auth.
  • Reachability: public — askari is internet-facing. Caddy is the single public entry point; upstreams sit on the internal boma Docker network and are reached by name, not published directly.
  • Data sensitivity: none persistent worth protecting — only ACME account keys + issued certificates in the caddy_data volume, which are re-issuable (HTTP-01). No user data, no secrets at rest. See backup record: backup__state: false (stateless).

Checklist status

Each item from docs/security/service-checklist.md:

  • Secrets in vault; no default creds; nothing secret in git/images — n/a: HTTP-01 needs no credentials; the only config input is reverse_proxy__acme_email (not secret).
  • Non-root; no privileged/host-network unless justified; minimal mounts; caps dropped — ⚠️ official caddy:2 runs as root (to bind 80/443); no privileged, no host network (bridge boma); mounts are the read-only Caddyfile + two named volumes. Root inside the container is the upstream default; revisit if Caddy ships a rootless variant.
  • Ports declared in group_vars; behind reverse proxy + auth if exposed; least-privilege inter-service reach — 80/443 in the catalog; Caddy is the proxy; upstreams are not published, only reachable on the boma network.
  • Image pinned (tag/digest), update path known — ⚠️ pinned to the caddy:2 major tag (stateless tier, ADR-011/ADR-004), not a digest; refreshed deliberately and watched by DIUN. Tighten to tag@digest if the proxy is reclassified as stateful.
  • Logs reviewable; backup/restore covered if stateful — stateless (no backup needed); logs via docker logs caddy now, Loki labels declared for the ADR-018 pipeline.

Service-specific hardening

  • HTTP-01 only, no DNS token: vanilla caddy:2, no caddy-dns/gandi plugin and no Gandi API token on the host — removes a credential and a custom-image supply chain (ADR-024 revised Status).
  • Caddyfile is read-only in the container (:ro mount); rendered solely by Ansible from the group_vars route catalog — no dynamic label discovery, so no route exists that wasn't declared (the reason Caddy was chosen over Traefik, ADR-024 §1).
  • Admin API not exposed: Caddy's admin endpoint stays on container-localhost :2019; never published, never in the firewall catalog (access__api.enabled: false).
  • Automatic HTTPS: HTTP is redirected to HTTPS and modern TLS defaults are Caddy's out-of-the-box behaviour (no manual cipher config needed).

Residual / accepted risks

  • Container runs as root — upstream caddy:2 default (needs to bind low ports). Rationale: official image, no rootless variant wired yet; blast radius limited to the proxy container. Revisit: adopt a rootless Caddy image if upstream stabilises one.
  • Image pinned to a major tag, not a digest — accepted for the stateless tier (ADR-011). Revisit if the role gains state.
  • ACME re-issuance vs Let's Encrypt rate limits — losing caddy_data triggers re-issuance; rapid repeated rebuilds could hit LE rate limits. Acceptable for a handful of askari hostnames; noted in the backup rationale.