◂ Blog
2026-09-19 7 min read

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:

FileHolds
rpc-api.json99 methods with auth levels and parameters, plus 10 recorded as removed
config-keys.json77 core keys and 19 web keys, with defaults
events.jsonThe 22 events the daemon broadcasts
alerts.jsonThe 24 libtorrent alerts the daemon handles
rencode-corpus.json73 conformance cases for the codec
rpc-frames.json10 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.