PacketRelayDocs

HTTP endpoints#

PacketRelay serves four plain-HTTP responses and one WebSocket upgrade. There is no HTTP framework: classify_request hand-parses the request line and matches the target against a fixed set of arms — src/main.rs:454-486. Everything the relay can say over HTTP is on this page.

This page lists every validation rule, every route, and the literal bytes of every response. Rows are in source order — the order the checks and match arms appear in src/main.rs.

Request-line validation#

classify_request receives the bytes read by read_request_head and returns one of four RequestRoute variants. Any rule below that fails returns RequestRoute::Reject, which is answered 404 Not Found. Only the first line of the head is examined; no header is parsed.

# Rule Source Notes
1 The head contains \r\n\r\n src/main.rs:455-457 Guaranteed by read_request_head, which only returns on this sequence
2 The bytes before \r\n\r\n are valid UTF-8 src/main.rs:458-460 Checked over the whole head, not just the request line
3 A first \r\n-delimited line exists src/main.rs:461-463 Defensive; str::split always yields at least one item, so this arm is unreachable
4 The first whitespace-separated field is exactly GET src/main.rs:464-466 Case-sensitive. HEAD, POST, OPTIONS and get all reject
5 A second field exists (the target) src/main.rs:468-470
6 The third field is exactly HTTP/1.1 src/main.rs:471 HTTP/1.0 and HTTP/2 reject
7 There is no fourth field src/main.rs:471
8 The target starts with / src/main.rs:471 Absolute-form targets such as http://host/path reject

Fields are split with split_ascii_whitespacesrc/main.rs:464. Runs of spaces, and tabs, are accepted as separators, and leading whitespace on the request line is ignored.

Note

Every violation above produces 404 Not Found, not the status HTTP would suggest. A POST gets 404 rather than 405, an HTTP/1.0 request gets 404 rather than 505, and a malformed line gets 404 rather than 400. There is one reject status on purpose: the relay is not an HTTP server and declines to imply that it is one.

Route table#

Matched against the exact target string, in source order — src/main.rs:474-486.

Target Route Response Source
/health Health 200 OK, body ok\n src/main.rs:475
/ready Ready 200 OK, body ready\n src/main.rs:476
/healthz Health 200 OK, body ok\n src/main.rs:477
/readyz Ready 200 OK, body ready\n src/main.rs:478
Any other target starting /health, /ready or /readyz Reject 404 Not Found, body not found\n src/main.rs:479-484
Everything else, including / Legacy WebSocket upgrade, or 503 if no permit is free src/main.rs:485

The prefix guard on the fifth row is what makes the reserved paths exact-match-only. /health?detail=1, /healthz/, /readyz2 and /health-check are all 404 rather than WebSocket upgrades, so a probe with a stray query string fails loudly instead of being silently promoted to a relay session. reserved_routes_require_exact_paths_without_queries pins this — src/main.rs:607-634.

The /readyz term in that guard is redundant: /readyz already starts with /ready, so the third condition can never be the one that matches. It is harmless and it is in the source as written.

Important

The path carries no protocol meaning. Every Legacy target reaches the same WebSocket handler, and the session protocol is selected solely by the first application message — src/connection.rs:502-524. Do not read /ws/host or /api into the route table; those are Planned and no such string exists in src/.

Response format#

write_http_status builds every plain-HTTP response from one format string, writes it, and shuts the socket down — src/main.rs:489-499. The bytes are exactly:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 3
Connection: close

ok
Property Value
Header lines Exactly three: Content-Type, Content-Length, Connection
Content-Type text/plain, on every response including the 404 and the 503
Content-Length body.len() in bytes, including the trailing newline
Connection Always close
Date header Absent
Server header Absent
Body encoding ASCII, always ending \n
After the write stream.shutdown()src/main.rs:498. Keep-alive and pipelining are impossible

Reason phrases are mapped from the status code — src/main.rs:490-495:

Status Reason phrase Body Emitted when
200 OK ok\n Target is /health or /healthzsrc/main.rs:395-398
200 OK ready\n Target is /ready or /readyzsrc/main.rs:399-402
404 Not Found not found\n Any Reject classification — src/main.rs:403-406
503 Service Unavailable route capacity reached\n Legacy route, no semaphore permit free — src/main.rs:409-412
Any other Bad Request Unreachable. No call site passes a status other than the three above

Capacity exhaustion returns 503#

