A small local relay that makes Transmission's tracker announces survive a hostile network path. It runs on the same host as Transmission, listens on loopback, and forwards each announce to the real tracker over a fresh, certificate-verified HTTPS connection, hedging stalled connections and retrying once — all inside the 45 seconds Transmission gives an announce.
Peer traffic is not touched. Nothing is tunnelled. Your public IP does not change. The relay only makes the tiny HTTP requests that tell a tracker "I am still seeding" reliable again.
Two pieces, deliberately separate:
transmission-announce-relay— the daemon (a loopback HTTP server).tar-ctl— a control CLI that lists your torrents, points the matching tracker URLs at the relay (by tracker host, regex, name, status, ids — or all), and puts the originals back.
Standard library only, Python 3.8+. No dependencies.
In June 2025 Cloudflare published Russian internet users are unable to access the open internet. According to that report, since 9 June 2025 Russian ISPs — which Cloudflare attributes to direction from the regulator, Roskomnadzor (RKN) — have been throttling and partially blocking traffic to websites served through Cloudflare: connections that are cut off after the first ~16 KB, packets that are silently dropped, and connections that simply time out. Cloudflare's description is of interference on the Russian side of the path, not a Cloudflare ban on Russian users. This project takes that report as context; it has not independently verified the mechanism.
Many private BitTorrent trackers sit behind Cloudflare. For a BitTorrent client the effect looks like this:
- Announces (
GET https://tracker.example.org/.../announce?...) sometimes get no reply at all: the TCP connection is up, the request is sent, and nothing comes back. Other announces on a fresh connection succeed immediately. - Transmission talks to trackers through libcurl with connection reuse and HTTP/2. A connection that has gone silent stays in the pool, so a whole batch of announces can fail on it before it is discarded.
- Every failed announce makes Transmission back off: 20 s, then roughly 5, 15 and 30 minutes. Hours of seeding go unreported. Private trackers then see you as not seeding and hand out hit-and-run warnings, even though the data was available the whole time.
The relay attacks exactly that failure mode. It does not know or care what the filtering mechanism is; it just refuses to wait on a connection that has gone quiet.
If you are not on a throttled path, you do not need this. It is not a way to hide traffic, evade a ban, or reach a tracker your account cannot reach.
Transmission ──HTTP──▶ 127.0.0.1:19053/r/<route>/announce?…
│ paced scheduler (one announce start per 3 s per route)
│ fresh TLS connection, HTTP/1.1, Connection: close
│ no headers after 3 s? → open a second socket, first to answer wins
│ 5xx / stall on both? → one requeue after 10 s (Retry-After honoured ≤10 s)
▼
https://tracker.example.org:8443/<passkey>/announce?… (bytes forwarded unchanged)
- Named routes. The relay config maps a route name to the real announce
URL, passkey included. Transmission's tracker list only ever contains
http://127.0.0.1:19053/r/example/announce. Your passkey never appears in Transmission's settings, logs or UI again. - Byte-for-byte forwarding. The query string is passed through untouched
(binary
info_hashandpeer_idare not re-encoded), the response body andRetry-Afterheaders come back unchanged. The tracker sees a normal Transmission announce with Transmission'sUser-Agent. - Hard deadline. Every announce is finished or failed about 40 s after arrival at the latest (queue wait, hedge, requeue delay and second attempt included; the scheduler polls every 250 ms and the caller is released within one further second), so Transmission never waits past its own 45 s timeout for us.
- Never retries a verdict. Tracker rejections (
failure reason), 4xx responses (except a429carrying a shortRetry-After), redirects and malformed bodies are returned as-is; redirects are not followed. Transport failures — timeouts, resets, TLS/certificate errors — become a relay502after the (single) requeue policy above has run out; certificate errors are never retried. Ambienthttp_proxy/https_proxyare ignored. TLS is always verified against the system trust store. - Exact duplicates share one upstream request while it is in flight; nothing is cached afterwards.
- Backpressure, not buffering. Per route: at most 64 announces waiting or in
flight (then an immediate
503), and an announce that could not even start within about 20 s is dropped with503. So:503means the relay did not try,502means the relay tried and the upstream failed. Transmission retries both on its own schedule. - Nothing sensitive is logged. Request logs contain the route name, event
type (
started/stopped/completed/update), outcome, timings and counters — never URLs, query strings, hashes or peer ids.
pipx install transmission-announce-relay # or: pip install --user .-
Write the relay config, keep it private (it holds your passkey):
mkdir -p ~/.config/transmission-announce-relay cat > ~/.config/transmission-announce-relay/relay.json <<'EOF' { "listen": {"host": "127.0.0.1", "port": 19053}, "routes": { "example": {"upstream": "https://tracker.example.org:8443/YOUR_PASSKEY/announce"} } } EOF chmod 600 ~/.config/transmission-announce-relay/relay.json transmission-announce-relay --config ~/.config/transmission-announce-relay/relay.json --check
-
Run the relay (see
examples/systemd/for a user unit withRestart=always;examples/docker-compose.ymlfor containers):transmission-announce-relay --config ~/.config/transmission-announce-relay/relay.json --json -
Look at what would change, then apply:
tar-ctl list --tracker-host tracker.example.org tar-ctl route --route example --tracker-host tracker.example.org # dry run tar-ctl route --route example --tracker-host tracker.example.org --apply # 5 s between torrents tar-ctl status
-
To undo, at any time, relay running or not:
tar-ctl restore --all # dry run tar-ctl restore --all --apply
tar-ctl talks to Transmission's RPC (--rpc-url, --rpc-user,
--rpc-password, or TR_RPC_URL / TR_RPC_USER / TR_RPC_PASSWORD).
Default: http://127.0.0.1:9091/transmission/rpc.
Selectors, usable on list, route and restore (combine freely):
| flag | meaning |
|---|---|
--tracker-host HOST |
torrents with a tracker on this host (and, for route, the lines to replace) |
--tracker-regex RE |
regular expression over tracker URLs |
--name-regex RE |
regular expression over torrent names |
--status paused|seeding|downloading|active|queued |
Transmission state |
--ids 1,2,3 |
Transmission ids |
--all |
everything (with the other filters) |
route and restore are dry runs unless --apply is given, and require a
selector. route replaces only the tracker lines that match; other trackers
and tiers in a multi-tracker torrent are preserved byte-for-byte. Originals are
written to a private state file (~/.local/state/transmission-announce-relay/routes.json,
mode 600) before Transmission is changed, so restore can put back exactly
what was there. restore refuses a torrent whose tracker list changed since
routing unless --force.
--pace SECONDS (default 5) spaces out changes. --reannounce asks
Transmission for an announce after each change; without it Transmission
re-announces on its own schedule, which usually is what you want.
Output modes: human (default), --agent (deterministic tab-separated), --json.
AGENT_SESSION=1 in the environment selects agent mode.
{
"listen": {"host": "127.0.0.1", "port": 19053},
"routes": {
"name": {
"upstream": "https://host[:port]/path/announce",
"info_hashes": ["<40 hex chars>", "..."]
}
}
}listen.hostmust be127.0.0.1(orlocalhost); the relay carries passkeys on behalf of a local client and is not a general proxy. Default127.0.0.1:19053. IPv6 loopback is not supported.routes.<name>.upstreammust be a cleanhttps://URL (no query, fragment or credentials). One route per tracker; each route has its own scheduler.routes.<name>.info_hashes(optional) restricts the route to listed torrents.- Scrape:
/r/<name>/scrapeis mapped when the upstream path containsannounce(the usual.../announce→.../scrapeconvention). Set"scrape": falseon a route to answer scrapes with404locally — useful when the tracker does not implement scrape, since Transmission scrapes every routed torrent and each scrape would otherwise take an announce slot. Announces are always started before waiting scrapes. GET /healthzreturns status and counters per route (queue depth, in-flight, hedges, hedge wins, requeues, transport and HTTP errors, expired announces) plus each route's upstream hostname — but no paths, passkeys, query strings or hashes. It is reachable by any local process, like the relay itself.
- Which torrents to route. Any torrent whose tracker is on the affected
path.
tar-ctl route --tracker-host …handles hundreds of torrents in one paced run; a relay restart while torrents are routed only fails the announces in flight, which Transmission retries 20 s later. - Capacity. One announce start per 3 s per route is ~1,200/hour, plenty
for several hundred torrents at a typical 30–45 minute interval. Bursts
(starting many torrents at once, or many torrents retrying rejections every
20 s) can exceed it; the relay then answers
503early rather than queueing for minutes, and Transmission's own backoff spreads the load. - Retired torrents. Trackers answer deleted/trumped torrents with a
failure reason; the relay passes that through unchanged and never retries it. Such torrents keep re-announcing on Transmission's failure backoff; pause or remove them. - Restart safety.
tar-ctl restoreneeds only Transmission, not the relay. If the relay is unreachable, routed torrents simply fail their announces until it is back or they are restored. - Containers. Transmission announces to
127.0.0.1, so the relay must share its network namespace (network_mode: hostfor both, ornetwork_mode: "service:transmission"for the relay).
- It is not a VPN or SOCKS/HTTP proxy and does not carry peer connections.
- It does not spoof, rewrite or add anything to the announce; the tracker sees your client, your IP and your stats.
- It does not bypass tracker-side restrictions (revoked download rights, hit-and-run limits, unregistered torrents). It only makes sure your announces arrive.
- It does not prove what the network is doing; it just stops waiting on connections that have gone quiet.
python -m unittest discover -s tests -vTests use fake sockets and a fake Transmission; nothing touches the network. The dispatcher's timing constants are patched down in tests so the suite runs in seconds.
MIT.