From 7f4ce0692740ff5831479e78ef8148c6fe73cfce Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 22 Aug 2026 02:39:41 -0700 Subject: [PATCH] Caddy phase 2: TLS via Porkbun DNS-01 wildcard certs - Caddyfile: drop http:// site prefixes, global acme_dns porkbun block, HTTPS redirects on the bare-IP and legacy vhosts - new dankclaw/caddy/Containerfile: xcaddy build with caddy-dns/porkbun - compose: caddy uses franksdank/caddy-porkbun:latest + Porkbun env vars - deploy.sh: build the image on first deploy if missing - .env.example: rename PORKBUN_API_SECRET -> PORKBUN_API_SECRET_KEY (the name the caddy-dns/porkbun module expects) - tests: 7 new caddyfile/compose seams for the TLS config --- dankclaw/.env.example | 8 +++-- dankclaw/Caddyfile | 68 +++++++++++++++++++++--------------- dankclaw/caddy/Containerfile | 22 ++++++++++++ dankclaw/compose.yaml | 8 ++++- scripts/deploy.sh | 4 +++ tests/caddyfile.bats | 37 ++++++++++++++++++++ 6 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 dankclaw/caddy/Containerfile diff --git a/dankclaw/.env.example b/dankclaw/.env.example index c2b4c8e..397433f 100644 --- a/dankclaw/.env.example +++ b/dankclaw/.env.example @@ -35,7 +35,9 @@ SIGNAL_ACCOUNT=+15551234567 # Comma-separated E.164 numbers allowed to message the agent. SIGNAL_ALLOWED_USERS=+15551234567 -# Porkbun API (franksdank.cloud zone). Caddy uses these for DNS-01 -# Let's Encrypt wildcard cert issuance. Scope the token to this zone only. +# Porkbun API (franksdank.cloud + franksdank.casa zones). Caddy uses these +# for DNS-01 Let's Encrypt wildcard cert issuance. Porkbun API keys are +# account-wide; rotate them here rather than in the dashboard. +# PORKBUN_API_SECRET_KEY is the name the caddy-dns/porkbun module expects. PORKBUN_API_KEY=changeme -PORKBUN_API_SECRET=changeme +PORKBUN_API_SECRET_KEY=changeme diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index ffddce4..628b389 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -15,21 +15,33 @@ # hash (CADDY_BASICAUTH_HASH from the host .env, git-ignored) into # /home/dank/Containers/caddy/Caddyfile, which the container mounts. # -# Phase 1 uses explicit http:// site addresses (no TLS yet). Phase 2 drops -# the prefix and adds a wildcard Let's Encrypt cert via DNS-01 (Porkbun). +# TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix, +# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are wildcard +# (dns-01) via the global acme_dns block below, using the Porkbun module +# built into the franksdank/caddy-porkbun image (caddy/Containerfile). +# That needs no inbound ports and works from the LAN, where dnsmasq +# resolves these names to 192.168.0.143. { admin off 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 http://dankclaw.franksdank.cloud/ 302 + redir https://dankclaw.franksdank.cloud/ 302 } # --- Homepage (password protected) --- -http://dankclaw.franksdank.cloud { +dankclaw.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -38,8 +50,8 @@ http://dankclaw.franksdank.cloud { } # --- Legacy name: the media host has no Caddy of its own anymore --- -http://dankserver.franksdank.cloud { - redir http://dankclaw.franksdank.cloud/ 302 +dankserver.franksdank.cloud { + redir https://dankclaw.franksdank.cloud/ 302 } # ═══════════════════════════════════════════════════════════════ @@ -47,7 +59,7 @@ http://dankserver.franksdank.cloud { # ═══════════════════════════════════════════════════════════════ # --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) --- -http://search.franksdank.cloud { +search.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -55,12 +67,12 @@ http://search.franksdank.cloud { } # --- ComfyUI --- -http://comfyui.franksdank.cloud { +comfyui.franksdank.cloud { reverse_proxy comfyui:8188 } # --- OpenWebUI (quadlet on slirp4netns; published 3005) --- -http://chat.franksdank.cloud { +chat.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -68,7 +80,7 @@ http://chat.franksdank.cloud { } # --- Hermes --- -http://hermes.franksdank.cloud { +hermes.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -76,12 +88,12 @@ http://hermes.franksdank.cloud { } # --- Gitea (quadlet; published 3000) --- -http://gitea.franksdank.cloud { +gitea.franksdank.cloud { reverse_proxy 192.168.0.143:3000 } # --- Grafana --- -http://grafana.franksdank.cloud { +grafana.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -89,7 +101,7 @@ http://grafana.franksdank.cloud { } # --- Prometheus --- -http://prometheus.franksdank.cloud { +prometheus.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -97,7 +109,7 @@ http://prometheus.franksdank.cloud { } # --- Loki --- -http://loki.franksdank.cloud { +loki.franksdank.cloud { reverse_proxy loki:3100 } @@ -106,21 +118,21 @@ http://loki.franksdank.cloud { # ═══════════════════════════════════════════════════════════════ # --- Plex (existing, kept) --- -http://plex.franksdank.casa { +plex.franksdank.casa { reverse_proxy 192.168.0.235:32400 } -http://plex.franksdank.cloud { +plex.franksdank.cloud { reverse_proxy 192.168.0.235:32400 } # --- Calibre-Web --- -http://books.franksdank.cloud { +books.franksdank.cloud { reverse_proxy 192.168.0.235:8083 } # --- RomM --- -http://romm.franksdank.cloud { +romm.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -128,7 +140,7 @@ http://romm.franksdank.cloud { } # --- Copyparty --- -http://files.franksdank.cloud { +files.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -136,7 +148,7 @@ http://files.franksdank.cloud { } # --- Jellyfin --- -http://jellyfin.franksdank.cloud { +jellyfin.franksdank.cloud { reverse_proxy 192.168.0.235:8096 { transport http { versions h2c 1.1 @@ -145,36 +157,36 @@ http://jellyfin.franksdank.cloud { } # --- *arr Stack --- -http://radarr.franksdank.cloud { +radarr.franksdank.cloud { reverse_proxy 192.168.0.235:7878 } -http://sonarr.franksdank.cloud { +sonarr.franksdank.cloud { reverse_proxy 192.168.0.235:8989 } -http://lidarr.franksdank.cloud { +lidarr.franksdank.cloud { reverse_proxy 192.168.0.235:8686 } -http://readarr.franksdank.cloud { +readarr.franksdank.cloud { reverse_proxy 192.168.0.235:8787 } -http://prowlarr.franksdank.cloud { +prowlarr.franksdank.cloud { reverse_proxy 192.168.0.235:9696 } -http://qbittorrent.franksdank.cloud { +qbittorrent.franksdank.cloud { reverse_proxy 192.168.0.235:8080 } # --- Optional: Jackett --- -# http://jackett.franksdank.cloud { +# jackett.franksdank.cloud { # reverse_proxy 192.168.0.235:9117 # } # --- Optional: Whisparr --- -# http://whisparr.franksdank.cloud { +# whisparr.franksdank.cloud { # reverse_proxy 192.168.0.235:6969 # } diff --git a/dankclaw/caddy/Containerfile b/dankclaw/caddy/Containerfile new file mode 100644 index 0000000..bd8ad5d --- /dev/null +++ b/dankclaw/caddy/Containerfile @@ -0,0 +1,22 @@ +# Caddy with the Porkbun DNS provider (github.com/caddy-dns/porkbun). +# +# The porkbun module lets Caddy solve Let's Encrypt DNS-01 challenges +# directly through the Porkbun API, so the wildcard cert for +# *.franksdank.cloud / *.franksdank.casa can be issued and renewed without +# any port 80/443 reachability from the internet (see the Caddyfile in +# ../Caddyfile and docs/Infrastructure/LAN-DNS.md). +# +# Build on dankclaw (podman rootless, same as every other container here): +# podman build -t franksdank/caddy-porkbun:latest -f dankclaw/caddy/Containerfile . +# +# The first build runs an xcaddy Go compilation and takes a few minutes. +# Rebuild only when Caddy or the porkbun module must be updated. + +FROM docker.io/library/caddy:2-builder AS builder + +RUN xcaddy build \ + --with github.com/caddy-dns/porkbun \ + --output /usr/bin/caddy-porkbun + +FROM docker.io/library/caddy:2 +COPY --from=builder /usr/bin/caddy-porkbun /usr/bin/caddy \ No newline at end of file diff --git a/dankclaw/compose.yaml b/dankclaw/compose.yaml index 46e5961..2e80e55 100644 --- a/dankclaw/compose.yaml +++ b/dankclaw/compose.yaml @@ -199,12 +199,18 @@ services: # REPLACE_WITH_HASH placeholder. Certs/config live under # /home/dank/Containers/caddy (rootless-writable). caddy: - image: docker.io/library/caddy:latest + # Locally built: stock Caddy + the Porkbun DNS provider + # (dankclaw/caddy/Containerfile). See that file for build instructions. + image: franksdank/caddy-porkbun:latest container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" + environment: + # Consumed by the Caddyfile's acme_dns block (DNS-01 wildcard certs). + PORKBUN_API_KEY: ${PORKBUN_API_KEY} + PORKBUN_API_SECRET_KEY: ${PORKBUN_API_SECRET_KEY} volumes: - /home/dank/Containers/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - /home/dank/Containers/caddy/data:/data diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f910b5e..9cdcd9d 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -137,6 +137,10 @@ fi if [ '${COMPOSE_UP[$TARGET]}' = '1' ]; then echo '[4/4] Restarting stack...' + if ! podman image exists franksdank/caddy-porkbun:latest; then + echo ' franksdank/caddy-porkbun image missing; building it (a few minutes)...' + podman build -t franksdank/caddy-porkbun:latest -f dankclaw/caddy/Containerfile . + fi podman-compose -f '${COMPOSE[$TARGET]}' up -d --pull=missing else echo '[4/4] Skipping compose restart (stack runs as bare containers on this host)' diff --git a/tests/caddyfile.bats b/tests/caddyfile.bats index 6c48828..2afb4fe 100644 --- a/tests/caddyfile.bats +++ b/tests/caddyfile.bats @@ -58,3 +58,40 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile" @test "the caddy service healthcheck probes the bare-IP catch-all" { grep -q 'curl.*-sf.*http://localhost:80/' "$REPO_ROOT/dankclaw/compose.yaml" } + +# --- Phase 2: real TLS via DNS-01 (Porkbun wildcard cert) --- + +@test "all vhosts use TLS (no http:// site prefix remains)" { + ! grep -qE '^http://' "$CADDYFILE" +} + +@test "the Caddyfile configures the Porkbun DNS-01 provider globally" { + grep -q 'acme_dns porkbun' "$CADDYFILE" + grep -q '{env.PORKBUN_API_KEY}' "$CADDYFILE" + grep -q '{env.PORKBUN_API_SECRET_KEY}' "$CADDYFILE" +} + +@test "the bare-IP catch-all redirects to the HTTPS homepage" { + grep -q 'redir https://dankclaw.franksdank.cloud/' "$CADDYFILE" +} + +@test "the caddy service uses the locally built porkbun image" { + grep -q 'franksdank/caddy-porkbun' "$REPO_ROOT/dankclaw/compose.yaml" +} + +@test "the caddy service passes the Porkbun credentials from .env" { + grep -q 'PORKBUN_API_KEY: \${PORKBUN_API_KEY}' "$REPO_ROOT/dankclaw/compose.yaml" + grep -q 'PORKBUN_API_SECRET_KEY: \${PORKBUN_API_SECRET_KEY}' "$REPO_ROOT/dankclaw/compose.yaml" +} + +@test ".env.example carries the Porkbun secret under the module's name" { + grep -q 'PORKBUN_API_SECRET_KEY=' "$REPO_ROOT/dankclaw/.env.example" + ! grep -q '^PORKBUN_API_SECRET=' "$REPO_ROOT/dankclaw/.env.example" +} + +@test "the porkbun caddy image build lives in the repo" { + local cf="$REPO_ROOT/dankclaw/caddy/Containerfile" + [ -f "$cf" ] || { echo "missing $cf"; return 1; } + grep -q 'xcaddy build' "$cf" + grep -q 'github.com/caddy-dns/porkbun' "$cf" +}