PacketRelayDocs

Introduction#

PacketRelay is a WebSocket relay server. It accepts connections, groups them by a session id, and forwards frames between the members of a group without reading them. It exists for PacketBench clients that cannot reach one another directly — a desktop behind one NAT and a phone behind another — and it is the only part of that path that runs on a machine neither client owns. It is one Rust binary with no database, no HTTP framework, and no view of what it carries.

This page says what the relay does today, draws the boundaries it deliberately does not cross, and records the state of the code at version 0.1.0.

What it is#

A router for opaque frames. A client opens a WebSocket, sends one JSON hello naming a session id, and from then on every text frame it sends is copied to the other members of that session — verbatim for bridge and broadcast sessions, wrapped in a member_frame envelope for rooms (connection.rs:357-388). The relay does not parse the payload, because the payload is the clients' business and the crate's own module header says so (main.rs:4-5).

Three session shapes share that one handler, and all three are Implemented. A bridge pairs one desktop with one mobile. A broadcast fans one host out to many spectators — 64 by default — with presence events and a viewer count. A room carries a roster of signed members, 32 by default, who each receive every other member's frames attributed by member_id. Which one a connection joins is decided by the hello it sends, not by the URL it dialled — see Core concepts, because getting this backwards is the single most common way to misconfigure a client.

There is no HTTP framework in the process. Every TCP connection is accepted raw, its request head is read in 1 KiB chunks up to a 16 KiB ceiling, and it is hand-validated: \r\n\r\n present, valid UTF-8, method exactly GET, exactly three whitespace-separated fields, version exactly HTTP/1.1, target starting with / (main.rs:431-473). Only four targets are reserved — /health, /healthz, /ready, /readyz — and anything else beginning /health or /ready, a query string included, gets 404 Not Found rather than a fuzzy match (main.rs:474-486). Every other target attempts a WebSocket upgrade.

Admission control runs before any of that. The per-IP and global rate limiter is consulted at accept, before a single byte is read, which is why health probes consume connection budget like any other client (main.rs:360-369). On a breach the TCP stream is dropped with no HTTP or WebSocket response at all — only a warn! log — so a rate-limited caller sees a bare disconnect rather than a status code.

Room members are the one authenticated population. A room_hello carries a P-256 public key, a millisecond nonce and a signature over a canonical payload under the jarvis-room-hello-v1 domain (protocol.rs:13, protocol.rs:42-73). The relay checks nonce freshness within ±30 s, verifies the ECDSA signature, binds a fingerprint-prefixed member_id to SHA-256(SPKI-DER)[..8], and pins the key trust-on-first-use with a strictly monotonic nonce high-water mark (room_auth.rs:157-203). Reject reasons returned to the client are deliberately coarse — two messages cover five distinct failures — so a prober cannot learn which check it tripped (room_auth.rs:81-116).

What it is not#

It does not encrypt anything#

The relay holds no key material for payloads and performs no encryption or decryption on any path. Its only cryptography is signature verification: p256::ecdsa::SigningKey and EncodePublicKey appear solely under #[cfg(test)] (room_auth.rs:356-357), so the relay never produces a signature, and the ecdh feature compiled in from Cargo.toml:20 has no corresponding code path. Confidentiality is entirely a client responsibility (main.rs:4-5). If two endpoints relay plaintext, the relay carries plaintext, and anyone who terminates TLS in front of it sees it.

Nothing survives a restart#

Sessions, TOFU pins, nonce high-water marks and limiter state all live in process memory (session.rs:93, room_auth.rs:141, rate_limit.rs:78). There is no database, no disk, and no external store. A restart discards every open session and forgets every pinned identity, which means a member_id that was bound to one key is unbound again and the next valid signer to present it takes the pin.

The same locality rules out a second replica: two instances share nothing, so a client routed to the wrong one joins an empty session rather than the one its peer is in. Shutdown is equally blunt — there is no signal handling at all, the accept loop is an unconditional loop (main.rs:357-381), so a deploy severs live connections instead of draining them.

Warning

Restarting the relay silently releases every room member-id pin. Between the restart and the next hello, a member id is available to whoever signs for it first — the residual trust-on-first-use window (room_auth.rs:41-45) reopens on every deploy.

