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:
17
PROGRESS.md
17
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
|
||||
|
||||
@@ -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
212
docs/sso.md
Normal 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.
|
||||
17
src/web.rs
17
src/web.rs
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user