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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
2026-09-12 13:58:18 +00:00
parent 586d2c07a1
commit bedf64e645
4 changed files with 165 additions and 167 deletions

View File

@@ -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 - 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. 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 ## [0.5.1] - 2026-09-12

View File

@@ -17,6 +17,7 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` | | Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` | | 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` | | 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. Deploying a change is: build and push the image, then pull it and recreate the container.

View File

@@ -6,6 +6,46 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is. 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 ## 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 An audit of the database layer, with a read-only copy of production to check it against. The

View File

@@ -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 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 password itself. The proxy authenticates the person and passes the result to ipx in a **header**;
pass the result to the origin in a **header**. ipx reads that header, finds (or creates) the ipx reads it, finds (or creates) the matching account, and gets on with it.
matching account, and gets on with it.
Read [How this is secured](#how-this-is-secured) before exposing anything. The short version: a 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 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 ```toml
[web] [web]
enabled = true enabled = true
bind = "0.0.0.0:8099" bind = "0.0.0.0:8099"
token = "…" # keep it: it is the admin, used by the healthcheck trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
# 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.
auto_create_users = true auto_create_users = true
session_days = 30 session_days = 30
``` ```
Restart the daemon after editing. Accounts made this way have **no password**: they can only ever Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
arrive through the proxy. `ipx user list` marks them `proxy only`. restart ipodderx`.
The first account created is an admin. Every later one is an ordinary user, and an ordinary user ### What was missing
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).
Somebody arriving through the proxy for the first time starts with **no feeds**, because Cloudflare and Authentik were already right. Three things on the ipx side were not:
subscriptions are per person. Adding a feed someone else already reads costs no second fetch and no
second copy on disk.
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 ```sh
ipx user list docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
echo -n 'a good password' | ipx user passwd <name> # optional: also lets them sign in directly
``` ```
Local sign-in at `/login` keeps working alongside all of this, which is how you get in from the LAN That was measured, not assumed. ipx does not log where a request came from, so the addresses were
when the tunnel is down. So does the shared `[web] token`, which signs in as the admin: that is read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
what the Docker healthcheck uses, and the way back in if you lock yourself out. A brand new database lists them in hex.)
starts with **admin / ipodderx** — change it.
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 <old name> <email address>
```
### 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 | Key | What it does |
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 |
|---|---| |---|---|
| Subdomain / domain | `ipodderx` / `sdf1.net` | | `trusted_header` | The header the proxy sets. Empty, the default, turns the proxy path off. |
| Type | HTTP | | `trusted_proxies` | The addresses allowed to set it. Nothing else is believed. |
| URL | `localhost:8099` (or the LAN address of the box) | | `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 The first account ever created is an admin. Every later one is an ordinary user, who cannot change
coming from `127.0.0.1`, which is already in `trusted_proxies`. If `cloudflared` runs elsewhere (its global settings, a feed's URL or folder, or how often feeds are scanned: the API refuses those with
own container, another host), put **its** address in `trusted_proxies` instead, and make sure a `403`, not just the UI. Everything else about a feed is theirs alone; see [users.md](users.md).
nothing else can reach port 8099.
### 2. The Access application 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
**Zero Trust → Access → Applications → Add an application → Self-hosted**: way back in if you lock yourself out. A brand new database starts with **admin / ipodderx**;
change it.
- 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.
--- ---
## Authentik ## Authentik in the request path instead
Authentik does this with a **Proxy Provider** plus an **outpost**, which sits in the request path and Not what ipodderx.sdf1.net uses, and **not verified**. Authentik can also sit in front of ipx
adds `X-authentik-username` (also `X-authentik-email`, `X-authentik-name`, `X-authentik-groups`). itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-username`:
### 1. Provider - Applications → Providers → Create → Proxy Provider; mode **Proxy** (the outpost talks to ipx) or
**Forward auth** (an existing reverse proxy asks the outpost).
**Applications → Providers → Create → Proxy Provider**: - 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
- Name: `ipx` in `trusted_proxies`. Measure that address as above rather than guessing it.
- 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://<ip of the ipx box>: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.
--- ---
## How this is secured ## How this is secured
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the **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, request falls through to a session cookie or the shared token. That is the whole security boundary.
so:
- List the **proxy's own address**, not a range. `["127.0.0.1"]` when the tunnel runs beside ipx; With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower
the container's IP when it does not. that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
- Never list a LAN subnet. Anyone on your network could then send itself, arrive under their own addresses and cannot set the header; the checks above show both
`Cf-Access-Authenticated-User-Email: admin@…` and be your admin. sides. Never list a LAN address or range: anyone there could then send
- Make sure the origin port is not reachable *around* the proxy by anyone you would not admit `Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
through it. If it is, bind ipx to `127.0.0.1` and let only the proxy reach it.
Verify the refusal, don't assume it — set `trusted_proxies = ["10.9.9.9"]` briefly and confirm a **What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
header from your machine gets a `401`: 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 **Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
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
in with them until they are given a password (`ipx user passwd <name>`). in with them until they are given a password (`ipx user passwd <name>`).
--- ---
@@ -207,22 +163,20 @@ in with them until they are given a password (`ipx user passwd <name>`).
## Everyday administration ## Everyday administration
```sh ```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 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 echo -n 'newsecret' | ipx user passwd sam # change a password
ipx user rm sam # remove the account ipx user rm sam # remove the account
``` ```
Set `auto_create_users = false` once everyone who should have an account has one. After that the In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
proxy vouching for an unknown name is logged and refused, rather than quietly making an account. that read a password.
Pre-create people instead with `ipx user add <name> --no-password`, using exactly the name the
header will carry (Cloudflare sends the email address, lower-cased).
Scanning intervals, the disk quota, retention, the download folder and a feed's URL are Set `auto_create_users = false` once everyone who should have an account has one. After that the
**admin-only**: the Settings button is hidden for everyone else, and the API refuses the change even proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
if the request is made by hand. Everyone controls their own keywords, auto-download, explicit the exact name the header will carry.
setting and per-scan cap, along with their own read state and which feeds they see.
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md) 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. for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.