The zone is in Cloudflare and the rays-unraid tunnel routes the name to ipx-directory, as it does ipx-dir.sdf1.net, which keeps answering until every server using it has moved: a redirect to another host would drop the Authorization header. Production ipx shares with the new name. CLAUDE.md, docs/directory.md, the prod-check skill and the dashboard name it; the admin page's placeholder and the settings' hint use directory.example.net. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
320 lines
22 KiB
Markdown
320 lines
22 KiB
Markdown
# Working on ipx
|
|
|
|
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
|
thing is built; this file is about working on it without repeating mistakes that have already been
|
|
made here.
|
|
|
|
## Where things are
|
|
|
|
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
|
Arcane project `ipx`: `/mnt/fast/arcane/projects/ipx/compose.yaml`. That file is what runs, and
|
|
the containers of the split (#207) join it there. `docker-compose.yml` in this repo is a sample for
|
|
others, ipx alone on SQLite; editing it changes nothing in production. Until 2026-10-12 ipx was a
|
|
service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
|
|
|
| | Host | In the container |
|
|
|---|---|---|
|
|
| Image | `192.168.1.130:5000/ipx:latest` | |
|
|
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
|
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
|
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
|
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
|
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel (`ipx-dir.sdf1.net` also answers until #214 retires it) with no Access in front: tokens are its gate. Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
|
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
|
|
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
|
|
|
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
|
|
|
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
|
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
|
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
|
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
|
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
|
|
|
```sh
|
|
R="--login git.sdf1.net --repo rays/ipx"
|
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
|
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
|
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
|
t issues close $R 40
|
|
```
|
|
|
|
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
|
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
|
|
|
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
|
for later stays open, and that is how it gets picked up again.
|
|
|
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
|
|
|
```sh
|
|
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx
|
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx
|
|
docker logs --tail 20 iPX
|
|
```
|
|
|
|
The directory is the same, with its own image:
|
|
|
|
```sh
|
|
docker buildx build -f Dockerfile.directory --tag 192.168.1.130:5000/ipx-directory:latest . --push
|
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory
|
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory
|
|
```
|
|
|
|
And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one:
|
|
|
|
```sh
|
|
docker buildx build -f Dockerfile.web --tag 192.168.1.130:5000/ipx-web:latest . --push
|
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-web
|
|
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-web
|
|
```
|
|
|
|
Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own requests reach the
|
|
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
|
|
container on another network, as cloudflared's are (#209).
|
|
|
|
**Name the service.** A bare `up -d` recreates every container in the project, which once the
|
|
split lands means the frontend and directory too. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
|
|
|
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
|
|
this host. There is no Docker Hub login here, and the build asks about `debian:trixie-slim` and
|
|
`rust:1-slim-trixie` every time unless they are already stored locally. Pull them from Google's
|
|
mirror and tag them; the build then uses the local copies without asking Docker Hub:
|
|
|
|
```sh
|
|
docker pull mirror.gcr.io/library/debian:trixie-slim
|
|
docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
|
|
docker pull mirror.gcr.io/library/rust:1-slim-trixie
|
|
docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
|
|
```
|
|
|
|
Run those again now and then, or the local copies go stale.
|
|
|
|
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
|
|
the daemon answers there and can read its database, not just that the web port is up. The socket
|
|
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or
|
|
download does not fail the check; it also means a worker stuck on one job would still pass. The
|
|
container restarts on its own after a reboot.
|
|
|
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
|
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
|
|
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
|
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
|
running the command and kills the session (exit 144), which has happened more than once.
|
|
|
|
## Before you touch the page
|
|
|
|
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
|
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
|
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
|
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
|
|
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
|
|
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
|
|
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
|
|
serves no page. The page loads its script as `/app.js?v=<hash of its contents>`, and `/app.css`
|
|
and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser
|
|
keeps them until a deploy changes them and their names. So **every page change needs ipx-web
|
|
rebuilt and deployed**, not ipx, and building needs node and `npm ci` run once.
|
|
|
|
Caddy asks ipx before it serves a page (`forward_auth`): `/api/page-check` sends a browser that is
|
|
not signed in to `/login` and anyone but an admin away from `/admin` (and refuses them
|
|
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
|
|
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
|
|
for everyone, so what ipx used to write into it per person is the page's own job now:
|
|
`web/boot.js`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
|
|
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
|
|
theme).
|
|
|
|
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
|
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
|
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
|
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
|
|
|
After any edit to it:
|
|
|
|
```sh
|
|
npx tsc -p .
|
|
node tests/page-smoke.js
|
|
```
|
|
|
|
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
|
|
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
|
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
|
function, `String.replace` silently matched nothing, and the whole UI died with a
|
|
`ReferenceError` while every server-side test passed.
|
|
|
|
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
|
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
|
|
|
## Tests
|
|
|
|
```sh
|
|
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
|
npx tsc -p . # type-checks web/src
|
|
node tests/page-smoke.js
|
|
node tests/native-bridge.js # the page hands playback to a native shell
|
|
node tests/contrast.js # every theme's palette against WCAG AA
|
|
npx playwright test # 66 browser tests against a real daemon on fixture feeds
|
|
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
|
```
|
|
|
|
Things about the browser suite that have cost time:
|
|
|
|
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
|
|
re-imports the config in every worker, so `prepare()` guards on `TEST_WORKER_INDEX` — without
|
|
that guard a worker deleted the database out from under the running daemon, which then kept
|
|
serving from the unlinked inode while everything else saw an empty file.
|
|
* Tests **share that daemon and run in order**. A test that opens an item marks it read and changes
|
|
what later tests see. Write assertions that do not depend on what ran before, or normalise the
|
|
state first.
|
|
* Fixture feeds must not share an enclosure URL, because `enclosures.url` is globally unique and
|
|
whichever feed is scanned first claims it.
|
|
* `webServer` starts **before** `globalSetup`, which is why the fixture config is written at
|
|
config-load time instead.
|
|
|
|
The load tests (`tests/load`, issue #133) are for what one browser cannot show: twenty-five people
|
|
browsing at once, a hundred players saving positions while a scan writes, fifty listeners
|
|
seeking through files, a flood of wrong passwords. Things about them:
|
|
|
|
* They need **k6**, which `/src/install.sh` installs from k6's own signed apt repository, and they
|
|
build and run a **release** binary: Argon2 in a debug build takes about a second a sign-in,
|
|
which would measure the build.
|
|
* `run.js` serves the feeds itself, generated, and starts **its own daemon** under
|
|
`/tmp/ipx-load`, wiped each run, on ports 8793 and 8794, so it can run beside the browser suite.
|
|
It stops that daemon by its PID. People sign in by the `X-Load-User` header, trusted from
|
|
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
|
|
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
|
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
|
* `ipx status`, the Docker healthcheck, runs every second throughout, and a run fails if one takes
|
|
the healthcheck's 5s.
|
|
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
|
|
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
|
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
|
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
|
|
`ipx.env`; the harness refuses a database whose name does not end in `_test`. Postgres's
|
|
numbers are what production would see, and a run loads the server production's database is on,
|
|
so not while someone is using iPX in earnest. Each budget has a value for each database.
|
|
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
|
|
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
|
|
|
|
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
|
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
|
|
|
## Things that are true and easy to get wrong
|
|
|
|
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
|
|
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
|
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
|
and `position` columns from before accounts; two bugs came from queries still reading them
|
|
(retention, and the entry pruner), and they were dropped in 0.5.
|
|
* **The catalogue and the server settings are in the database, not config.toml** (issue #18):
|
|
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
|
|
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
|
|
and who gets in, the database for the rest (`assemble_config`); a change goes through
|
|
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
|
|
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
|
|
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
|
derived and never in the catalogue.
|
|
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
|
|
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
|
|
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
|
|
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
|
Anyone wanting an item is enough to fetch it.
|
|
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
|
handler and return `403`.
|
|
* **A `tokio::select!` only races its branches at the point of selection.** A long download has to
|
|
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
|
|
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
|
|
fighting over the database, with the stale one still holding the port.
|
|
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
|
|
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
|
|
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
|
|
Rename one and its panels go blank without an error; regenerate the dashboard to match.
|
|
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
|
different task. `ipx status` checks the control socket and the database; to see the worker
|
|
getting through its jobs, watch for `scan complete` in the log.
|
|
* **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.**
|
|
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
|
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
|
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
|
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
|
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for
|
|
the column first and alter only when it is missing, as `feeds.description` does.
|
|
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
|
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
|
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
|
1; no `rowid`, `GLOB` or `UPDATE OR IGNORE`. `Args` in `db.rs` builds the parameters.
|
|
|
|
## House style
|
|
|
|
Comments explain **why**, not what. If a line looks odd, the comment says what went wrong without
|
|
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
|
|
addressed to the person using it.
|
|
|
|
Every change gets one line under `## [Unreleased]` in the changelog of the service it changes, in
|
|
its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
|
Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md)
|
|
(the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page
|
|
and ipx-web) and [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory). A
|
|
change to more than one gets a line in each, each saying what that service does differently. The
|
|
monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not
|
|
a service anyone uses and get no line. Say it the way someone using ipx would notice it. When there is more to
|
|
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
|
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
|
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
|
|
|
Each service has its own version and is released on its own, when its changelog has something to
|
|
release:
|
|
|
|
| service | version in | changelog | tag |
|
|
|---|---|---|---|
|
|
| ipx | `crates/ipx/Cargo.toml` | `crates/ipx/CHANGELOG.md` | `vX.Y.Z`, as every release before the split |
|
|
| ipx-directory | `crates/directory/Cargo.toml` | `crates/directory/CHANGELOG.md` | `directory-vX.Y.Z` |
|
|
| ipx-web, the page | `package.json` | `web/CHANGELOG.md` | `web-vX.Y.Z` |
|
|
|
|
`crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it
|
|
is a line in the changelog of each service it changes. The page shows ipx's version and its own in
|
|
the logo's tooltip; the directory logs its own at start; each binary answers `--version`.
|
|
|
|
Cutting a release of one: first read its `[Unreleased]` against its last release, not line by
|
|
line, and rewrite it as what changed between the two. Lines go in as the work happens, so they
|
|
record the way there: a feature added and then reworked in the same release is one line saying
|
|
what it is now, under Added; something added and taken out again before the release, or a fix for
|
|
a bug no release ever had, is not there at all; and "instead of" and "no longer" compare with the
|
|
last release, not with last week. Issue numbers stay, all of them, on the line that survives. Then
|
|
rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it,
|
|
set the service's version to `X.Y.Z` (dropping `-dev`), tag the commit with the service's tag, and
|
|
update the compare links at the bottom of its changelog. Then, in the next commit, set its version
|
|
to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
|
|
instead of claiming to be the last release. The release that follows can still be a minor or major
|
|
one; `-dev` only says the work comes after `X.Y.Z`. A service never released is at `0.1.0-dev`,
|
|
working towards its first release, `0.1.0`.
|
|
|
|
**Traces first, logs second.** Anything that does work (a service, a handler, a job, a call to
|
|
another service) gets spans, and passes `traceparent` on to whatever it calls, so one trace in
|
|
Tempo follows a request or a job across ipx-web, ipx and the directory. A trace shows where the
|
|
time went and what called what; a log line only that something happened. Log what a trace cannot
|
|
carry: events worth counting on the dashboard, warnings and errors. A new service or route without
|
|
traces is not finished.
|
|
|
|
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
|
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
|
|
|
## Known gaps
|
|
|
|
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
|
issue left open.
|
|
|
|
<!-- rtk-instructions v2 -->
|
|
# Command output
|
|
|
|
Command output here is condensed to save tokens, keeping every signal and
|
|
dropping costly noise. Treat it as the complete result: run commands
|
|
normally, and batch related commands into one call to avoid extra turns.
|
|
Truncated results state their recovery path in their own output. Re-run a
|
|
command as `rtk proxy <cmd>` only when its result is unusable: empty when
|
|
output was clearly expected, contradicting its exit code, or garbled.
|
|
<!-- /rtk-instructions --> |