diff --git a/Netgrimoire/Network/Security/Caddy.md b/Netgrimoire/Network/Security/Caddy.md new file mode 100644 index 0000000..7175f04 --- /dev/null +++ b/Netgrimoire/Network/Security/Caddy.md @@ -0,0 +1,522 @@ +--- +title: Caddy Reverse Proxy +description: Curreent and future config +published: true +date: 2026-02-23T22:09:16.106Z +tags: +editor: markdown +dateCreated: 2026-02-23T22:09:16.106Z +--- + +# Caddy Reverse Proxy + +**Host:** znas (Docker Swarm node) +**Internal IP:** 192.168.5.10 +**Data Path:** `/export/Docker/caddy/` +**Networks:** `netgrimoire` (service network), `vpn` +**Ports:** 80 (mapped to host 8900), 443 + +--- + +## Overview + +Caddy serves as the primary reverse proxy for all public and internal web services. It uses the `caddy-docker-proxy` pattern, which allows services to register themselves with Caddy by adding Docker labels to their compose files — no manual Caddyfile edits required per service. + +Configuration is **hybrid**: some services are defined entirely via Docker labels, others are defined statically in the Caddyfile, and most use both (labels for routing, Caddyfile for shared snippets). The `caddy-docker-proxy` container merges both sources at runtime. + +--- + +## Current State + +### Image + +```yaml +image: lucaslorentz/caddy-docker-proxy:ci-alpine +``` + +This image provides the Docker Proxy module only. It has no CrowdSec, GeoIP, or rate limiting built in. + +### Docker Compose (`/export/Docker/caddy/docker-compose.yml`) + +```yaml +configs: + caddy-basic-content: + file: ./Caddyfile + labels: + caddy: + +services: + caddy: + image: lucaslorentz/caddy-docker-proxy:ci-alpine + ports: + - 8900:80 + - 443:443 + environment: + - CADDY_INGRESS_NETWORKS=netgrimoire + networks: + - netgrimoire + - vpn + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /export/Docker/caddy/Caddyfile:/etc/caddy/Caddyfile + - /export/Docker/caddy:/data + #- /export/Docker/caddy/logs:/var/log/caddy # Placeholder for CrowdSec log mount + deploy: + placement: + constraints: + - node.hostname == znas + +networks: + netgrimoire: + external: true + vpn: + external: true +``` + +### Caddyfile (`/export/Docker/caddy/Caddyfile`) + +The Caddyfile defines shared authentication snippets and static site blocks. These snippets are available to all services — including label-defined ones — via `import`. + +```caddyfile +# ───────────────────────────────────────────────────────────────────────────── +# AUTH SNIPPETS +# ───────────────────────────────────────────────────────────────────────────── + +(authentik) { + route /outpost.goauthentik.io/* { + reverse_proxy http://authentik:9000 + } + + forward_auth http://authentik:9000 { + uri /outpost.goauthentik.io/auth/caddy + header_up X-Forwarded-URI {http.request.uri} + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email \ + X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt \ + X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider \ + X-Authentik-Meta-App X-Authentik-Meta-Version + } +} + +(authelia) { + forward_auth http://authelia:9091 { + uri /api/verify?rd=https://login.wasted-bandwidth.net/ + copy_headers Remote-User Remote-Groups Remote-Email Remote-Name + } +} + +# ───────────────────────────────────────────────────────────────────────────── +# MAIL SNIPPETS +# ───────────────────────────────────────────────────────────────────────────── + +(email-proxy) { + redir https://mail.netgrimoire.com/sogo 301 +} + +(mailcow-proxy) { + reverse_proxy nginx-mailcow:80 +} + +# ───────────────────────────────────────────────────────────────────────────── +# STATIC SITE BLOCKS — NETGRIMOIRE.COM +# ───────────────────────────────────────────────────────────────────────────── + +cloud.netgrimoire.com { + reverse_proxy http://nextcloud-aio-apache:11000 +} + +log.netgrimoire.com { + reverse_proxy http://graylog:9000 +} + +win.netgrimoire.com { + reverse_proxy http://192.168.5.10:8006 +} + +docker.netgrimoire.com { + reverse_proxy http://portainer:9000 +} + +immich.netgrimoire.com { + reverse_proxy http://192.168.5.10:2283 +} + +npm.netgrimoire.com { + reverse_proxy http://librenms:8000 +} + +#jellyfin.netgrimoire.com { +# reverse_proxy http://jellyfin:8096 +#} + +# ───────────────────────────────────────────────────────────────────────────── +# AUTHENTICATED — NETGRIMOIRE.COM +# ───────────────────────────────────────────────────────────────────────────── + +dozzle.netgrimoire.com { + import authentik + reverse_proxy http://192.168.4.72:8043 +} + +dns.netgrimoire.com { + import authentik + reverse_proxy http://192.168.5.7:5380 +} + +webtop.netgrimoire.com { + import authentik + reverse_proxy http://webtop:3000 +} + +jackett.netgrimoire.com { + import authentik + reverse_proxy http://gluetun:9117 +} + +transmission.netgrimoire.com { + import authentik + reverse_proxy http://gluetun:9091 +} + +scrutiny.netgrimoire.com { + import authentik + reverse_proxy http://192.168.5.10:8081 +} + +# ───────────────────────────────────────────────────────────────────────────── +# AUTHENTICATED — WASTED-BANDWIDTH.NET (Authelia) +# ───────────────────────────────────────────────────────────────────────────── + +stash.wasted-bandwidth.net { + import authelia + reverse_proxy http://192.168.5.10:9999 +} + +namer.wasted-bandwidth.net { + import authelia + reverse_proxy http://192.168.5.10:6980 +} + +# ───────────────────────────────────────────────────────────────────────────── +# PUBLIC — PNCHARRIS.COM / WASTED-BANDWIDTH.NET +# ───────────────────────────────────────────────────────────────────────────── + +fish.pncharris.com { + reverse_proxy http://web +} + +www.wasted-bandwidth.net { + reverse_proxy http://web +} + +# ───────────────────────────────────────────────────────────────────────────── +# MAILCOW — MULTI-DOMAIN +# ───────────────────────────────────────────────────────────────────────────── + +mail.netgrimoire.com, autodiscover.netgrimoire.com, autoconfig.netgrimoire.com, \ +mail.wasted-bandwidth.net, autodiscover.wasted-bandwidth.net, autoconfig.wasted-bandwidth.net, \ +mail.gnarlypandaproductions.com, autodiscover.gnarlypandaproductions.com, autoconfig.gnarlypandaproductions.com, \ +mail.pncfishandmore.com, autodiscover.pncfishandmore.com, autoconfig.pncfishandmore.com, \ +mail.pncharrisenterprises.com, autodiscover.pncharrisenterprises.com, autoconfig.pncharrisenterprises.com, \ +mail.pncharris.com, autodiscover.pncharris.com, autoconfig.pncharris.com, \ +mail.florosafd.org, autodiscover.florosafd.org, autoconfig.florosafd.org { + import mailcow-proxy +} +``` + +### Docker Label Pattern (label-defined services) + +Services not in the Caddyfile are registered via labels on their own containers. The snippet defined in the Caddyfile is available to them via `caddy.import`: + +```yaml +labels: + - caddy=homepage.netgrimoire.com + - caddy.import=authentik + - caddy.reverse_proxy={{upstreams 3000}} +``` + +For services that need no auth: +```yaml +labels: + - caddy=myservice.netgrimoire.com + - caddy.reverse_proxy={{upstreams 8080}} +``` + +--- + +## Authentication Layers + +Two identity proxies are in use, each serving different domains/use cases: + +| Provider | Domain Pattern | Snippet | +|----------|----------------|---------| +| Authentik | `*.netgrimoire.com` internal tools | `import authentik` | +| Authelia | `*.wasted-bandwidth.net` | `import authelia` | + +Services without an auth import are either public (e.g. `fish.pncharris.com`) or carry their own authentication (e.g. Nextcloud, Graylog, Portainer). + +--- + +## Current Security Posture + +CrowdSec protection exists only at the **OPNsense firewall level** — IP reputation blocking before traffic reaches Caddy. CrowdSec does not currently inspect HTTP traffic at the application layer. This means: + +- Known-bad IPs are blocked at the perimeter +- Application-layer attacks (SQLi in URLs, malicious paths, bad user agents, brute force on specific endpoints) are not blocked at the Caddy level +- Services behind Authentik/Authelia have an additional protection layer; unauthenticated public services do not + +--- + +## Future State: CrowdSec + GeoIP + Rate Limiting + +### Target Image + +```yaml +image: ghcr.io/serfriz/caddy-crowdsec-geoip-ratelimit-security-dockerproxy:latest +``` + +This is a drop-in replacement for `lucaslorentz/caddy-docker-proxy`. All existing Docker labels and Caddyfile site blocks continue to work unchanged. The image is automatically rebuilt monthly when Caddy releases updates — no custom image maintenance required. + +**Included modules:** +- `caddy-docker-proxy` — same label-based config as current +- `caddy-crowdsec-bouncer` — inline HTTP blocking based on CrowdSec decisions +- `caddy-geoip` — GeoIP filtering at the application layer +- `caddy-ratelimit` — per-endpoint rate limiting +- `caddy-security` — additional auth/security middleware + +### Updated Compose + +```yaml +configs: + caddy-basic-content: + file: ./Caddyfile + labels: + caddy: + +services: + caddy: + image: ghcr.io/serfriz/caddy-crowdsec-geoip-ratelimit-security-dockerproxy:latest + ports: + - 8900:80 + - 443:443 + environment: + - CADDY_INGRESS_NETWORKS=netgrimoire + - CADDY_DOCKER_EVENT_THROTTLE_INTERVAL=2000 # Prevents non-deterministic reload with CrowdSec module + - CROWDSEC_API_KEY=${CROWDSEC_API_KEY} + networks: + - netgrimoire + - vpn + - crowdsec_net + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /export/Docker/caddy/Caddyfile:/etc/caddy/Caddyfile + - /export/Docker/caddy:/data + - caddy-logs:/var/log/caddy + deploy: + placement: + constraints: + - node.hostname == znas + + crowdsec: + image: crowdsecurity/crowdsec + restart: unless-stopped + environment: + COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-cve crowdsecurity/whitelist-good-actors" + BOUNCER_KEY_CADDY: ${CROWDSEC_API_KEY} # Pre-registers the Caddy bouncer automatically + volumes: + - crowdsec-db:/var/lib/crowdsec/data + - ./crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml + - caddy-logs:/var/log/caddy:ro + networks: + - crowdsec_net + deploy: + placement: + constraints: + - node.hostname == znas + +volumes: + caddy-logs: + crowdsec-db: + +networks: + netgrimoire: + external: true + vpn: + external: true + crowdsec_net: + driver: overlay # Swarm overlay network +``` + +### CrowdSec Log Acquisition (`./crowdsec/acquis.yaml`) + +```yaml +filenames: + - /var/log/caddy/access.log +labels: + type: caddy +``` + +### Environment File (`.env`) + +```env +CROWDSEC_API_KEY= +``` + +The `BOUNCER_KEY_CADDY` env var in the CrowdSec container pre-registers the bouncer key at startup. Set the same value in `.env` as `CROWDSEC_API_KEY` and both sides will be in sync on first boot — no need to run `cscli bouncers add` manually. + +### Updated Caddyfile Additions + +Add a global block at the top of the Caddyfile and a new `crowdsec` snippet. All other existing content remains unchanged. + +```caddyfile +# ───────────────────────────────────────────────────────────────────────────── +# GLOBAL BLOCK — add this at the very top before any snippets +# ───────────────────────────────────────────────────────────────────────────── + +{ + crowdsec { + api_url http://crowdsec:8080 + api_key {$CROWDSEC_API_KEY} + } + log { + output file /var/log/caddy/access.log { + roll_size 50mb + roll_keep 5 + } + format json + } +} + +# ───────────────────────────────────────────────────────────────────────────── +# CROWDSEC SNIPPET — add alongside existing auth snippets +# ───────────────────────────────────────────────────────────────────────────── + +(crowdsec) { + route { + crowdsec + } +} +``` + +### Applying CrowdSec to Existing Services + +Once the snippet exists, add `import crowdsec` to site blocks and container labels. This is a **gradual rollout** — services without it remain fully functional, just without Caddy-level CrowdSec inspection (they still have OPNsense perimeter protection). + +**In the Caddyfile:** +```caddyfile +# Before +cloud.netgrimoire.com { + reverse_proxy http://nextcloud-aio-apache:11000 +} + +# After +cloud.netgrimoire.com { + import crowdsec + reverse_proxy http://nextcloud-aio-apache:11000 +} + +# With auth +dozzle.netgrimoire.com { + import crowdsec + import authentik + reverse_proxy http://192.168.4.72:8043 +} +``` + +**In Docker labels:** +```yaml +labels: + - caddy=homepage.netgrimoire.com + - caddy.import=crowdsec + - caddy.import=authentik + - caddy.reverse_proxy={{upstreams 3000}} +``` + +### CrowdSec Rollout Priority + +Roll out `import crowdsec` in this order based on risk exposure: + +**High priority — do first (public, no auth):** +- `cloud.netgrimoire.com` (Nextcloud) +- `immich.netgrimoire.com` +- `docker.netgrimoire.com` (Portainer) +- `fish.pncharris.com` +- `www.wasted-bandwidth.net` + +**Medium priority — high value behind auth:** +- `log.netgrimoire.com` (Graylog) +- `win.netgrimoire.com` (Proxmox) +- All `dozzle`, `dns`, `webtop`, `jackett`, `transmission`, `scrutiny` + +**Lower priority — already protected by Authelia/Authentik:** +- `stash.wasted-bandwidth.net` +- `namer.wasted-bandwidth.net` +- All label-defined services behind auth + +**Skip:** +- Mailcow block — handled by nginx-mailcow, different threat model + +### Behavior if CrowdSec Container Goes Down + +The bouncer is designed to **fail open** by default. If `crowdsec` is unreachable, Caddy continues serving traffic normally — enforcement is temporarily suspended but the site stays up. This is the safe default for a homelab. To change this behavior, set `enable_hard_fails true` in the global crowdsec block (will cause 500 errors if CrowdSec is down — not recommended for homelab). + +--- + +## Bootstrap Steps + +When ready to migrate to the new image: + +**Step 1 — Add the CrowdSec global block and snippet to the Caddyfile** before changing the image. This ensures the Caddyfile is valid for the new image on startup. + +**Step 2 — Create `./crowdsec/acquis.yaml`** with the content above. + +**Step 3 — Create `.env`** with a strong random value for `CROWDSEC_API_KEY`: +```bash +openssl rand -hex 32 +``` + +**Step 4 — Update the image and add the CrowdSec service to the compose file**, then redeploy: +```bash +docker stack deploy -c docker-compose.yml caddy +``` + +**Step 5 — Verify CrowdSec is reading Caddy logs:** +```bash +docker exec cscli metrics +``` +Look for the `Acquisition Metrics` table showing hits from `/var/log/caddy/access.log`. + +**Step 6 — Test a ban manually:** +```bash +docker exec cscli decisions add --ip 1.2.3.4 --duration 5m +# Verify the IP gets a 403 from Caddy +curl -I https://yoursite.com --resolve yoursite.com:443:1.2.3.4 +docker exec cscli decisions delete --ip 1.2.3.4 +``` + +**Step 7 — Gradually add `import crowdsec`** to site blocks and labels per the priority order above. + +--- + +## File Layout + +``` +/export/Docker/caddy/ +├── Caddyfile # Shared snippets and static site blocks +├── docker-compose.yml # Caddy + CrowdSec services +├── .env # CROWDSEC_API_KEY (future) +├── data/ # Caddy data volume (TLS certs, etc.) +├── logs/ # caddy-logs volume mount point (future) +└── crowdsec/ + └── acquis.yaml # Tells CrowdSec where to read Caddy logs (future) +``` + +--- + +## Known Issues / Notes + +- Port 80 is mapped to host port 8900 — this is intentional for Swarm. OPNsense NAT handles the external 80→8900 translation. +- The `CADDY_DOCKER_EVENT_THROTTLE_INTERVAL=2000` setting is **required** with the CrowdSec module to prevent non-deterministic domain matching behavior during container label reloads (see [issue #61](https://github.com/hslatman/caddy-crowdsec-bouncer/issues/61)). +- Jellyfin is commented out in the Caddyfile — likely served via a different path or disabled temporarily. +- The `web` upstream referenced by `fish.pncharris.com` and `www.wasted-bandwidth.net` resolves to a container named `web` on the `netgrimoire` network. +- Authelia redirect URL is `https://login.wasted-bandwidth.net/` — update if this changes. +- The serfriz image is rebuilt on the **1st of each month** for module updates, and on every new Caddy release. Force a module update by recreating the container: `docker service update --force caddy_caddy`.