Only rooms are authenticated#

desktop_hello, mobile_hello, host_hello and spectator_hello carry nothing but a session_id (protocol.rs:81-91). Possession of that id, plus self-selection of a role, is the entire admission boundary for bridge and broadcast sessions. A desktop or host reconnect replaces the incumbent's routing slot with no proof of identity whatsoever (session.rs:169-175, session.rs:202-206), and a spectator_hello against an unknown id auto-creates the broadcast session rather than failing (connection.rs:99-107).

There is no Origin header check anywhere in the codebase, which is recorded as open work rather than as a decision. There are no accounts, no ACLs, no tickets and no revocation. Payload contents are unattributed: on a room frame the member_id is relay-authenticated, but any user_id a client writes inside the payload is self-asserted and the relay does not look at it (protocol.rs:146-150).

The Remote Agents surface is planned, not built#

The project README describes a large "PacketBench Remote Agents" consumer: /ws/host and /ws/device routes, an HTTPS ticket and control plane, account/host/device records with ACLs and revocation, single-use tickets, Origin enforcement, PostgreSQL durability with replay cursors, audit trails and an outbox, Web Push, encrypted artifact references, and passkey or magic-link authentication. All of it is Planned, and none of it has any implementation: no such route, table or module exists in src/. A connection to /ws/host today is classified exactly like a connection to / — an unreserved target that attempts a WebSocket upgrade into the same handler (main.rs:485). The only statement in that README section matching the code is the 64 KiB inline ceiling (main.rs:39).

The product route is gone#

GET /v1/product-route was Removed on 2026-08-28 along with src/product_route.rs, src/product_crypto.rs, its protocol document, its reserved connection pool, the --product-connection-reserve flag, and the aes-gcm, ed25519-dalek, hkdf, x25519-dalek and zeroize_derive dependencies (CHANGELOG.md:10-28). RequestRoute now has four variants — Health, Ready, Legacy, Reject (main.rs:44-50) — and a test pins the old path to Legacy so its removal cannot regress into a silent special case (main.rs:610-613). The README still documents the removed flag with a default of 32; that row is stale and the code is authoritative.

Current baseline#

Item Current fact
Version 0.1.0, publish = falseCargo.toml:2-10
Language Rust, edition 2021, rust-version = "1.83"; toolchain pinned to 1.83.0
Artefact One binary, packet-relay, from src/main.rs
Licence MIT
Transport Plain HTTP and WebSocket. No TLS in-process — termination is the hosting edge's job, so clients dial wss://
State In-memory and process-local. A restart loses every session, pin and counter
Persistence None. PostgreSQL durability is Planned
Protocols Three — bridge, broadcast, room — all Implemented on one handler
Authentication room_hello only: ECDSA P-256, ±30 s nonce window, TOFU pin. The other four hellos are unauthenticated
Encryption None. The relay verifies signatures and never signs or decrypts
Listen address 0.0.0.0:$PORT, else --port, else 8080main.rs:313-317
Configuration 17 CLI flags and exactly two environment variables, PORT and RUST_LOG
Tests 53, all in #[cfg(test)] modules inside src/. There is no tests/ directory
CI GitHub Actions on Ubuntu: cargo fmt --check, cargo clippy -D warnings, cargo test --locked
Container Dockerfile on debian:bookworm-slim, non-root uid/gid 65532, no CMD — so every flag runs at its default
Deployment None live. Railway is the target; railway.json declares no health-check path and no start command
Graceful shutdown None. No signal handling — main.rs:357-381
Note

The count of 53 tests comes from enumerating #[test] and #[tokio::test] attributes in a read-only survey, not from a test-runner total. The suite has not been executed for this documentation, and the project's own backlog still records a stale count of 61.

Where to go next#

  • Core concepts — sessions, roles, kinds, and why the hello and not the URL selects the protocol.
  • How a connection works — the accept-to-forward path a single connection takes.
  • Run the relay — building it, the container, and what Railway is and is not configured to do.
  • Security model — what the room handshake proves, and what the other four hellos do not.