trails/infrastructure/caddy/Caddyfile
Ullrich Schäfer d8fd4ff655
security: bind Caddy admin API to loopback, split metrics to :2020
The flagship Caddy ran its admin API on 0.0.0.0:2019, reachable by
every container on the Docker network. The admin API can rewrite
routes and proxy targets, so a journal/planner RCE could repoint
traffic with no further auth. (The brouter-host Caddyfile already
binds admin to localhost; the flagship didn't.)

The admin endpoint also served Prometheus metrics (global `metrics`
option → admin endpoint), and Prometheus scrapes caddy:2019
cross-container — so admin couldn't just move to loopback without
breaking metrics. Split them:

- admin localhost:2019 (loopback only). All reloads are in-container
  (`docker compose exec caddy caddy reload`) so they use this endpoint
  unaffected.
- a dedicated `:2020` server exposes the read-only metrics handler;
  prometheus.yml now scrapes caddy:2020.

Validated with `caddy validate` + `caddy adapt` against caddy:2:
admin.listen=localhost:2019, srv on :2020 carries the metrics handler,
the :443 site is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:56:30 +02:00

129 lines
5 KiB
Caddyfile
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
# Admin API on loopback only. It can reconfigure the proxy, so it must
# not be reachable by other containers on the Docker network (a journal
# / planner RCE could otherwise rewrite routes). Reloads run in-container
# (`docker compose exec caddy caddy reload`) over this loopback endpoint,
# matching infrastructure/brouter-host/Caddyfile.
admin localhost:2019
}
# Read-only Prometheus metrics on a dedicated network-reachable port,
# split off the admin endpoint above so scraping doesn't require exposing
# the admin API. Prometheus scrapes caddy:2020 (see prometheus.yml).
:2020 {
metrics /metrics
}
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss: https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';"
}
}
(block_scanners) {
@scanners path /.env* /.git* /wp-* /wp-admin* /admin* /config.* /backup* /.aws* /.docker* /composer* /vendor* *.php *.asp
respond @scanners 403
}
{$DOMAIN:trails.cool} {
import security_headers
import block_scanners
log {
output stdout
format json
}
reverse_proxy journal:3000 {
# During an `apps` deploy the journal container is briefly down
# (~1030s) while compose swaps containers. Without these,
# Caddy returns 502 immediately and the `caddy-502-rate` alert
# trips on every deploy. With them, Caddy holds and retries
# against the upstream for up to 30s — restart becomes
# invisible to clients. A real outage longer than 30s still
# 502s and correctly trips the alert.
lb_try_duration 30s
lb_try_interval 250ms
}
}
www.{$DOMAIN:trails.cool} {
redir https://{$DOMAIN:trails.cool}{uri} permanent
}
grafana.internal.{$DOMAIN:trails.cool} {
reverse_proxy grafana:3000
}
planner.{$DOMAIN:trails.cool} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss: https://{$DOMAIN:trails.cool} https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';"
}
import block_scanners
log {
output stdout
format json
}
reverse_proxy planner:3001 {
# Same rationale as the journal block — see the comment there.
lb_try_duration 30s
lb_try_interval 250ms
}
}
# ── Staging ──────────────────────────────────────────────────────────────
# Persistent staging instance. CSP allow-lists hardcode `staging.trails.cool`
# rather than `{$DOMAIN}` because the Caddy container runs with the
# production DOMAIN env (`trails.cool`); staging blocks need their own domain
# baked in. Upstreams are reached via `host.docker.internal` because the
# staging compose project publishes its containers on the host's loopback
# (127.0.0.1:3100/3101) rather than joining the production Caddy network.
staging.{$DOMAIN:trails.cool} {
import security_headers
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:3110 {
lb_try_duration 30s
lb_try_interval 250ms
}
}
planner.staging.{$DOMAIN:trails.cool} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
# connect-src includes wss + https://*.staging so PR-preview journals
# (pr-N.staging.trails.cool) can use this shared planner. PR previews
# are journal-only; this is the planner they all talk to.
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss://*.staging.{$DOMAIN:trails.cool} https://*.staging.{$DOMAIN:trails.cool} https://staging.{$DOMAIN:trails.cool} https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';"
}
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:3111 {
lb_try_duration 30s
lb_try_interval 250ms
}
}
# Per-PR preview snippets are written by cd-staging.yml into
# /etc/caddy/sites/pr-<N>.caddyfile and picked up here on Caddy reload. The
# glob is allowed to match nothing — Caddy treats an empty match as a no-op.
import /etc/caddy/sites/*.caddyfile