From bedf64e64595edfd8930a44bfb78eb8af1ee3310 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 12 Sep 2026 13:58:18 +0000 Subject: [PATCH] docs/sso.md: the sign-in setup ipodderx.sdf1.net really runs Authentik is Cloudflare Access's OpenID Connect identity provider, not something in the request path, and the tunnel's requests reach ipx from the content_default gateway, 192.168.16.1, not 127.0.0.1. The page is rewritten from what was measured, with checks for both the trusted and the refused path, and docs/history.md records every change made to get there with how to undo it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk --- CHANGELOG.md | 3 + CLAUDE.md | 1 + docs/history.md | 40 +++++++ docs/sso.md | 288 ++++++++++++++++++++---------------------------- 4 files changed, 165 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bea689..d049dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ The long form, with what was wrong before and how it was found, is in - Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of HTML: the item's other copy of its notes is shown instead. Daily Meditation Podcast had 57. +- The sign-in guide, `docs/sso.md`, describes the setup ipodderx.sdf1.net really runs: Authentik as + Cloudflare Access's identity provider, and how to find the address ipx has to trust. It had never + been checked against a real setup, and pointed at the wrong address. ## [0.5.1] - 2026-09-12 diff --git a/CLAUDE.md b/CLAUDE.md index b96138e..64359e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That | Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` | | Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` | | Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` | +| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway | Deploying a change is: build and push the image, then pull it and recreate the container. diff --git a/docs/history.md b/docs/history.md index 07e80d9..5b26d62 100644 --- a/docs/history.md +++ b/docs/history.md @@ -6,6 +6,46 @@ reasoning lives. New write-ups go at the top. See [README.md](../README.md) for what the thing is. +## 2026-09-12 — Signing in through Authentik, for real + +Ray could not get Authentik's sign-in to reach ipx, following `docs/sso.md`, which had been written +without ever being tried. Looking at the Cloudflare account through its API showed that side was +already complete. Authentik is Zero Trust's OpenID Connect identity provider; the Access application +`ipodderx` allows only it and a list of five addresses; the tunnel `rays-unraid` routes +`ipodderx.sdf1.net` to `192.168.1.130:8099`; DNS is a proxied CNAME to the tunnel. Access's log +showed `rays@sdf1.net` signing in through it. Nothing on Cloudflare was changed, so no other site +was touched. + +The gaps were all at ipx's end: `trusted_header` was empty, `trusted_proxies` held only loopback, +and the account was called `rays` while the header carries `rays@sdf1.net`. + +Finding the address to trust took the most time. The page said `127.0.0.1`, but `cloudflared` runs in +its own container and reaches ipx through the host's published port. ipx logs no peer addresses, so +the address was read from `/proc/net/tcp` inside the ipx container: `192.168.16.1`, the gateway of +`content_default`, where Docker's masquerade puts traffic crossing from another bridge. A request +from Tower's own shell arrived as `192.168.1.130` instead, and a throwaway `busybox` on the default +bridge as `192.168.16.1`: the first was refused with the header, the second believed. LAN machines +keep their own addresses, since Docker forwards published ports with iptables (the userland proxy +only handles loopback). + +Every change, in order, with how to undo it: + +1. **Code**, commit `586d2c0`: `ipx user rename`, deployed. Revert the commit and redeploy to + remove it; nothing depends on it once used. +2. **Account**: `docker exec iPodderX ipx user rename rays rays@sdf1.net`. Same id, so its feeds, + read state, password and admin rights stayed. Undo: `docker exec iPodderX ipx user rename + rays@sdf1.net rays`. Signing in at `/login` now takes the new name. +3. **Config**, `/mnt/fast/appdata/ipodderx/config.toml`, `[web]`: `trusted_header` from `""` to + `"Cf-Access-Authenticated-User-Email"`, and `"192.168.16.1"` added to `trusted_proxies`. The + file as it was is `config.toml.2026-09-12-sso.bak` beside it. Undo: copy the backup back and + `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml restart ipodderx`. +4. **Cloudflare, Authentik, Docker networks and other containers**: unchanged. The `busybox` test + container was removed when it exited, and its image afterwards. + +What the address trusts is any container on Tower that connects through the host's port, not only +`cloudflared`. Verifying Cloudflare's signed `Cf-Access-Jwt-Assertion` would remove that, and is +the upgrade if it matters. + ## 2026-09-12 — Trimming the state database An audit of the database layer, with a read-only copy of production to check it against. The diff --git a/docs/sso.md b/docs/sso.md index 86d1da7..f4c414e 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -1,9 +1,8 @@ -# Signing in through Cloudflare Zero Trust or Authentik +# Signing in through Cloudflare Access and Authentik ipx can take the signed-in identity from whatever sits in front of it, instead of asking for a -password itself. Both products below do the same thing in the end: they authenticate the person and -pass the result to the origin in a **header**. ipx reads that header, finds (or creates) the -matching account, and gets on with it. +password itself. The proxy authenticates the person and passes the result to ipx in a **header**; +ipx reads it, finds (or creates) the matching account, and gets on with it. Read [How this is secured](#how-this-is-secured) before exposing anything. The short version: a header is worth exactly as much as the hop that set it, so ipx only believes one from an address you @@ -11,195 +10,152 @@ list. --- -## The ipx side (both setups) +## How ipodderx.sdf1.net does it + +Checked end to end on 2026-09-12. An earlier version of this page had never been tried against a +real setup and pointed at the wrong address. + +``` +browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect) + ─► tunnel "rays-unraid" (the cloudflared container on Tower) + ─► http://192.168.1.130:8099 ─► ipx +``` + +Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access +then adds `Cf-Access-Authenticated-User-Email`, the email address Authentik gave it, to every +request it forwards through the tunnel, and ipx signs that person in. + +| Piece | Where | Setting | +|---|---|---| +| Identity provider | Zero Trust → Settings → Authentication | `Authentik`, OpenID Connect; scopes `openid email profile` | +| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses | +| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` | +| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied | +| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below | ```toml [web] enabled = true -bind = "0.0.0.0:8099" -token = "…" # keep it: it is the admin, used by the healthcheck - -# The header your proxy sets. Empty (the default) disables this whole path. -trusted_header = "Cf-Access-Authenticated-User-Email" # Authentik: "X-authentik-username" - -# Addresses allowed to assert that header -- the proxy, and nothing else. -trusted_proxies = ["127.0.0.1", "::1"] - -# Create an account the first time the proxy vouches for a name ipx has not seen. +bind = "0.0.0.0:8099" +trusted_header = "Cf-Access-Authenticated-User-Email" +trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"] auto_create_users = true - session_days = 30 ``` -Restart the daemon after editing. Accounts made this way have **no password**: they can only ever -arrive through the proxy. `ipx user list` marks them `proxy only`. +Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml +restart ipodderx`. -The first account created is an admin. Every later one is an ordinary user, and an ordinary user -cannot change global settings, a feed's URL or folder, or how often feeds are scanned: the API -refuses those with a `403`, not just the UI. Everything else about a feed (which items they want, -whether to fetch them, how many at a time) is theirs alone; see [users.md](users.md). +### What was missing -Somebody arriving through the proxy for the first time starts with **no feeds**, because -subscriptions are per person. Adding a feed someone else already reads costs no second fetch and no -second copy on disk. +Cloudflare and Authentik were already right. Three things on the ipx side were not: -Promote someone with: +1. **`trusted_header` was empty**, which switches the whole proxy path off. ipx ignored the header + and asked for a password. +2. **`trusted_proxies` listed only `127.0.0.1`.** The tunnel's requests do not come from there; + see the next section. +3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries + `rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty + account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything. + +### The address to trust, and why it is 192.168.16.1 + +`cloudflared` runs in its own container and reaches ipx through the host's published port. Docker +(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's +requests arrive from the **gateway of ipx's own network**, `content_default`: ```sh -ipx user list -echo -n 'a good password' | ipx user passwd # optional: also lets them sign in directly +docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' ``` -Local sign-in at `/login` keeps working alongside all of this, which is how you get in from the LAN -when the tunnel is down. So does the shared `[web] token`, which signs in as the admin: that is -what the Docker healthcheck uses, and the way back in if you lock yourself out. A brand new database -starts with **admin / ipodderx** — change it. +That was measured, not assumed. ipx does not log where a request came from, so the addresses were +read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp` +lists them in hex.) + +If the `content` project's network is ever recreated, its gateway can change. Check it again, and +update `trusted_proxies` to match. + +### Names + +The username is the email address, lower-cased: `rays@sdf1.net`. To sign in at `/login` with a +password from the LAN, use that name too. + +To let someone else in, add their address to the Access policy; they need an Authentik account with +that email. With `auto_create_users = true` they get an ipx account on their first visit, as an +ordinary user with no feeds. An account made before the proxy can be given the name the proxy will +send: + +```sh +docker exec iPodderX ipx user rename +``` + +### Check it + +```sh +# From Tower itself: not a trusted address, so the header is ignored. +curl -s -H 'Accept: application/json' -H 'Cf-Access-Authenticated-User-Email: rays@sdf1.net' \ + http://192.168.1.130:8099/api/me # -> sign in + +# From a container on a Docker bridge, as cloudflared is: believed. +docker run --rm --network bridge mirror.gcr.io/library/busybox wget -qO- \ + --header 'Accept: application/json' --header 'Cf-Access-Authenticated-User-Email: rays@sdf1.net' \ + http://192.168.1.130:8099/api/me # -> {"admin":true,"name":"rays@sdf1.net"} +``` + +Then open `https://ipodderx.sdf1.net` in a private window. Authentik should ask who you are, and +ipx should show `rays@sdf1.net` in the sidebar footer without asking for a password. --- -## Cloudflare Zero Trust +## The ipx settings -This is what runs `ipodderx.sdf1.net`: a `cloudflared` tunnel to the origin, with an Access -application in front of it. Cloudflare authenticates the visitor and adds -`Cf-Access-Authenticated-User-Email` to every request it forwards. - -### 1. The tunnel - -In **Zero Trust → Networks → Tunnels**, either use the existing tunnel or create one, then add a -public hostname: - -| Field | Value | +| Key | What it does | |---|---| -| Subdomain / domain | `ipodderx` / `sdf1.net` | -| Type | HTTP | -| URL | `localhost:8099` (or the LAN address of the box) | +| `trusted_header` | The header the proxy sets. Empty, the default, turns the proxy path off. | +| `trusted_proxies` | The addresses allowed to set it. Nothing else is believed. | +| `auto_create_users` | Make an account the first time the proxy vouches for a name ipx has not seen. | +| `session_days` | How long a password sign-in lasts without use. | -Use `localhost` when `cloudflared` runs on the same machine as ipx — that keeps the origin request -coming from `127.0.0.1`, which is already in `trusted_proxies`. If `cloudflared` runs elsewhere (its -own container, another host), put **its** address in `trusted_proxies` instead, and make sure -nothing else can reach port 8099. +The first account ever created is an admin. Every later one is an ordinary user, who cannot change +global settings, a feed's URL or folder, or how often feeds are scanned: the API refuses those with +a `403`, not just the UI. Everything else about a feed is theirs alone; see [users.md](users.md). -### 2. The Access application - -**Zero Trust → Access → Applications → Add an application → Self-hosted**: - -- Application domain: `ipodderx.sdf1.net` -- Session duration: whatever suits; ipx keeps its own 30-day session on top. -- Add a policy — *Allow*, with a rule such as `Emails` → your address, or `Emails ending in` → - your domain. Anyone this policy admits gets an ipx account when `auto_create_users` is on, so keep - the policy as narrow as the people you actually want reading your feeds. - -### 3. Point ipx at the header - -```toml -trusted_header = "Cf-Access-Authenticated-User-Email" -trusted_proxies = ["127.0.0.1", "::1"] -``` - -The username becomes the email address, lower-cased (`ray@example.com`). That is what shows in the -sidebar and what `ipx user list` prints. - -### 4. Check it - -```sh -# From the box itself: no header, no session -> the sign-in page. -curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -H 'Accept: text/html' http://127.0.0.1:8099/ - -# Pretending to be the tunnel (only works because 127.0.0.1 is trusted): -curl -s -H 'Cf-Access-Authenticated-User-Email: you@example.com' http://127.0.0.1:8099/api/me -``` - -Then load `https://ipodderx.sdf1.net` in a browser: Cloudflare should ask who you are, and ipx -should show your address in the sidebar footer without ever asking for a password. +Local sign-in at `/login` keeps working alongside the proxy, which is how you get in from the LAN +when the tunnel is down. So does the shared `[web] token`, which signs in as the admin and is the +way back in if you lock yourself out. A brand new database starts with **admin / ipodderx**; +change it. --- -## Authentik +## Authentik in the request path instead -Authentik does this with a **Proxy Provider** plus an **outpost**, which sits in the request path and -adds `X-authentik-username` (also `X-authentik-email`, `X-authentik-name`, `X-authentik-groups`). +Not what ipodderx.sdf1.net uses, and **not verified**. Authentik can also sit in front of ipx +itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-username`: -### 1. Provider - -**Applications → Providers → Create → Proxy Provider**: - -- Name: `ipx` -- Authorization flow: your usual (`default-provider-authorization-implicit-consent`) -- Mode: **Forward auth (single application)** if an existing reverse proxy fronts ipx, or - **Proxy** to let the outpost talk to ipx directly. -- External host: `https://ipodderx.example.net` -- Internal host (Proxy mode): `http://:8099` - -### 2. Application and outpost - -**Applications → Create**, bind it to that provider, and give it a policy so only the people you -mean are let through. Then add the provider to an outpost (**Applications → Outposts**, the embedded -one is fine). - -### 3. Forward auth, if you use nginx/SWAG in front - -In the server block for ipx: - -```nginx -location /outpost.goauthentik.io { - proxy_pass http://authentik-server:9000/outpost.goauthentik.io; - proxy_set_header Host $host; - proxy_set_header X-Original-URL $scheme://$http_host$request_uri; - add_header Set-Cookie $auth_cookie; - auth_request_set $auth_cookie $upstream_http_set_cookie; -} - -location / { - auth_request /outpost.goauthentik.io/auth/nginx; - error_page 401 = @goauthentik_proxy_signin; - auth_request_set $auth_cookie $upstream_http_set_cookie; - add_header Set-Cookie $auth_cookie; - - # This is the line that matters to ipx. - auth_request_set $authentik_username $upstream_http_x_authentik_username; - proxy_set_header X-authentik-username $authentik_username; - - proxy_pass http://ipx:8099; -} -``` - -### 4. Point ipx at the header - -```toml -trusted_header = "X-authentik-username" -trusted_proxies = ["172.18.0.5"] # the outpost or nginx container, NOT a whole subnet -``` - -Usernames arrive as Authentik knows them (`ray`), lower-cased. +- Applications → Providers → Create → Proxy Provider; mode **Proxy** (the outpost talks to ipx) or + **Forward auth** (an existing reverse proxy asks the outpost). +- Applications → Create, bound to that provider, with a policy; add the provider to an outpost. +- In ipx: `trusted_header = "X-authentik-username"`, and the outpost's or reverse proxy's address + in `trusted_proxies`. Measure that address as above rather than guessing it. --- ## How this is secured **The header is only believed from `trusted_proxies`.** Every other source is ignored, and the -request falls through to a session cookie or the shared token. This is the whole security boundary, -so: +request falls through to a session cookie or the shared token. That is the whole security boundary. -- List the **proxy's own address**, not a range. `["127.0.0.1"]` when the tunnel runs beside ipx; - the container's IP when it does not. -- Never list a LAN subnet. Anyone on your network could then send - `Cf-Access-Authenticated-User-Email: admin@…` and be your admin. -- Make sure the origin port is not reachable *around* the proxy by anyone you would not admit - through it. If it is, bind ipx to `127.0.0.1` and let only the proxy reach it. +With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower +that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower +itself, arrive under their own addresses and cannot set the header; the checks above show both +sides. Never list a LAN address or range: anyone there could then send +`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you. -Verify the refusal, don't assume it — set `trusted_proxies = ["10.9.9.9"]` briefly and confirm a -header from your machine gets a `401`: +**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It +trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the +boundary, and is the upgrade if that ever matters. -```sh -curl -s -o /dev/null -w '%{http_code}\n' \ - -H 'Cf-Access-Authenticated-User-Email: someone@example.com' http://127.0.0.1:8099/api/me -``` - -**What ipx does not do:** it does not verify Cloudflare's `Cf-Access-Jwt-Assertion` signature or -Authentik's session. It trusts the hop. That is a deliberate trade — it keeps the configuration to -three lines — and it is sound exactly as long as the point above holds. - -**Turning it off:** clear `trusted_header`. Existing proxy-only accounts stay, but nobody can sign +**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign in with them until they are given a password (`ipx user passwd `). --- @@ -207,22 +163,20 @@ in with them until they are given a password (`ipx user passwd `). ## Everyday administration ```sh -ipx user list # who exists, and how each one signs in +ipx user list # who exists, how each signs in, and when echo -n 'secret123' | ipx user add sam # local account, password on stdin -ipx user add sam --no-password # proxy-only account, created ahead of time +ipx user add sam@example.com --no-password # proxy-only account, made ahead of time +ipx user rename sam sam@example.com # give an account the name the proxy sends echo -n 'newsecret' | ipx user passwd sam # change a password ipx user rm sam # remove the account ``` -Set `auto_create_users = false` once everyone who should have an account has one. After that the -proxy vouching for an unknown name is logged and refused, rather than quietly making an account. -Pre-create people instead with `ipx user add --no-password`, using exactly the name the -header will carry (Cloudflare sends the email address, lower-cased). +In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones +that read a password. -Scanning intervals, the disk quota, retention, the download folder and a feed's URL are -**admin-only**: the Settings button is hidden for everyone else, and the API refuses the change even -if the request is made by hand. Everyone controls their own keywords, auto-download, explicit -setting and per-scan cap, along with their own read state and which feeds they see. +Set `auto_create_users = false` once everyone who should have an account has one. After that the +proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with +the exact name the header will carry. See also [users.md](users.md) for what several people share, [configuration.md](configuration.md) for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.