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
2026-08-24 23:48:27 -07:00
parent 7ba1acdbc5
commit 3cc99d81cc
17 changed files with 634 additions and 137 deletions
+152
View File
@@ -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
+1 -1
View File
@@ -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.
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
+44 -29
View File
@@ -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.
**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
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
@@ -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 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
@@ -110,42 +112,55 @@ Styling is **toggleable** (Q27). The default is the full terminal theme. A toggl
## 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
handle /admin* {
basicauth {
frank REPLACE_WITH_ADMIN_HASH
}
try_files /admin.html
file_server
}
route {
# 1. The outpost path is proxied to Authentik (login UI + session).
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
@data path /services.json /status.json /app.js /style.css /deployed.js
handle @data {
basicauth {
dank REPLACE_WITH_HASH
frank REPLACE_WITH_ADMIN_HASH
# 2. forward_auth checks the session before serving the page.
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
}
file_server
}
handle {
basicauth {
dank REPLACE_WITH_HASH
# 3. The pages, in file order. 'handle' preserves order; a top-level
# 'rewrite' would be sorted first and break the path matchers.
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
@@ -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 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)
+14
View File
@@ -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.
+15
View File
@@ -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.
+14
View File
@@ -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.