PacketRelayDocs

Run the relay#

PacketRelay is one Rust binary that binds a single TCP port and speaks plain HTTP and plain WebSocket on it. Three routes get it running: cargo against a checkout, the repository's own Dockerfile, and Railway, which builds that same Dockerfile. This page walks all three, then covers the two facts that catch people out — the container image passes no flags at all, and the relay terminates no TLS.

Important

No PacketRelay deployment is currently live. Railway is the target platform and railway.json is committed, but nothing on this page has been observed running against Railway's edge. Where that matters, it is marked.

Before you start#

  • Rust 1.83.0, for the source route only. rust-toolchain.toml:2-4 pins the channel to 1.83.0 with clippy and rustfmt on the minimal profile, and Cargo.toml:5 declares rust-version = "1.83". A rustup installation reads the pin and fetches that toolchain on the first build.
  • Docker, for the container route. Nothing else: the compile happens inside rust:1.83-slim, so the host needs no Rust at all.
  • A committed Cargo.lock. Every build path uses --locked and fails rather than quietly resolving a different dependency graph.
  • A TLS-terminating proxy in front of anything a browser will reach. See TLS terminates at the edge.
  • One instance. Not a cluster — see Two replicas are two relays.

Run it from source#

  1. Clone the repository and change into it. The toolchain file does the version selection; there is nothing to configure.

  2. Build the release binary.

    cargo build --locked --release
  3. Run it, either through cargo or directly.

    cargo run --locked -- --port 8080
    ./target/release/packet-relay --port 8080
  4. List the flags before you tune anything. Defaults and ranges are enforced by a validator that exits through clap's ValueValidation path — main.rs:212-266.

    cargo run --locked -- --help

The release profile is thin LTO, codegen-units = 1, strip = "symbols" and panic = "abort"Cargo.toml:33-37. panic = "abort" is the one worth knowing: a panic in a connection task takes the whole process down rather than unwinding, and on a platform with a restart policy that reads as a restart, not as one dropped client.

Run it in Docker#

  1. Build the image from the repository root.

    docker build -t packet-relay .
  2. Run it, publishing the port.

    docker run --rm -p 8080:8080 packet-relay
  3. Append flags after the image name. They land in the binary's argv — see below for why.

    docker run --rm -p 8080:8080 packet-relay --max-connections 512 --max-spectators 200
  4. Change the port with the environment, not the flag.

    docker run --rm -e PORT=3000 -p 3000:3000 packet-relay

The build context needs testdata/#

Dockerfile:5-7 copies exactly four things into the builder: Cargo.toml, Cargo.lock, src/ and testdata/. The last one is not test scaffolding that could be dropped to slim the context — protocol.rs:161-169 pulls the two canonical wire fixtures in with include_str!, so they are read at compile time and baked into the binary. Remove testdata/ and the build fails during compilation, long before any test runs.

.dockerignore excludes target, .git, .env and *.log, so a local build tree never inflates the context.

The image runs as uid 65532 and passes no flags#

The runtime stage is debian:bookworm-slim with ca-certificates installed and a system group and user both named packet-relay at gid and uid 65532. The binary lands at /usr/local/bin/packet-relay. The image then sets ENV PORT=8080, EXPOSE 8080, USER 65532:65532 and ENTRYPOINT ["packet-relay"].

There is no CMD. Every flag therefore takes its compiled default: 128 concurrent connections, 256 sessions, 64 KiB maximum message, a 300-second session TTL, a 5-second hello deadline, 32 room members, 64 spectators. If a deployment needs anything else, it has to supply it — nothing in the image or in railway.json does.

The absence of CMD is also what makes step 3 above work. With an exec-form entrypoint and no default command, whatever you write after the image name is appended to the binary's argv rather than replacing a command.

USER 65532:65532 is numeric and unprivileged, so the container cannot bind a port below 1024. The default 8080 is already clear of that; keep any override above 1024 too.

