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.
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 = false — Cargo.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 8080 — main.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 |
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.