diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index 76457f1..3b15794 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -101,11 +101,50 @@ cgroup regardless of `GroupAdd=keep-groups`. ## Authentication -- **Built-in auth (default):** Per-user libraries, parental controls, max bitrate limits. Manage via Dashboard → Users. -- **Caddy basic auth:** Single login layer. Does not integrate with per-user Jellyfin permissions. -- **LDAP/OIDC:** Available via plugins (LDAP-Auth, Jellyfin SSO). Integrates with Authentik, Keycloak, etc. +- **Built-in auth:** per-user libraries, parental controls, max bitrate + limits. The local `dank` admin account is built-in and its password is + Frank's — never recorded in this repo (the bootstrap-era value no longer + authenticates on the live database). +- **LDAP via Authentik (live 2026-09-01):** the LDAP-Auth plugin + authenticates users against the authentik LDAP outpost on Dankclaw — see + [[Authentik]] for the outpost unit, provider, and RBAC objects. -For Dankserver, built-in auth is recommended. Use Caddy basic auth on top for external access. +### LDAP-Auth plugin (v23.0.0.0, live 2026-09-01) + +- **Install from the GitHub releases zip** — `repo.jellyfin.org`'s stable + manifest path 404s here, so fetch + `jellyfin/jellyfin-plugin-ldapauth` release asset + `ldap-authentication_23.0.0.0.zip` from GitHub and unzip into + `config/plugins/LDAP-Auth_23.0.0.0/`. The config lives at + `config/plugins/configurations/LDAP-Auth.xml` — write it directly, then + restart the service. +- **Live config:** server `192.168.0.143:389`, base DN + `dc=franksdank,dc=cloud`, bind DN + `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`, bind password = + the authentik outpost token (secret — the XML lives on the host only, + never in the repo), search filter + `(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`, admin + filter on `cn=jellyfin_admins,ou=groups,…`, `CreateUsersFromLdap=true` + (auto-create on first login). +- **Attribute separator is a comma.** `LdapSearchAttributes` is + `Split(',')` in the plugin source — a semicolon-separated list + (`uid;cn;mail;displayName`) parses as ONE bogus attribute, the search + silently matches nothing, and the log says `Found no users matching X`. + Use `uid,cn,mail,displayName`. +- **Username attribute is `cn`.** Authentik 2026.x maps `uid` to an opaque + per-user hash, not the username, so `uid=` never matches. Set + `LdapUsernameAttribute=cn` (authentik exposes the username as `cn`). +- **Verify with a throwaway user:** create a test member of + `jellyfin_users` in authentik, then + `POST /Users/AuthenticateByName` with headers `Content-Type: + application/json` **and** `X-Emby-Authorization: MediaBrowser + Client="…", Device="…", DeviceId="…", Version="10.11.0"` — both are + required in 10.11 (missing `X-Emby-Authorization` → 400). 200 + non-admin + policy = LDAP path works. Delete the test user from authentik **and** + Jellyfin afterward. +- **Plugin load lines only show in `journalctl --user -u jellyfin.service`** + (`Loaded plugin: LDAP-Auth 23.0.0.0`) — `podman logs` does not carry the + loader lines in this image. ## Metadata Scraping diff --git a/serverAppConfigs-docs/Infrastructure/Authentik.md b/serverAppConfigs-docs/Infrastructure/Authentik.md index f3694f1..98d9df6 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik.md @@ -11,6 +11,8 @@ Pi-hole) are not wrapped — they handle their own session. - **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) +- **LDAP outpost:** a 4th, quadlet-deployed unit (`authentik-outpost-ldap`) + serves LDAP for Jellyfin — see the section below. ## Why Authentik @@ -155,6 +157,51 @@ implementation diverges from upstream in ways that bit us; all verified live: `grafana-profile-scope`, Python-syntax expressions) and will start emitting claims the moment the evaluator bug is fixed — no config change needed then. +## LDAP outpost (Jellyfin auth, live 2026-09-01) + +A fourth unit, `dankclaw/authentik/quadlet/authentik-outpost-ldap.container` +→ `authentik-outpost-ldap.service`, serves LDAP for Jellyfin's LDAP-Auth +plugin. The image is pinned by its **amd64 single-platform digest** +(`ghcr.io/goauthentik/ldap@sha256:fa33bc8f522113830e1662f4c034352f9eb08e0a7d3da2ddadad9a4b33b4cbf4`): +podman 5.7.0 refuses the +`ghcr.io/goauthentik/ldap:2026.8.0` tag because the tag resolves to a +*nested* manifest list (index → per-platform manifest lists) — +`unexpectedly received a manifest list instead of a manifest for a single +image`. Resolve once with `podman manifest inspect | jq -r +'.manifests[] | select(.platform.architecture=="amd64") | .digest'` and pin +that digest. + +- Ports **389** (ldap) and **636** (ldaps) publish on the LAN; Jellyfin on + Dankserver binds `192.168.0.143:389`. The outpost token travels in + `AUTHENTIK_LDAP_OUTPOST_TOKEN` from the host `.env` (git-ignored). + +### Authentik-side objects (provisioned via `ak shell` ORM) + +| Object | Value | +|---|---| +| LDAP provider | `Jellyfin LDAP` (pk 11), base DN `dc=franksdank,dc=cloud` | +| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` | +| Service account | `svc-jellyfin-ldap` — passwordless/unusable; authenticates by token | +| Bind token | intent **`verification`**, non-expiring — its key is the LDAP bind password | +| RBAC role | `svc-jellyfin-ldap-search` carrying object-level permission `authentik_providers_ldap.search_full_directory` on the provider | +| Groups | `jellyfin_users`, `jellyfin_admins` | +| Application | `jellyfin` (slug `jellyfin`) | + +Hard-won rules, each verified live: + +- **Token intent is load-bearing.** A bind token with `intent=app_password` + is rejected by the flow's password stage with `Invalid password`; + `intent=verification` passes. The outpost's direct binder executes the + provider's authorization flow, and that password stage only accepts + verification-intent tokens as static passwords. +- **Search needs `search_full_directory`.** Without the RBAC grant the + outpost returns only the bind user itself — a `(memberOf=…)` filter then + matches zero entries and the plugin logs `Found no users matching X`. + The permission is object-level on the provider; grant it via an RBAC role + and retest. +- `ak shell` import path for the provider model in 2026.8: + `from authentik.providers.ldap.models import LDAPProvider`. + ## Configuration (secrets) Two secrets live in the host `.env` (git-ignored; placeholders in @@ -205,5 +252,6 @@ Two secrets live in the host `.env` (git-ignored; placeholders in - [Caddy](Caddy.md) — the reverse proxy carrying the `:8016`/`:8017` auth-chain listeners - [Pi-hole](Pi-hole.md) — the LAN DNS that resolves `auth.franksdank.cloud` +- [Jellyfin](../Dankserver/Jellyfin.md) — consumes the LDAP outpost via its LDAP-Auth plugin - [Network Architecture](../Architecture/Network%20Architecture.md) — the flat naming scheme - Research: `.docaudit/research/auth.md` — the full provider comparison diff --git a/serverAppConfigs-docs/log.md b/serverAppConfigs-docs/log.md index 6e542f2..a91f275 100644 --- a/serverAppConfigs-docs/log.md +++ b/serverAppConfigs-docs/log.md @@ -11,6 +11,46 @@ timestamp: 2026-09-14 # Bundle Change Log +## 2026-09-01 — Jellyfin authentication moved to Authentik LDAP +- `/dankclaw/authentik/quadlet/authentik-outpost-ldap.container` (`4ad85fa`, + digest-pinned in `f4ef2b8`) — new `authentik-outpost-ldap.service` on + Dankclaw serving LDAP :389/:636 for Jellyfin. Image + `ghcr.io/goauthentik/ldap` pinned by **amd64 single-platform digest**: + podman 5.7.0 refuses the tag because it resolves to a nested manifest + list ("unexpectedly received a manifest list instead of a manifest for a + single image") — resolve via `podman manifest inspect | jq` and pin. +- Authentik objects provisioned via `ak shell` ORM (live, idempotent + get_or_create): LDAP provider `Jellyfin LDAP`, service account + `svc-jellyfin-ldap` + bind token, groups `jellyfin_users` / + `jellyfin_admins`, application `jellyfin`. +- `/scripts/deploy-remote.sh` (`691da47`) — `authentik-outpost-ldap` added + to `service_is_allowed` (the deploy runner has its own allowlist beyond + deploy-plan.py's). +- Two authentik-side rules proven by live bind tests (`ldapsearch` through + the published port): + 1. Bind token **intent must be `verification`** — `app_password` tokens + are rejected by the authorization flow's password stage with + `Invalid password`. + 2. Search requires the **`search_full_directory`** object permission on + the provider (granted via RBAC role `svc-jellyfin-ldap-search`); + without it the outpost returns only the bind user, so `memberOf` + filters match zero entries. +- Jellyfin side (host-only, no repo change): LDAP-Auth **23.0.0.0** + installed from the GitHub releases zip (the `repo.jellyfin.org` stable + manifest path 404s), config XML at + `config/plugins/configurations/LDAP-Auth.xml`, username attribute `cn` + (authentik 2026.x maps `uid` to an opaque hash), and — the big one — + **`LdapSearchAttributes` is comma-separated** (`Split(',')` in the plugin + source; semicolons silently produce "Found no users matching X"). + End-to-end verified with a throwaway `jellyfin_users` member + (`AuthenticateByName` 200, non-admin policy), then removed from both + authentik and the Jellyfin DB directly (its admin-token cleanup 401'd — + the local `dank` admin password on the live DB is Frank's, not the + bootstrap-era value; notated in the Jellyfin doc). +- Docs: [[Authentik]] gained the LDAP-outpost section (objects table + + rules), [[Jellyfin]] gained the LDAP-Auth plugin section. Docs-only + commit → deploy no-op. + ## 2026-09-01 — frank-default-small: two compressed-tensors FP8 swaps fail to load; Huihui restored - `/scripts/frank-default-small.env`, `/scripts/serve-frank-small.sh`, `/scripts/frank-default-small.service` (`af6cd72` → `34ab6b4` → reverted