diff --git a/AGENTS.md b/AGENTS.md index 6581b14a..06e417d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ Name a variable by what it *is*, never by its format (`SESSION_KEY`, not `KEY_HE Common shared variables a target's vaults map into one or more apps' `.env`: - `DOMAIN` - Base domain for all services -- `DNS_CHALLENGE_PROVIDER`, `DNS_CHALLENGE_TOKEN` - Optional DNS-01 ACME challenge, traefik-only (see "Traefik Integration" below) +- `DNS_CHALLENGE_PROVIDER` - Optional DNS-01 ACME challenge provider selector, traefik-only; the selected provider's own credential variable(s) are documented in "Traefik Integration" below - `DATABASE_PASSWORD` - Shared database password - `SESSION_KEY` - Shared session-signing key (safe to share - see above) - `TIMEZONE` - System timezone @@ -202,7 +202,11 @@ Apps are accessible at `{app-name}.{DOMAIN}` with automatic SSL. `acmeresolver` `apps/traefik/docker-compose.yml` has no config template at all - Traefik's entire static configuration (entryPoints, the docker provider, logging, and the ACME resolver) is expressed directly as `TRAEFIK_*` environment variables, split across `x-vault-env`/`x-internal-env` like any other app. This isn't a style choice: Traefik's static config sources (file, env vars, CLI flags) are mutually exclusive, not merged - if a config file is present, environment variables for static config are silently ignored entirely, verified empirically while building this. Keeping traefik file-free sidesteps that trap. -`acmeresolver` only has `dnsChallenge` configured - a target's vault must provide `DNS_CHALLENGE_PROVIDER` (a lego provider name, e.g. `cloudflare`) and `DNS_CHALLENGE_TOKEN` (a single credential value). `httpChallenge` used to be configured unconditionally alongside it as a zero-config default, but Traefik picks http-01 whenever both challenge types are present on the same resolver, silently ignoring the configured DNS provider - that broke renewal outright on any target where the http-01 path isn't actually reachable (traffic to `/.well-known/acme-challenge/...` routed to an app container instead of Traefik's own ACME handler). See #202. `DNS_CHALLENGE_TOKEN` is mapped into every supported provider's own expected variable name at once, in its own `x-dns-challenge-tokens` anchor (separate from `x-vault-env` - it's not a direct 1:1 vault declaration, it's one vault value fanned out into several provider-specific slots): Cloudflare's `CF_DNS_API_TOKEN`, DigitalOcean's `DO_AUTH_TOKEN`, Hetzner's `HETZNER_API_TOKEN`, Linode's `LINODE_TOKEN`. lego only ever reads the one belonging to whichever provider was actually selected, so the others sit unused and harmless. This only works for providers needing a single token; add one by adding one more `x-dns-challenge-tokens` line mapping `${DNS_CHALLENGE_TOKEN:-}` into that provider's lego variable name (see [go-acme.github.io/lego/dns](https://go-acme.github.io/lego/dns/) for the full ~200-provider list and each one's required variable(s)). Providers needing more than one credential (Route53, GoDaddy, Namecheap, Selectel's current v2 API, and most others actually dominant by real market share - checked deliberately, single-token providers are the minority) don't fit this pattern and would need their own explicit, non-fanned-out vault variables instead - not yet added since nothing in the catalog needs one today. +`acmeresolver` only has `dnsChallenge` configured - a target's vault must provide `DNS_CHALLENGE_PROVIDER` (a lego provider name, e.g. `cloudflare`) and that provider's own credential variable(s). `httpChallenge` used to be configured unconditionally alongside it as a zero-config default, but Traefik picks http-01 whenever both challenge types are present on the same resolver, silently ignoring the configured DNS provider - that broke renewal outright on any target where the http-01 path isn't actually reachable (traffic to `/.well-known/acme-challenge/...` routed to an app container instead of Traefik's own ACME handler). See #202. + +Every provider's credential variable(s) are declared directly in `apps/traefik/docker-compose.yml`'s `x-vault-env`, each optional and defaulting to empty (`${VAR:-}`): Cloudflare's `CF_DNS_API_TOKEN`, DigitalOcean's `DO_AUTH_TOKEN`, Hetzner's `HETZNER_API_TOKEN`, Linode's `LINODE_TOKEN`, and Selectel v2's `SELECTELV2_USERNAME`/`SELECTELV2_PASSWORD`/`SELECTELV2_ACCOUNT_ID`/`SELECTELV2_PROJECT_ID` (lego's `selectelv2` provider authenticates via Keystone/OpenStack, needing all four at once; lego's older `selectel` provider is a different, legacy Selectel DNS API taking a single `SELECTEL_API_TOKEN` instead). lego only ever reads the credential(s) belonging to whichever provider was actually selected, so the rest sit unused and harmless. A single-token provider used to share one generic `DNS_CHALLENGE_TOKEN` vault variable fanned out into each provider's slot, but that made single-token and multi-credential providers (like Selectel v2) inconsistent with each other; naming every provider's variable(s) explicitly, with no fan-out, keeps the pattern uniform regardless of how many credentials a provider needs. Add a new provider by adding its lego variable name(s) (see [go-acme.github.io/lego/dns](https://go-acme.github.io/lego/dns/) for the full ~200-provider list and each one's required variable(s)) as their own entries in `x-vault-env`. + +The map *key* in `x-vault-env` (`CF_DNS_API_TOKEN`, `DO_AUTH_TOKEN`, ...) is the literal env var name the traefik container needs - lego reads it verbatim via `os.Getenv`, so it's non-negotiable, abbreviated prefix (`CF_`, `DO_`) and all. The `${VAR:-}` on the right is a different name: the app-facing variable a target's vault actually declares, free to spell the provider out in full since nothing reads it but `docker compose`'s own substitution - `CF_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN:-}`, not `${CF_DNS_API_TOKEN:-}`. Hetzner and Linode's forced key names already spell the provider in full, so their `${VAR:-}` reference matches the key as-is; only Cloudflare and DigitalOcean's lego-forced keys are actually abbreviated, so only those two need the two names to diverge. A target's own vault then maps that full app-facing name to its real GitHub Secret (see `vaults/traefik.yml`'s `CLOUDFLARE_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN}`). `apps/cloudflared/` is a separate, optional catalog app - not part of `apps/traefik/` itself - for targets that want to front Traefik with a Cloudflare Tunnel instead of (or alongside) exposing `HTTP_PORT`/`HTTPS_PORT` directly. It's a single `cloudflared` service joined to the external `traefik` network only (no `internal` network, no traefik labels - it isn't routed *to*, it originates the connection), running `cloudflared tunnel --no-autoupdate run` with the token-based (dashboard-managed) tunnel mode: `TUNNEL_TOKEN` in the environment is all `cloudflared` needs, no local tunnel config file. Connection direction is `cloudflared` → `traefik:80` over the docker network (the tunnel's ingress rule, set in the Cloudflare Zero Trust dashboard, points at `http://traefik:80`), and Traefik then routes onward to backend services exactly as it already does for direct traffic - no change to how apps register with Traefik. Because `cloudflared` reaches Traefik over the `traefik` docker network, its connecting IP already falls inside the existing `TRAEFIK_ENTRYPOINTS_HTTPS_FORWARDEDHEADERS_TRUSTEDIPS` range (`172.16.0.0/12,10.0.0.0/8`) - no trustedIPs change needed for this topology. Deploying `cloudflared` doesn't remove Traefik's own published ports; the two paths coexist unless a target deliberately stops publishing `HTTP_PORT`/`HTTPS_PORT`. diff --git a/apps/traefik/docker-compose.yml b/apps/traefik/docker-compose.yml index 9c6a40f4..12fda5fd 100644 --- a/apps/traefik/docker-compose.yml +++ b/apps/traefik/docker-compose.yml @@ -1,11 +1,14 @@ x-vault-env: &vault-env TRAEFIK_CERTIFICATESRESOLVERS_ACMERESOLVER_ACME_EMAIL: ${ADMIN_MAIL} TRAEFIK_CERTIFICATESRESOLVERS_ACMERESOLVER_ACME_DNSCHALLENGE_PROVIDER: ${DNS_CHALLENGE_PROVIDER:-} -x-dns-challenge-tokens: &dns-challenge-tokens - CF_DNS_API_TOKEN: ${DNS_CHALLENGE_TOKEN:-} - DO_AUTH_TOKEN: ${DNS_CHALLENGE_TOKEN:-} - HETZNER_API_TOKEN: ${DNS_CHALLENGE_TOKEN:-} - LINODE_TOKEN: ${DNS_CHALLENGE_TOKEN:-} + CF_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN:-} + DO_AUTH_TOKEN: ${DIGITALOCEAN_AUTH_TOKEN:-} + HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-} + LINODE_TOKEN: ${LINODE_TOKEN:-} + SELECTELV2_USERNAME: ${SELECTELV2_USERNAME:-} + SELECTELV2_PASSWORD: ${SELECTELV2_PASSWORD:-} + SELECTELV2_ACCOUNT_ID: ${SELECTELV2_ACCOUNT_ID:-} + SELECTELV2_PROJECT_ID: ${SELECTELV2_PROJECT_ID:-} x-internal-env: &internal-env TRAEFIK_CERTIFICATESRESOLVERS_ACMERESOLVER_ACME_STORAGE: /letsencrypt/acme.json TRAEFIK_GLOBAL_CHECKNEWVERSION: false @@ -24,7 +27,7 @@ services: traefik: image: traefik environment: - <<: [*vault-env, *dns-challenge-tokens, *internal-env] + <<: [*vault-env, *internal-env] ports: - "${HTTP_PORT}:80" - "${HTTPS_PORT}:443" diff --git a/vaults/traefik.yml b/vaults/traefik.yml index 1d7e147f..93ef8c94 100644 --- a/vaults/traefik.yml +++ b/vaults/traefik.yml @@ -4,6 +4,6 @@ keys: env: ADMIN_MAIL: ${ADMIN_MAIL} DNS_CHALLENGE_PROVIDER: ${DNS_CHALLENGE_PROVIDER} - DNS_CHALLENGE_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN} + CLOUDFLARE_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN} HTTP_PORT: ${TRAEFIK_HTTP_PORT} HTTPS_PORT: ${TRAEFIK_HTTPS_PORT}