Authentik SSO + homepage naked domain

Auth layer (Authentik 2026.8.0):
- compose: 3-service stack (authentik-postgres, authentik-server, authentik-worker),
  postgres health-gated, bind mounts under /home/dank/authentik
- Caddyfile: auth.franksdank.cloud vhost; forward_auth on the no-auth vhosts
  (homepage, SearXNG, Hermes, Prometheus, RomM, Copyparty); outpost path
  proxied first in a route block
- .env.example: AUTHENTIK_PG_PASS + AUTHENTIK_SECRET_KEY
- deploy.sh: render only CADDY_BASICAUTH_HASH (the homepage per-user hashes
  are retired; grafana + pihole keep basicauth)
- tests/authentik.bats (new) + updated homepage/caddyfile/pihole seams
- docs: Authentik.md + per-service pages; Caddy.md + Homepage.md updated

Homepage (naked domain):
- Caddyfile: dankclaw.franksdank.cloud -> franksdank.cloud www.franksdank.cloud;
  dankclaw + dankserver redirect to it; bare-IP catch-all updated
- addn-hosts: A records for the naked domain, www, and auth (all -> 143)
- tests/pihole.bats: vhost_names handles multi-address blocks; TLD check
  allows the naked domain

All 146 tests pass.
This commit is contained in:
Frank committed 2026-08-24 23:48:27 -07:00
1 parent 7ba1acdbc5
commit 3cc99d81cc
17 files changed
+634 -137

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bats
# Seam: Authentik (SSO / OIDC provider) — the homelab's auth layer.
#
# Authentik is a 3-service stack (PostgreSQL + server + worker) on dankclaw.
# It provides OIDC, SAML, and Caddy forward_auth for the vhosts that do not
# have their own application-level authentication (SearXNG, Hermes,
# Prometheus, RomM, Copyparty, the homepage).
#
# The Caddyfile carries an `auth.franksdank.cloud` vhost that proxies to the
# Authentik server. Vhosts without their own auth are wrapped in a `route`
# block that (1) proxies the outpost path to Authentik, (2) runs forward_auth,
# (3) serves the app.
#
# See ../docs/Infrastructure/Authentik.md.
load helpers
COMPOSE="$REPO_ROOT/dankclaw/compose.yaml"
CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile"
ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example"
# --- Compose: the 3-service stack ---
@test "compose defines authentik-postgres" {
grep -qE '^ authentik-postgres:' "$COMPOSE"
}
@test "compose defines authentik-server" {
grep -qE '^ authentik-server:' "$COMPOSE"
}
@test "compose defines authentik-worker" {
grep -qE '^ authentik-worker:' "$COMPOSE"
}
@test "authentik-postgres uses the official postgres image" {
grep -q 'image: docker.io/library/postgres:' "$COMPOSE"
}
@test "authentik-server uses the official goauthentik image" {
grep -q 'image: ghcr.io/goauthentik/server:' "$COMPOSE"
}
@test "authentik-server runs the 'server' command" {
# The same image runs both server and worker; the command selects the role.
grep -qE '^\s+command:\s*server' "$COMPOSE"
}
@test "authentik-worker runs the 'worker' command" {
grep -qE '^\s+command:\s*worker' "$COMPOSE"
}
@test "authentik-server depends on a healthy authentik-postgres" {
local block
block=$(awk -v s="^ authentik-server:\$" '
$0 ~ s { inblk = 1; next }
inblk && /^ [a-z]/ { inblk = 0 }
inblk' "$COMPOSE")
echo "$block" | grep -q 'condition: service_healthy'
}
@test "authentik-worker depends on a healthy authentik-postgres" {
local block
block=$(awk -v s="^ authentik-worker:\$" '
$0 ~ s { inblk = 1; next }
inblk && /^ [a-z]/ { inblk = 0 }
inblk' "$COMPOSE")
echo "$block" | grep -q 'condition: service_healthy'
}
@test "authentik-server exposes port 9000" {
local block
block=$(awk -v s="^ authentik-server:\$" '
$0 ~ s { inblk = 1; next }
inblk && /^ [a-z]/ { inblk = 0 }
inblk' "$COMPOSE")
echo "$block" | grep -qE '"9000:9000"'
}
@test "authentik-postgres healthcheck uses pg_isready" {
local block
block=$(awk -v s="^ authentik-postgres:\$" '
$0 ~ s { inblk = 1; next }
inblk && /^ [a-z]/ { inblk = 0 }
inblk' "$COMPOSE")
echo "$block" | grep -q 'pg_isready'
}
# --- Compose: environment (secrets from .env) ---
@test "authentik services consume AUTHENTIK_PG_PASS from .env" {
grep -q 'AUTHENTIK_PG_PASS' "$COMPOSE"
}
@test "authentik services consume AUTHENTIK_SECRET_KEY from .env" {
grep -q 'AUTHENTIK_SECRET_KEY' "$COMPOSE"
}
@test "no real authentik secret is committed in compose" {
# The compose must reference the secret via ${VAR}, never a literal.
! grep -qE 'AUTHENTIK_SECRET_KEY:\s*["\x27]?\w{16,}' "$COMPOSE"
}
# --- Caddyfile: the auth vhost ---
@test "the Caddyfile has an auth.franksdank.cloud vhost" {
grep -qE '^auth\.franksdank\.cloud[[:space:]]*\{' "$CADDYFILE"
}
@test "the auth vhost proxies to authentik-server:9000" {
local block
block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'reverse_proxy authentik-server:9000'
}
# --- Caddyfile: forward_auth on vhosts without their own auth ---
@test "the Caddyfile uses forward_auth" {
grep -q 'forward_auth' "$CADDYFILE"
}
@test "forward_auth targets the authentik outpost caddy endpoint" {
grep -q '/outpost.goauthentik.io/auth/caddy' "$CADDYFILE"
}
@test "forward_auth copies the X-Authentik-Username header" {
grep -q 'X-Authentik-Username' "$CADDYFILE"
}
@test "the outpost path is proxied to authentik in forward_auth vhosts" {
grep -q '/outpost.goauthentik.io/\*' "$CADDYFILE"
}
# --- Homepage: moved to naked domain + www ---
@test "the homepage vhost is the naked domain + www" {
grep -qE '^franksdank\.cloud[[:space:]]+www\.franksdank\.cloud[[:space:]]*\{' "$CADDYFILE"
}
@test "dankclaw.franksdank.cloud redirects to the naked domain" {
local block
block=$(awk '/^dankclaw\.franksdank\.cloud/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'redir https://franksdank.cloud/'
}
@test "dankserver.franksdank.cloud redirects to the naked domain" {
local block
block=$(awk '/^dankserver\.franksdank\.cloud/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'redir https://franksdank.cloud/'
}
@test "the bare-IP catch-all redirects to the naked domain" {
grep -q 'redir https://franksdank.cloud/' "$CADDYFILE"
}
# --- .env.example: authentik placeholders ---
@test ".env.example carries AUTHENTIK_PG_PASS" {
grep -q 'AUTHENTIK_PG_PASS=' "$ENV_EXAMPLE"
}
@test ".env.example carries AUTHENTIK_SECRET_KEY" {
grep -q 'AUTHENTIK_SECRET_KEY=' "$ENV_EXAMPLE"
}
@test ".env.example authentik placeholders are not real secrets" {
# Placeholders must be obvious (REPLACE_WITH or empty), not 64-char strings.
local pg sk
pg=$(grep '^AUTHENTIK_PG_PASS=' "$ENV_EXAMPLE" | cut -d= -f2-)
sk=$(grep '^AUTHENTIK_SECRET_KEY=' "$ENV_EXAMPLE" | cut -d= -f2-)
[ ${#pg} -lt 32 ] || { echo "AUTHENTIK_PG_PASS looks like a real secret (len=${#pg})"; return 1; }
[ ${#sk} -lt 32 ] || { echo "AUTHENTIK_SECRET_KEY looks like a real secret (len=${#sk})"; return 1; }
}
+1 -1
View File
@@ -103,7 +103,7 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile"
}
@test "the bare-IP catch-all redirects to the HTTPS homepage" {
grep -q 'redir https://dankclaw.franksdank.cloud/' "$CADDYFILE"
grep -q 'redir https://franksdank.cloud/' "$CADDYFILE"
}
@test "the caddy service uses the locally built porkbun image" {
+17 -26
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bats
# Seam: the homepage site block in dankclaw/Caddyfile. It must carry a root
# basicauth hash and a distinct /admin/-scoped admin hash (Q18/Q19), and serve
# /srv/homepage. caddy is not on the dev box, so the tests assert the
# structure statically; the deploy renders the two real hashes from the .env.
# Seam: the homepage site block in dankclaw/Caddyfile. It serves /srv/homepage
# from the naked domain (franksdank.cloud) and www. Authentication is via
# Caddy forward_auth (Authentik SSO) — the basicauth hashes were retired in
# favor of the unified Authentik login. caddy is not on the dev box, so the
# tests assert the structure statically.
load helpers
@@ -10,38 +11,28 @@ setup() {
CADDY="$REPO_ROOT/dankclaw/Caddyfile"
}
@test "the homepage has its own hash and a distinct admin hash" {
grep -q "REPLACE_WITH_HOMEPAGE_HASH" "$CADDY"
grep -q "REPLACE_WITH_ADMIN_HASH" "$CADDY"
}
@test "the admin basicauth is scoped to /admin/ (Q19)" {
# The admin page is a 'handle /admin*' block with the operator (frank)
# credential. Caddy sorts a top-level 'rewrite' before 'basicauth', which
# would change the path and break the path-based auth matcher; 'handle'
# preserves order, so the auth is checked on the original path.
grep -qE "handle[[:space:]]+/admin\*" "$CADDY"
grep -qE "frank[[:space:]]+REPLACE_WITH_ADMIN_HASH" "$CADDY"
}
@test "the homepage serves /srv/homepage" {
grep -q "root \* /srv/homepage" "$CADDY"
grep -q "file_server" "$CADDY"
}
@test "the /admin/ page serves admin.html and / serves index.html" {
# Each page is served by its own 'handle' block via 'try_files' (which
# serves the named file without a top-level 'rewrite' that Caddy would
# sort before the basicauth).
# Each page is served by its own 'handle' block via 'try_files'.
grep -qE "handle[[:space:]]+/admin\*" "$CADDY"
grep -qE "try_files[[:space:]]+/admin.html" "$CADDY"
grep -qE "try_files[[:space:]]+/index.html" "$CADDY"
}
@test "the shared data + assets accept both the family and the operator" {
# The admin page (frank) and the user page (dank) both fetch the shared
# data (services.json, status.json) and assets. The browser reuses the
# page's credential for these, so the block must accept both users.
@test "the shared data + assets are served to any authenticated user" {
# The data (services.json, status.json) and assets are fetched by both the
# user page and the admin page. With forward_auth, any authenticated user
# can access them (the browser's Authentik session covers the fetch).
grep -qE "@data[[:space:]]+path[[:space:]]+/services\.json[[:space:]]+/status\.json" "$CADDY"
grep -qE "handle[[:space:]]+@data" "$CADDY"
grep -qE "dankuser[[:space:]]+REPLACE_WITH_HOMEPAGE_HASH" "$CADDY"
}
@test "the homepage vhost uses forward_auth (Authentik SSO)" {
local block
block=$(awk '/^franksdank\.cloud[[:space:]]/,/^}/' "$CADDY")
echo "$block" | grep -q 'forward_auth'
}
+5 -4
View File
@@ -1,17 +1,18 @@
#!/usr/bin/env bats
# Seam: scripts/deploy.sh deploys the homepage, and the checker runs as a
# systemd timer. The deploy is git-driven (master = the deployed state, Q33).
# The tests assert the deploy references the homepage steps and renders BOTH
# hashes, and that the checker timer + service exist and point at the checker.
# The tests assert the deploy references the homepage steps and renders the
# basicauth hash, and that the checker timer + service exist and point at the
# checker. (The homepage's per-user hashes were retired: it now uses Authentik
# forward_auth, so only the shared family hash is rendered.)
load helpers
@test "deploy.sh ships the homepage (assets, timer, both hashes, deployed.js)" {
@test "deploy.sh ships the homepage (assets, timer, basicauth hash, deployed.js)" {
local d="$REPO_ROOT/scripts/deploy.sh"
grep -q "/srv/homepage" "$d"
grep -q "homepage-status" "$d"
grep -q "CADDY_BASICAUTH_HASH" "$d"
grep -q "CADDY_ADMIN_HASH" "$d"
grep -q "deployed.js" "$d"
}
+8 -4
View File
@@ -87,17 +87,21 @@ HOSTS="$PIHOLE_DIR/addn-hosts"
case "$line" in \#*) continue ;; esac
ip="$(echo "$line" | awk '{print $1}')"
for n in $(echo "$line" | cut -d' ' -f2-); do
echo "$n" | grep -qE '\.(franksdank\.(cloud|casa))$' \
echo "$n" | grep -qE '(^|\.)franksdank\.(cloud|casa)$' \
|| { echo "UNEXPECTED TLD on $ip: $n"; bad=1; }
done
done < "$HOSTS"
[ "$bad" -eq 0 ]
}
# vhost_names <caddyfile> — print site-block names under the flat scheme
# vhost_names <caddyfile> — print site-block names under the flat scheme.
# Handles both single-address blocks ("name {") and multi-address blocks
# ("name1 name2 {"), one name per line.
vhost_names() {
grep -oE '^(http://)?[a-z0-9.-]+\.franksdank\.(cloud|casa)[[:space:]]*\{' "$1" \
| sed -E 's#^http://##; s/[[:space:]]*\{$//'
grep -E '^(http://)?[a-z0-9.-]+\.franksdank\.(cloud|casa)([[:space:]]+[a-z0-9.-]+\.franksdank\.(cloud|casa))*[[:space:]]*\{' "$1" \
| sed -E 's/^[[:space:]]+//; s/[[:space:]]*\{$//' \
| tr ' ' '\n' \
| sed -E 's#^http://##'
}
# record_ip_for <name> — print the IP a name maps to in addn-hosts