Deploy it on Railway#

railway.json:1-11 is eleven lines and configures two things.

  1. Push the repository to a Git remote Railway can build from.
  2. Create a service pointing at it. The build block sets builder = "DOCKERFILE" and dockerfilePath = "Dockerfile", so Railway builds the image described above rather than guessing at a buildpack.
  3. Leave the start command empty. The image's ENTRYPOINT is the start command, and railway.json sets no startCommand to override it.
  4. Leave PORT alone unless you want a specific value. Railway injects one; main.rs:313-316 reads PORT, parses it as a u16, and gives it precedence over --port. The listener then binds 0.0.0.0:{port}main.rs:317.
  5. Point clients at wss://<your-service-domain>/. Any path works; only /health, /healthz, /ready and /readyz are reserved.

The deploy block sets restartPolicyType = "ON_FAILURE" with restartPolicyMaxRetries = 10. That is the whole of it.

What railway.json does not configure#

No healthcheckPath, no startCommand, no replica count and no port setting. /health and /ready exist in the code and answer 200 with bodies ok\n and ready\nmain.rs:396, main.rs:400 — but nothing in this repository wires them to a platform health check.

If you add one in the Railway dashboard, point it at exactly /health or /healthz. Reserved paths are matched on the full request target, so /health?detail=1 classifies as a reject and returns 404 Not Found with body not found\nmain.rs:479-484, pinned by the test at main.rs:608.

Set the interval with the rate limiter in mind. Admission runs at TCP accept, before a single byte is read — main.rs:360-369 — so a health probe consumes connection budget exactly like a client does. At one probe per second an edge checker spends 60 of the 240 attempts per minute that --max-connect-rate-per-ip allows its source IP, and rejected attempts still count toward that budget — rate_limit.rs:111-112.

Two replicas are two relays#

Every piece of state lives in process memory: the session map (session.rs:93), the trust-on-first-use pins and nonce high-water marks (room_auth.rs:141), and the limiter's per-IP counters (rate_limit.rs:78). Nothing is written down and nothing is shared.

Two replicas behind one domain are therefore two disjoint relays. A desktop client that lands on replica A and a mobile client that lands on replica B do not form a bridge; the mobile gets session not found, because on replica B the session was never created. Run a single instance until there is shared coordination, and there is none.

The same locality governs restarts. restartPolicyType = "ON_FAILURE" brings the process back, but every live session dies with the old one — and there is no graceful shutdown to soften it. main.rs:357-381 is an unconditional accept loop with no signal handling, so a stop is a stop: no drain, no close frames, no warning to connected clients.

TLS terminates at the edge#

The relay speaks plain HTTP/1.1 and plain WebSocket. There is no TLS in the process — no certificate flag in the CLI, no rustls or native-tls in Cargo.toml:16-28, and no code path that wraps the accepted TcpStream. Every connection is accepted raw and its request head hand-parsed — main.rs:431-452.

A deployment must therefore put a TLS-terminating proxy in front of the relay, and clients must connect with wss://, never ws://. On the Railway route that is the platform's edge; that behaviour belongs to the platform and is not configured, or verified, in this repository.

Warning

Nothing in the relay refuses a plain ws:// connection. If TLS termination is absent or misconfigured, sessions still open and frames still flow — in the clear. The relay does not encrypt payloads either: main.rs:4-5 states that end-to-end encryption is the endpoints' job, and no key material is ever exchanged with the relay.

There is also no Origin header check anywhere in the listener, recorded as open work at BACKLOG.md:151-158. Terminating TLS at an edge does not constrain which page may open a socket; only an unguessable session id does. See Security model.

Railway edge WebSocket lifetime is unverified#

How long Railway's edge holds an idle WebSocket open is not known — BACKLOG.md:25-28 records this as unverified, and this documentation has not tested it.

