diff --git a/infrastructure.org b/infrastructure.org
index 1dfba6a..e02b32c 100644
--- a/infrastructure.org
+++ b/infrastructure.org
@@ -16,6 +16,7 @@
** External Access Architecture
+#+BEGIN_EXAMPLE
Cloudflare (edge, orange cloud)
└─ Cloudflare Tunnel "home" (cloudflared on production-1)
└─ Traefik (entrypoint=tunnel, port 8081)
@@ -24,9 +25,11 @@ Cloudflare (edge, orange cloud)
├─ jellyfin (SSO via plugin + OIDC)
├─ gitea (native OIDC)
└─ *.gharbeia.net services
+#+END_EXAMPLE
** Internal Access Architecture
+#+BEGIN_EXAMPLE
LAN client (browser)
└─ Traefik (entrypoint=secureweb, port 443)
├─ Authentik Forward Auth (internal.yaml routers)
@@ -36,7 +39,8 @@ LAN client (browser)
Service-to-service / automation / cross-VLAN
└─ Traefik (entrypoint=internal, port 8083 — NO auth)
- └─ Same routing as secureweb, from =traefik-internal-noauth.yaml=
+ └─ Same routing as secureweb, from traefik-internal-noauth.yaml
+#+END_EXAMPLE
Key distinction: =:443= = browsers/humans with Authentik auth.
=:8083= = runners, automated tooling, services on other VLANs.
@@ -736,7 +740,57 @@ Generated by stripping the auth middleware from =traefik-internal.yaml=.
# See: docker/appdata/traefik/internal-noauth.yaml for the production copy.
#+END_SRC
-* Docker Services
+** Authentication Architecture
+
+Three authentication mechanisms depending on the service type:
+
+*** Forward Auth (default for web-only services)
+
+Traefik middleware intercepts every request and redirects unauthenticated
+users to the Authentik login page. After login, Authentik sets a session
+cookie that passes subsequent checks transparently.
+
+Used by: all =*arr=, dashboards, monitoring, Portainer, Guacamole, etc.
+Limitation: only works in browsers — native/TV apps can't use Forward Auth.
+
+*** Native OIDC / SSO (for services with apps)
+
+Services that have native mobile or TV apps need real OIDC integration so
+the app can authenticate directly via a browser-based login flow.
+
+- Gitea: configured with Authentik OIDC provider in Gitea's admin panel.
+ Users log in via "Sign in with Authentik" button on the Gitea login page.
+ Existing user accounts are linked by username match.
+
+- Jellyfin: uses the SSO-Auth plugin (v4.0.0.4) with an Authentik OIDC
+ provider (client_id = =jellyfin-sso=). The plugin does a two-step flow:
+ 1. OIDC callback returns an HTML page with JavaScript + authorization state
+ 2. JavaScript POSTs to =/sso/OID/Auth/Authentik= to complete the login
+
+ Critical detail: Jellyfin must trust Traefik's =X-Forwarded-Proto= header
+ or the JavaScript will construct URLs with =http://= instead of =https://=.
+ This is configured via =KnownProxies= in =/config/network.xml=:
+
+ #+BEGIN_SRC xml
+
+ 172.28.10.0/24
+ 172.28.10.4
+
+ #+END_SRC
+
+ Without this, =GetRequestBase()= returns =http://jellyfin.gharbeia.net= and
+ the iframe, auth POST, and final redirect all use the wrong scheme.
+
+*** Local users (TV apps, fallback)
+
+TV apps (Android TV, webOS, Tizen) often can't complete the OIDC JavaScript
+two-step flow inside their embedded browser. For these, create a dedicated
+Jellyfin local user (e.g. =tv=) with a simple password. The app logs in
+directly with password — no SSO involved. The user keeps library access
+without losing admin history/settings.
+
+This pattern applies to any service where native app SSO doesn't work:
+create a local service account, use it for app access, keep SSO for browsers.
The entire stack runs on =production-1= using Docker Compose. Services are
split into individual YAML fragment files under =/docker/compose/services/=,
@@ -814,6 +868,65 @@ All 43 services are organized alphabetically by category in the include list.
The order matters for startup dependencies: infrastructure services (gluetun,
postgresql, valkey, authentik, traefik) come first.
+** Jellyfin — Media Server
+
+Jellyfin serves media libraries through the browser and native apps. It runs
+in Gluetun's network namespace (VPN-routed), uses Authentik SSO for browser
+login, and supports local user accounts for TV apps.
+
+*** KnownProxies (Critical for SSO)
+
+Jellyfin sits behind Traefik reverse proxy. Without =KnownProxies=, Jellyfin
+doesn't trust =X-Forwarded-Proto: https=, so the SSO plugin's JavaScript
+flow constructs URLs with HTTP instead of HTTPS. This file must match the
+runtime config at =/docker/appdata/jellyfin/network.xml=.
+
+#+BEGIN_SRC xml :tangle /docker/appdata/jellyfin/network.xml
+
+
+
+ false
+ false
+
+
+ 8096
+ 8920
+ 8096
+ 8920
+ true
+ true
+ true
+ false
+ true
+
+
+
+ 172.28.10.0/24
+ 172.28.10.4
+
+ true
+
+ veth
+
+ false
+
+
+ false
+
+#+END_SRC
+
+*** SSO-Auth Plugin Configuration
+
+The SSO plugin config lives at =/docker/appdata/jellyfin/plugins/configurations/SSO-Auth.xml=.
+Key settings:
+- OIDC provider pointing to =https://auth.gharbeia.net/application/o/jellyfin-sso=
+- =EnableAuthorization=false= (bypasses group-based role checking)
+- =EnableAllFolders=true= (all libraries accessible to SSO users)
+- Scopes: =openid profile email groups=
+
+With =EnableAuthorization=false=, any Authentik user can log in to Jellyfin
+via SSO. Admin rights are managed within Jellyfin itself.
+
** Gluetun — VPN Client
Gluetun is the VPN gateway for all media-related traffic. Services that need
@@ -1111,6 +1224,17 @@ Key variables:
* LOGBOOK
+** [2026-05-15 Thu 09:30] Jellyfin SSO fixed — KnownProxies and Two-Step Flow
+- Root cause: Jellyfin's empty KnownProxies caused SSO plugin to use HTTP
+ base URL, breaking the JavaScript two-step auth flow (iframe/POST/redirect)
+- Fix: Added 172.28.10.0/24 and 172.28.10.4 to Jellyfin's KnownProxies
+- Created jellyfin_admin Authentik group + linked user amr to it
+- Set EnableAuthorization=false in SSO-Auth plugin config
+- Documented three authentication mechanisms: Forward Auth, OIDC/SSO, local users
+- Fixed ASCII tree diagrams by wrapping in #+BEGIN_EXAMPLE blocks
+- Fixed trees rendering issue: Unicode box-drawing chars must be inside
+ example blocks in org-mode, otherwise font rendering mangles them
+
** [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth
- Fixed Emacs org-loaddefs.el path in tangle-deploy
- Created Gitea access token for git operations
diff --git a/tangle-deploy.sh b/tangle-deploy.sh
new file mode 100644
index 0000000..d46ec96
--- /dev/null
+++ b/tangle-deploy.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# tangle-deploy — Tangle infrastructure.org and restart affected services
+GITEA_URL='http://amr:tangle-deploy-2026@10.10.10.201:3001/amr/infrastructure.git'
+REPO_DIR="${1:-/docker/compose/infrastructure}"
+ORG_FILE="${REPO_DIR}/infrastructure.org"
+if [ -z "${1:-}" ]; then
+ if [ ! -d "$REPO_DIR" ]; then
+ git clone "$GITEA_URL" "$REPO_DIR"
+ else
+ cd "$REPO_DIR" && git pull
+ fi
+fi
+if [ ! -f "$ORG_FILE" ]; then
+ echo "ERROR: $ORG_FILE not found in $REPO_DIR"
+ exit 1
+fi
+echo "=== Tangling $ORG_FILE ==="
+emacs --batch -Q --load /usr/share/emacs/28.2/lisp/org/org-loaddefs.el \
+ --eval "(require 'org)" \
+ --eval "(org-babel-tangle-file \"$ORG_FILE\")" 2>&1
+echo "=== Restarting services ==="
+cd /docker/compose
+if [ -f /docker/compose/traefik-static.yaml ] || \
+ [ -f /docker/compose/traefik-internal.yaml ] || \
+ [ -f /docker/compose/traefik-internal-noauth.yaml ] || \
+ [ -f /docker/compose/traefik-dynamic.yaml ]; then
+ echo 'Traefik config changed -- restarting...'
+ docker compose up -d traefik
+fi
+if [ -f /docker/compose/docker-compose.yaml ]; then
+ echo 'Docker compose changed -- restarting all services'
+ docker compose up -d 2>&1 | tail -5
+fi
+echo '=== Deploy complete ==='