#!/usr/bin/env bats # Seam: Caddyfile validity. # # There is exactly one Caddy in the homelab: the container on dankclaw # (dankclaw/compose.yaml, service 'caddy'). It fronts every service on both # hosts — local services by container name, dankserver services by published # LAN port. Site blocks use the flat franksdank.cloud / franksdank.casa # convention. The basicauth hash is a placeholder in git; deploy.sh renders # the real hash from the host .env (CADDY_BASICAUTH_HASH). load helpers CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile" @test "the Caddyfile lives on dankclaw only (Caddy is consolidated there)" { [ -f "$CADDYFILE" ] [ ! -f "$REPO_ROOT/dankserver/Caddyfile" ] } @test "the Caddyfile uses the flat franksdank.cloud hostname convention" { grep -qE '\.franksdank\.cloud' "$CADDYFILE" } @test "no .local vhost names remain in the Caddyfile" { ! grep -qE '^([a-z0-9.-]+\.)?(dankserver|dankclaw)\.local[[:space:]]*\{' \ "$CADDYFILE" } @test "the Caddyfile has balanced braces" { local open close open=$(grep -o '{' "$CADDYFILE" | wc -l) close=$(grep -o '}' "$CADDYFILE" | wc -l) [ "$open" -eq "$close" ] || { echo "unbalanced braces ($open vs $close)"; return 1; } } @test "caddy validate passes when caddy is available" { if command -v caddy >/dev/null 2>&1; then caddy validate --config "$CADDYFILE" --adapter caddyfile else skip "caddy binary not installed locally" fi } @test "homepage basic auth exists in the Caddyfile" { grep -qiE 'basicauth|basic_auth' "$CADDYFILE" } @test "the Caddyfile carries the renderable basicauth placeholder" { grep -q 'REPLACE_WITH_HASH' "$CADDYFILE" } @test "the Caddyfile does not disable the admin endpoint (hot reload needs it)" { # admin off disables Caddy's config file watcher: rendered Caddyfile # changes are never loaded until the container is recreated, and the # stale config fails silently (2026-08-22: basicauth stayed broken for # hours across deploys). The admin API binds to 127.0.0.1:2019 inside # the container and the port is not published, so it is not reachable # from the LAN. if grep -qE '^\s*admin\s+off' "$CADDYFILE"; then echo "Caddyfile: 'admin off' disables hot reload of rendered config" return 1 fi } @test "the basicauth placeholder is a bare token (no bcrypt prefix in the template)" { # Regression (2026-08-22): the template line was # dank $2a$14$REPLACE_WITH_HASH # and deploy.sh substitutes the FULL hash (prefix included), so the # rendered line became an invalid double-prefixed hash and every # basicauth vhost rejected every password with 401. if grep -qE '\$2[abxy]\$[0-9]+\$REPLACE_WITH_HASH' "$CADDYFILE"; then echo "Caddyfile: bcrypt prefix before REPLACE_WITH_HASH (double-prefix regression)" return 1 fi grep -qE 'dankuser REPLACE_WITH_HASH' "$CADDYFILE" } @test "compose no longer defines the quadlet-replaced services" { # gitea, its postgres, open-webui, and caddy run as boot-persistent # quadlets on dankclaw (see dankclaw/gitea/quadlet, # dankclaw/open-webui/quadlet, dankclaw/caddy/quadlet). ! grep -qE '^\s+(gitea|postgres|openwebui|caddy):\s*$' "$REPO_ROOT/dankclaw/compose.yaml" } @test "the caddy quadlet healthcheck probes the bare-IP catch-all" { grep -q 'HealthCmd=curl -sf -o /dev/null http://localhost:80/' \ "$REPO_ROOT/dankclaw/caddy/quadlet/caddy.container" } @test "plex vhosts proxy to PMS over a TLS upstream (PMS drops plain HTTP off-loopback)" { # 2026-08-26 diagnosis: PMS 1.43.x on :32400 accepts, reads, and then # silently closes every non-TLS connection from a non-loopback source # (loopback is exempt). A plain-HTTP reverse_proxy therefore gets EOF # and Caddy answers 502 with an empty body (blank page in browsers). # Both plex vhosts must dial the upstream over TLS. PMS serves a # self-signed cert, so verification is skipped on this pinned-LAN-IP # hop only; client-facing Let's Encrypt TLS is untouched. local block name for name in 'plex.franksdank.cloud {' 'http://plex.franksdank.casa {'; do block=$(awk -v s="$name" ' index($0, s) == 1 { inblk = 1 } inblk { print } inblk && /^}/ { exit }' "$CADDYFILE") echo "$block" | grep -q 'reverse_proxy 192.168.0.235:32400' \ || { echo "$name: missing PMS upstream"; return 1; } echo "$block" | grep -q 'transport http' \ || { echo "$name: upstream must use the http transport with TLS"; return 1; } echo "$block" | grep -q 'tls_insecure_skip_verify' \ || { echo "$name: TLS upstream must set tls_insecure_skip_verify (PMS self-signed cert)"; return 1; } done } @test "Gitea has no standalone reverse-proxy Quadlet" { [ ! -e "$REPO_ROOT/dankclaw/gitea/quadlet/gitea-caddy.container" ] } @test "the canonical Caddy owns the Gitea route" { grep -qF 'gitea.franksdank.cloud {' "$CADDYFILE" grep -qF 'reverse_proxy 192.168.0.143:3000' "$CADDYFILE" } # --- Phase 2: real TLS via DNS-01 (Porkbun wildcard cert) --- @test "all vhosts use TLS except the legacy .casa name" { # franksdank.casa is not in the same Porkbun account, so it cannot get # a DNS-01 Let's Encrypt cert; it stays explicitly http. Every other # site address must carry no http:// prefix. local offenders offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' || true) [ -z "$offenders" ] || { echo "non-.casa http:// vhosts: $offenders"; return 1; } } @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://franksdank.cloud/' "$CADDYFILE" } @test "the caddy quadlet uses the locally built porkbun image without pulling" { # The image is built on-host from dankclaw/caddy/Containerfile; a boot # must not stall on a registry that does not have it. grep -q '^Image=franksdank/caddy-porkbun:latest$' \ "$REPO_ROOT/dankclaw/caddy/quadlet/caddy.container" grep -q '^Pull=never$' "$REPO_ROOT/dankclaw/caddy/quadlet/caddy.container" } @test "the caddy quadlet reads the Porkbun credentials from its env file" { grep -q '^EnvironmentFile=/home/dank/Containers/caddy/env$' \ "$REPO_ROOT/dankclaw/caddy/quadlet/caddy.container" grep -q '^PORKBUN_API_KEY=changeme$' "$REPO_ROOT/dankclaw/caddy/env.example" grep -q '^PORKBUN_API_SECRET_KEY=changeme$' "$REPO_ROOT/dankclaw/caddy/env.example" } @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" } # --- Observability configs (deployed by deploy.sh step 3) --- @test "no legacy .local service names remain in shared configs or compose" { ! grep -rn 'dankclaw\.local\|dankserver\.local' \ "$REPO_ROOT/shared" "$REPO_ROOT/dankclaw/compose.yaml" } @test "loki compactor has a delete_request_store (required when retention is on)" { local loki="$REPO_ROOT/shared/loki/loki-config.yaml" if grep -q 'retention_enabled: true' "$loki"; then grep -q 'delete_request_store:' "$loki" fi } @test "prometheus targets are host:port (no URL schemes)" { ! grep -nE '^\s*-\s*"?http' "$REPO_ROOT/shared/prometheus/prometheus.yml" } @test "observability containers run as container root (rootless = host user)" { # In rootless podman, container root maps to the invoking user (dank), # which owns the /opt data dirs. The images' default users (loki 10001, # prometheus nobody, grafana 472) map to subuids that own nothing under # /opt and cannot write the data dirs. local compose="$REPO_ROOT/dankclaw/compose.yaml" local svc block for svc in prometheus loki grafana; do block=$(awk -v s="^ $svc:\$" ' $0 ~ s { inblk = 1; next } inblk && /^ [a-z]/ { inblk = 0 } inblk' "$compose") echo "$block" | grep -q 'user: "0:0"' || { echo "service $svc does not run as user 0:0"; return 1; } done } # --- distroless healthchecks (loki/alloy carry a static busybox) --- @test "loki and alloy run from local images carrying a static busybox" { local compose="$REPO_ROOT/dankclaw/compose.yaml" grep -q 'image: franksdank/loki:latest' "$compose" || { echo "loki image not local"; return 1; } grep -q 'image: franksdank/alloy:latest' "$compose" || { echo "alloy image not local"; return 1; } local img for img in loki alloy; do local cf="$REPO_ROOT/dankclaw/$img/Containerfile" [ -f "$cf" ] || { echo "missing $cf"; return 1; } grep -q 'busybox' "$cf" || { echo "$cf does not install busybox"; return 1; } done } @test "loki and alloy healthchecks use the busybox wget" { local compose="$REPO_ROOT/dankclaw/compose.yaml" local n n=$(grep -c '/usr/local/bin/busybox' "$compose") [ "$n" -ge 2 ] || { echo "expected >=2 busybox healthchecks, found $n"; return 1; } } @test "alloy config declares no ui/server block (default UI listens on 127.0.0.1:12345)" { # alloy 1.x refuses to configure the ui service from a config block # ("service ui does not support being configured"). The default UI # already listens where the in-container healthcheck probes. ! grep -qE '^(ui|server) \{' "$REPO_ROOT/shared/alloy/config.alloy" }