Rewriting Deluge in Rust Without Breaking a Single Client
Replacing a Python BitTorrent daemon with a Rust one, where the wire protocol is extracted from the original instead of transcribed by hand, and a container gate refuses anything that drifts.
I have run Deluge on a headless box for years. It works. What stopped being
fine was everything around it: a plugin system that executes uploaded code, a
daemon certificate no modern TLS stack will accept, passwords compared with
==, and a Web UI shipping a Create button that was hidden and wired to
nothing because only the GTK client could make a torrent.
So I rewrote the management layer in Rust and kept libtorrent underneath. redeluge is two binaries, six crates and about 35 600 lines of Rust and C++. The wire protocol, the config files and the Web UI are unchanged, so existing clients keep working.
The interesting part is not that it is Rust. It is how I made sure a rewrite of 99 remote methods did not quietly lose one.
Do not transcribe a protocol, extract it
The obvious way to reimplement an RPC surface is to read the Python and write the Rust. That approach fails silently: you notice the method you forgot when a client errors out in six months.
So phase zero produced no Rust at all. It parsed the Python tree with ast and
froze what it found into JSON. Deluge marks its remote methods with a
decorator, in two shapes, and the auth level is the argument:
def auth_level_of(decorator: ast.expr) -> int | None: """Return the auth level an @export decorator grants, or None if not one.""" # Bare `@export` if isinstance(decorator, ast.Name) and decorator.id == 'export': return DEFAULT_AUTH_LEVEL if not isinstance(decorator, ast.Call): return None func = decorator.func if not (isinstance(func, ast.Name) and func.id == 'export'): return None # `@export(AUTH_LEVEL_X)` or `@export()` if not decorator.args: return DEFAULT_AUTH_LEVEL arg = decorator.args[0] if isinstance(arg, ast.Name) and arg.id in AUTH_LEVELS: return AUTH_LEVELS[arg.id] raise ValueError(f'unrecognised auth level expression: {ast.dump(arg)}')
Regexes would have been quicker and wrong: signatures carry defaults and annotations, and I needed the arity too.
What came out:
| File | Holds |
|---|---|
rpc-api.json | 99 methods with auth levels and parameters, plus 10 recorded as removed |
config-keys.json | 77 core keys and 19 web keys, with defaults |
events.json | The 22 events the daemon broadcasts |
alerts.json | The 24 libtorrent alerts the daemon handles |
rencode-corpus.json | 73 conformance cases for the codec |
rpc-frames.json | 10 wire frames captured from the reference implementation |
The ten removals are listed with a reason rather than silently dropped. My
favourite reads no plugin system, and it executed uploaded code.
Making the daemon prove it
A frozen JSON file is a document. It becomes useful when the code has to answer
to it. redeluge-contract embeds the contract at compile time, and a test
walks every method in it and calls it:
for method in Contract::get().methods_for(Transport::Daemon) { // No arguments: most calls will refuse, which is the right answer and // proves the method is there. let outcome = core.call(&context, name, Vec::new(), Vec::new()).await; if let Err(error) = outcome { if error.exception == "NotImplementedError" { missing.push(name.to_owned()); } } }
The inverse test matters just as much: the advertised method list must equal the contract plus this fork's own additions, and nothing else. A rewrite that invents a method is as broken as one that loses a method, because a client written against the invention will not survive meeting a real Deluge daemon.
Auth levels are read from the contract instead of written in the daemon. A level that drifted would otherwise stay invisible until someone with a read-only account deleted a torrent.
A corpus nobody wrote by hand
Deluge's wire format is rencode. It has a detail that will ruin your week:
floats are encoded as 32 bit, so a round trip is lossy in a way your Rust
implementation will not reproduce by accident.
Rather than write test vectors from the spec, I generated 73 cases by running the reference Python implementation and recording what it produced. Same for the frames: 10 real DelugeRPC exchanges, captured, replayed against the Rust codec.
One of those captures is a compression bomb. Frames are zlib compressed, so two hundred kilobytes on the wire become two hundred megabytes once inflated. The Python daemon inflates it, and it does so before authentication, which makes it a pre-auth memory exhaustion against anything exposed. Here the body limit is enforced against the inflated size, so the frame is refused rather than expanded, and the test asserts exactly that.
Nothing from libtorrent crosses into Rust
libtorrent alerts are a C++ class hierarchy you read with alert_cast.
Translating that hierarchy into Rust would mean one extern function per alert
type, and a maintenance bill on every libtorrent release.
The shim flattens each alert into a plain struct: a discriminant, a message, an
optional infohash, a few payload slots. Rust reassembles that into a typed
Alert. Torrents are addressed by hex infohash rather than by handle, so Rust
never holds a C++ object it could outlive.
The discriminants are just the order the contract lists the alerts in, which means the two sides can drift. So they are not allowed to:
assert_eq!( expected, actual, "AlertKind no longer matches contract/alerts.json. \ Re-run tools/extract_contract.py, then update src/alert.rs and src/shim.cc together." );
An assertion message that tells you what to do next is worth the two minutes it takes to write.
Modern where the original was quietly unsafe
Three things changed on purpose, and each one is announced in the log rather than done behind your back.
Passwords. Deluge's auth manager compares any hash it does not recognise in
plaintext, with ==. Every comparison here is constant time, and a plaintext
entry is rewritten as scrypt the first time it is used, so an old account file
converges on something safe by being used.
The exception is localclient, which exists so a tool on the same machine can
log in without anyone typing anything: it reads the password straight out of
the file. Hashing it makes the account unusable, which is not hypothetical, it
is how the first run of this daemon locked itself out. So it stays plaintext,
in a 0600 file, twenty random bytes nobody chose, reachable over loopback only.
Anyone who can read that file can already read everything else the daemon owns.
The certificate. Deluge generates a self-signed X.509 version 1 certificate. rustls will not use one. Refusing to start would strand every existing installation over a certificate that nothing verifies, so the unusable pair is moved aside, a new one is generated, and the old files are kept.
The plugin system. Gone, along with the upload endpoint that ran whatever you sent it.
Complete, meaning four plugins became four config keys
Labels, watched directories, the block list and the schedule are not missing
and are not plugins. Each is a key of core.conf.
That has a consequence worth stating plainly: there is no label.* or
blocklist.* RPC namespace, because there is no plugin to talk to.
core.get_config and core.set_config are the whole interface, and every
client that speaks DelugeRPC already has them.
curl -s -b cookies.txt -H 'Content-Type: application/json' \ -d '{"method":"core.set_config","params":[{"scheduler":{"enabled":true}}],"id":1}' \ http://127.0.0.1:8112/json
Everything is off by default, so an upgrade changes nothing until you turn something on.
Automated, meaning one command and no excuses
Every generated artifact has a --check mode, so none of them can drift from
the tree without failing the build. The whole gate runs in a container, which
is how it runs in development:
docker/rust.sh
That is: SPDX headers on every source file, wiki link checking, ruff on the
Python that is left, icon regeneration check, cargo fmt, clippy with
warnings denied, 494 tests across the workspace, then the contract extractor,
the OpenAPI generator and the state converter self-test.
The wiki check exists for a stupid reason that took me a while to see. GitHub
renders a link to a wiki page that does not exist as an invitation to create
it, not as an error. The broken link is invisible on the site, so it has to be
caught before it ships. The wiki itself lives in wiki/ and a workflow mirrors
it on push, which makes the repository the single source of truth and the wiki
a generated copy.
What it does not do
No GTK client, no console UI, no plugins, no translations. The interface is the Web UI and it is in English. A thin client still works, because it speaks the same protocol.
Coming from Python Deluge, you convert the torrent list once. Nothing is deleted, and the daemon refuses to start until the conversion is done rather than presenting you with an empty list, which is the kind of empty list that makes people reach for a backup at two in the morning.
python3 tools/migrate_state.py ~/.config/deluge
Six Python files survive the rewrite, all of them in tools/: the extractor,
the generators, the state converter. The contract is frozen now that the
Python tree is gone, which is the point. It was never documentation. It was the
test oracle, and it outlived the thing it was extracted from.