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_whitespace — src/main.rs:464. Runs of
spaces, and tabs, are accepted as separators, and leading whitespace on the
request line is ignored.
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.
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 /healthz — src/main.rs:395-398 |
200 |
OK |
ready\n |
Target is /ready or /readyz — src/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 _ => None — src/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_released — src/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_listener — src/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.
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 8080 — src/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 |
Related#
- How a connection works — the ordering of admission, parsing, routing and upgrade, and where each silent drop occurs.
- Flags & environment —
--max-connections,--hello-timeoutand--max-message-byteswith 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.jsonthis page cites. - Troubleshooting — matching an observed 404, 503 or silent close to its cause.