FrankBook-Pro becomes the only *arr/qBittorrent deployment. Every fleet container (qB + five arrs) now shares gluetun's netns, so all egress — torrent, indexer APIs, metadata lookups — exits the WireGuard tunnel by construction (AllowedIPs=0.0.0.0/0); the arrs' own ports publish on the gluetun unit, none on the arr units themselves. - dankserver/compose.yaml: remove the six arr services (the Aug 20 compose copy — never healthy, superseded; config dirs trashed on the host) - dankclaw/Caddyfile + shared/homepage/services.json: arr vhosts and probes repoint 192.168.0.235 -> 192.168.0.112 (FrankBook-Pro) - frankbook-pro units: five arrs join gluetun netns (Wants/After/Network); gluetun publishes 9696/8989/7878/8686/8787 alongside 8080/6881 - tests/frankbook_quadlet.bats: new VPN egress seam — every arr+qB unit must share gluetun's netns and publish no port; gluetun must publish the whole fleet's ports - deploy-plan.py: gluetun changes carry the whole netns family (a gluetun recreation kills all six joiners); dankserver compose set loses the arrs - AGENTS.md: deployment is complete only when unit tests AND the Playwright E2E suite both pass
395 lines
14 KiB
Caddyfile
395 lines
14 KiB
Caddyfile
# Homelab Caddyfile — the single Caddy for both hosts.
|
|
#
|
|
# Caddy runs as a container on dankclaw (192.168.0.143, this repo's
|
|
# compose.yaml service 'caddy') and fronts every service on both hosts:
|
|
# - dankclaw services: by container name (compose network) or by the
|
|
# published LAN port (quadlet services on isolated networks)
|
|
# - dankserver services (192.168.0.235): by published LAN port
|
|
#
|
|
# Naming: flat franksdank.cloud scheme (see serverAppConfigs-docs/Infrastructure/LAN-DNS.md).
|
|
# DNS for these names is served by the Pi-hole quadlet on this host
|
|
# (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143)
|
|
# and handed to LAN clients by the SURFboard G36 router.
|
|
#
|
|
# Authentication chain (2026-08-29): the guarded vhosts (homepage, SearXNG,
|
|
# Prometheus, RomM, Copyparty) proxy EVERYTHING through the Anubis
|
|
# bot-challenge proxy first, then the whole request is proxied THROUGH
|
|
# Authentik (2026.8 proxy-provider mode: this build serves no forward_auth
|
|
# endpoint; the authentication flow runs inline — unauthenticated clients get
|
|
# the login UI served in place, and once a session exists Authentik proxies
|
|
# the request on to the app via the provider's internal_host). Chain: client →
|
|
# Caddy (TLS, public listener) → Anubis :8923 (verify: DENY known AI scrapers,
|
|
# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth
|
|
# decision; Host headers survive every hop) → authenticated traffic is
|
|
# forwarded back to http://:8017 and routed straight to the app.
|
|
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) and the
|
|
# Authentik IdP itself (auth.franksdank.cloud — its login flow must never sit
|
|
# behind a challenge) are exempt by construction: their vhosts never route
|
|
# through Anubis. ComfyUI left this exempt set on 2026-08-31 and Grafana left
|
|
# it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit
|
|
# behind the guard chain; Grafana authenticates users itself via the OAuth2
|
|
# code flow, so no identity header is forwarded to it.
|
|
# The pihole vhost keeps its basicauth edge; the hash placeholder below is
|
|
# rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into
|
|
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
|
|
#
|
|
# TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix,
|
|
# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via
|
|
# dns-01 through the Porkbun module built into the franksdank/caddy-porkbun
|
|
# image (caddy/Containerfile). That needs no inbound ports and works from the
|
|
# LAN, where Pi-hole resolves these names to 192.168.0.143.
|
|
{
|
|
# Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the
|
|
# port is not published, so nothing outside the container can reach it.
|
|
# It powers the config file watcher, so a rendered-config change
|
|
# hot-reloads without a container restart. 'admin off' once left a broken
|
|
# basicauth hash sitting on disk unapplied for hours (2026-08-22).
|
|
email admin@franksdank.cloud
|
|
|
|
# Solve ACME challenges for every site via Porkbun DNS (no port 80/443
|
|
# reachability needed). Credentials come from the container environment
|
|
# (compose.yaml, from the host .env — never committed).
|
|
acme_dns porkbun {
|
|
api_key {env.PORKBUN_API_KEY}
|
|
api_secret_key {env.PORKBUN_API_SECRET_KEY}
|
|
}
|
|
}
|
|
|
|
# --- Bare-IP access (health checks, convenience) ---
|
|
:80 {
|
|
redir https://www.franksdank.cloud/ 308
|
|
}
|
|
|
|
# --- Homepage (Anubis → Authentik SSO) ---
|
|
# www holds the homepage; the naked domain redirects onto it at the TLS edge.
|
|
# A redirect needs no challenge, so unauthenticated naked traffic never enters
|
|
# the auth chain. The proxy provider's external_host must match the host that
|
|
# actually carries traffic (https://www.franksdank.cloud) or Authentik matches
|
|
# zero providers and answers its error page instead.
|
|
franksdank.cloud {
|
|
redir https://www.franksdank.cloud/ 308
|
|
}
|
|
|
|
www.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- Legacy name: the old homepage name redirects straight onto www ---
|
|
dankclaw.franksdank.cloud {
|
|
redir https://www.franksdank.cloud/ 308
|
|
}
|
|
|
|
# --- Legacy name: the media host has no Caddy of its own anymore ---
|
|
dankserver.franksdank.cloud {
|
|
redir https://www.franksdank.cloud/ 308
|
|
}
|
|
|
|
# --- Authentik (SSO / OIDC provider) ---
|
|
# Exempt by construction: the IdP login flow must be reachable without a
|
|
# challenge, so this vhost never routes through Anubis.
|
|
auth.franksdank.cloud {
|
|
reverse_proxy authentik-server:9000
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Services on this host (dankclaw, 192.168.0.143)
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
# --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) ---
|
|
search.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- OpenWebUI (quadlet on slirp4netns; published 3005) ---
|
|
ai.franksdank.cloud {
|
|
reverse_proxy 192.168.0.143:3005
|
|
}
|
|
|
|
# --- Gitea (quadlet; published 3000) ---
|
|
# Exempt by construction: Gitea has its own login, and git smart-HTTP
|
|
# clients (git push/fetch over HTTPS) cannot solve JS challenges. The
|
|
# policy would need a bypass for /info/refs + /git-*-pack paths if this
|
|
# ever moves behind Anubis.
|
|
gitea.franksdank.cloud {
|
|
reverse_proxy 192.168.0.143:3000
|
|
}
|
|
|
|
# --- Grafana (guarded: Anubis → Authentik outpost gate; login inside Grafana) ---
|
|
# Auth model = OAuth2 relying party against authentik (OAuth2Provider "grafana",
|
|
# confidential client). The guard chain gates unauthenticated traffic; the
|
|
# outpost carries no provider token in this deployment, so identity headers
|
|
# never reach Grafana — RP mode needs none and works regardless of topology.
|
|
# The basicauth edge this replaces (CADDY_BASICAUTH_HASH) is retired.
|
|
grafana.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- Prometheus ---
|
|
prometheus.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- Loki (guarded: Anubis → Authentik; loki-proxy provider, internal_host caddy:8017) ---
|
|
loki.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- Seerr (guarded: Anubis → Authentik; seerr provider, internal_host caddy:8017) ---
|
|
seerr.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- Pi-hole (quadlet on slirp4netns; published 8083) ---
|
|
pihole.franksdank.cloud {
|
|
basicauth {
|
|
dankuser REPLACE_WITH_HASH
|
|
}
|
|
reverse_proxy 192.168.0.143:8083
|
|
}
|
|
|
|
# --- Hermes WebUI (Hermex webui; host-netns container, published 8787) ------
|
|
# Exempt by construction: the webui authenticates itself (HERMES_WEBUI_PASSWORD),
|
|
# so no Anubis challenge and no Authentik proxy provider is required — nothing
|
|
# here enters the http://:8016 auth chain. Internal-only by DNS absence: there is
|
|
# no public Porkbun A record for this name, so only LAN clients (Pi-hole
|
|
# addn-hosts → 192.168.0.143) can resolve it at all; DNS-01 issuance via the
|
|
# Porkbun module needs no public record. Upstream is the host LAN IP rather than
|
|
# localhost because Caddy itself lives on dankclaw_default: from that bridge the
|
|
# gateway address 10.89.1.1 has nothing serving 8787 (connection refused), while
|
|
# 192.168.0.143:8787 is open.
|
|
hermes.franksdank.cloud {
|
|
reverse_proxy 192.168.0.143:8787
|
|
}
|
|
|
|
# --- Hermes dashboard (admin UI; published 9119 on dankclaw_default) --------
|
|
# Exempt by construction: the app enforces its own complete auth — verified live
|
|
# from Caddy's own network, an unauthenticated GET / returns 302 → /login?next=%2F
|
|
# (uvicorn), and basic_auth.username + a scrypt password_hash are set in the
|
|
# dashboard's /data/config.yaml. No outpost provider needed.
|
|
hermesadmin.franksdank.cloud {
|
|
reverse_proxy 192.168.0.143:9119
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Services on dankserver (192.168.0.235) — published LAN ports
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
# --- Plex (existing, kept) ---
|
|
# http:// on purpose: franksdank.casa is not hosted in the same Porkbun
|
|
# account as franksdank.cloud, so the DNS-01 provider cannot issue a
|
|
# Let's Encrypt cert for it. It stays plain-HTTP legacy; the canonical
|
|
# name is plex.franksdank.cloud (HTTPS, below).
|
|
http://plex.franksdank.casa {
|
|
reverse_proxy 192.168.0.235:32400 {
|
|
transport http {
|
|
tls_insecure_skip_verify
|
|
}
|
|
}
|
|
}
|
|
|
|
# PMS requires TLS from every non-loopback client: plain-HTTP off-loopback
|
|
# is read and silently closed (diagnosed 2026-08-26), which surfaced as an
|
|
# empty-body 502 here. Dial the upstream over TLS; PMS's self-signed cert is
|
|
# skipped on this pinned-LAN hop only (client-facing LE TLS is untouched).
|
|
# The web UI lives at /web/; / returns the XML MediaContainer index.
|
|
plex.franksdank.cloud {
|
|
reverse_proxy 192.168.0.235:32400 {
|
|
transport http {
|
|
tls_insecure_skip_verify
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Calibre-Web ---
|
|
books.franksdank.cloud {
|
|
reverse_proxy 192.168.0.235:8083
|
|
}
|
|
|
|
# --- RomM ---
|
|
romm.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- Copyparty ---
|
|
files.franksdank.cloud {
|
|
reverse_proxy anubis:8923 {
|
|
header_up X-Real-Ip {remote_host}
|
|
}
|
|
}
|
|
|
|
# --- Jellyfin ---
|
|
jelly.franksdank.cloud {
|
|
reverse_proxy 192.168.0.235:8096
|
|
}
|
|
|
|
|
|
# --- *arr Stack (exempt by construction: own auth) ---
|
|
|
|
prowlarr.franksdank.cloud {
|
|
reverse_proxy 192.168.0.112:9696
|
|
}
|
|
|
|
radarr.franksdank.cloud {
|
|
reverse_proxy 192.168.0.112:7878
|
|
}
|
|
|
|
sonarr.franksdank.cloud {
|
|
reverse_proxy 192.168.0.112:8989
|
|
}
|
|
|
|
lidarr.franksdank.cloud {
|
|
reverse_proxy 192.168.0.112:8686
|
|
}
|
|
|
|
readarr.franksdank.cloud {
|
|
reverse_proxy 192.168.0.112:8787
|
|
}
|
|
|
|
qbittorrent.franksdank.cloud {
|
|
reverse_proxy 192.168.0.112:8080
|
|
}
|
|
|
|
# --- Internal listener: auth decision layer ---------------------------------
|
|
# Anubis dials http://caddy:8016 after verifying a client (TARGET in
|
|
# dankclaw/anubis/quadlet/anubis.container). Host headers survive the hop.
|
|
# Every request here is proxied whole-request to Authentik with Host
|
|
# preserved. This build's embedded outpost routes ONLY on the ORIGINAL Host
|
|
# against each proxy provider's external_host — meta headers are ignored and
|
|
# cross-host absolute next= redirects are rejected by the flow's open-redirect
|
|
# guard ("Request has been denied: Invalid next URL"), verified live.
|
|
# Unauthenticated clients get the login flow served in place; authenticated
|
|
# sessions are forwarded by the provider's internal_host back to
|
|
# http://:8017 and routed straight to the app. Every host that may enter this
|
|
# listener MUST have a proxy provider registered with the embedded outpost —
|
|
# the Anubis guard set, the Caddy TLS edge, and the provider list stay in
|
|
# lockstep. Exempt vhosts never enter this block.
|
|
http://:8016 {
|
|
reverse_proxy authentik-server:9000 {
|
|
header_up X-Forwarded-Proto https
|
|
}
|
|
}
|
|
|
|
# --- Post-auth listener: where Authentik forwards authenticated traffic ----
|
|
# The proxy provider's internal_host is http://caddy:8017, so every request
|
|
# that passes authentication lands here (Host header preserved) and routes
|
|
# straight to its app. This is the end of the chain — nothing dials back
|
|
# into Authentik from here, so the chain cannot loop.
|
|
http://:8017 {
|
|
@home host www.franksdank.cloud
|
|
handle @home {
|
|
root * /srv/homepage
|
|
# RBAC (2026-09-01): /admin requires membership in the homepage-admins
|
|
# authentik group. In proxy-provider mode the outpost injects
|
|
# X-Authentik-Groups on its upstream request to this listener and strips
|
|
# any client-supplied X-Authentik-* headers first, so browsers cannot
|
|
# spoof it. Regex is a substring match, insensitive to the group-list
|
|
# separator.
|
|
handle /admin* {
|
|
@adminok header_regexp X-Authentik-Groups homepage-admins
|
|
handle @adminok {
|
|
try_files /admin.html
|
|
file_server
|
|
}
|
|
respond "Forbidden: homepage admins only" 403
|
|
}
|
|
@data path /services.json /status.json /app.js /style.css /deployed.js
|
|
handle @data {
|
|
file_server
|
|
}
|
|
handle {
|
|
try_files /index.html
|
|
file_server
|
|
}
|
|
}
|
|
|
|
@search host search.franksdank.cloud
|
|
handle @search {
|
|
reverse_proxy 192.168.0.143:8081
|
|
}
|
|
|
|
@loki host loki.franksdank.cloud
|
|
handle @loki {
|
|
reverse_proxy loki:3100
|
|
}
|
|
|
|
@seerr host seerr.franksdank.cloud
|
|
handle @seerr {
|
|
reverse_proxy seerr:5055
|
|
}
|
|
|
|
@grafana host grafana.franksdank.cloud
|
|
handle @grafana {
|
|
reverse_proxy grafana:3000
|
|
}
|
|
|
|
@prometheus host prometheus.franksdank.cloud
|
|
handle @prometheus {
|
|
reverse_proxy prometheus:9090
|
|
}
|
|
|
|
@romm host romm.franksdank.cloud
|
|
handle @romm {
|
|
reverse_proxy 192.168.0.235:8085
|
|
}
|
|
|
|
@files host files.franksdank.cloud
|
|
handle @files {
|
|
reverse_proxy 192.168.0.235:3923
|
|
}
|
|
|
|
@prowlarr host prowlarr.franksdank.cloud
|
|
handle @prowlarr {
|
|
reverse_proxy 192.168.0.112:9696
|
|
}
|
|
|
|
@radarr host radarr.franksdank.cloud
|
|
handle @radarr {
|
|
reverse_proxy 192.168.0.112:7878
|
|
}
|
|
|
|
@sonarr host sonarr.franksdank.cloud
|
|
handle @sonarr {
|
|
reverse_proxy 192.168.0.112:8989
|
|
}
|
|
|
|
@lidarr host lidarr.franksdank.cloud
|
|
handle @lidarr {
|
|
reverse_proxy 192.168.0.112:8686
|
|
}
|
|
|
|
@readarr host readarr.franksdank.cloud
|
|
handle @readarr {
|
|
reverse_proxy 192.168.0.112:8787
|
|
}
|
|
|
|
@qbittorrent host qbittorrent.franksdank.cloud
|
|
handle @qbittorrent {
|
|
reverse_proxy 192.168.0.112:8080
|
|
}
|
|
}
|
|
|
|
# --- Optional: Jackett ---
|
|
# jackett.franksdank.cloud {
|
|
# reverse_proxy 192.168.0.235:9117
|
|
# }
|
|
|
|
# --- Optional: Whisparr ---
|
|
# whisparr.franksdank.cloud {
|
|
# reverse_proxy 192.168.0.235:6969
|
|
# }
|