The Legacy route acquires an owned permit from a Semaphore sized --max-connections, default 128 — src/main.rs:52-70, src/main.rs:297. On try_acquire_owned failure the connection is answered 503 Service Unavailable with the body route capacity reached\n and closed — src/main.rs:409-412.

Fact Detail
Semaphore size --max-connections, default 128, valid range 1–10000
Routes that take a permit Legacy only. Every other variant matches _ => Nonesrc/main.rs:64-70
Acquisition Non-blocking try_acquire_owned. A connection never queues for a permit
Release When the connection task ends and _route_permit drops
Relationship to the rate limiter Separate mechanism, same flag. The limiter counts every TCP connection including probes; the semaphore counts only upgrades in flight
Test legacy_capacity_is_bounded_and_releasedsrc/main.rs:518-525

This is the only capacity failure that produces an HTTP response. A rate-limiter rejection happens before any byte is read and drops the socket in silence — src/main.rs:365-369. See How a connection works for the ordering.

Nothing currently probes these endpoints#

railway.json sets builder, dockerfilePath, restartPolicyType and restartPolicyMaxRetries, and nothing else — railway.json:1-11. There is no healthcheckPath, no startCommand, no replica count and no port setting. The Dockerfile declares ENV PORT=8080 and EXPOSE 8080 but carries no CMD, so the binary runs on defaults — Dockerfile:1-26.

/health, /healthz, /ready and /readyz are Implemented and answer correctly, verified against a real ephemeral listener by health_response_uses_a_real_ephemeral_listenersrc/main.rs:561-584. They are wired to nothing. No platform health check in this repository calls them, so a relay that is listening but wedged restarts only when the process actually exits, which is what restartPolicyType: ON_FAILURE covers.

Warning

Whether the deployed Railway service defines a health-check path in the platform dashboard, outside this repository, is unverified. The claim on this page is about railway.json, which contains none.

/health and /ready return different bodies but identical logic. Neither consults the session store, the room-auth store, the semaphore or the limiter — both are constant responses (src/main.rs:395-402). A 200 from either proves the process is accepting connections and can write to a socket. It proves nothing about capacity, session state or whether upgrades are succeeding.

Removed and planned routes#

Route Status Detail
GET /v1/product-route Removed 2026-08-28 Deleted with src/product_route.rs, src/product_crypto.rs, the RequestRoute::Product variant, its reserved connection pool and the --product-connection-reserve flag — CHANGELOG.md:10-28. It is not a 404: the target has no reserved prefix, so it now classifies as an ordinary Legacy WebSocket path, asserted at src/main.rs:610-613
/ws/host, /ws/device Planned No such string exists in src/BACKLOG.md:32-39
/api, an HTTPS ticket and control plane Planned As above
/metrics Planned As above. There is no metrics endpoint and no metrics exporter

README.md:160 still documents --product-connection-reserve with a default of 32, and README.md:177-178 still claims product-route capacity is additive and isolated. Args has no such flag and RouteCapacity holds only the legacy semaphore. Where the README and the code disagree, the code is authoritative: those two README rows are stale and are contradicted here on the record.

Transport#

Property Value
Bind address 0.0.0.0:{port}src/main.rs:317
Port $PORT if present and parseable as u16, else --port, else 8080src/main.rs:313-316
TLS Not terminated in process. Plain HTTP and WebSocket only; the hosting edge terminates TLS, so clients use wss://
HTTP versions accepted HTTP/1.1 only
Methods accepted GET only
Request-head ceiling 16 KiB. Exceeding it drops the connection with no response — src/main.rs:42, src/main.rs:441-443
Request-head deadline --hello-timeout, default 5 s. Elapsing drops the connection with no response — src/main.rs:389, src/rate_limit.rs:170-172
Origin check None. Any origin may upgrade — open in BACKLOG.md:151-158
WebSocket buffers 16 KiB read, 16 KiB write, 128 KiB maximum write buffer — src/main.rs:417-420
WebSocket message ceiling max_message_size and max_frame_size both --max-message-bytes, default and hard maximum 64 KiB — src/main.rs:421-422, src/main.rs:39
  • How a connection works — the ordering of admission, parsing, routing and upgrade, and where each silent drop occurs.
  • Flags & environment--max-connections, --hello-timeout and --max-message-bytes with types, defaults and valid ranges.
  • Message reference — what a client sends once the upgrade succeeds.
  • Limits & capacity — the rate limiter that rejects before this page's rules ever run.
  • Run the relay — the Dockerfile and railway.json this page cites.
  • Troubleshooting — matching an observed 404, 503 or silent close to its cause.