diff --git a/dankclaw/.env.example b/dankclaw/.env.example index 6b709db..32b6194 100644 --- a/dankclaw/.env.example +++ b/dankclaw/.env.example @@ -25,16 +25,14 @@ GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_PASSWORD=changeme # 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. -# CADDY_BASICAUTH_HASH: the shared family password (the other vhosts: gitea, -# grafana, search, ...). Username: dank. +# CADDY_BASICAUTH_HASH: the shared family password, still used by the +# vhosts that carry basicauth rather than Authentik forward_auth +# (grafana, pihole). Username: dankuser. CADDY_BASICAUTH_HASH=changeme -# CADDY_ADMIN_HASH: the operator-only password (the homepage /admin/ page). -# Username: frank. -CADDY_ADMIN_HASH=changeme -# CADDY_HOMEPAGE_HASH: the homepage user-page password. Username: dankuser. -CADDY_HOMEPAGE_HASH=changeme +# (The homepage's per-user hashes were retired: the homepage and its /admin/ +# page are now behind Authentik forward_auth, not basicauth.) # 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). @@ -48,3 +46,11 @@ SIGNAL_ALLOWED_USERS=+15551234567 # PORKBUN_API_SECRET_KEY is the name the caddy-dns/porkbun module expects. PORKBUN_API_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 diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 304221d..7494e4f 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -11,16 +11,19 @@ # (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143) # and handed to LAN clients by the SURFboard G36 router. # -# The basicauth hash below 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. +# Authentication: Caddy forward_auth (Authentik SSO) protects the vhosts that +# do not have their own application-level auth (homepage, SearXNG, Hermes, +# 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, -# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are wildcard -# (dns-01) via the global acme_dns block below, using the Porkbun module -# built into the franksdank/caddy-porkbun image (caddy/Containerfile). -# That needs no inbound ports and works from the LAN, where dnsmasq -# resolves these names to 192.168.0.143. +# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via +# dns-01 through the Porkbun module built into the franksdank/caddy-porkbun +# image (caddy/Containerfile). That needs no inbound ports and works from the +# LAN, where Pi-hole resolves these names to 192.168.0.143. { # 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) --- :80 { - redir https://dankclaw.franksdank.cloud/ 302 + redir https://franksdank.cloud/ 302 } -# --- Homepage (password protected) --- -dankclaw.franksdank.cloud { +# --- Homepage (Authentik SSO) --- +franksdank.cloud www.franksdank.cloud { root * /srv/homepage - # Operator-only admin page (Q19). Auth is checked on the original path, - # before any rewrite, so the path matcher is reliable. Caddy sorts a - # top-level 'rewrite' before 'basicauth'; that would change the path and - # break the path-based auth matchers. 'handle' preserves order. - handle /admin* { - basicauth { - frank REPLACE_WITH_ADMIN_HASH - } - try_files /admin.html - file_server - } + route { + # Authentik outpost (login UI + session management). + reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - # Shared data + assets: the family OR the operator, so both pages can load - # them (the browser reuses the page's credential for these fetches). A - # 'handle' inline matcher is a single token, so the multi-path match is a - # named matcher. - @data path /services.json /status.json /app.js /style.css /deployed.js - handle @data { - basicauth { - dankuser REPLACE_WITH_HOMEPAGE_HASH - frank REPLACE_WITH_ADMIN_HASH + # Forward auth: check the Authentik 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 - } - # Everything else (the user page): the homepage login (dankuser). - handle { - basicauth { - dankuser REPLACE_WITH_HOMEPAGE_HASH + # Operator-only admin page. Auth is checked on the original path, + # before any rewrite, so the path matcher is reliable. 'handle' + # preserves order. + 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 --- 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) --- search.franksdank.cloud { - basicauth { - dankuser REPLACE_WITH_HASH + route { + 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 --- @@ -112,10 +130,15 @@ chat.franksdank.cloud { # --- Hermes --- hermes.franksdank.cloud { - basicauth { - dankuser REPLACE_WITH_HASH + route { + 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) --- @@ -133,10 +156,15 @@ grafana.franksdank.cloud { # --- Prometheus --- prometheus.franksdank.cloud { - basicauth { - dankuser REPLACE_WITH_HASH + route { + 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 --- @@ -176,18 +204,28 @@ books.franksdank.cloud { # --- RomM --- romm.franksdank.cloud { - basicauth { - dankuser REPLACE_WITH_HASH + route { + 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 --- files.franksdank.cloud { - basicauth { - dankuser REPLACE_WITH_HASH + route { + 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 --- diff --git a/dankclaw/compose.yaml b/dankclaw/compose.yaml index c0bac4e..5653f3f 100644 --- a/dankclaw/compose.yaml +++ b/dankclaw/compose.yaml @@ -207,6 +207,71 @@ services: timeout: 10s 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) --------------- # 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 @@ -235,6 +300,7 @@ services: # lacks the generated files.) - /srv/homepage:/srv/homepage:ro depends_on: + - authentik-server - comfyui - hermes - grafana diff --git a/dankclaw/pihole/addn-hosts b/dankclaw/pihole/addn-hosts index 384d9cc..1f3fc2c 100644 --- a/dankclaw/pihole/addn-hosts +++ b/dankclaw/pihole/addn-hosts @@ -10,10 +10,15 @@ # 192.168.0.235 = dankserver (media stack; reached by Caddy via published # 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 dankserver.franksdank.cloud +# --- Authentik (SSO / OIDC provider) --- +192.168.0.143 auth.franksdank.cloud + # --- Media services (dankserver, proxied) --- 192.168.0.143 plex.franksdank.cloud 192.168.0.143 plex.franksdank.casa diff --git a/docs/Infrastructure/Authentik.md b/docs/Infrastructure/Authentik.md new file mode 100644 index 0000000..fd366c0 --- /dev/null +++ b/docs/Infrastructure/Authentik.md @@ -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
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 diff --git a/docs/Infrastructure/Caddy.md b/docs/Infrastructure/Caddy.md index c3be436..f029949 100644 --- a/docs/Infrastructure/Caddy.md +++ b/docs/Infrastructure/Caddy.md @@ -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 diff --git a/docs/Infrastructure/Homepage.md b/docs/Infrastructure/Homepage.md index 3790046..b5b7eaf 100644 --- a/docs/Infrastructure/Homepage.md +++ b/docs/Infrastructure/Homepage.md @@ -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://.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://.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) diff --git a/docs/Infrastructure/authentik-postgres.md b/docs/Infrastructure/authentik-postgres.md new file mode 100644 index 0000000..4339334 --- /dev/null +++ b/docs/Infrastructure/authentik-postgres.md @@ -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. diff --git a/docs/Infrastructure/authentik-server.md b/docs/Infrastructure/authentik-server.md new file mode 100644 index 0000000..8fa9814 --- /dev/null +++ b/docs/Infrastructure/authentik-server.md @@ -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. diff --git a/docs/Infrastructure/authentik-worker.md b/docs/Infrastructure/authentik-worker.md new file mode 100644 index 0000000..faef2ea --- /dev/null +++ b/docs/Infrastructure/authentik-worker.md @@ -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. diff --git a/docs/index.md b/docs/index.md index fc60609..8c7b0ec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -65,6 +65,10 @@ covers both workloads). - [[LAN-DNS]] — name resolution, Pi-hole, router DNS hand-off - [[Pi-hole]] — the LAN DNS server (quadlet, records, web UI) - [[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 Design Grilling]] — homepage decision log and open questions - [[Observability]] — Grafana, Prometheus, Loki, Alloy diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 5d7fb44..94e5c52 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -160,22 +160,21 @@ if [ '${COPY_SHARED[$TARGET]}' = '1' ]; then cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml cp shared/alloy/config.alloy /opt/alloy/config/config.alloy - echo ' Rendering Caddyfile (basicauth hashes from .env)...' - # Extract the hashes with grep/cut rather than sourcing .env: sourcing a + echo ' Rendering Caddyfile (basicauth hash from .env)...' + # 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. # '|| 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_ADMIN_HASH=\"\$(grep -E '^CADDY_ADMIN_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)\" - 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 + if [ -n \"\${CADDY_BASICAUTH_HASH}\" ]; then # '|' delimiter: the bcrypt hash contains '/' characters. mkdir -p /home/dank/Containers/caddy 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 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 echo ' Deploying the homepage...' diff --git a/tests/authentik.bats b/tests/authentik.bats new file mode 100644 index 0000000..9b22a23 --- /dev/null +++ b/tests/authentik.bats @@ -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; } +} diff --git a/tests/caddyfile.bats b/tests/caddyfile.bats index 10a0018..8690529 100644 --- a/tests/caddyfile.bats +++ b/tests/caddyfile.bats @@ -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" { diff --git a/tests/homepage_caddy.bats b/tests/homepage_caddy.bats index 4920eb4..8f45fdb 100644 --- a/tests/homepage_caddy.bats +++ b/tests/homepage_caddy.bats @@ -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' } diff --git a/tests/homepage_deploy.bats b/tests/homepage_deploy.bats index 90a626a..f620b96 100644 --- a/tests/homepage_deploy.bats +++ b/tests/homepage_deploy.bats @@ -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" } diff --git a/tests/pihole.bats b/tests/pihole.bats index ba02f3b..5aa081f 100644 --- a/tests/pihole.bats +++ b/tests/pihole.bats @@ -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 — print site-block names under the flat scheme +# vhost_names — 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 — print the IP a name maps to in addn-hosts