Rust rewrite of @getpaseo/relay
(ported from upstream commit fd022bc).
The original is two things in one npm package; this workspace keeps them as two crates:
| Crate | What it is | Who links it |
|---|---|---|
crates/server (paseo-relay) |
Native tokio relay server: an opaque, bidirectional WebSocket forwarder between a daemon and its clients. | deployed as a binary |
crates/e2ee (paseo-relay-e2ee) |
The E2EE channel — NaCl box (Curve25519 + XSalsa20-Poly1305), wire-compatible with the existing JS clients. | a Rust daemon |
The relay never decrypts. server does not depend on e2ee.
- Native, not Cloudflare. The rewrite drops Durable Objects in favour of a
single tokio process. This removes the entire
serializeAttachment/ hibernation-rehydration dance — a socket's identity is just its key in an in-memory map (crates/server/src/session.rs). - v2 only. The legacy v1 protocol and the old JSON
ping/pongkeepalive are not ported. - One scaling seam. All session state lives behind
SessionRegistry. Today it is an in-memory map (single process). To scale out, put a sticky load balancer in front (hash onserverId, already in the URL) — the forwarding core does not change. A true backplane would reimplement only that one type. - Frozen wire format is the contract. A Rust daemon interoperates with the
existing JS browser client purely because
crates/e2ee/src/wire.rsreproduces the byte layout and handshake. The browser client is not part of this rewrite.
crates/
e2ee/ crypto.rs · channel.rs · wire.rs · base64.rs + tests/parity.rs
server/ main.rs · router.rs · session.rs · forward.rs · control.rs + tests/e2e.rs
cargo build
cargo test # unit tests run; parity/e2e tests are #[ignore] (see below)
cargo run -p paseo-relay-server # listens on RELAY_ADDR (default 0.0.0.0:8787)GET /health → {"status":"ok"}; GET /ws?role=..&serverId=..&v=2 upgrades.
E2EE crypto parity— ✅ done.crypto_box::SalsaBoxverified byte-identical totweetnacl@1.0.3(keygen, decrypt, and byte-exact ciphertext) incrates/e2ee/src/crypto.rsparity tests. Vectors regenerable viagen.cjs.Encrypted channel (protocol)— ✅ done.ChannelCore(on_frame, bufferedsend, re-hello same-key/different-key handling) and the pureclient_handshake/daemon_handshakefunctions inchannel.rs, with full unit coverage (handshake, bidirectional messages, buffering/flush, plaintext-fatal, key-mismatch-fatal). The async transport driver is spec'd at the bottom ofchannel.rsand lands with the daemon (item 4).Relay teardown cascades— ✅ done. Force-close via aCommand::Close{code}on each writer task: replaced socket → 1008, last client gone → data socket 1001 + controldisconnected, data socket drop → clients 1012. Covered by 5 real-WebSocket integration tests incrates/server/tests/e2e.rs(round-trip, buffering, all three cascades). The control-unresponsive nudge/reset timer is deliberately deferred — it is timer-based robustness, not core teardown; revisit once the daemon's keepalive behaviour is settled.Rust daemon integration— ✅ done.crates/e2ee/src/driver.rsbinds the pure core to anyStream + Sink<Frame>transport (connect_client/accept_daemon/channel+ therunpump), withFrame::Closecarrying fatal close codes. Proven two ways: an in-memory transport test indriver.rs, and a full-stack test (crates/server/tests/e2ee_relay.rs) where a Rust client and daemon exchange encrypted traffic through the live relay over real WebSockets.
- A
paseo-relaydaemon binary (control-socket discovery loop + a connection per client) reusing the driver. - TLS / deployment / observability for the relay server.
- Optional: the control-unresponsive nudge/reset keepalive (deferred from item 3).