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:
+14
-8
@@ -25,16 +25,14 @@ GRAFANA_ADMIN_USER=admin
|
|||||||
GRAFANA_ADMIN_PASSWORD=changeme
|
GRAFANA_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
# Caddy (the homelab's single reverse proxy, on this host)
|
# Caddy (the homelab's single reverse proxy, on this host)
|
||||||
# bcrypt hashes (caddy hash-password). deploy.sh renders them into
|
# bcrypt hash (caddy hash-password). deploy.sh renders it into
|
||||||
# /home/dank/Containers/caddy/Caddyfile.
|
# /home/dank/Containers/caddy/Caddyfile.
|
||||||
# CADDY_BASICAUTH_HASH: the shared family password (the other vhosts: gitea,
|
# CADDY_BASICAUTH_HASH: the shared family password, still used by the
|
||||||
# grafana, search, ...). Username: dank.
|
# vhosts that carry basicauth rather than Authentik forward_auth
|
||||||
|
# (grafana, pihole). Username: dankuser.
|
||||||
CADDY_BASICAUTH_HASH=changeme
|
CADDY_BASICAUTH_HASH=changeme
|
||||||
# CADDY_ADMIN_HASH: the operator-only password (the homepage /admin/ page).
|
# (The homepage's per-user hashes were retired: the homepage and its /admin/
|
||||||
# Username: frank.
|
# page are now behind Authentik forward_auth, not basicauth.)
|
||||||
CADDY_ADMIN_HASH=changeme
|
|
||||||
# CADDY_HOMEPAGE_HASH: the homepage user-page password. Username: dankuser.
|
|
||||||
CADDY_HOMEPAGE_HASH=changeme
|
|
||||||
|
|
||||||
# Hermes -> Signal (signal-cli daemon on the host, port 8080)
|
# Hermes -> Signal (signal-cli daemon on the host, port 8080)
|
||||||
# E.164 number of the linked Signal account (your primary number, for Note-to-Self).
|
# E.164 number of the linked Signal account (your primary number, for Note-to-Self).
|
||||||
@@ -48,3 +46,11 @@ SIGNAL_ALLOWED_USERS=+15551234567
|
|||||||
# PORKBUN_API_SECRET_KEY is the name the caddy-dns/porkbun module expects.
|
# PORKBUN_API_SECRET_KEY is the name the caddy-dns/porkbun module expects.
|
||||||
PORKBUN_API_KEY=changeme
|
PORKBUN_API_KEY=changeme
|
||||||
PORKBUN_API_SECRET_KEY=changeme
|
PORKBUN_API_SECRET_KEY=changeme
|
||||||
|
|
||||||
|
# Authentik (SSO / OIDC provider)
|
||||||
|
# AUTHENTIK_PG_PASS: the PostgreSQL password for authentik-postgres.
|
||||||
|
# Generate: openssl rand -base64 32
|
||||||
|
AUTHENTIK_PG_PASS=changeme
|
||||||
|
# AUTHENTIK_SECRET_KEY: the server's secret key (64+ chars).
|
||||||
|
# Generate: openssl rand -base64 48
|
||||||
|
AUTHENTIK_SECRET_KEY=changeme
|
||||||
|
|||||||
+93
-55
@@ -11,16 +11,19 @@
|
|||||||
# (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143)
|
# (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143)
|
||||||
# and handed to LAN clients by the SURFboard G36 router.
|
# and handed to LAN clients by the SURFboard G36 router.
|
||||||
#
|
#
|
||||||
# The basicauth hash below is a placeholder. deploy.sh renders the real
|
# Authentication: Caddy forward_auth (Authentik SSO) protects the vhosts that
|
||||||
# hash (CADDY_BASICAUTH_HASH from the host .env, git-ignored) into
|
# do not have their own application-level auth (homepage, SearXNG, Hermes,
|
||||||
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
|
# Prometheus, RomM, Copyparty). The basicauth hash is a placeholder; deploy.sh
|
||||||
|
# renders the real hash (CADDY_BASICAUTH_HASH from the host .env, git-ignored)
|
||||||
|
# into /home/dank/Containers/caddy/Caddyfile, which the container mounts.
|
||||||
|
# Apps with their own auth (Grafana, Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole)
|
||||||
|
# are not wrapped in forward_auth — they handle their own login.
|
||||||
#
|
#
|
||||||
# TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix,
|
# TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix,
|
||||||
# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are wildcard
|
# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via
|
||||||
# (dns-01) via the global acme_dns block below, using the Porkbun module
|
# dns-01 through the Porkbun module built into the franksdank/caddy-porkbun
|
||||||
# built into the franksdank/caddy-porkbun image (caddy/Containerfile).
|
# image (caddy/Containerfile). That needs no inbound ports and works from the
|
||||||
# That needs no inbound ports and works from the LAN, where dnsmasq
|
# LAN, where Pi-hole resolves these names to 192.168.0.143.
|
||||||
# resolves these names to 192.168.0.143.
|
|
||||||
|
|
||||||
{
|
{
|
||||||
# Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the
|
# Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the
|
||||||
@@ -41,51 +44,61 @@
|
|||||||
|
|
||||||
# --- Bare-IP access (health checks, convenience) ---
|
# --- Bare-IP access (health checks, convenience) ---
|
||||||
:80 {
|
:80 {
|
||||||
redir https://dankclaw.franksdank.cloud/ 302
|
redir https://franksdank.cloud/ 302
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Homepage (password protected) ---
|
# --- Homepage (Authentik SSO) ---
|
||||||
dankclaw.franksdank.cloud {
|
franksdank.cloud www.franksdank.cloud {
|
||||||
root * /srv/homepage
|
root * /srv/homepage
|
||||||
|
|
||||||
# Operator-only admin page (Q19). Auth is checked on the original path,
|
route {
|
||||||
# before any rewrite, so the path matcher is reliable. Caddy sorts a
|
# Authentik outpost (login UI + session management).
|
||||||
# top-level 'rewrite' before 'basicauth'; that would change the path and
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
# break the path-based auth matchers. 'handle' preserves order.
|
|
||||||
handle /admin* {
|
|
||||||
basicauth {
|
|
||||||
frank REPLACE_WITH_ADMIN_HASH
|
|
||||||
}
|
|
||||||
try_files /admin.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
# Shared data + assets: the family OR the operator, so both pages can load
|
# Forward auth: check the Authentik session before serving the page.
|
||||||
# them (the browser reuses the page's credential for these fetches). A
|
forward_auth authentik-server:9000 {
|
||||||
# 'handle' inline matcher is a single token, so the multi-path match is a
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
# named matcher.
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
@data path /services.json /status.json /app.js /style.css /deployed.js
|
trusted_proxies private_ranges
|
||||||
handle @data {
|
|
||||||
basicauth {
|
|
||||||
dankuser REPLACE_WITH_HOMEPAGE_HASH
|
|
||||||
frank REPLACE_WITH_ADMIN_HASH
|
|
||||||
}
|
}
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
# Everything else (the user page): the homepage login (dankuser).
|
# Operator-only admin page. Auth is checked on the original path,
|
||||||
handle {
|
# before any rewrite, so the path matcher is reliable. 'handle'
|
||||||
basicauth {
|
# preserves order.
|
||||||
dankuser REPLACE_WITH_HOMEPAGE_HASH
|
handle /admin* {
|
||||||
|
try_files /admin.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared data + assets: 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 these fetches).
|
||||||
|
@data path /services.json /status.json /app.js /style.css /deployed.js
|
||||||
|
handle @data {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else (the user page).
|
||||||
|
handle {
|
||||||
|
try_files /index.html
|
||||||
|
file_server
|
||||||
}
|
}
|
||||||
try_files /index.html
|
|
||||||
file_server
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Legacy name: the old homepage name redirects to the naked domain ---
|
||||||
|
dankclaw.franksdank.cloud {
|
||||||
|
redir https://franksdank.cloud/ 302
|
||||||
|
}
|
||||||
|
|
||||||
# --- Legacy name: the media host has no Caddy of its own anymore ---
|
# --- Legacy name: the media host has no Caddy of its own anymore ---
|
||||||
dankserver.franksdank.cloud {
|
dankserver.franksdank.cloud {
|
||||||
redir https://dankclaw.franksdank.cloud/ 302
|
redir https://franksdank.cloud/ 302
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Authentik (SSO / OIDC provider) ---
|
||||||
|
auth.franksdank.cloud {
|
||||||
|
reverse_proxy authentik-server:9000
|
||||||
}
|
}
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
@@ -94,10 +107,15 @@ dankserver.franksdank.cloud {
|
|||||||
|
|
||||||
# --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) ---
|
# --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) ---
|
||||||
search.franksdank.cloud {
|
search.franksdank.cloud {
|
||||||
basicauth {
|
route {
|
||||||
dankuser REPLACE_WITH_HASH
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy 192.168.0.143:8081
|
||||||
}
|
}
|
||||||
reverse_proxy 192.168.0.143:8081
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- ComfyUI ---
|
# --- ComfyUI ---
|
||||||
@@ -112,10 +130,15 @@ chat.franksdank.cloud {
|
|||||||
|
|
||||||
# --- Hermes ---
|
# --- Hermes ---
|
||||||
hermes.franksdank.cloud {
|
hermes.franksdank.cloud {
|
||||||
basicauth {
|
route {
|
||||||
dankuser REPLACE_WITH_HASH
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy hermes:8090
|
||||||
}
|
}
|
||||||
reverse_proxy hermes:8090
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Gitea (quadlet; published 3000) ---
|
# --- Gitea (quadlet; published 3000) ---
|
||||||
@@ -133,10 +156,15 @@ grafana.franksdank.cloud {
|
|||||||
|
|
||||||
# --- Prometheus ---
|
# --- Prometheus ---
|
||||||
prometheus.franksdank.cloud {
|
prometheus.franksdank.cloud {
|
||||||
basicauth {
|
route {
|
||||||
dankuser REPLACE_WITH_HASH
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy prometheus:9090
|
||||||
}
|
}
|
||||||
reverse_proxy prometheus:9090
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Loki ---
|
# --- Loki ---
|
||||||
@@ -176,18 +204,28 @@ books.franksdank.cloud {
|
|||||||
|
|
||||||
# --- RomM ---
|
# --- RomM ---
|
||||||
romm.franksdank.cloud {
|
romm.franksdank.cloud {
|
||||||
basicauth {
|
route {
|
||||||
dankuser REPLACE_WITH_HASH
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy 192.168.0.235:8085
|
||||||
}
|
}
|
||||||
reverse_proxy 192.168.0.235:8085
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Copyparty ---
|
# --- Copyparty ---
|
||||||
files.franksdank.cloud {
|
files.franksdank.cloud {
|
||||||
basicauth {
|
route {
|
||||||
dankuser REPLACE_WITH_HASH
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy 192.168.0.235:3923
|
||||||
}
|
}
|
||||||
reverse_proxy 192.168.0.235:3923
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Jellyfin ---
|
# --- Jellyfin ---
|
||||||
|
|||||||
@@ -207,6 +207,71 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# --- Authentik (SSO / OIDC provider) -------------------------------------
|
||||||
|
# 3-service stack: PostgreSQL + server + worker. Redis was removed in
|
||||||
|
# 2025.10 (sessions/caching moved to PostgreSQL). The Caddyfile's
|
||||||
|
# forward_auth vhosts proxy to authentik-server:9000 (compose network).
|
||||||
|
# See ../docs/Infrastructure/Authentik.md.
|
||||||
|
authentik-postgres:
|
||||||
|
image: docker.io/library/postgres:16-alpine
|
||||||
|
container_name: authentik-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: authentik
|
||||||
|
POSTGRES_USER: authentik
|
||||||
|
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||||
|
volumes:
|
||||||
|
- /home/dank/authentik/postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
|
||||||
|
interval: 30s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
authentik-server:
|
||||||
|
image: ghcr.io/goauthentik/server:2026.8.0
|
||||||
|
container_name: authentik-server
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
volumes:
|
||||||
|
- /home/dank/authentik/data:/data
|
||||||
|
shm_size: 512mb
|
||||||
|
depends_on:
|
||||||
|
authentik-postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/-/health/')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
authentik-worker:
|
||||||
|
image: ghcr.io/goauthentik/server:2026.8.0
|
||||||
|
container_name: authentik-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
command: worker
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
user: "0:0"
|
||||||
|
volumes:
|
||||||
|
- /home/dank/authentik/data:/data
|
||||||
|
depends_on:
|
||||||
|
authentik-postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# --- Reverse proxy + homepage (the homelab's single Caddy) ---------------
|
# --- Reverse proxy + homepage (the homelab's single Caddy) ---------------
|
||||||
# The Caddyfile mounted here is the RENDERED copy (basicauth hash filled in
|
# The Caddyfile mounted here is the RENDERED copy (basicauth hash filled in
|
||||||
# from the host .env by scripts/deploy.sh); the repo copy carries the
|
# from the host .env by scripts/deploy.sh); the repo copy carries the
|
||||||
@@ -235,6 +300,7 @@ services:
|
|||||||
# lacks the generated files.)
|
# lacks the generated files.)
|
||||||
- /srv/homepage:/srv/homepage:ro
|
- /srv/homepage:/srv/homepage:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- authentik-server
|
||||||
- comfyui
|
- comfyui
|
||||||
- hermes
|
- hermes
|
||||||
- grafana
|
- grafana
|
||||||
|
|||||||
@@ -10,10 +10,15 @@
|
|||||||
# 192.168.0.235 = dankserver (media stack; reached by Caddy via published
|
# 192.168.0.235 = dankserver (media stack; reached by Caddy via published
|
||||||
# LAN ports — no DNS records point here)
|
# LAN ports — no DNS records point here)
|
||||||
|
|
||||||
# --- Homepage + legacy name ---
|
# --- Homepage (naked domain + www) + legacy names ---
|
||||||
|
192.168.0.143 franksdank.cloud
|
||||||
|
192.168.0.143 www.franksdank.cloud
|
||||||
192.168.0.143 dankclaw.franksdank.cloud
|
192.168.0.143 dankclaw.franksdank.cloud
|
||||||
192.168.0.143 dankserver.franksdank.cloud
|
192.168.0.143 dankserver.franksdank.cloud
|
||||||
|
|
||||||
|
# --- Authentik (SSO / OIDC provider) ---
|
||||||
|
192.168.0.143 auth.franksdank.cloud
|
||||||
|
|
||||||
# --- Media services (dankserver, proxied) ---
|
# --- Media services (dankserver, proxied) ---
|
||||||
192.168.0.143 plex.franksdank.cloud
|
192.168.0.143 plex.franksdank.cloud
|
||||||
192.168.0.143 plex.franksdank.casa
|
192.168.0.143 plex.franksdank.casa
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Authentik — the homelab's SSO / OIDC provider
|
||||||
|
|
||||||
|
Authentik is the single sign-on (SSO) and OpenID Connect (OIDC) provider for the
|
||||||
|
homelab. It fronts the Caddy vhosts that do not carry their own
|
||||||
|
application-level authentication. Apps that have their own login (Grafana,
|
||||||
|
Gitea, OpenWebUI, Jellyfin, the *arr stack, Pi-hole) are not wrapped in
|
||||||
|
forward_auth — they handle their own session.
|
||||||
|
|
||||||
|
- **Version:** `2026.8.0` (pinned in `dankclaw/compose.yaml`)
|
||||||
|
- **Host:** dankclaw (`192.168.0.143`)
|
||||||
|
- **Name:** `auth.franksdank.cloud`
|
||||||
|
- **Stack:** 3 compose services — `authentik-postgres`, `authentik-server`,
|
||||||
|
`authentik-worker` (see [Pi-hole](Pi-hole.md) for the LAN-DNS pattern)
|
||||||
|
|
||||||
|
## Why Authentik
|
||||||
|
|
||||||
|
The comparison that chose it (against Authelia, Keycloak, Zitadel, Ory) is in
|
||||||
|
`.docaudit/research/auth.md`. In short: full OIDC with single-logout, a web GUI
|
||||||
|
for all configuration, a native Caddy `forward_auth` outpost, and a footprint
|
||||||
|
that fits on a 3-user homelab (~1–1.5 GB idle). Redis was removed in 2025.10,
|
||||||
|
so the stack is 3 services, not 4.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph Browser
|
||||||
|
U[User]
|
||||||
|
end
|
||||||
|
subgraph Caddy [Caddy - auth gate]
|
||||||
|
FA[forward_auth]
|
||||||
|
end
|
||||||
|
subgraph AK [Authentik]
|
||||||
|
SRV[server :9000]
|
||||||
|
WKR[worker]
|
||||||
|
PG[(postgres)]
|
||||||
|
OUT[outpost /auth/caddy]
|
||||||
|
end
|
||||||
|
subgraph App [Target app]
|
||||||
|
A1[SearXNG / Hermes / Prometheus<br/>RomM / Copyparty / homepage]
|
||||||
|
end
|
||||||
|
|
||||||
|
U -->|1. GET /| FA
|
||||||
|
FA -->|2. GET /outpost.../auth/caddy| OUT
|
||||||
|
OUT -->|3. no session: 302 login| U
|
||||||
|
U -->|4. login at auth.franksdank.cloud| SRV
|
||||||
|
SRV --> PG
|
||||||
|
WKR --> PG
|
||||||
|
SRV -->|5. 2xx: session valid| FA
|
||||||
|
FA -->|6. proxy with headers| A1
|
||||||
|
```
|
||||||
|
|
||||||
|
1. The browser hits a protected vhost (e.g. `search.franksdank.cloud`).
|
||||||
|
2. Caddy `forward_auth` clones the request to the Authentik outpost
|
||||||
|
(`/outpost.goauthentik.io/auth/caddy`).
|
||||||
|
3. With no valid session, the outpost returns a `302` to the Authentik login
|
||||||
|
page (`auth.franksdank.cloud`).
|
||||||
|
4. The user logs in at Authentik (email + password, TOTP/WebAuthn MFA, or an
|
||||||
|
upstream provider).
|
||||||
|
5. On the next request the outpost returns `2xx`; Caddy copies the
|
||||||
|
`X-Authentik-*` headers and proxies to the app.
|
||||||
|
|
||||||
|
## The three compose services
|
||||||
|
|
||||||
|
| Service | Role | Image |
|
||||||
|
|---|---|---|
|
||||||
|
| `authentik-postgres` | PostgreSQL 16 (users, flows, sessions, events) | `docker.io/library/postgres:16-alpine` |
|
||||||
|
| `authentik-server` | Core + admin API + the embedded Caddy outpost (port 9000) | `ghcr.io/goauthentik/server:2026.8.0` |
|
||||||
|
| `authentik-worker` | Background tasks (event processing, provider sync, flows) | `ghcr.io/goauthentik/server:2026.8.0` |
|
||||||
|
|
||||||
|
- **Postgres** is the only stateful store. Sessions, caching, and WebSockets
|
||||||
|
moved to PostgreSQL in 2025.10 (Redis removed).
|
||||||
|
- **Server** and **worker** run the same image; the `command` selects the role
|
||||||
|
(`server` / `worker`).
|
||||||
|
- The worker runs as `0:0` (rootless: container root maps to the host user that
|
||||||
|
owns the data dir).
|
||||||
|
- Data persists under `/home/dank/authentik/` (bind mounts, repo policy — no
|
||||||
|
named volumes).
|
||||||
|
|
||||||
|
## Caddy integration
|
||||||
|
|
||||||
|
The Caddyfile wraps the no-auth vhosts in a `route` block. The pattern (one
|
||||||
|
example; the others are identical):
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
search.franksdank.cloud {
|
||||||
|
route {
|
||||||
|
# 1. The outpost path is proxied to Authentik (login UI + session).
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
# 2. forward_auth checks the session before serving the app.
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
# 3. The app.
|
||||||
|
reverse_proxy 192.168.0.143:8081
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Vhosts protected by forward_auth: the homepage (`franksdank.cloud`,
|
||||||
|
`www.franksdank.cloud`), SearXNG, Hermes, Prometheus, RomM, Copyparty. The
|
||||||
|
`auth.franksdank.cloud` vhost proxies to the server directly (no forward_auth —
|
||||||
|
it *is* the login).
|
||||||
|
|
||||||
|
## Configuration (secrets)
|
||||||
|
|
||||||
|
Two secrets live in the host `.env` (git-ignored; placeholders in
|
||||||
|
`.env.example`):
|
||||||
|
|
||||||
|
- `AUTHENTIK_PG_PASS` — the PostgreSQL password. Generate: `openssl rand -base64 32`.
|
||||||
|
- `AUTHENTIK_SECRET_KEY` — the server's signing key. Generate: `openssl rand -base64 48`.
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
1. `./scripts/deploy.sh dankclaw` — pulls, starts the 3 services (postgres
|
||||||
|
health-gated), reloads Caddy.
|
||||||
|
2. Open `https://auth.franksdank.cloud` — the initial setup wizard runs. It
|
||||||
|
creates the superuser (email + password) and generates the DB schema.
|
||||||
|
3. In the admin interface: **Directory → Applications** → add a **Proxy**
|
||||||
|
application (or an **OAuth2/OpenID Connect** provider) for each vhost you
|
||||||
|
protect. The Caddy outpost reads the application's policy.
|
||||||
|
4. For apps with native OIDC (Grafana, Gitea, OpenWebUI): add an **OAuth2/OpenID
|
||||||
|
Connect** provider, create the app with the correct redirect URI, and paste
|
||||||
|
the `client_id` / `client_secret` into the app's SSO settings.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- **Health:** `https://auth.franksdank.cloud/-/health/` (the server healthcheck
|
||||||
|
probes this).
|
||||||
|
- **Logs:** `podman logs authentik-server` / `podman logs authentik-worker`.
|
||||||
|
- **Backups:** `pg_dump` the `authentik-postgres` database (the single source of
|
||||||
|
truth). The `/home/dank/authentik/` dir holds media + static files.
|
||||||
|
- **Upgrades:** bump the image tag in `dankclaw/compose.yaml`, run
|
||||||
|
`./scripts/deploy.sh dankclaw`. Authentik migrates the schema on startup.
|
||||||
|
|
||||||
|
## Failure modes
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Every vhost 502s at the outpost | `authentik-server` down | `podman logs authentik-server`; check postgres health |
|
||||||
|
| Login loops (302 → login → 302) | `trusted_proxies` / `X-Forwarded-*` mismatch | Confirm Caddy sets `X-Forwarded-For`; the outpost trusts `private_ranges` |
|
||||||
|
| "Invalid signature" on the outpost | `AUTHENTIK_SECRET_KEY` differs between server and worker | Both services read the same `.env` var; re-deploy |
|
||||||
|
| Postgres "password authentication failed" | `AUTHENTIK_PG_PASS` changed after first init | Reset via `pg_ctl` or re-init the data dir |
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- [Caddy](Caddy.md) — the reverse proxy that carries the forward_auth directives
|
||||||
|
- [Pi-hole](Pi-hole.md) — the LAN DNS that resolves `auth.franksdank.cloud`
|
||||||
|
- [Network Architecture](../Architecture/Network%20Architecture.md) — the flat naming scheme
|
||||||
|
- Research: `.docaudit/research/auth.md` — the full provider comparison
|
||||||
@@ -63,7 +63,7 @@ chat.franksdank.cloud {
|
|||||||
|
|
||||||
After rendering, the line reads `dank $2a$14$xxxx…` (one complete hash). Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password). `tests/deployment_flow.bats` pins the render contract.
|
After rendering, the line reads `dank $2a$14$xxxx…` (one complete hash). Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password). `tests/deployment_flow.bats` pins the render contract.
|
||||||
|
|
||||||
The homepage also uses basic auth; see [[Homepage]].
|
The homepage (and SearXNG, Hermes, Prometheus, RomM, Copyparty) use `forward_auth` (Authentik SSO) rather than basicauth; see [[Homepage]] and [[Authentik]]. The basicauth hash above is still rendered for the vhosts that keep it (Grafana, Pi-hole).
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
A password-protected web site on Dankclaw lists the services that are up, with a short description of each and a link to both the service and its usage documentation.
|
A password-protected web site on Dankclaw lists the services that are up, with a short description of each and a link to both the service and its usage documentation.
|
||||||
|
|
||||||
**Served by:** Caddy, `file_server` + `basicauth` — **Docs:** this page
|
**Served by:** Caddy, `file_server` + `forward_auth` (Authentik SSO) — **Docs:** this page
|
||||||
|
|
||||||
## Status — 2026-08-23: deployed to production
|
## Status — 2026-08-23: deployed to production
|
||||||
|
|
||||||
The homepage is live on Dankclaw. `./scripts/deploy.sh dankclaw` copies the static assets to `/srv/homepage/`, installs the status checker (systemd timer, every 3 min, writes `status.json`), writes `deployed.js` (the last master deployment datetime), renders the Caddyfile (both basicauth hashes from the host `.env`), reloads Caddy, and checks health. 116 tests pass. The Caddyfile auth uses `handle` blocks (see [Auth](#auth)); the caddy container mounts the host's `/srv/homepage` (not the repo source) so the generated files serve.
|
The homepage is live on Dankclaw. `./scripts/deploy.sh dankclaw` copies the static assets to `/srv/homepage/`, installs the status checker (systemd timer, every 3 min, writes `status.json`), writes `deployed.js` (the last master deployment datetime), renders the Caddyfile (the shared basicauth hash from the host `.env`), reloads Caddy, and checks health. 146 tests pass. The Caddyfile auth uses a `route` block with `forward_auth` (see [Auth](#auth)); the caddy container mounts the host's `/srv/homepage` (not the repo source) so the generated files serve.
|
||||||
|
|
||||||
To change the operator password: regenerate `CADDY_ADMIN_HASH` (`caddy hash-password`) in the host `.env`, then redeploy. The per-service `/docs/` pages, the *arr stack, and the optional `/how/` page remain separate threads.
|
**Moved to the naked domain (2026-08-24):** the homepage now serves `franksdank.cloud` and `www.franksdank.cloud`. The old names `dankclaw.franksdank.cloud` and `dankserver.franksdank.cloud` redirect to it. Authentication moved from per-page basicauth to Authentik `forward_auth` (see [Authentik](Authentik.md)).
|
||||||
|
|
||||||
|
The per-service `/docs/` pages, the *arr stack, and the optional `/how/` page remain separate threads.
|
||||||
|
|
||||||
## Design decision — 2026-08-22
|
## Design decision — 2026-08-22
|
||||||
|
|
||||||
@@ -16,9 +18,9 @@ The live up/down check runs server-side. A checker on Dankclaw probes each backe
|
|||||||
|
|
||||||
The site has two pages. The root lists user-facing services only, with no host labels. An `/admin/` subpage lists the admin and infrastructure services behind a separate password for the operator. Gitea is a user service. Hermes is an admin service. A "how it is built" page for visitors is deferred (Q6).
|
The site has two pages. The root lists user-facing services only, with no host labels. An `/admin/` subpage lists the admin and infrastructure services behind a separate password for the operator. Gitea is a user service. Hermes is an admin service. A "how it is built" page for visitors is deferred (Q6).
|
||||||
|
|
||||||
The client-side probe design is blocked in this topology. The page origin is `https://dankclaw.franksdank.cloud`. The service URLs are `http://<name>.franksdank.cloud`. That is cross-origin, mixed content, and auth-gated (HTTP 401). All three fail a browser `fetch`.
|
The client-side probe design is blocked in this topology. The page origin is `https://franksdank.cloud`. The service URLs are `https://<name>.franksdank.cloud`. That is cross-origin and auth-gated. Both fail a browser `fetch`.
|
||||||
|
|
||||||
Intended direction: move to client-side probing. Serve every service as a same-origin `https` path under `dankclaw.franksdank.cloud`. Add a health endpoint and CORS headers to each backend. Then the page probes each service directly. Keep this note until that shift is complete.
|
Intended direction: move to client-side probing. Serve every service as a same-origin `https` path under `franksdank.cloud`. Add a health endpoint and CORS headers to each backend. Then the page probes each service directly. Keep this note until that shift is complete.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -110,42 +112,55 @@ Styling is **toggleable** (Q27). The default is the full terminal theme. A toggl
|
|||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
Caddy wraps the site with HTTP basic auth. The root page uses the shared password. The `/admin/` page uses a second, operator-only password. The shared data + assets accept **both** (the browser reuses the page's credential for the data fetches). The hashes come from the host `.env` (git-ignored). Do not commit the hash or the password.
|
Caddy wraps the site with `forward_auth` (Authentik SSO). A single Authentik
|
||||||
|
session covers the whole site: the user page, the `/admin/` page, and the
|
||||||
|
shared data + assets. The browser's Authentik cookie is reused for the data
|
||||||
|
fetches, so no per-page credential is needed. See [Authentik](Authentik.md) for
|
||||||
|
the provider, the outpost, and the first-time setup.
|
||||||
|
|
||||||
The pages are served from `handle` blocks, not a top-level `rewrite`. Caddy **sorts** top-level directives, so a `rewrite` runs before a `basicauth`; the rewrite would change `/admin/` to `/admin.html` and the path-based auth matchers would then see the rewritten path and apply the wrong block. A `handle` block preserves file order, so the auth is checked on the original path.
|
The Caddyfile pattern (one of the forward_auth vhosts):
|
||||||
|
|
||||||
```
|
```
|
||||||
dankclaw.franksdank.cloud {
|
franksdank.cloud www.franksdank.cloud {
|
||||||
root * /srv/homepage
|
root * /srv/homepage
|
||||||
|
|
||||||
handle /admin* {
|
route {
|
||||||
basicauth {
|
# 1. The outpost path is proxied to Authentik (login UI + session).
|
||||||
frank REPLACE_WITH_ADMIN_HASH
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
}
|
|
||||||
try_files /admin.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
@data path /services.json /status.json /app.js /style.css /deployed.js
|
# 2. forward_auth checks the session before serving the page.
|
||||||
handle @data {
|
forward_auth authentik-server:9000 {
|
||||||
basicauth {
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
dank REPLACE_WITH_HASH
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
frank REPLACE_WITH_ADMIN_HASH
|
trusted_proxies private_ranges
|
||||||
}
|
}
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
handle {
|
# 3. The pages, in file order. 'handle' preserves order; a top-level
|
||||||
basicauth {
|
# 'rewrite' would be sorted first and break the path matchers.
|
||||||
dank REPLACE_WITH_HASH
|
handle /admin* {
|
||||||
|
try_files /admin.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
@data path /services.json /status.json /app.js /style.css /deployed.js
|
||||||
|
handle @data {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
try_files /index.html
|
||||||
|
file_server
|
||||||
}
|
}
|
||||||
try_files /index.html
|
|
||||||
file_server
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A `handle` inline matcher is a single token; the multi-path `@data` match is a named matcher. The caddy container mounts the host's `/srv/homepage` (where the deploy writes the static files and the checker writes `status.json`), not the repo's `shared/homepage`. A compose change needs a container recreate (`podman compose up -d caddy`), not a reload. The Caddyfile syntax is documented in the agent knowledge base (`~/.pi/agent/docs/caddy/caddyfile.md`).
|
A `handle` inline matcher is a single token; the multi-path `@data` match is a
|
||||||
|
named matcher. The caddy container mounts the host's `/srv/homepage` (where the
|
||||||
|
deploy writes the static files and the checker writes `status.json`), not the
|
||||||
|
repo's `shared/homepage`. A compose change needs a container recreate (`podman
|
||||||
|
compose up -d caddy`), not a reload. The Caddyfile syntax is documented in the
|
||||||
|
agent knowledge base (`~/.pi/agent/docs/caddy/caddyfile.md`).
|
||||||
|
|
||||||
## Service entry format
|
## Service entry format
|
||||||
|
|
||||||
@@ -175,7 +190,7 @@ A `handle` inline matcher is a single token; the multi-path `@data` match is a n
|
|||||||
|
|
||||||
The deploy is **git-driven**. Master always reflects the currently deployed state (Q33). `deploy.sh` copies the assets to `/srv/homepage/`, installs the checker timer, renders the Caddyfile (both hashes), and reloads Caddy. It stamps the deployment datetime the footer shows (Q43).
|
The deploy is **git-driven**. Master always reflects the currently deployed state (Q33). `deploy.sh` copies the assets to `/srv/homepage/`, installs the checker timer, renders the Caddyfile (both hashes), and reloads Caddy. It stamps the deployment datetime the footer shows (Q43).
|
||||||
|
|
||||||
The build is **full TDD** (Q36–Q38). Tests exist before the code: the checker (a fake up port, a down port, a WIP service, and the JSON schema), the pages and config (the `services.json` schema, `caddy validate`, the `status.json` reference), and the security check (two distinct basicauth hashes).
|
The build is **full TDD** (Q36–Q38). Tests exist before the code: the checker (a fake up port, a down port, a WIP service, and the JSON schema), the pages and config (the `services.json` schema, `caddy validate`, the `status.json` reference), and the auth check (the forward_auth block and the renderable basicauth placeholder).
|
||||||
|
|
||||||
## Per-service docs (separate workstream)
|
## Per-service docs (separate workstream)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# authentik-postgres
|
||||||
|
|
||||||
|
The PostgreSQL 16 database for [Authentik](Authentik.md) — the homelab's SSO /
|
||||||
|
OIDC provider. It is the only stateful store in the stack (users, flows,
|
||||||
|
sessions, events). Redis was removed in Authentik 2025.10; sessions and caching
|
||||||
|
now live here.
|
||||||
|
|
||||||
|
- **Compose service:** `authentik-postgres` (in `dankclaw/compose.yaml`)
|
||||||
|
- **Image:** `docker.io/library/postgres:16-alpine`
|
||||||
|
- **Data:** `/home/dank/authentik/postgres` (bind mount; no named volumes)
|
||||||
|
- **Health:** `pg_isready -d authentik -U authentik`
|
||||||
|
|
||||||
|
See [Authentik.md](Authentik.md) for the full architecture, setup, backups
|
||||||
|
(`pg_dump`), and failure modes.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# authentik-server
|
||||||
|
|
||||||
|
The Authentik server — the core, the admin API, and the embedded Caddy outpost.
|
||||||
|
This is the service the Caddy `forward_auth` vhosts proxy to
|
||||||
|
(`authentik-server:9000`). It also serves the login page at
|
||||||
|
`https://auth.franksdank.cloud`.
|
||||||
|
|
||||||
|
- **Compose service:** `authentik-server` (in `dankclaw/compose.yaml`)
|
||||||
|
- **Image:** `ghcr.io/goauthentik/server:2026.8.0` (command: `server`)
|
||||||
|
- **Port:** `9000` (published; the outpost + admin + login all live here)
|
||||||
|
- **Data:** `/home/dank/authentik/data` (bind mount, shared with the worker)
|
||||||
|
- **Health:** `http://localhost:9000/-/health/`
|
||||||
|
|
||||||
|
See [Authentik.md](Authentik.md) for the full architecture, the Caddy
|
||||||
|
forward_auth pattern, first-time setup, and failure modes.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# authentik-worker
|
||||||
|
|
||||||
|
The Authentik worker — background tasks: event processing, provider
|
||||||
|
synchronization, scheduled flows, and certificate renewal. It runs the same
|
||||||
|
image as the server; the `command: worker` selects the role.
|
||||||
|
|
||||||
|
- **Compose service:** `authentik-worker` (in `dankclaw/compose.yaml`)
|
||||||
|
- **Image:** `ghcr.io/goauthentik/server:2026.8.0` (command: `worker`)
|
||||||
|
- **Runs as:** `0:0` (rootless: container root maps to the host user that owns the data dir)
|
||||||
|
- **Data:** `/home/dank/authentik/data` (bind mount, shared with the server)
|
||||||
|
- **No port** — the worker has no HTTP endpoint.
|
||||||
|
|
||||||
|
See [Authentik.md](Authentik.md) for the full architecture, setup, and failure
|
||||||
|
modes.
|
||||||
@@ -65,6 +65,10 @@ covers both workloads).
|
|||||||
- [[LAN-DNS]] — name resolution, Pi-hole, router DNS hand-off
|
- [[LAN-DNS]] — name resolution, Pi-hole, router DNS hand-off
|
||||||
- [[Pi-hole]] — the LAN DNS server (quadlet, records, web UI)
|
- [[Pi-hole]] — the LAN DNS server (quadlet, records, web UI)
|
||||||
- [[Caddy]] — reverse proxy and TLS
|
- [[Caddy]] — reverse proxy and TLS
|
||||||
|
- [[Authentik]] — the homelab's SSO / OIDC provider (forward_auth)
|
||||||
|
- [[authentik-postgres]] — Authentik database service
|
||||||
|
- [[authentik-server]] — Authentik server + Caddy outpost service
|
||||||
|
- [[authentik-worker]] — Authentik background worker service
|
||||||
- [[Homepage]] — password-protected service index
|
- [[Homepage]] — password-protected service index
|
||||||
- [[Homepage Design Grilling]] — homepage decision log and open questions
|
- [[Homepage Design Grilling]] — homepage decision log and open questions
|
||||||
- [[Observability]] — Grafana, Prometheus, Loki, Alloy
|
- [[Observability]] — Grafana, Prometheus, Loki, Alloy
|
||||||
|
|||||||
+7
-8
@@ -160,22 +160,21 @@ if [ '${COPY_SHARED[$TARGET]}' = '1' ]; then
|
|||||||
cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
|
cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
|
||||||
cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
|
cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
|
||||||
|
|
||||||
echo ' Rendering Caddyfile (basicauth hashes from .env)...'
|
echo ' Rendering Caddyfile (basicauth hash from .env)...'
|
||||||
# Extract the hashes with grep/cut rather than sourcing .env: sourcing a
|
# Extract the hash with grep/cut rather than sourcing .env: sourcing a
|
||||||
# bcrypt value in a set -u shell dies on its dollar-sign expansion.
|
# bcrypt value in a set -u shell dies on its dollar-sign expansion.
|
||||||
# '|| true' so a missing hash warns below instead of aborting under set -e.
|
# '|| true' so a missing hash warns below instead of aborting under set -e.
|
||||||
|
# Only the shared family hash remains: the homepage's per-user hashes were
|
||||||
|
# retired in favor of Authentik forward_auth (the /admin/ page and the user
|
||||||
|
# page are now behind the same SSO session).
|
||||||
CADDY_BASICAUTH_HASH=\"\$(grep -E '^CADDY_BASICAUTH_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)\"
|
CADDY_BASICAUTH_HASH=\"\$(grep -E '^CADDY_BASICAUTH_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)\"
|
||||||
CADDY_ADMIN_HASH=\"\$(grep -E '^CADDY_ADMIN_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)\"
|
if [ -n \"\${CADDY_BASICAUTH_HASH}\" ]; then
|
||||||
CADDY_HOMEPAGE_HASH=\"\$(grep -E '^CADDY_HOMEPAGE_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)\"
|
|
||||||
if [ -n \"\${CADDY_BASICAUTH_HASH}\" ] && [ -n \"\${CADDY_ADMIN_HASH}\" ] && [ -n \"\${CADDY_HOMEPAGE_HASH}\" ]; then
|
|
||||||
# '|' delimiter: the bcrypt hash contains '/' characters.
|
# '|' delimiter: the bcrypt hash contains '/' characters.
|
||||||
mkdir -p /home/dank/Containers/caddy
|
mkdir -p /home/dank/Containers/caddy
|
||||||
sed -e \"s|REPLACE_WITH_HASH|\${CADDY_BASICAUTH_HASH}|g\" \\
|
sed -e \"s|REPLACE_WITH_HASH|\${CADDY_BASICAUTH_HASH}|g\" \\
|
||||||
-e \"s|REPLACE_WITH_ADMIN_HASH|\${CADDY_ADMIN_HASH}|g\" \\
|
|
||||||
-e \"s|REPLACE_WITH_HOMEPAGE_HASH|\${CADDY_HOMEPAGE_HASH}|g\" \\
|
|
||||||
dankclaw/Caddyfile > /home/dank/Containers/caddy/Caddyfile
|
dankclaw/Caddyfile > /home/dank/Containers/caddy/Caddyfile
|
||||||
else
|
else
|
||||||
echo ' WARN: a Caddy hash is not set in .env; keeping the placeholder(s)'
|
echo ' WARN: CADDY_BASICAUTH_HASH is not set in .env; keeping the placeholder(s)'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ' Deploying the homepage...'
|
echo ' Deploying the homepage...'
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile"
|
|||||||
}
|
}
|
||||||
|
|
||||||
@test "the bare-IP catch-all redirects to the HTTPS homepage" {
|
@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" {
|
@test "the caddy service uses the locally built porkbun image" {
|
||||||
|
|||||||
+17
-26
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bats
|
#!/usr/bin/env bats
|
||||||
# Seam: the homepage site block in dankclaw/Caddyfile. It must carry a root
|
# Seam: the homepage site block in dankclaw/Caddyfile. It serves /srv/homepage
|
||||||
# basicauth hash and a distinct /admin/-scoped admin hash (Q18/Q19), and serve
|
# from the naked domain (franksdank.cloud) and www. Authentication is via
|
||||||
# /srv/homepage. caddy is not on the dev box, so the tests assert the
|
# Caddy forward_auth (Authentik SSO) — the basicauth hashes were retired in
|
||||||
# structure statically; the deploy renders the two real hashes from the .env.
|
# favor of the unified Authentik login. caddy is not on the dev box, so the
|
||||||
|
# tests assert the structure statically.
|
||||||
|
|
||||||
load helpers
|
load helpers
|
||||||
|
|
||||||
@@ -10,38 +11,28 @@ setup() {
|
|||||||
CADDY="$REPO_ROOT/dankclaw/Caddyfile"
|
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" {
|
@test "the homepage serves /srv/homepage" {
|
||||||
grep -q "root \* /srv/homepage" "$CADDY"
|
grep -q "root \* /srv/homepage" "$CADDY"
|
||||||
grep -q "file_server" "$CADDY"
|
grep -q "file_server" "$CADDY"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "the /admin/ page serves admin.html and / serves index.html" {
|
@test "the /admin/ page serves admin.html and / serves index.html" {
|
||||||
# Each page is served by its own 'handle' block via 'try_files' (which
|
# Each page is served by its own 'handle' block via 'try_files'.
|
||||||
# serves the named file without a top-level 'rewrite' that Caddy would
|
grep -qE "handle[[:space:]]+/admin\*" "$CADDY"
|
||||||
# sort before the basicauth).
|
|
||||||
grep -qE "try_files[[:space:]]+/admin.html" "$CADDY"
|
grep -qE "try_files[[:space:]]+/admin.html" "$CADDY"
|
||||||
grep -qE "try_files[[:space:]]+/index.html" "$CADDY"
|
grep -qE "try_files[[:space:]]+/index.html" "$CADDY"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "the shared data + assets accept both the family and the operator" {
|
@test "the shared data + assets are served to any authenticated user" {
|
||||||
# The admin page (frank) and the user page (dank) both fetch the shared
|
# The data (services.json, status.json) and assets are fetched by both the
|
||||||
# data (services.json, status.json) and assets. The browser reuses the
|
# user page and the admin page. With forward_auth, any authenticated user
|
||||||
# page's credential for these, so the block must accept both users.
|
# 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 "@data[[:space:]]+path[[:space:]]+/services\.json[[:space:]]+/status\.json" "$CADDY"
|
||||||
grep -qE "handle[[:space:]]+@data" "$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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
#!/usr/bin/env bats
|
#!/usr/bin/env bats
|
||||||
# Seam: scripts/deploy.sh deploys the homepage, and the checker runs as a
|
# 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).
|
# systemd timer. The deploy is git-driven (master = the deployed state, Q33).
|
||||||
# The tests assert the deploy references the homepage steps and renders BOTH
|
# The tests assert the deploy references the homepage steps and renders the
|
||||||
# hashes, and that the checker timer + service exist and point at the checker.
|
# 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
|
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"
|
local d="$REPO_ROOT/scripts/deploy.sh"
|
||||||
grep -q "/srv/homepage" "$d"
|
grep -q "/srv/homepage" "$d"
|
||||||
grep -q "homepage-status" "$d"
|
grep -q "homepage-status" "$d"
|
||||||
grep -q "CADDY_BASICAUTH_HASH" "$d"
|
grep -q "CADDY_BASICAUTH_HASH" "$d"
|
||||||
grep -q "CADDY_ADMIN_HASH" "$d"
|
|
||||||
grep -q "deployed.js" "$d"
|
grep -q "deployed.js" "$d"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -87,17 +87,21 @@ HOSTS="$PIHOLE_DIR/addn-hosts"
|
|||||||
case "$line" in \#*) continue ;; esac
|
case "$line" in \#*) continue ;; esac
|
||||||
ip="$(echo "$line" | awk '{print $1}')"
|
ip="$(echo "$line" | awk '{print $1}')"
|
||||||
for n in $(echo "$line" | cut -d' ' -f2-); do
|
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; }
|
|| { echo "UNEXPECTED TLD on $ip: $n"; bad=1; }
|
||||||
done
|
done
|
||||||
done < "$HOSTS"
|
done < "$HOSTS"
|
||||||
[ "$bad" -eq 0 ]
|
[ "$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() {
|
vhost_names() {
|
||||||
grep -oE '^(http://)?[a-z0-9.-]+\.franksdank\.(cloud|casa)[[:space:]]*\{' "$1" \
|
grep -E '^(http://)?[a-z0-9.-]+\.franksdank\.(cloud|casa)([[:space:]]+[a-z0-9.-]+\.franksdank\.(cloud|casa))*[[:space:]]*\{' "$1" \
|
||||||
| sed -E 's#^http://##; s/[[:space:]]*\{$//'
|
| 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
|
# record_ip_for <name> — print the IP a name maps to in addn-hosts
|
||||||
|
|||||||
Reference in New Issue
Block a user