Repository navigation
Release 0.35.0
101 commits since 0.34.0 — 20 features, 68 fixes, the rest docs, tests, CI
and two performance changes. Most of it came out of an audit pass over the
four apps, where the findings that mattered came from probing the running
stack, not from static analysis: semgrep, trivy, gitleaks, gosec, hadolint,
actionlint, zizmor, govulncheck and osv-scanner were clean or already
arbitrated, while calling 131 GET and 90 write routes without a session — and
then with another member's session — was what turned up the real leak.
Security
safeFetchhanded the caller's credentials to whoever asked for a
redirect. The SSRF guard re-validated every hop, so no private range could
be reached, but the caller'sinitwas spread into everyfetch— headers
included. A public target answering302 Location: https://elsewhere
received the sixteen member-chosen webhook headers, the body HMAC, the ntfy
Authorizationor the object store's SigV4 signature. Fixed with an
allowlist rather than the standard denylist, because the callers here set
their own credential headers. A307/308to another origin carrying a body
is now refused outright rather than replayed.- The freeleech pool named its top members to anonymous callers, with
internal ids. The anonymous path existed for one field; the contributor list
had come along with it. - The Caddy credential scrub in the access log had never matched a field —
uriinstead ofrequest>uri— so passkeys had been written in cleartext
since it was added, without a warning. - A banned member kept the account routes under
/api/auth; an IP ban locked
staff out of the very route that lifts it; the API served a CSP written for
the web application. - The Torznab feed published the announce passkey to read-key holders, and a
block a member could lift by rotating their own key. encryptwas an unbounded oracle against the panic password, and a
twelve-character password was derived at Node's defaults.- One address seen on five days read as five different places in the login
history. - Postgres trusted the network in the reference compose, and Caddy had no body
limit.
Performance
- Byte credits no longer ride the announce. A member seeding 70 torrents
was updating their own row 70 times per interval, and the increments are
commutative. Measured over five minutes at 1 964 credited announces/s, 50 000
members, steady state: WAL 113–125 MB → 45.8 MB at the 60 s default and
7.5 MB at 300 s; statements 1.00 per announce → 0.038. Table and index sizes
unchanged. Flushed in SMALL chunks, because a single-block flush defeats HOT
pruning and writes more WAL than the individual writes it replaces. - Migration 0071 was measured wrong; 0072 corrects it. Its benchmark ran
100 000 updates inside oneDOblock — one transaction — so nothing could
prune andfillfactorlooked guilty. Remeasured with one transaction per
announce,hnr_trackingreaches full HOT at 95 for +0.5% of table, where 70
cost +35% for the same result. - The swarm-cache ceiling counted traffic rather than memory; a hundred
concurrent peer ids could reach a hundred GiB/s of credit; an unauthenticated
scrape could reach Postgres.
Features
Members — a copy of your own record and what it deliberately leaves out;
three keys instead of one doing three jobs; alerts on saved searches; "sign out
everywhere"; where an account has been signed in from; drafts kept until sent.
Tracker — BEP 52: the second infohash a hybrid torrent announces under; a
partial seed is not a seed; an operator can close UDP scrape (BEP 15 has no
field for authentication in a scrape request, so a private tracker closes the
door rather than guarding it).
Torznab / integrations — a Prowlarr definition generated per instance; the
seeding rules a client was never told, and rates that were a lie; one line in
an IRC channel and the parser for it in the same breath.
Staff — who vouched for them and who they let in; an audit register across
the whole console; the moderation queue says which of these are yours; a
freeleech you can put on a single torrent.
Site — a statistics page, the site looking at itself; installable as a PWA,
with an icon size nobody made up; three things a catalogue needs when it gets
old.
Interface and accessibility
An interface pass produced five hundred findings; four systemic faults were
behind most of them and were fixed at the root. Since then: 49 form controls
had no accessible name, then 24 more that a first crude count had put at ~300 —
the difference was three classes of false positive, now documented in the test
that guards the number. A modal with no focus trap and a group row only a mouse
could open. A sanitiser that passed a phishing link and ate legitimate
attributes. Two locales hardcoded in a bilingual site, an error page in
English, numbers formatted in the wrong locale. Two pages that shipped the
server's clock and corrected it on hydration. document.write on the
recovery-codes screen. Three pages rendering bare native buttons.
Correctness
- A failed seed-time write blocked its own retry for 900 seconds.
- Closing the UDP socket stopped the read loop but not the goroutines writing
to Postgres; shutdown now drains them. - A topic author could delete everyone else's replies.
- A takedown left the catalogue, the page and the file untouched.
- The IRC queue paced nothing and two thirds of announces never left.
stop()thenstart()left an orphan message stream delivering twice.- A relay race unsubscribed a channel for good, and the drain signalled nobody.
- A post from twelve minutes ago read as two hours old —
db.executesilently
drops the timestamp parser thatdb.selecthonours. - 40 routes where a malformed query parameter answered 500 instead of a
readable 400. - A case-different username was a second account, and the foreign keys had no
index. - One proof of work could be spent twice.
- The dashboard's tracker light was a constant, not a status.
- Saved searches rebuilt their link with the API's parameter names, so the
saved URL never reopened the search. - The integration suite never connected Redis, so eleven failures per CI
job were invisible — and worse, the tests were not exercising what they
appeared to: account erasure passed while its Redis half did nothing at all.
Operators
Two new settings, both on the tracker, both optional:
| Variable | Default | What it does |
|---|---|---|
TRACKER_STATS_FLUSH_INTERVAL |
60s |
Byte-credit batching window. 0s restores the per-announce write. 300s divides WAL by 15 at the cost of five minutes of staleness on the counters a member sees. |
TRACKER_STATS_FLUSH_CHUNK |
10 |
Members written per transaction during a flush. Do not raise without measuring — past a few dozen, a flush writes more WAL than what it replaces. |
TRACKER_UDP_SCRAPE_ENABLED shipped in 0.34.0's successor work but was never
in the env reference or the Helm chart; both are corrected here
(tracker.udp.scrapeEnabled).
15 migrations (0058–0072), applied at boot. Verified to apply to an empty
database: 204 tables, 1 672 columns, 578 indexes.
Shutdown grace period. The tracker now flushes byte credits (up to 10 s)
and drains in-flight UDP datagrams (up to 5 s) before the existing HnR drain
(8 s) — 33 s worst case. Compose gained stop_grace_period: 45s; the chart's
terminationGracePeriodSeconds moved 30 → 45. Docker's 10 s default was
already cutting the HnR drain short before this release.
A NetworkPolicy is now shipped with the chart.
After upgrading
Migration 0072 sets fillfactor on hnr_tracking but deliberately rewrites
nothing — a boot-time migration must not take an exclusive lock on a table in
the announce path. To realise it on existing pages:
pg_repack --no-order --table public.hnr_tracking -d trackarr
Online, no maintenance window. VACUUM FULL public.hnr_tracking is the
alternative and takes an ACCESS EXCLUSIVE lock.
Verification
Every CI gate, plus what CI cannot reach. The behavioural claims above were
checked against a running compiled stack rather than read off the source: the
Caddy scrub with a canary, the forum cascade, session revocation across two
devices, the credit-budget clamp (2 000 GiB claimed → 68 GiB credited), the UDP
scrape switch in both positions, the byte-credit batching against a real
tracker with real announces, and the redirect header-stripping against real
undici with two servers on two origins.
🤖 Generated with Claude Code
What's Changed
- Audit pass over the four apps: BEP 52, GDPR, PWA — and what probing the running stack found by @Dim145 in #28
- Release 0.35.0 by @Dim145 in #29
Full Changelog: 0.34.0...0.35.0