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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 23:16:33 +00:00
parent 06f182b555
commit 2c52da4f16
5 changed files with 261 additions and 14 deletions

View File

@@ -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

View File

@@ -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 <password> | 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).

212
docs/sso.md Normal file
View File

@@ -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 <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
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://<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
**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 <name>`).
---
## 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.

View File

@@ -432,6 +432,13 @@ impl ApiError {
status: StatusCode::BAD_REQUEST,
}
}
fn forbidden(msg: impl Into<String>) -> 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<WebState>,
Path(id): Path<String>,
user: crate::db::User,
Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> {
// 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<WebState>,
user: crate::db::User,
Json(body): Json<SettingsPatch>,
) -> Result<StatusCode, ApiError> {
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();

View File

@@ -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(`<h3>${esc(f.title||f.id)}</h3>
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is an OPML subscription. These
@@ -1236,13 +1235,6 @@ function settingsModal(f){
<div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div>
<div class="field"><label>Check schedule</label>
<div class="inline">
<input type="number" id="snum" min="1" max="999" value="${fs.n}" ${f.schedule_mins?'':'disabled'}>
<select id="sunit">${unitOptions(f.schedule_mins?fs.u:'', `Use the default — every ${everyText(globalEvery)}`)}</select>
</div>
<span class="hint">Overrides the global schedule, and the feed's own suggested
interval, for this feed only.</span></div>
<div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
@@ -1259,16 +1251,12 @@ function settingsModal(f){
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="ssave">Save</button></div>`);
$('#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'));