Commit Graph

9 Commits

Author SHA1 Message Date
711b0395f1 A terminal reader, ipx; the server is ipxd (#437)
ipx-tui (crates/tui) is iPX's reader in ratatui: your feeds, a feed's
items, an item's text as plain text, read and flagged as on the page,
through the API with an API token. It installs as `ipx`, so the daemon's
binary is now `ipxd`: the Dockerfile, release script, tests and every
doc and message that names its commands follow.

Naming the binary ipxd alone renamed its crate, and with it every log
target (ipx::scan became ipxd::scan): the default filters are ipx=info,
so nearly every line would have been dropped and the dashboard's queries
on the targets gone blank. The daemon's code is now a library still
called ipx, under a five-line ipxd main.rs, and the targets are as they
were.

ratatui is taken without its default features: 32 new packages in place
of 78.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-09 11:52:10 +00:00
8acf57961e Ten wrong passwords from one address hold it off sign-in for a quarter hour (#409)
POST /api/login answered every wrong password 401 for as long as someone
kept trying. Now ipx counts them by the client's address: the tenth in a
window holds that address off until the window, which starts at its first
wrong password, ends, answered 429 with how long is left. Counted by
address and not name, since by name anyone could lock anyone out; a right
password does not reset the count, or a guesser with an account of their
own could clear it between guesses.

Behind Caddy every peer is Caddy, so the address is the last one in
X-Forwarded-For, taken only from trusted_proxies. Caddy now sets that to
{client_ip}: Cf-Connecting-IP for the tunnel's requests, believed only
from TUNNEL_PEER (servers trusted_proxies), and the peer for everyone
else, replacing whatever the client sent. Checked with Caddy against an
echo upstream: a forged XFF and Cf-Connecting-IP from elsewhere reach ipx
as the peer, the tunnel's Cf-Connecting-IP as itself.

The sign-in load test sends each attempt from its own address, so it
still measures a flood from many, whose passwords are all checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:18:38 +00:00
f8408a3d26 Tests, tooling and the served scripts are TypeScript (#392)
The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).

Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.

The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.

boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.

Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:23:22 +00:00
a20062d0f7 Fetches refuse internal addresses unless fetch_internal lists them (#320)
ipx's clients had no destination policy and followed redirects
anywhere. Adding a feed returned the status or first line of whatever
answered at the address; an enclosure declared as audio was downloaded
whole from any URL and served back through /media; /api/art fetched
any image a feed named. Feeds come from publishers and anyone signed
in, so loopback, the Docker network and the LAN were all reachable.

ipx-feed's guard is a connector layer: after the name resolves and the
connection opens, before the request is written, it looks at the
peer's address and refuses anything off the public internet unless
config.toml's [general] fetch_internal lists it. Being on the
connection, it covers every request on ctx.client and feed_client,
each redirect hop, IP literals (which never reach a DNS resolver) and
a name that resolves differently the second time. reqwest's rustls
connection reports the peer address; a test run by hand checks HTTPS
gets through, since were it lost every https fetch would be refused.

The directory is on the server's own network by design
(http://ipx-directory:8100), so it moves to local_client, unguarded,
set by an admin. The browser suite and load tests serve their feeds on
loopback and list 127.0.0.1.

Not covered: librqbit's own connections (trackers, peers), which do
not go through these clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:27:40 +00:00
62a3241139 Database connections kept open; a click's wait is measured (#241, #245, #246)
#241: on production, All Subscriptions' query runs in 31 ms, unread oldest-first in 30, the
feed list's count in 5, generic plans included (EXPLAIN ANALYZE, BUFFERS, read only), against
0.3-1.6 s in ipx. A new Postgres connection takes 80-330 ms to open, and a page load asks for
more than are idle: four of six slow loads came as the pool grew (3 to 6, 2 to 6, 3 to 5, 6 to
8), and 20 requests at once took a median 414 ms while the pool grew from 8 to 20. The daemon
and the directory's server now keep their whole pool open (min = max); a command such as `ipx
status`, and the tests, which share the server's 100 connections, open as they need.

#246: the page reports list_shown, the ms from asking for a list to drawing it, with the view
and filter, under the click's trace; background refreshes are not timed. Timings skip report()'s
once-a-minute limit, which is for errors in a loop. A dashboard panel and table, and the prod
check, read it.

#245: the load test's browse scenario subscribes by OPML and leaves the Directory, which is
ipx-directory's since #204, to it.

CLAUDE.md records the owner's leave to EXPLAIN (ANALYZE, BUFFERS) any query on production,
read only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:52:14 +00:00
cc155274e9 No Docker healthchecks: the daemon keeps /metrics' counts itself (#218)
Docker runs a healthcheck with exec, and containerd puts each exec's state in XDG_RUNTIME_DIR,
/run/user/0 on Tower, which elogind removes when root's last session ends. Every check then
failed and every container showed unhealthy while all of them answered. The checks are gone from
the sample compose file and production's; ipx's library counts for /metrics, which only the
healthcheck's status call refreshed, are counted every 30 s by the daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:08:22 +00:00
c9964778cb Give SQLite several connections, so requests are answered at once (#136)
SeaORM gives a SQLite pool one connection unless told otherwise, and connect() did not say, so
every request, every scan write and every read went through it in turn. Twenty-five people
browsing in the load tests (#133) got 50 requests a second out of 12 cores, the fastest answers
in 3-30ms and the medians ten times that.

SQLite now gets eight. In WAL readers run beside the one writer. Every transaction here writes
first, so it takes the write lock or waits out the busy timeout for it; SQLite refuses at once
only a transaction holding a snapshot a write has moved past, and none here does. Db::memory
switches its file to WAL too, as Db::open does, so the tests share it as the daemon does. A test
holds a write open and reads beside it, and fails with one connection.

The same browsing: a feed's items at p95 10ms where it was 133, a search 22 where 119, the feed
list 89 where 242. The load tests' SQLite budgets are tightened to match; the Rust tests take
2.8s where they took 7.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:22 +00:00
bbdcbe213f Run the load tests on Postgres too, as production runs (#139)
node tests/load/run.js --postgres puts the scratch daemon in IPX_TEST_DATABASE_URL, the database
the Rust tests already use on Postgres, ipodderx_test on the databases project's server, in a
schema of its own, ipx_load, dropped and made again each run as /tmp/ipx-load is wiped. The URL
is in /src/.envrc, taken from ipx.env. It is production's server and role, so the harness refuses
a database whose name does not end in _test, and hands psql the password in its environment,
where no other process on Tower can read it. psql comes from /src/install.sh.

After seeding it runs ANALYZE: a schema filled seconds before has no planner statistics until
autovacuum gets there, which it did partway through a test, and the search's p95 swung between
40ms and 166ms from run to run. Analyzed first, three runs gave 37-38ms.

Each budget now has a value for each database, about twice what it measures on Tower: Postgres
answers the feed list at p95 54ms to SQLite's 242, having no one connection to queue for (#136).
Running it found #140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:31:18 +00:00
b43ba89778 Load tests with k6, for what many people at once do to the server (#133)
The browser tests drive one person against a handful of feeds, one request at a time, and cannot
show what production's load does. node tests/load/run.js builds a release binary, serves 1,500
generated feeds from itself, starts a daemon of its own under /tmp/ipx-load, seeds a hundred
listeners with thirty feeds each and four downloaded files, and runs four k6 scripts, with
`ipx status` -- the healthcheck -- run every second and failing the run at its 5s timeout:

  browse     25 people at once: feed list, All Subscriptions, a feed, a search, the Directory,
             a feed's page in it
  listening  100 players saving positions every second and marking items read while scans write;
             each reads its own state back, which must be as it left it
  media      50 listeners seeking: every range checked byte for byte against the served file
  signin     a flood of wrong passwords while others browse, and the gap between refusing a
             known name and an unknown one

Each budget is about twice what it measures now. Getting here found #135 (SQLite waiting for the
disk after every write, a first scan of 1,500 feeds estimated at 50 minutes, now 27s), #136
(SQLite's single connection, left open), #137 and #138 (fixed in the commit before this). k6 is
installed from its own signed apt repository by /src/install.sh, outside this repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:38:20 +00:00