Pocket ID replaces Forgejo as the OIDC provider for Outline, Vaultwarden and NetBird; Forgejo logs in through it. Uptime Kuma sits behind tinyauth via Caddy forward_auth (group ops), the public status page stays public. offboarding/offboard.py switches one person off in every service with an API and prints what a human still has to do: the shared passwords they could read. Measured: SSO lock alone left 10 of 13 ways open for 45 minutes; the script leaves 1 of 14, the vault copy on their laptop. HARDENING.md: the ufw hairpin that broke Vaultwarden SSO and Kuma, the mesh route into the docker bridges, and a fail2ban exception for Pocket ID.
159 lines
4.1 KiB
Caddyfile
159 lines
4.1 KiB
Caddyfile
# ~$ selfhostedops — Caddyfile
|
|
# Automatic HTTPS for every subdomain. Nothing to renew, ever.
|
|
{
|
|
email {$ACME_EMAIL}
|
|
# episode 10: rate_limit has to run before reverse_proxy hands the request on
|
|
order rate_limit before basic_auth
|
|
# episode 12: CrowdSec decides first (banned IP -> 403), then the AppSec WAF
|
|
# checks the request itself. Both talk to CrowdSec on the host.
|
|
order crowdsec first
|
|
order appsec after crowdsec
|
|
crowdsec {
|
|
api_url http://host.docker.internal:8090
|
|
api_key {$CROWDSEC_BOUNCER_KEY}
|
|
ticker_interval 10s
|
|
appsec_url http://host.docker.internal:7422
|
|
}
|
|
}
|
|
|
|
# episode 10: one snippet, imported into every site. JSON access log (fail2ban
|
|
# on the host reads it) and the Server header stripped so responses no longer
|
|
# announce Rocket/Caddy. client_ip in the log is the real remote address, so no
|
|
# trusted_proxies needed - the Docker DNAT already carries it through.
|
|
(common) {
|
|
log {
|
|
output file /var/log/caddy/access.log {
|
|
roll_size 50mb
|
|
roll_keep 5
|
|
}
|
|
format json
|
|
}
|
|
header -Server
|
|
}
|
|
|
|
# episode 12: blocklist + WAF. Imported everywhere except vpn. (NetBird gRPC streams).
|
|
(crowdsec) {
|
|
crowdsec
|
|
appsec
|
|
}
|
|
|
|
git.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
# episode 10: throttle login attempts (10 per minute per source IP)
|
|
rate_limit {
|
|
zone git_login {
|
|
match {
|
|
method POST
|
|
path /user/login
|
|
}
|
|
key {remote_host}
|
|
events 10
|
|
window 1m
|
|
}
|
|
}
|
|
reverse_proxy forgejo:3000
|
|
}
|
|
|
|
wiki.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
reverse_proxy outline:3000
|
|
}
|
|
|
|
vault.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
# episode 10: the admin panel is a setup tool, not a public page (was 200)
|
|
@admin path /admin*
|
|
respond @admin 404
|
|
# episode 10: throttle the login endpoints (10 per minute per source IP)
|
|
rate_limit {
|
|
zone vault_login {
|
|
match {
|
|
path /identity/connect/token /api/accounts/prelogin
|
|
}
|
|
key {remote_host}
|
|
events 10
|
|
window 1m
|
|
}
|
|
}
|
|
reverse_proxy vaultwarden:80
|
|
}
|
|
|
|
status.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
# episode 15: the Kuma UI only behind the login gate (tinyauth, group ops).
|
|
# Status pages, badges and push heartbeats stay public.
|
|
@public path /status/* /api/status-page/* /api/badge/* /api/push/* /assets/* /upload/* /icon.svg /favicon.ico /manifest.json
|
|
handle @public {
|
|
reverse_proxy uptime-kuma:3001
|
|
}
|
|
handle {
|
|
forward_auth tinyauth:3000 {
|
|
uri /api/auth/caddy
|
|
copy_headers Remote-User Remote-Email Remote-Groups
|
|
# tinyauth v5 answers 401 and names its login page in a header;
|
|
# without this the browser only ever sees {"message":"Unauthorized"}
|
|
@login status 401
|
|
handle_response @login {
|
|
redir {rp.header.X-Tinyauth-Location} 302
|
|
}
|
|
}
|
|
reverse_proxy uptime-kuma:3001
|
|
}
|
|
}
|
|
|
|
# Push notifications (episode 6). Websockets and long polling pass through as-is.
|
|
ntfy.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
reverse_proxy ntfy:80
|
|
}
|
|
|
|
# Automation (episode 8). The editor never becomes public: only the webhook
|
|
# paths are served, everything else under this host answers an empty 404.
|
|
# A webhook sender out on the internet cannot join your mesh, so this is the
|
|
# one door that has to stay open - and it is exactly one door wide.
|
|
n8n.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
@webhooks path /webhook/* /webhook-test/* /webhook-waiting/*
|
|
handle @webhooks {
|
|
reverse_proxy n8n:5678
|
|
}
|
|
handle {
|
|
respond 404
|
|
}
|
|
}
|
|
|
|
# NetBird control plane (episode 5) - dashboard, API, gRPC, relay, built-in IdP
|
|
vpn.{$DOMAIN} {
|
|
import common
|
|
# Native gRPC (needs HTTP/2 cleartext to backend)
|
|
@grpc header Content-Type application/grpc*
|
|
reverse_proxy @grpc h2c://netbird-server:80
|
|
|
|
# Combined server paths (relay, signal, management, OAuth2)
|
|
@backend path /relay* /ws-proxy/* /api/* /oauth2/*
|
|
reverse_proxy @backend netbird-server:80
|
|
|
|
# Dashboard (everything else)
|
|
reverse_proxy /* netbird-dashboard:80
|
|
}
|
|
|
|
# Identity provider (episode 15). Everyone logs in here, with a passkey.
|
|
id.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
reverse_proxy pocket-id:1411
|
|
}
|
|
|
|
# Login gate for services without their own SSO (episode 15), used by forward_auth.
|
|
auth.{$DOMAIN} {
|
|
import common
|
|
import crowdsec
|
|
reverse_proxy tinyauth:3000
|
|
}
|