From 4810bb5cfb4dd30b5cb819440b8f8b7477c01d97 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 23:16:33 +0000 Subject: [PATCH] Make scanning an admin setting, and document SSO The per-feed schedule picker is gone and global Settings is admin-only, enforced in the handlers with 403s rather than just hidden: polling costs bandwidth and affects everyone reading the feed, so it belongs to the operator. Folders, keywords and per-feed limits stay open to anyone. docs/sso.md covers Cloudflare Zero Trust and Authentik end to end, including why trusted_proxies names the proxy and not a subnet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 17 ++++ README.md | 8 ++ docs/sso.md | 212 +++++++++++++++++++++++++++++++++++++++++++++++++ src/web.rs | 17 ++++ web/index.html | 21 ++--- 5 files changed, 261 insertions(+), 14 deletions(-) create mode 100644 docs/sso.md diff --git a/PROGRESS.md b/PROGRESS.md index b1248bf..5cc634a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,23 @@ and until now nothing set them. --- +## 2026-09-10 — Scanning is the operator's decision + +The per-feed **Check schedule** picker is gone from feed settings, and the global Settings page is +hidden from anyone who is not an admin. Both are enforced in the handlers rather than merely hidden: +`PATCH /api/settings` and a `schedule` in `PATCH /api/feeds/{id}` return 403 for an ordinary user. +Polling costs bandwidth, is what a publisher notices, and one impatient setting affects everyone +reading that feed -- it belongs in config.toml. + +Folders, keywords, per-feed download limits and the feed URL stay editable by anyone signed in. + +`docs/sso.md` covers putting Cloudflare Zero Trust or Authentik in front of ipx: the tunnel and +Access application, the Authentik proxy provider and its forward-auth nginx block, the three lines +of ipx config each needs, and why `trusted_proxies` must name the proxy rather than a subnet -- +with the command to prove the refusal works. + +--- + ## 2026-09-10 — Marking an item read Two bugs in one place. `epAction`'s `redraw` closure called *itself* when it had a row to diff --git a/README.md b/README.md index 2289550..eabe14a 100644 --- a/README.md +++ b/README.md @@ -228,3 +228,11 @@ rather run periodic scans with no daemon (in which case there is no socket for a ## License MIT. See [LICENSE](LICENSE). + +## Signing in + +A new database starts with **admin / ipodderx** at `/login`; change it with +`echo -n | ipx user passwd admin`. Accounts are managed with `ipx user add|list|passwd|rm`. + +To let Cloudflare Zero Trust or Authentik do the authenticating instead, see +[docs/sso.md](docs/sso.md). diff --git a/docs/sso.md b/docs/sso.md new file mode 100644 index 0000000..a187830 --- /dev/null +++ b/docs/sso.md @@ -0,0 +1,212 @@ +# Signing in through Cloudflare Zero Trust or 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. + +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 +list. + +--- + +## The ipx side (both setups) + +```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. +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`. + +The first account created is an admin. Every later one is an ordinary user, and an ordinary user +cannot change global settings or how often feeds are scanned. Promote someone with: + +```sh +ipx user list +echo -n 'a good password' | ipx user passwd # 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 +when the tunnel is down. A brand new database starts with **admin / ipodderx** — change it. + +--- + +## Cloudflare Zero Trust + +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 | +|---|---| +| Subdomain / domain | `ipodderx` / `sdf1.net` | +| Type | HTTP | +| URL | `localhost:8099` (or the LAN address of the box) | + +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. + +### 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. + +--- + +## Authentik + +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`). + +### 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. + +--- + +## 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: + +- 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. + +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`: + +```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 +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 +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 +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. + +Scanning intervals, the disk quota, retention and the download folder 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. Ordinary users still control their own folders, keywords and downloads per feed. diff --git a/src/web.rs b/src/web.rs index 98e0048..6d2fdb2 100644 --- a/src/web.rs +++ b/src/web.rs @@ -432,6 +432,13 @@ impl ApiError { status: StatusCode::BAD_REQUEST, } } + + fn forbidden(msg: impl Into) -> Self { + Self { + error: anyhow::Error::msg(msg.into()), + status: StatusCode::FORBIDDEN, + } + } } impl IntoResponse for ApiError { @@ -616,8 +623,14 @@ where async fn patch_feed( State(state): State, Path(id): Path, + user: crate::db::User, Json(body): Json, ) -> Result { + // How often a feed is polled is the operator's call: it costs bandwidth, it is what + // publishers notice, and one impatient setting affects everyone reading the feed. + if body.schedule.is_some() && !user.is_admin { + return Err(ApiError::forbidden("only an admin sets when feeds are scanned")); + } let mut cfg = (*state.ctx.cfg()).clone(); // Derived feeds have no config entry. Editing one is the moment it earns a real @@ -990,8 +1003,12 @@ struct SettingsPatch { async fn patch_settings( State(state): State, + user: crate::db::User, Json(body): Json, ) -> Result { + if !user.is_admin { + return Err(ApiError::forbidden("only an admin changes these settings")); + } let mut cfg = (*state.ctx.cfg()).clone(); if let Some(sched) = body.schedule { let sched = sched.trim().to_owned(); diff --git a/web/index.html b/web/index.html index fedb616..92c167d 100644 --- a/web/index.html +++ b/web/index.html @@ -500,7 +500,7 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden= /* ---------------- state ---------------- */ const S = { feeds:[], feed:null, entries:[], total:0, offset:0, limit:50, - filter:'all', q:'', sel:null, busy:new Set(), + filter:'all', q:'', sel:null, busy:new Set(), me:null, }; const LIMIT = 50; @@ -1223,7 +1223,6 @@ async function prefsModal(){ } function settingsModal(f){ - const fs = splitEvery(f.schedule_mins || globalEvery); const isGroup = S.feeds.some(c=>c.group===f.id); openModal(`

${esc(f.title||f.id)}

${isGroup?`

This is an OPML subscription. These @@ -1236,13 +1235,6 @@ function settingsModal(f){

Comma separated. Empty takes everything.
-
-
- - -
- Overrides the global schedule, and the feed's own suggested - interval, for this feed only.
Blank follows the global default (${globalMax}). The rest wait for @@ -1259,16 +1251,12 @@ function settingsModal(f){
`); $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); - // An empty unit means "follow the global default", so the number has nothing to say. - $('#sunit').onchange=()=>{ $('#snum').disabled = !$('#sunit').value; }; $('#ssave').onclick=async()=>{ const max=$('#smax').value; try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({ url:$('#surl').value.trim(), folder:$('#sfolder').value.trim()||null, - schedule:$('#sunit').value - ? `every ${Math.max(1,Number($('#snum').value)||1)}${$('#sunit').value}` : null, keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean), max_new_per_check:max===''?null:Number(max), auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked})}); @@ -1335,7 +1323,12 @@ function on(sel,ev,fn){ $('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }; on('#prefs','onclick',prefsModal); on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; }); -api('/api/me').then(u=>{ $('#who').textContent=u.name+(u.admin?' · admin':''); }).catch(()=>{}); +api('/api/me').then(u=>{ + S.me=u; + $('#who').textContent=u.name+(u.admin?' · admin':''); + // Scanning, quotas and the download folder are the operator's business. + if(!u.admin) $('#prefs').hidden=true; +}).catch(()=>{}); on('#logs','onclick',logsModal); $('#feedFilter').oninput=renderFeeds; $('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));