What is known is that the relay will never be the side that closes an idle session. It enforces no post-hello idle timeout (connection.rs:333-409) and sends no server-initiated WebSocket Ping, only Pong in reply to a peer's Ping (connection.rs:397-399). If the edge has an idle cut-off, clients have to generate their own traffic to stay under it. The {"type":"ping"} envelope is recognised as a relay keepalive and dropped rather than forwarded to the peer (connection.rs:361, connection.rs:480-488), which makes it the frame to use for that.

Verify#

Check the HTTP surface first. It needs no WebSocket client and proves the listener is bound and routing.

curl -i http://127.0.0.1:8080/health              # 200, body: ok
curl -i http://127.0.0.1:8080/ready               # 200, body: ready
curl -i "http://127.0.0.1:8080/health?detail=1"   # 404, body: not found

All three responses carry Content-Type: text/plain, a Content-Length and Connection: close, after which the socket is shut down — main.rs:489-499. The third is not a failure: it confirms that reserved paths match exactly, which is the behaviour a health-check URL has to respect.

Then prove the WebSocket path with a hello. Any client that sends text frames does; the examples use websocat, which is not part of this repository.

websocat ws://127.0.0.1:8080/

Send {"type":"desktop_hello","session_id":"verify-1"} and read back {"type":"session_ready","session_id":"verify-1"}. That exact exchange is pinned end-to-end by main.rs:670, which stands up a real listener, connects over ws://, sends the hello and asserts session_ready comes back.

For the image specifically, two one-liners confirm the pieces that have no runtime signal:

docker run --rm packet-relay --help          # entrypoint resolves, argv appends
docker run --rm --entrypoint id packet-relay # uid=65532 gid=65532

If it does not work#

What you see What happened
The build fails on a file missing inside include_str! The build context excluded testdata/. protocol.rs:161-169 reads testdata/session_ready.json and testdata/room_ready.json at compile time; Dockerfile:7 copies the directory for exactly this reason.
The container listens on 8080 although you passed --port 3000 PORT is set. Dockerfile:22 sets ENV PORT=8080, and Railway injects its own. main.rs:313-316 gives PORT precedence over --port.
PORT is set and the relay uses --port anyway The value did not parse as a u16. main.rs:313-316 falls back silently — there is no warning log for this.
The TCP stream closes with no HTTP and no WebSocket response at all The connection-rate limiter rejected the connection at accept, before a byte was read, and dropped the stream — main.rs:365-369. Only a warn! records it. Health probes consume the same budget.
503 Service Unavailable, body route capacity reached The Legacy route's semaphore of --max-connections permits is exhausted — main.rs:409-412.
404 Not Found from a health check that used to work The path is not one of the four exact reserved targets. Query strings reject — main.rs:479-484, test main.rs:608.
A browser client fails the handshake although curl succeeds The scheme. A page served over HTTPS may only open wss://, and the relay terminates no TLS, so the edge has to supply it.
Bridges disappear roughly five minutes after they open --session-ttl defaults to 300 s, and a bridge with no mobile peer is reaped once age > ttl even with the desktop socket still open — session.rs:459-461. age runs from creation and is never refreshed by activity — session.rs:137-147.

For anything else, raise the log level. RUST_LOG is consumed by EnvFilter::try_from_default_env(), and on absence or a parse failure the filter falls back to packet_relay=infomain.rs:281-286.

RUST_LOG=packet_relay=debug ./target/release/packet-relay

PORT and RUST_LOG are the only two environment variables the process reads. The clap env feature is not enabled, so no flag has an environment binding — a deployment that wants a non-default limit has to pass the flag.

Google Cloud Run support is removed#

cloudbuild.yaml, deploy.sh and scripts/smoke-cloud-run.py were deleted on 2026-08-28 — CHANGELOG.md:30-34. Removed. CHANGELOG.md:52-54 still describes the Cloud Run deployment as if it existed; the removal entry is the authoritative one. Any instruction that references those files is stale.