Files
Frank f134cc8876 docs+units: fix every claim my own changes made false
Retiring dankclaw/compose.yaml and adding alert rules invalidated claims across
the repo, including in the rule file agents read first. Clean cutover includes
the prose.

**Tests that had gone hollow.** Four assertions grepped dankclaw/compose.yaml
for a service name. With the file gone they passed by *absence* — a gate that
can no longer fail. Each now asserts the real invariant ("no dankclaw compose
file competes with the quadlet units") and keeps a live check for a re-added
entry, so it still fails when one appears: verified by recreating a compose file
with a comfyui service and watching three tests go red, then removing it.

**AGENTS.md, which was wrong about ComfyUI.** It said ComfyUI runs CPU-only
("no ROCm wheels for gfx1151, CUDA images wrong"). That was true in the
compose era and is false now: the restored unit runs with /dev/kfd + /dev/dri and
must NOT pass --cpu. An agent trusting the rule file would have re-broken it.
Also corrected: the observability paragraph ("RUNNING BUT UNCONFIGURED, no
datasource, no rules" — all three have been configured since 2026-09-16), the
Active runbook row that still named halogen for frank-large, halogen's absence
from the Retired table, the benchmark policy naming halogen's harness, and the
seam list.

**Unit and page claims.** prometheus.container said "no alerting rules anywhere
in this stack"; node-exporter.container said Dankserver runs from its compose
stack; Prometheus.md documented three jobs as working (they scrape HTML: they
can never report up) and said no dashboard existed; index.md and the three
authentik service pages pointed at the retired file; Authentik.md's upgrade
instruction told you to bump an image tag in it.

One bad edit of my own on the way (a stale line number clobbered a See Also
entry in Prometheus.md) — caught by reading back, fixed, and the vault link
checker now proves the cross-references hold.
2026-09-16 07:46:23 -07:00

170 lines
6.9 KiB
Bash

#!/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 protects the vhosts that have no application-level authentication
# (SearXNG, Hermes, Prometheus, RomM, Copyparty, the homepage) with its
# embedded proxy outpost.
#
# The Caddyfile carries an `auth.franksdank.cloud` vhost for the admin UI, and
# an internal `:8016` listener that Anubis dials after verifying a client.
# :8016 proxies every request whole to the Authentik server with the Host
# header preserved; the embedded outpost routes on the ORIGINAL Host against
# each proxy provider's external_host (meta headers do NOT route it —
# disproved live, 2026-08-29) and forwards authenticated traffic back to the
# post-auth listener `:8017`.
#
# See ../docs/Infrastructure/Authentik.md.
load helpers
# (no COMPOSE here: dankclaw/compose.yaml is retired — every container on that
# host is unit-managed, and this seam reads the units.)
CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile"
ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example"
# --- Quadlet units: the 3-service stack ------------------------------------
# The trio left compose: they are boot-persistent Quadlet units installed to
# ~/.config/containers/systemd/ by deploy-remote.sh (deploy_authentik).
UNIT_DIR="$REPO_ROOT/dankclaw/authentik/quadlet"
@test "the three authentik quadlet units exist" {
for unit in authentik-postgres authentik-server authentik-worker; do
[ -f "$UNIT_DIR/$unit.container" ]
done
}
@test "authentik-postgres uses the official postgres image" {
grep -q 'Image=docker.io/library/postgres:16-alpine' "$UNIT_DIR/authentik-postgres.container"
}
@test "server and worker use the official goauthentik image" {
grep -q 'Image=ghcr.io/goauthentik/server:' "$UNIT_DIR/authentik-server.container"
grep -q 'Image=ghcr.io/goauthentik/server:' "$UNIT_DIR/authentik-worker.container"
}
@test "the same image runs both roles; the Exec command selects the role" {
grep -qE '^Exec=server$' "$UNIT_DIR/authentik-server.container"
grep -qE '^Exec=worker$' "$UNIT_DIR/authentik-worker.container"
}
@test "startup order is postgres -> server -> worker via unit ordering" {
grep -q 'After=network.target authentik-postgres.service' "$UNIT_DIR/authentik-server.container"
grep -q 'Wants=authentik-postgres.service' "$UNIT_DIR/authentik-server.container"
grep -q 'Wants=authentik-server.service' "$UNIT_DIR/authentik-worker.container"
}
@test "the server publishes 9000 on loopback only; LAN reaches it through Caddy" {
grep -q 'PublishPort=127.0.0.1:9000:9000' "$UNIT_DIR/authentik-server.container"
}
@test "authentik-postgres healthcheck uses pg_isready" {
grep -q 'HealthCmd=.*pg_isready' "$UNIT_DIR/authentik-postgres.container"
}
# --- Units: environment (secrets stay in the host .env, expanded at start) --
@test "the units consume AUTHENTIK_PG_PASS via systemd expansion, not literals" {
grep -q 'POSTGRES_PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-postgres.container"
grep -q 'PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-server.container"
grep -q 'PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-worker.container"
}
@test "the units consume AUTHENTIK_SECRET_KEY via systemd expansion, not literals" {
for unit in authentik-server authentik-worker; do
grep -q 'AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}' "$UNIT_DIR/$unit.container"
done
}
@test "no secret material is committed in the quadlet units" {
! grep -rqE 'SECRET_KEY=[^$]' "$UNIT_DIR"
}
@test "deploy-remote.sh routes the authentik trio to the quadlet handler, not compose up" {
grep -q 'authentik-postgres|authentik-server|authentik-worker)' "$REPO_ROOT/scripts/deploy-remote.sh"
grep -q 'deploy_authentik()' "$REPO_ROOT/scripts/deploy-remote.sh"
! grep -qE '^[[:space:]]+comfyui\|.*authentik' "$REPO_ROOT/scripts/deploy-remote.sh"
}
# --- 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: guarded vhosts proxy the whole request through Authentik ----
@test "guarded vhosts proxy the whole request through authentik on the internal listener" {
awk '/^http:\/\/:8016/,/^\}/' "$CADDYFILE" | grep -q 'reverse_proxy authentik-server:9000'
}
@test "the internal listener preserves Host and never emits cross-host next=" {
# The outpost routes on the original Host: no header_up Host override...
! grep -q 'header_up Host' "$CADDYFILE"
# ...and the rejected design (absolute cross-host next= into the flow UI)
# must never return — it produces "Invalid next URL" at flow completion.
! grep -qE 'if/flow/[^ ]*next=https' "$CADDYFILE"
}
@test "authenticated traffic returns to Caddy on the post-auth listener" {
grep -qE '^http://:8017 \{' "$CADDYFILE"
}
# --- Homepage: www holds the homepage; naked redirects onto it ---
@test "the homepage vhost is www, guarded by Anubis" {
local block
block=$(awk '/^www\.franksdank\.cloud[[:space:]]*\{/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'reverse_proxy anubis:8923'
}
@test "the naked domain redirects to www at the TLS edge" {
local block
block=$(awk '/^franksdank\.cloud[[:space:]]*\{/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308'
}
@test "dankclaw.franksdank.cloud redirects straight onto www" {
local block
block=$(awk '/^dankclaw\.franksdank\.cloud/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308'
}
@test "dankserver.franksdank.cloud redirects straight onto www" {
local block
block=$(awk '/^dankserver\.franksdank\.cloud/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308'
}
@test "the bare-IP catch-all redirects onto www" {
local block
block=$(awk '/^:80 \{/,/^}/' "$CADDYFILE")
echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308'
}
# --- .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; }
}