Relay reference
Everything a running relay exposes or accepts. For getting one up in the first place, see Self-hosting the relay.
Environment variables
Section titled “Environment variables”All configuration is environment variables. Every one has a working default, and .env.example in the repository is the annotated version of these tables.
Empty strings are treated as unset and fall back to the default. Booleans accept only true or false, and integers must be non-negative. Anything else is a startup error rather than a silent fallback.
Network
Section titled “Network”| Variable | Default | Meaning |
|---|---|---|
PORT | 8080 | Port for health, metrics, and the WebSocket upgrade |
BIND_ADDRESS | 0.0.0.0 | Listen address |
WS_PATH | / | Path the WebSocket upgrade must arrive on |
SLOT_ID_PATTERN | ^([0-9a-f]{32}|[0-9a-f]{64})$ | Format a slot id must match before rendezvous is attempted |
Connection caps
Section titled “Connection caps”| Variable | Default | Meaning |
|---|---|---|
MAX_CONNECTIONS | 10000 | Global concurrent connection cap |
MAX_UNPAIRED_CONNECTIONS | (half of MAX_CONNECTIONS) | Ceiling on connections not yet paired, so parked sockets cannot starve pairings that would otherwise succeed. Minimum 2 |
MAX_CONNECTIONS_PER_IP | 20 | Per resolved-IP cap |
MAX_CONNECTIONS_PER_SLOT | 2 | Per-slot cap |
Rate limits
Section titled “Rate limits”| Variable | Default | Meaning |
|---|---|---|
RATE_LIMIT_IP_PER_MIN | 120 | New connections per minute, per IP |
RATE_LIMIT_IP_BURST | 40 | Burst allowance for the above |
RATE_LIMIT_SLOT_PER_MIN | 60 | New connections per minute, per slot |
RATE_LIMIT_SLOT_BURST | 20 | Burst allowance for the above |
These are cost and abuse controls, not anti-enumeration ones. A previously unseen key starts with a full burst, so an attacker trying a different slot on every attempt never trips the per-slot limiter at all.
Byte caps
Section titled “Byte caps”| Variable | Default | Meaning |
|---|---|---|
MAX_MESSAGE_BYTES | 1114112 | Per-message ceiling, 1 MiB plus overhead |
MAX_SESSION_BYTES | 1073741824 | Total bytes per tunnel before teardown, 1 GiB |
MAX_PARKED_BUFFER_BYTES | 1048576 | How much an unpaired peer may buffer |
MAX_BUFFERED_BYTES | 16777216 | Per-connection outbound backlog cap, 16 MiB |
Lifecycle
Section titled “Lifecycle”| Variable | Default | Meaning |
|---|---|---|
PING_INTERVAL_MS | 30000 | Ping/pong cadence for reaping half-open sockets |
CONTENTION_PROBE_TIMEOUT_MS | 2000 | When a newcomer arrives on a slot that is already paired, both incumbents are pinged and given this long to answer; the one that stays silent is reaped at once, so a phone that changed networks re-pairs in seconds instead of waiting out a ping cycle. The newcomer is still rejected with 4409 either way; nothing is evicted for being newer. 0 disables the probe |
PARK_TIMEOUT_MS | 60000 | How long a connection may wait unpaired |
MAX_SESSION_MS | 0 | Wall-clock cap per tunnel; 0 disables it |
SHUTDOWN_GRACE_MS | 10000 | Drain budget on shutdown before sockets are terminated |
Client IP resolution
Section titled “Client IP resolution”| Variable | Default | Meaning |
|---|---|---|
TRUST_PROXY | false | Whether to consult CF-Connecting-IP and X-Forwarded-For |
TRUSTED_PROXY_CIDRS | (empty) | Comma-separated CIDRs. Required when TRUST_PROXY=true |
IPV6_PREFIX_BITS | 64 | Prefix IPv6 addresses are bucketed to for caps and limits |
Setting TRUST_PROXY=true with an empty CIDR list is a startup error, not a warning. See the setting that will stop it booting.
Metrics and logging
Section titled “Metrics and logging”| Variable | Default | Meaning |
|---|---|---|
METRICS_ENABLED | true | When false, both metrics endpoints return 404 |
METRICS_TOKEN | (unset) | When set, metrics require a matching bearer token |
METRICS_ALLOW_UNAUTHENTICATED | false | Serve metrics with no token at all, for a genuinely private deployment |
METRICS_HISTORY_PATH | (empty) | Where the metrics recorder keeps its history. Empty keeps it in memory only, roughly the last two hours, lost on restart. Set an absolute path on a mounted volume; a relative path or one inside the container’s writable layer is discarded by the next deploy. Retention is tiered on its own: one-minute rows for 7 days, five-minute rows for 30 days, hourly rows for a year, which settles at roughly 25,000 rows |
METRICS_HISTORY_INTERVAL_MS | 60000 | How often the recorder samples the counters |
ADMIN_ENABLED | false | Serves an /admin page with the recorded history and live gauges. The relay does not authenticate it, on purpose, so gate it upstream (an access proxy scoped to /admin*, a private network, or an SSH tunnel); the relay logs a warning at startup while it is on. Like the metrics surfaces it never shows a slot id, an IP address, or traffic content |
LOG_LEVEL | info | One of error, warn, info, debug |
LOG_SLOT_HASHING | true | Configures the salted hash that any future slot logging would go through |
SLOT_LOG_SALT | (random per process) | Salt for that hash |
Admission
Section titled “Admission”| Variable | Default | Meaning |
|---|---|---|
ADMISSION_WEBHOOK_URL | (unset) | Unset means every connection is admitted |
ADMISSION_WEBHOOK_TIMEOUT_MS | 3000 | Timeout for the admission request |
ADMISSION_FAIL_OPEN | true | On webhook error, admit anyway or deny |
This is the open-core seam, and the shipped binary does not construct it: setting ADMISSION_WEBHOOK_URL on a stock docker compose up gates nothing. It is a library seam for an embedder. Self-hosting leaves it unset, so every connection is admitted and the relay never talks to any control plane.
HTTP endpoints
Section titled “HTTP endpoints”| Endpoint | Purpose |
|---|---|
GET /healthz | Liveness. Always returns 200 while the process is up |
GET /readyz | Readiness. Returns 503 while draining for shutdown |
GET /metrics | Prometheus text format |
GET /metricz | The same counters as JSON, plus uptime and memory |
GET /admin | The admin dashboard: recorded metrics history and live gauges. Served only with ADMIN_ENABLED=true; otherwise it 404s like any unknown path |
GET /admin/data | The JSON the dashboard reads, under the same switch |
GET / | A static page. WebSocket upgrades arrive on the same path but a different code path |
Neither metrics surface ever contains a slot id, an IP address, or frame content. Both answer 404 rather than 401 when no token is configured, so an exposed relay does not advertise that a gated surface exists. /healthz and /readyz are always open and need no token.
Close codes
Section titled “Close codes”Useful when reading logs or debugging a client:
| Code | Meaning |
|---|---|
4000 | The peer closed, so this half was torn down too |
4403 | Admission policy denied the connection |
4408 | Stayed unpaired past the park timeout |
4409 | Slot already has two peers |
4431 | Exceeded a buffer or backpressure guard |
4432 | Exceeded the session byte cap |
4433 | Exceeded the session time cap |
Before the upgrade completes, failures are plain HTTP instead: 404 for the wrong path, 400 for a bad slot, 429 for rate limits, and 503 when capped or draining.
Reserved codes
Section titled “Reserved codes”Three more codes are allocated in the relay’s source but never sent, because each condition resolves before a close frame could carry one. Do not write a client that waits for them, since it would wait forever.
| Code | Reserved for | What you actually see |
|---|---|---|
4400 | Missing or malformed slot | HTTP 400, rejected before the upgrade completes, so there is no WebSocket to close |
4410 | A ping went unanswered | The socket is reaped with terminate(), which surfaces as the standard abnormal closure 1006 |
4503 | Server is draining for shutdown | New upgrades get HTTP 503; connections already established close with the standard 1001 (going away) |
See also
Section titled “See also”- Self-hosting the relay - getting an instance running in the first place
- Relay security model - why these defaults are what they are
- The hosted relay - the instance Kangentic runs, and what it observes
Kangentic is free and open source. A star helps other people find it.
Star on GitHub