@openchambery/relay-server 1.17.1-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ # Relay Server Package Documentation
2
+
3
+ `packages/relay-server/` owns the self-hosted Layer 1 Relay server, the `openchamber-relay` CLI, and the package release and deployment contract.
4
+
5
+ ## Purpose and security boundary
6
+
7
+ The Relay server brokers Layer 1 routing: Host control connections, client route requests, and matching Host data connections. It forwards opaque Layer 2/3 frames verbatim.
8
+
9
+ Host and Client terminate the E2EE channel. Each Host authenticates Relay connections with its long-lived P-256 signing key. Pairing secrets and client bearer credentials continue through endpoint validation; Relay reachability grants transport access.
10
+
11
+ Relay v1 admission accepts anonymous Client route requests. Per-IP, global, pending-connection, raw-socket, frame, and queue limits bound that public entry point. Configure limits for the expected traffic volume and keep the Relay behind TLS. Pair queues pause the fast sender at half the per-connection byte limit so a slow peer applies TCP backpressure instead of filling memory until `4029`. Ready pairs send one frame per tick so one tunnel cannot monopolize the event loop.
12
+
13
+ The Relay keeps process-local routing state only. Hosts reconnect after Relay restarts, and a control disconnect retains its Host route for the 30-second grace period.
14
+
15
+ ## Quick deployment
16
+
17
+ Install the package, then start the Relay:
18
+
19
+ ```sh
20
+ npm install -g @openchambery/relay-server
21
+ openchamber-relay
22
+ ```
23
+
24
+ The default listener is `127.0.0.1:8787` and the WebSocket path is `/ws`. Deploy with the default loopback listener and a TLS reverse proxy. Set the public URL to the same public scheme, host, and path:
25
+
26
+ ```sh
27
+ openchamber-relay --public-url wss://relay.example.com/ws
28
+ ```
29
+
30
+ ### Caddy
31
+
32
+ ```caddyfile
33
+ relay.example.com {
34
+ reverse_proxy 127.0.0.1:8787 {
35
+ header_up X-Forwarded-For {remote_host}
36
+ }
37
+ }
38
+ ```
39
+
40
+ Run the Relay with `--public-url wss://relay.example.com/ws`. Caddy proxies WebSocket upgrades for `/ws` and serves `/healthz` and `/readyz` through the same upstream. `header_up X-Forwarded-For {remote_host}` replaces the inbound value with the single client source IP.
41
+
42
+ ### Nginx
43
+
44
+ ```nginx
45
+ server {
46
+ listen 443 ssl;
47
+ server_name relay.example.com;
48
+
49
+ ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
50
+ ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
51
+
52
+ location / {
53
+ proxy_pass http://127.0.0.1:8787;
54
+ proxy_http_version 1.1;
55
+ proxy_set_header Host $host;
56
+ proxy_set_header X-Forwarded-For $remote_addr;
57
+ proxy_set_header Upgrade $http_upgrade;
58
+ proxy_set_header Connection "upgrade";
59
+ }
60
+ }
61
+ ```
62
+
63
+ Run the Relay with `--public-url wss://relay.example.com/ws`. The `/ws` path in the public URL and Relay configuration must match. `proxy_set_header X-Forwarded-For $remote_addr;` replaces the inbound value with the single client source IP.
64
+
65
+ ## Docker
66
+
67
+ Each non-dry-run OpenChamber release publishes a multi-platform Relay image for `linux/amd64` and `linux/arm64` to Docker Hub. CI builds each architecture natively in parallel (`ubuntu-latest` and `ubuntu-24.04-arm`), then merges digests into a single multi-arch manifest tagged as:
68
+
69
+ ```text
70
+ <DOCKERHUB_USERNAME>/openchamber-relay:<version>
71
+ <DOCKERHUB_USERNAME>/openchamber-relay:latest
72
+ ```
73
+
74
+ The release workflow reads the Docker Hub account from the `DOCKERHUB_USERNAME` GitHub Actions repository variable and authenticates with the `DOCKERHUB_TOKEN` repository secret. Use a Docker Hub personal access token with Read and Write permissions; Delete permission is not required. A failed image build or push blocks final publication of the GitHub Release.
75
+
76
+ For an image-only republish of the current package version, run the `Relay Docker` workflow directly or dispatch the `Release` workflow with `relay_only` enabled. The image-only path does not create or modify a GitHub Release or any desktop and mobile artifacts.
77
+
78
+ Pull and run a published image behind a host TLS reverse proxy:
79
+
80
+ ```sh
81
+ docker pull <dockerhub-username>/openchamber-relay:<version>
82
+ docker run -d \
83
+ --name openchamber-relay \
84
+ --restart unless-stopped \
85
+ --read-only \
86
+ --tmpfs /tmp \
87
+ --security-opt no-new-privileges:true \
88
+ -p 127.0.0.1:8787:8787 \
89
+ -e OPENCHAMBER_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
90
+ <dockerhub-username>/openchamber-relay:<version>
91
+ ```
92
+
93
+ Use an immutable version tag in production. Each non-dry-run release updates `latest` together with its immutable version tag.
94
+
95
+ For a complete public deployment using the published immutable image, automatic HTTPS, and an internal-only Relay listener, use [`docker-compose.relay.remote.yml`](../../docker-compose.relay.remote.yml). Docker Compose 2.23.1 or later is required for its inline Caddy config. Point the domain's `A` and/or `AAAA` record at the server, allow inbound TCP ports 80 and 443 plus UDP port 443, then run:
96
+
97
+ ```sh
98
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
99
+ RELAY_DOMAIN=relay.example.com \
100
+ ACME_EMAIL=admin@example.com \
101
+ docker compose -f docker-compose.relay.remote.yml up -d
102
+ ```
103
+
104
+ `OPENCHAMBER_RELAY_IMAGE` is required; the deployment file must never bind to a personal registry namespace. Supply an immutable version-and-manifest-digest reference in production. The file keeps Relay off host ports, replaces forwarded client IPs at Caddy, persists Caddy certificates and configuration, and enables trusted-proxy mode on Relay. Inspect startup and readiness with:
105
+
106
+ ```sh
107
+ docker compose -f docker-compose.relay.remote.yml ps
108
+ curl -fsS https://relay.example.com/healthz
109
+ curl -fsS https://relay.example.com/readyz
110
+ ```
111
+
112
+ From the repository root, build and start the supplied service. The compatibility assets are [`Dockerfile.relay`](../../Dockerfile.relay) and [`docker-compose.relay.yml`](../../docker-compose.relay.yml):
113
+
114
+ ```sh
115
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
116
+ OPENCHAMBER_RELAY_PUBLISHED_PORT=8787 \
117
+ docker compose -f docker-compose.relay.yml up -d --build
118
+ ```
119
+
120
+ Compose publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` by default; use `OPENCHAMBER_RELAY_PUBLISHED_PORT` to select the host port. The Compose service uses an ephemeral filesystem and keeps Host identity keys on each OpenChamber Host. Its image health check calls `GET /healthz`. Terminate public TLS at an external reverse proxy and publish `wss://relay.example.com/ws`. A public Relay port binding requires firewall rules and TLS; loopback publishing with a TLS reverse proxy is the deployment path.
121
+
122
+ ## Connect Hosts
123
+
124
+ Set the Relay URL on every OpenChamber Host, start the Host, then generate a Relay pairing link or enable Relay pairing in the application:
125
+
126
+ ```sh
127
+ export OPENCHAMBER_RELAY_URL=wss://relay.example.com/ws
128
+ openchamber
129
+ ```
130
+
131
+ The **Add a device** dialog can select this endpoint per pairing. An owner UI session or the local Desktop shell (`desktop-local`) may persist a custom Host endpoint and switch the control connection; the effective `relayUrl` is embedded in the pairing-v2 candidate before the QR code. Endpoints must be `ws://` or `wss://` without userinfo; query and fragment are not part of identity and are stripped. `OPENCHAMBER_RELAY_URL` pins the endpoint and disables the override. The creating client remembers its last effective choice locally; consuming Mobile and Desktop clients persist the endpoint snapshot with the saved connection.
132
+
133
+ Existing clients switch to a new Relay after a new pairing flow; generate a fresh pairing link when endpoint replacement is required.
134
+
135
+ ## Configuration
136
+
137
+ Configuration precedence is command flags, then `OPENCHAMBER_RELAY_SERVER_*` variables, then defaults. `--host`, `--port`, `--path`, `--public-url`, `--trust-proxy`, `--no-trust-proxy`, `--json`, and `--quiet` are available.
138
+
139
+ `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` affects startup output. `OPENCHAMBER_RELAY_SERVER_PATH` selects the actual WebSocket upgrade endpoint. Relay listens on loopback by default. Enable `OPENCHAMBER_RELAY_SERVER_TRUST_PROXY=true` when a trusted reverse proxy fully isolates Relay ingress and replaces any client-supplied `X-Forwarded-For` value with one canonical client IP. Relay accepts one forwarded IP in this mode.
140
+
141
+ With correctly configured trusted proxying, client and admission limits use that canonical client IP. `OPENCHAMBER_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP` always counts the TCP peer that connects to Relay; reverse-proxy deployments therefore count raw sockets against the proxy and require a higher value sized for aggregate concurrent traffic.
142
+
143
+ For an IPv6 literal in a public URL, enclose the host in brackets: `wss://[2001:db8::1]/ws`.
144
+
145
+ | Variable | Default | Unit / purpose |
146
+ | --- | --- | --- |
147
+ | `OPENCHAMBER_RELAY_SERVER_HOST` | `127.0.0.1` | Listener address |
148
+ | `OPENCHAMBER_RELAY_SERVER_PORT` | `8787` | TCP port |
149
+ | `OPENCHAMBER_RELAY_SERVER_PATH` | `/ws` | WebSocket upgrade path |
150
+ | `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` | unset | Startup output URL; `ws://` or `wss://`, same path as `PATH` |
151
+ | `OPENCHAMBER_RELAY_SERVER_TRUST_PROXY` | `false` | Read one canonical client IP from proxy-replaced `X-Forwarded-For` |
152
+ | `OPENCHAMBER_RELAY_SERVER_MAX_URL_BYTES` | `4096` | bytes |
153
+ | `OPENCHAMBER_RELAY_SERVER_MAX_FIELD_BYTES` | `512` | bytes per routing field |
154
+ | `OPENCHAMBER_RELAY_SERVER_MAX_HOSTS` | `256` | active Host routes |
155
+ | `OPENCHAMBER_RELAY_SERVER_MAX_SOCKETS` | `2048` | upgraded WebSockets |
156
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CONNECTIONS` | `1000` | global client connections |
157
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CLIENTS_PER_HOST` | `100` | connections per Host |
158
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CLIENTS_PER_IP` | `30` | connections per client IP |
159
+ | `OPENCHAMBER_RELAY_SERVER_MAX_PENDING_CLIENTS` | `30` | clients awaiting Host data connection |
160
+ | `OPENCHAMBER_RELAY_SERVER_PENDING_MS` | `15000` | ms awaiting Host data connection |
161
+ | `OPENCHAMBER_RELAY_SERVER_MAX_RAW_SOCKETS` | `4096` | accepted TCP sockets before upgrade |
162
+ | `OPENCHAMBER_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP` | `128` | TCP sockets per Relay TCP peer IP |
163
+ | `OPENCHAMBER_RELAY_SERVER_GRACE_MS` | `30000` | ms Host control disconnect grace |
164
+ | `OPENCHAMBER_RELAY_SERVER_TIMESTAMP_SKEW_MS` | `60000` | ms Host signature timestamp window |
165
+ | `OPENCHAMBER_RELAY_SERVER_REPLAY_MS` | `120000` | ms replay-record lifetime; at least twice timestamp skew |
166
+ | `OPENCHAMBER_RELAY_SERVER_MAX_REPLAY_ENTRIES` | `10000` | Host signature replay records |
167
+ | `OPENCHAMBER_RELAY_SERVER_MAX_FRAME_BYTES` | `131072` | bytes per forwarded frame |
168
+ | `OPENCHAMBER_RELAY_SERVER_MAX_QUEUED_BYTES_PER_CONNECTION` | `2097152` | bytes per client pair |
169
+ | `OPENCHAMBER_RELAY_SERVER_MAX_GLOBAL_QUEUED_BYTES` | `33554432` | bytes across all queues |
170
+ | `OPENCHAMBER_RELAY_SERVER_MAX_BUFFERED_AMOUNT` | `2097152` | bytes buffered by a WebSocket before pump retry |
171
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CONTROL_QUEUE_ENTRIES` | `256` | queued Host control messages |
172
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CONTROL_QUEUED_BYTES` | `2097152` | bytes queued for Host control |
173
+ | `OPENCHAMBER_RELAY_SERVER_PUMP_RETRY_MS` | `25` | ms between backpressure retries |
174
+ | `OPENCHAMBER_RELAY_SERVER_HEARTBEAT_MS` | `30000` | ms WebSocket ping interval |
175
+ | `OPENCHAMBER_RELAY_SERVER_HANDSHAKE_MS` | `10000` | ms for TCP and WebSocket admission |
176
+ | `OPENCHAMBER_RELAY_SERVER_CLOSE_DEADLINE_MS` | `5000` | ms before forced socket close |
177
+ | `OPENCHAMBER_RELAY_SERVER_ADMISSION_WINDOW_MS` | `60000` | ms per-IP admission window |
178
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSIONS_PER_IP` | `120` | upgrades per role and IP per admission window |
179
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
180
+ | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
181
+
182
+ ## Operations
183
+
184
+ - `GET` and `HEAD` requests to `/healthz` return process health. `/readyz` returns ready status after the listener reaches running state.
185
+ - `SIGTERM` and `SIGINT` begin graceful Relay shutdown. Docker grants a 30-second stop period.
186
+ - Hosts automatically reconnect after a Relay process restart. Relay state remains ephemeral.
187
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, and encrypted payloads.
188
+
189
+ ### systemd
190
+
191
+ Create `/etc/openchamber-relay.env`:
192
+
193
+ ```sh
194
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws
195
+ ```
196
+
197
+ Create `/etc/systemd/system/openchamber-relay.service`:
198
+
199
+ ```ini
200
+ [Unit]
201
+ Description=OpenChamber Private Relay
202
+ After=network-online.target
203
+ Wants=network-online.target
204
+
205
+ [Service]
206
+ Type=simple
207
+ EnvironmentFile=/etc/openchamber-relay.env
208
+ ExecStart=/usr/local/bin/openchamber-relay
209
+ Restart=on-failure
210
+ RestartSec=5
211
+
212
+ [Install]
213
+ WantedBy=multi-user.target
214
+ ```
215
+
216
+ Enable it:
217
+
218
+ ```sh
219
+ sudo systemctl daemon-reload
220
+ sudo systemctl enable --now openchamber-relay
221
+ ```
package/README.md ADDED
@@ -0,0 +1,304 @@
1
+ # OpenChamber Relay Server
2
+
3
+ `openchamber-relay` is the self-hosted Layer 1 relay for OpenChamber remote access. It gives OpenChamber Hosts an outbound Relay connection and carries encrypted client traffic through that connection.
4
+
5
+ The Relay routes opaque Layer 2/3 frames verbatim. E2EE terminates at the OpenChamber Host and Client, so the Relay has routing metadata and transport state while the endpoints hold application plaintext, pairing secrets, and client bearer credentials. Host Relay connections authenticate with the Host's long-lived P-256 signing key. Relay reachability grants transport access; endpoint validation continues to enforce pairing and client credentials.
6
+
7
+ ## Architecture and transport
8
+
9
+ The components have the following responsibilities:
10
+
11
+ - **Host** maintains an authenticated control WebSocket to the Relay, receives client connection requests, and opens matching Host data WebSockets.
12
+ - **Control channel** associates a Host identity with its active route and communicates connection lifecycle events.
13
+ - **Data channel** pairs one Host data WebSocket with one Client WebSocket and forwards opaque encrypted frames in both directions.
14
+ - **Client** requests a route to a Host and multiplexes application traffic through its encrypted Relay tunnel.
15
+
16
+ The tunneled application transport supports HTTP, streaming SSE, and WebSocket traffic. HTTP and SSE use the Client bearer credential through the encrypted tunnel. WebSockets use a short-lived URL-scoped credential (`oc_url_token`) minted by the Host endpoint.
17
+
18
+ Relay state lives in process memory. Hosts reconnect after a Relay restart. A disconnected Host control connection retains its route during the configurable 30-second grace period.
19
+
20
+ Relay v1 accepts anonymous Client route requests. Admission, connection, frame, queue, and socket limits bound this public entry point.
21
+
22
+ ## Requirements
23
+
24
+ - `@openchambery/relay-server` installation: Node.js 22 or later and a supported package manager such as npm, pnpm, yarn, or Bun.
25
+ - Single-file bundle build: Bun. This repository uses Bun 1.3.14.
26
+ - Public deployment: a DNS name, TLS certificate, reverse proxy, and firewall policy appropriate for the deployment.
27
+
28
+ ## Install and quick start
29
+
30
+ `openchamber-relay` ships as an executable in the public `@openchambery/relay-server` package.
31
+
32
+ ```sh
33
+ npm install -g @openchambery/relay-server
34
+ openchamber-relay --public-url wss://relay.example.com/ws
35
+ ```
36
+
37
+ The default listener is `127.0.0.1:8787` and the default WebSocket upgrade path is `/ws`. Keep this loopback listener behind a TLS reverse proxy and set `--public-url` to the public `ws://` or `wss://` URL with the same path.
38
+
39
+ ### Build a standalone executable
40
+
41
+ Run these commands from the repository root. `bun build --compile` creates a single executable for the current platform and architecture.
42
+
43
+ ```sh
44
+ bun build --compile --outfile ./openchamber-relay ./packages/relay-server/bin/openchamber-relay.js
45
+ sudo install -m 0755 ./openchamber-relay /usr/local/bin/openchamber-relay
46
+ ```
47
+
48
+ Smoke-test the installed executable, process health, and readiness:
49
+
50
+ ```sh
51
+ openchamber-relay --version
52
+ openchamber-relay --host 127.0.0.1 --port 8787 --json > /tmp/openchamber-relay-startup.json 2> /tmp/openchamber-relay.stderr &
53
+ relay_pid=$!
54
+ curl -fsS http://127.0.0.1:8787/healthz
55
+ curl -fsS http://127.0.0.1:8787/readyz
56
+ kill -TERM "$relay_pid"
57
+ wait "$relay_pid"
58
+ ```
59
+
60
+ The executable remains independently deployable after compilation. `@openchambery/relay-server` owns its distribution and publishes the `openchamber-relay` executable.
61
+
62
+ ## Configure the listener and public URL
63
+
64
+ Command flags take precedence over `OPENCHAMBER_RELAY_SERVER_*` environment variables, which take precedence over defaults.
65
+
66
+ ```sh
67
+ openchamber-relay \
68
+ --host 127.0.0.1 \
69
+ --port 8787 \
70
+ --path /relay \
71
+ --public-url wss://relay.example.com/relay
72
+ ```
73
+
74
+ The public URL path and the configured Relay path must match. An IPv6 public URL uses brackets around the literal:
75
+
76
+ ```sh
77
+ openchamber-relay --public-url wss://[2001:db8::1]/ws
78
+ ```
79
+
80
+ Available CLI options:
81
+
82
+ ```text
83
+ --host HOST
84
+ --port PORT
85
+ --path PATH
86
+ --public-url WS_URL
87
+ --trust-proxy | --no-trust-proxy
88
+ --json
89
+ --quiet, -q
90
+ --help, -h
91
+ --version, -v
92
+ ```
93
+
94
+ ## Connect OpenChamber Hosts
95
+
96
+ Set the public Relay URL in every OpenChamber Host environment, then start the Host and create a Relay pairing link or enable Relay pairing in the application.
97
+
98
+ ```sh
99
+ export OPENCHAMBER_RELAY_URL=wss://relay.example.com/ws
100
+ openchamber
101
+ ```
102
+
103
+ The **Add a device** dialog can override the Relay endpoint for a pairing. The Host persists and switches to the selected endpoint before it emits the QR code, unless `OPENCHAMBER_RELAY_URL` pins the deployment endpoint. The effective `relayUrl` is part of the pairing-v2 candidate and is saved by Mobile and Desktop clients for later reconnects.
104
+
105
+ Existing Clients use a new pairing flow to receive a changed Relay endpoint. Create a fresh pairing link when endpoint replacement is required.
106
+
107
+ ## TLS reverse proxies
108
+
109
+ The Relay serves loopback HTTP health endpoints and WebSocket upgrades. The public proxy terminates TLS and forwards HTTP, SSE, and WebSocket upgrade traffic to the Relay.
110
+
111
+ ### Caddy
112
+
113
+ ```caddyfile
114
+ relay.example.com {
115
+ reverse_proxy 127.0.0.1:8787 {
116
+ header_up X-Forwarded-For {remote_host}
117
+ }
118
+ }
119
+ ```
120
+
121
+ Run the Relay with `--public-url wss://relay.example.com/ws`. Caddy forwards WebSocket upgrades and serves `/healthz` and `/readyz` from the same upstream. The `X-Forwarded-For` rule writes one canonical Client source IP.
122
+
123
+ ### Nginx
124
+
125
+ ```nginx
126
+ server {
127
+ listen 443 ssl;
128
+ server_name relay.example.com;
129
+
130
+ ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
131
+ ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
132
+
133
+ location / {
134
+ proxy_pass http://127.0.0.1:8787;
135
+ proxy_http_version 1.1;
136
+ proxy_set_header Host $host;
137
+ proxy_set_header X-Forwarded-For $remote_addr;
138
+ proxy_set_header Upgrade $http_upgrade;
139
+ proxy_set_header Connection "upgrade";
140
+ }
141
+ }
142
+ ```
143
+
144
+ Run the Relay with `--public-url wss://relay.example.com/ws`. Nginx writes one canonical Client source IP with `$remote_addr` and forwards WebSocket upgrades over HTTP/1.1.
145
+
146
+ ## Trusted proxies and capacity
147
+
148
+ Enable `OPENCHAMBER_RELAY_SERVER_TRUST_PROXY=true` or `--trust-proxy` when a trusted reverse proxy fully isolates Relay ingress and replaces each inbound `X-Forwarded-For` value with one canonical Client IP.
149
+
150
+ ```sh
151
+ OPENCHAMBER_RELAY_SERVER_TRUST_PROXY=true \
152
+ openchamber-relay --public-url wss://relay.example.com/ws
153
+ ```
154
+
155
+ Trusted-proxy mode accepts exactly one valid IP address in `X-Forwarded-For`. Client admission and per-Client-IP limits then use that address. Configure the proxy to replace the header, and restrict direct access to the Relay listener so proxy peer identity remains authoritative.
156
+
157
+ `OPENCHAMBER_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP` always counts the TCP peer connected to the Relay. A reverse proxy is that peer, so set this limit for the proxy's aggregate concurrent traffic rather than an individual public Client.
158
+
159
+ ## Environment variables
160
+
161
+ | Variable | Default | Unit / purpose |
162
+ | --- | --- | --- |
163
+ | `OPENCHAMBER_RELAY_SERVER_HOST` | `127.0.0.1` | Listener address |
164
+ | `OPENCHAMBER_RELAY_SERVER_PORT` | `8787` | TCP port |
165
+ | `OPENCHAMBER_RELAY_SERVER_PATH` | `/ws` | WebSocket upgrade path |
166
+ | `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` | unset | Startup output URL; `ws://` or `wss://`, same path as `PATH` |
167
+ | `OPENCHAMBER_RELAY_SERVER_TRUST_PROXY` | `false` | Read one canonical Client IP from proxy-replaced `X-Forwarded-For` |
168
+ | `OPENCHAMBER_RELAY_SERVER_MAX_URL_BYTES` | `4096` | bytes |
169
+ | `OPENCHAMBER_RELAY_SERVER_MAX_FIELD_BYTES` | `512` | bytes per routing field |
170
+ | `OPENCHAMBER_RELAY_SERVER_MAX_HOSTS` | `256` | active Host routes |
171
+ | `OPENCHAMBER_RELAY_SERVER_MAX_SOCKETS` | `2048` | upgraded WebSockets |
172
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CONNECTIONS` | `1000` | global client connections |
173
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CLIENTS_PER_HOST` | `100` | connections per Host |
174
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CLIENTS_PER_IP` | `30` | connections per Client IP |
175
+ | `OPENCHAMBER_RELAY_SERVER_MAX_PENDING_CLIENTS` | `30` | Clients awaiting a Host data connection |
176
+ | `OPENCHAMBER_RELAY_SERVER_PENDING_MS` | `15000` | ms awaiting a Host data connection |
177
+ | `OPENCHAMBER_RELAY_SERVER_MAX_RAW_SOCKETS` | `4096` | accepted TCP sockets before upgrade |
178
+ | `OPENCHAMBER_RELAY_SERVER_MAX_RAW_SOCKETS_PER_IP` | `128` | TCP sockets per Relay TCP peer IP |
179
+ | `OPENCHAMBER_RELAY_SERVER_GRACE_MS` | `30000` | ms Host control disconnect grace |
180
+ | `OPENCHAMBER_RELAY_SERVER_TIMESTAMP_SKEW_MS` | `60000` | ms Host signature timestamp window |
181
+ | `OPENCHAMBER_RELAY_SERVER_REPLAY_MS` | `120000` | ms replay-record lifetime; at least twice timestamp skew |
182
+ | `OPENCHAMBER_RELAY_SERVER_MAX_REPLAY_ENTRIES` | `10000` | Host signature replay records |
183
+ | `OPENCHAMBER_RELAY_SERVER_MAX_FRAME_BYTES` | `131072` | bytes per forwarded frame |
184
+ | `OPENCHAMBER_RELAY_SERVER_MAX_QUEUED_BYTES_PER_CONNECTION` | `2097152` | bytes per Client pair |
185
+ | `OPENCHAMBER_RELAY_SERVER_MAX_GLOBAL_QUEUED_BYTES` | `33554432` | bytes across all queues |
186
+ | `OPENCHAMBER_RELAY_SERVER_MAX_BUFFERED_AMOUNT` | `2097152` | bytes buffered by a WebSocket before pump retry |
187
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CONTROL_QUEUE_ENTRIES` | `256` | queued Host control messages |
188
+ | `OPENCHAMBER_RELAY_SERVER_MAX_CONTROL_QUEUED_BYTES` | `2097152` | bytes queued for Host control |
189
+ | `OPENCHAMBER_RELAY_SERVER_PUMP_RETRY_MS` | `25` | ms between backpressure retries |
190
+ | `OPENCHAMBER_RELAY_SERVER_HEARTBEAT_MS` | `30000` | ms WebSocket ping interval |
191
+ | `OPENCHAMBER_RELAY_SERVER_HANDSHAKE_MS` | `10000` | ms for TCP and WebSocket admission |
192
+ | `OPENCHAMBER_RELAY_SERVER_CLOSE_DEADLINE_MS` | `5000` | ms before forced socket close |
193
+ | `OPENCHAMBER_RELAY_SERVER_ADMISSION_WINDOW_MS` | `60000` | ms per-IP admission window |
194
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSIONS_PER_IP` | `120` | upgrades per role and IP per admission window |
195
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
196
+ | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
197
+
198
+ ## systemd
199
+
200
+ Create `/etc/openchamber-relay.env`:
201
+
202
+ ```sh
203
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws
204
+ ```
205
+
206
+ Create `/etc/systemd/system/openchamber-relay.service`:
207
+
208
+ ```ini
209
+ [Unit]
210
+ Description=OpenChamber Private Relay
211
+ After=network-online.target
212
+ Wants=network-online.target
213
+
214
+ [Service]
215
+ Type=simple
216
+ EnvironmentFile=/etc/openchamber-relay.env
217
+ ExecStart=/usr/local/bin/openchamber-relay
218
+ Restart=on-failure
219
+ RestartSec=5
220
+
221
+ [Install]
222
+ WantedBy=multi-user.target
223
+ ```
224
+
225
+ Enable and inspect the service:
226
+
227
+ ```sh
228
+ sudo systemctl daemon-reload
229
+ sudo systemctl enable --now openchamber-relay
230
+ sudo systemctl status openchamber-relay
231
+ ```
232
+
233
+ ## Operations and security
234
+
235
+ - `GET` and `HEAD` requests to `/healthz` report process health. `/readyz` reports ready status after the listener reaches the running state.
236
+ - `SIGTERM` and `SIGINT` start graceful Relay shutdown. Hosts reconnect after a process restart.
237
+ - Size Host, Client, pending, socket, frame, and queue limits for expected concurrency and message volume.
238
+ - Keep the default loopback listener, terminate public TLS at a reverse proxy, restrict ingress with firewall rules, and publish the matching `wss://` URL.
239
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, pairing material, and bearer credentials.
240
+
241
+ ## Docker delivery assets
242
+
243
+ Each non-dry-run OpenChamber release publishes a Docker Hub image for `linux/amd64` and `linux/arm64` as `<DOCKERHUB_USERNAME>/openchamber-relay:<version>` and `<DOCKERHUB_USERNAME>/openchamber-relay:latest`. The release pipeline requires the `DOCKERHUB_USERNAME` GitHub Actions repository variable and a `DOCKERHUB_TOKEN` repository secret with Docker Hub Read and Write permissions. Image publication must succeed before the GitHub Release is finalized.
244
+
245
+ The `Relay Docker` workflow can republish only the current Relay package version without creating or modifying a GitHub Release or other platform artifacts.
246
+
247
+ Pull and run an immutable release tag behind a host TLS reverse proxy:
248
+
249
+ ```sh
250
+ docker pull <dockerhub-username>/openchamber-relay:<version>
251
+ docker run -d \
252
+ --name openchamber-relay \
253
+ --restart unless-stopped \
254
+ --read-only \
255
+ --tmpfs /tmp \
256
+ --security-opt no-new-privileges:true \
257
+ -p 127.0.0.1:8787:8787 \
258
+ -e OPENCHAMBER_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
259
+ <dockerhub-username>/openchamber-relay:<version>
260
+ ```
261
+
262
+ For an end-to-end public deployment with Caddy-managed HTTPS, use [`docker-compose.relay.remote.yml`](../../docker-compose.relay.remote.yml) with Docker Compose 2.23.1 or later:
263
+
264
+ ```sh
265
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
266
+ RELAY_DOMAIN=relay.example.com \
267
+ ACME_EMAIL=admin@example.com \
268
+ docker compose -f docker-compose.relay.remote.yml up -d
269
+ ```
270
+
271
+ `OPENCHAMBER_RELAY_IMAGE` is required so the repository never binds this reusable deployment file to a personal registry namespace. Use an immutable version-and-digest reference in production.
272
+
273
+ The repository provides optional Docker delivery assets at [`Dockerfile.relay`](../../Dockerfile.relay) and [`docker-compose.relay.yml`](../../docker-compose.relay.yml). The Compose service publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` and accepts `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` plus selected Relay limits.
274
+
275
+ ```sh
276
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL=wss://relay.example.com/ws \
277
+ OPENCHAMBER_RELAY_PUBLISHED_PORT=8787 \
278
+ docker compose -f docker-compose.relay.yml up -d --build
279
+ ```
280
+
281
+ These assets define an optional follow-on deployment path. Validate the image, proxy integration, TLS configuration, and operational limits in the target environment before production use.
282
+
283
+ ## Troubleshooting
284
+
285
+ | Symptom | Checks and resolution |
286
+ | --- | --- |
287
+ | Host or Client cannot connect | Confirm the public URL uses the deployed `wss://` scheme, host, and exact Relay path. Confirm DNS, certificate, firewall, and proxy upstream reachability. |
288
+ | `/healthz` succeeds and `/readyz` fails | Wait for the listener startup to complete, then inspect process stderr and service logs for bind errors. |
289
+ | Clients receive admission or connection limits | Review `MAX_CONNECTIONS`, per-Host, per-Client-IP, pending, admission, and raw-socket limits against current traffic. |
290
+ | Many Clients share a reverse proxy | Increase `MAX_RAW_SOCKETS_PER_IP` for aggregate proxy-peer concurrency. |
291
+ | Per-Client IP limits behave as proxy limits | Enable trusted-proxy mode, fully isolate Relay ingress behind that proxy, and configure a single replaced `X-Forwarded-For` IP. |
292
+ | Existing clients continue using an earlier endpoint | Refresh the candidate or create a new pairing link after changing `OPENCHAMBER_RELAY_URL`. |
293
+ | WebSocket application traffic fails while HTTP works | Confirm the Host endpoint mints and supplies a short-lived `oc_url_token` for the WebSocket path. |
294
+
295
+ ## Development and test coverage
296
+
297
+ Run Relay package unit tests and Host/Client end-to-end coverage independently from the repository root:
298
+
299
+ ```sh
300
+ bunx vitest run --project @openchamber/relay-server
301
+ bunx vitest run --project @openchamber/web packages/web/server/lib/relay/relay-server.e2e.test.ts
302
+ ```
303
+
304
+ `packages/web/server/lib/relay/relay-server.e2e.test.ts` builds a compiled Relay executable and exercises a real Host and TypeScript Client across authenticated HTTP, streaming SSE, URL-token WebSocket traffic, Relay restart recovery, and cleanup. Use this E2E coverage when validating Relay transport changes.
@@ -0,0 +1,55 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath, pathToFileURL } from 'url';
4
+
5
+ function normalizeCliEntryPath(filePath, realpath = fs.realpathSync) {
6
+ if (typeof filePath !== 'string' || filePath.trim().length === 0) {
7
+ return null;
8
+ }
9
+
10
+ const resolvedPath = path.resolve(filePath);
11
+ try {
12
+ return realpath(resolvedPath);
13
+ } catch {
14
+ return resolvedPath;
15
+ }
16
+ }
17
+
18
+ function isModuleCliExecution(
19
+ entryPath = process.argv[1],
20
+ moduleUrl,
21
+ realpath = fs.realpathSync,
22
+ expectedBinName,
23
+ ) {
24
+ if (typeof entryPath !== 'string' || entryPath.trim().length === 0) {
25
+ return false;
26
+ }
27
+ if (typeof moduleUrl !== 'string' || moduleUrl.trim().length === 0) {
28
+ return false;
29
+ }
30
+
31
+ try {
32
+ const normalizedEntryPath = normalizeCliEntryPath(entryPath, realpath);
33
+ const normalizedModulePath = normalizeCliEntryPath(fileURLToPath(moduleUrl), realpath);
34
+ if (!normalizedEntryPath || !normalizedModulePath) {
35
+ return false;
36
+ }
37
+ if (pathToFileURL(normalizedEntryPath).href === pathToFileURL(normalizedModulePath).href) {
38
+ return true;
39
+ }
40
+
41
+ if (typeof expectedBinName === 'string' && expectedBinName.trim().length > 0) {
42
+ const parsedEntryName = path.parse(normalizedEntryPath).name.toLowerCase();
43
+ return parsedEntryName === expectedBinName.trim().toLowerCase();
44
+ }
45
+
46
+ return false;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ export {
53
+ normalizeCliEntryPath,
54
+ isModuleCliExecution,
55
+ };
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import packageJson from '../package.json' with { type: 'json' };
3
+
4
+ import { isModuleCliExecution } from './cli-entry.js';
5
+ import { runRelayServerCli } from '../src/cli.js';
6
+
7
+ const version = packageJson.version;
8
+
9
+ if (isModuleCliExecution(process.argv[1], import.meta.url, undefined, 'openchamber-relay')) {
10
+ runRelayServerCli(process.argv.slice(2), { version }).then((code) => { process.exitCode = code; });
11
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@openchambery/relay-server",
3
+ "version": "1.17.1-beta.10",
4
+ "description": "Self-hosted private relay server for OpenChamber",
5
+ "private": false,
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js",
13
+ "default": "./src/index.js"
14
+ }
15
+ },
16
+ "bin": {
17
+ "openchamber-relay": "./bin/openchamber-relay.js"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.0.0"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/yee94/openchamber.git"
29
+ },
30
+ "homepage": "https://github.com/yee94/openchamber#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/yee94/openchamber/issues"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "src",
37
+ "README.md",
38
+ "DOCUMENTATION.md"
39
+ ],
40
+ "scripts": {
41
+ "test": "vitest run",
42
+ "test:node": "node test/node-smoke.js",
43
+ "type-check": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js",
44
+ "lint": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js",
45
+ "build:standalone": "bun build --compile --outfile dist/openchamber-relay bin/openchamber-relay.js"
46
+ },
47
+ "dependencies": {
48
+ "ws": "^8.18.3"
49
+ }
50
+ }
package/src/cli.js ADDED
@@ -0,0 +1,110 @@
1
+ import { startPrivateRelayServer } from './index.js';
2
+ import { isIP } from 'node:net';
3
+
4
+ const LIMIT_KEYS = ['maxUrlBytes', 'maxFieldBytes', 'maxHosts', 'maxSockets', 'maxConnections', 'maxClientsPerHost', 'maxClientsPerIp', 'maxPendingClients', 'pendingMs', 'maxRawSockets', 'maxRawSocketsPerIp', 'graceMs', 'timestampSkewMs', 'replayMs', 'maxReplayEntries', 'maxFrameBytes', 'maxQueuedBytesPerConnection', 'maxGlobalQueuedBytes', 'maxBufferedAmount', 'maxControlQueueEntries', 'maxControlQueuedBytes', 'pumpRetryMs', 'heartbeatMs', 'handshakeMs', 'closeDeadlineMs', 'admissionWindowMs', 'maxAdmissionsPerIp', 'maxAdmissionEntries', 'idAttempts'];
5
+ const upperSnake = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
6
+ const envName = (key) => `OPENCHAMBER_RELAY_SERVER_${upperSnake(key)}`;
7
+ const fail = (name) => { throw new Error(`Invalid ${name}`); };
8
+
9
+ export const parseRelayServerArgs = (argv = []) => {
10
+ const parsed = {};
11
+ const values = new Map([['--host', 'host'], ['--port', 'port'], ['--path', 'path'], ['--public-url', 'publicUrl']]);
12
+ for (let index = 0; index < argv.length; index += 1) {
13
+ const arg = argv[index];
14
+ if (values.has(arg)) { const value = argv[++index]; if (!value || value.startsWith('--')) fail(arg, value ?? ''); parsed[values.get(arg)] = value; continue; }
15
+ if (arg === '--trust-proxy') { parsed.trustProxy = true; continue; }
16
+ if (arg === '--no-trust-proxy') { parsed.trustProxy = false; continue; }
17
+ if (arg === '--json') { parsed.json = true; continue; }
18
+ if (arg === '--quiet' || arg === '-q') { parsed.quiet = true; continue; }
19
+ if (arg === '--help' || arg === '-h') { parsed.help = true; continue; }
20
+ if (arg === '--version' || arg === '-v') { parsed.version = true; continue; }
21
+ fail(arg, '');
22
+ }
23
+ return parsed;
24
+ };
25
+
26
+ const positive = (name, value) => {
27
+ if (!/^[1-9][0-9]*$/.test(String(value))) fail(name, value);
28
+ const number = Number(value); if (!Number.isSafeInteger(number)) fail(name, value);
29
+ return number;
30
+ };
31
+ const bool = (name, value) => {
32
+ if (value === undefined) return false;
33
+ if (value === 'true' || value === '1' || value === true) return true;
34
+ if (value === 'false' || value === '0' || value === false) return false;
35
+ fail(name, value);
36
+ };
37
+ const validPath = (name, value) => {
38
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('?') || value.includes('#') || new URL(value, 'http://relay').pathname !== value) fail(name, value);
39
+ return value;
40
+ };
41
+ const validHost = (name, value) => {
42
+ if (typeof value !== 'string' || value.trim() !== value || value.length === 0 || value.includes('/') || value.includes('\\') || value.includes('@') || value.includes(':') && !isIP(value) || (!isIP(value) && !/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/.test(value))) fail(name, value);
43
+ return value;
44
+ };
45
+ const publicUrl = (name, value, path) => {
46
+ if (!value) return undefined;
47
+ let url; try { url = new URL(value); } catch { fail(name, value); }
48
+ if (!['ws:', 'wss:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash || url.pathname !== path) fail(name, value);
49
+ return url.toString();
50
+ };
51
+
52
+ export const buildRelayConfig = (parsed = {}, env = process.env) => {
53
+ const envValue = (key) => {
54
+ const value = env[envName(key)];
55
+ return typeof value === 'string' && value.trim().length === 0 ? undefined : value;
56
+ };
57
+ const pick = (key, fallback) => parsed[key] ?? envValue(key) ?? fallback;
58
+ const portValue = pick('port', 8787); const port = positive(parsed.port !== undefined ? '--port' : envName('port'), portValue);
59
+ if (port > 65535) fail(parsed.port !== undefined ? '--port' : envName('port'), portValue);
60
+ const path = validPath(parsed.path !== undefined ? '--path' : envName('path'), pick('path', '/ws'));
61
+ const host = validHost(parsed.host !== undefined ? '--host' : envName('host'), pick('host', '127.0.0.1'));
62
+ const outputPublicUrl = publicUrl(parsed.publicUrl !== undefined ? '--public-url' : envName('publicUrl'), pick('publicUrl', undefined), path);
63
+ const limits = {};
64
+ for (const key of LIMIT_KEYS) { const value = pick(key, undefined); if (value !== undefined) limits[key] = positive(parsed[key] !== undefined ? `--${upperSnake(key).toLowerCase().replaceAll('_', '-')}` : envName(key), value); }
65
+ const replayMs = limits.replayMs ?? 120_000; const timestampSkewMs = limits.timestampSkewMs ?? 60_000; const maxConnections = limits.maxConnections ?? 1_000;
66
+ if (replayMs < timestampSkewMs * 2) fail(parsed.replayMs !== undefined ? '--replay-ms' : envName('replayMs'), replayMs);
67
+ if ((limits.maxPendingClients ?? 30) > maxConnections) fail(parsed.maxPendingClients !== undefined ? '--max-pending-clients' : envName('maxPendingClients'), limits.maxPendingClients ?? 30);
68
+ if ((limits.maxClientsPerHost ?? 100) > maxConnections) fail(parsed.maxClientsPerHost !== undefined ? '--max-clients-per-host' : envName('maxClientsPerHost'), limits.maxClientsPerHost ?? 100);
69
+ return { host, port, path, publicUrl: outputPublicUrl, trustProxy: parsed.trustProxy ?? bool(envName('trustProxy'), envValue('trustProxy')), limits };
70
+ };
71
+
72
+ const helpText = 'Usage: openchamber-relay [--host HOST] [--port PORT] [--path PATH] [--public-url WS_URL] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
73
+ const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
74
+
75
+ export const runRelayServerCli = async (argv, dependencies = {}) => {
76
+ const processLike = dependencies.process ?? process; const stdout = dependencies.stdout ?? process.stdout; const stderr = dependencies.stderr ?? process.stderr; const version = dependencies.version ?? '0.0.0';
77
+ let parsed;
78
+ try { parsed = parseRelayServerArgs(argv ?? processLike.argv?.slice(2) ?? []); } catch (error) {
79
+ const json = (argv ?? processLike.argv?.slice(2) ?? []).includes('--json');
80
+ if (json) writeJson(stdout, { status: 'error', error: error.message }); else stderr.write(`${error.message}\n`);
81
+ processLike.exitCode = 1; return 1;
82
+ }
83
+ const json = parsed.json;
84
+ const respond = (payload, error = false, essential = false) => { if (json) writeJson(stdout, payload); else if (payload.message && (essential || !parsed.quiet || error)) (error ? stderr : stdout).write(`${payload.message}\n`); };
85
+ if (parsed.help) { respond(json ? { status: 'ok', help: helpText.trim() } : { message: helpText.trim() }, false, true); return 0; }
86
+ if (parsed.version) { respond(json ? { status: 'ok', version } : { message: version }, false, true); return 0; }
87
+ let config;
88
+ try { config = buildRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
89
+ try {
90
+ const relay = await (dependencies.start ?? startPrivateRelayServer)(config);
91
+ const url = config.publicUrl ?? relay.wsUrl;
92
+ respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path } : { message: `Relay listening at ${url}` });
93
+ let stopping = false;
94
+ const stop = async () => {
95
+ if (stopping) return Promise.resolve();
96
+ stopping = true;
97
+ processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
98
+ try {
99
+ await relay.stop();
100
+ processLike.exit?.(0);
101
+ } catch {
102
+ processLike.exitCode = 1;
103
+ if (json) writeJson(stderr, { status: 'error', error: 'Relay stop failed' }); else stderr.write('Relay stop failed\n');
104
+ processLike.exit?.(1);
105
+ }
106
+ };
107
+ processLike.on?.('SIGINT', stop); processLike.on?.('SIGTERM', stop);
108
+ return 0;
109
+ } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
110
+ };
package/src/index.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+
4
+ export type RelaySocketRole = 'client' | 'host-control' | 'host-data';
5
+
6
+ export interface Limits {
7
+ maxUrlBytes: number;
8
+ maxFieldBytes: number;
9
+ maxHosts: number;
10
+ maxSockets: number;
11
+ maxConnections: number;
12
+ maxClientsPerHost: number;
13
+ maxClientsPerIp: number;
14
+ maxPendingClients: number;
15
+ pendingMs: number;
16
+ maxRawSockets: number;
17
+ maxRawSocketsPerIp: number;
18
+ graceMs: number;
19
+ timestampSkewMs: number;
20
+ replayMs: number;
21
+ maxReplayEntries: number;
22
+ maxFrameBytes: number;
23
+ maxQueuedBytesPerConnection: number;
24
+ maxGlobalQueuedBytes: number;
25
+ maxBufferedAmount: number;
26
+ maxControlQueueEntries: number;
27
+ maxControlQueuedBytes: number;
28
+ pumpRetryMs: number;
29
+ heartbeatMs: number;
30
+ handshakeMs: number;
31
+ closeDeadlineMs: number;
32
+ admissionWindowMs: number;
33
+ maxAdmissionsPerIp: number;
34
+ maxAdmissionEntries: number;
35
+ idAttempts: number;
36
+ }
37
+
38
+ export interface SnapshotReasons {
39
+ authRejected: number;
40
+ policyRejected: number;
41
+ limited: number;
42
+ heartbeatReaped: number;
43
+ replayRejected: number;
44
+ }
45
+
46
+ export interface Snapshot {
47
+ state: 'idle' | 'starting' | 'running' | 'stopping' | 'stopped';
48
+ hosts: number;
49
+ sockets: number;
50
+ rawSockets: number;
51
+ controls: number;
52
+ clients: number;
53
+ pairs: number;
54
+ pending: number;
55
+ queuedBytes: number;
56
+ reasons: SnapshotReasons;
57
+ }
58
+
59
+ export interface Clock {
60
+ now: () => number;
61
+ setTimeout: typeof globalThis.setTimeout;
62
+ clearTimeout: typeof globalThis.clearTimeout;
63
+ setInterval: typeof globalThis.setInterval;
64
+ clearInterval: typeof globalThis.clearInterval;
65
+ setImmediate: typeof globalThis.setImmediate;
66
+ }
67
+
68
+ export interface Options {
69
+ host?: string;
70
+ port?: number;
71
+ path?: string;
72
+ trustProxy?: boolean;
73
+ requestHandler?: (request: IncomingMessage, response: ServerResponse) => boolean;
74
+ clock?: Partial<Clock>;
75
+ randomBytes?: (size: number) => Buffer;
76
+ logger?: Pick<Console, 'info' | 'warn' | 'error'>;
77
+ limits?: Partial<Limits>;
78
+ resolveClientIp?: (request: IncomingMessage) => string;
79
+ onSocketAccepted?: (connection: { socket: unknown; role: RelaySocketRole }) => void;
80
+ }
81
+
82
+ export interface ServerInstance {
83
+ start(): Promise<void>;
84
+ stop(): Promise<void>;
85
+ address(): string | AddressInfo | null | undefined;
86
+ readonly wsUrl: string | null;
87
+ getSnapshot(): Snapshot;
88
+ }
89
+
90
+ export function resolveRelayClientIp(request: IncomingMessage, trustProxy?: boolean): string;
91
+ export function formatRelayWsUrl(host: string, port: number, relayPath: string): string;
92
+ export function createPrivateRelayServer(options?: Options): ServerInstance;
93
+ export function startPrivateRelayServer(options?: Options): Promise<ServerInstance>;
package/src/index.js ADDED
@@ -0,0 +1,366 @@
1
+ import crypto from 'node:crypto';
2
+ import http from 'node:http';
3
+ import { isIP } from 'node:net';
4
+ import { WebSocketServer } from 'ws';
5
+
6
+ const CLOSE = { replaced: 4001, duplicate: 4002, stuck: 4003, unavailable: 4008, auth: 4010, limit: 4029, away: 1012, malformed: 1008 };
7
+ const B64 = /^[A-Za-z0-9_-]+$/;
8
+ const SERVER_ID_BYTES = 32;
9
+ const DEFAULT_LIMITS = {
10
+ maxUrlBytes: 4096, maxFieldBytes: 512, maxHosts: 256, maxSockets: 2048, maxConnections: 1000,
11
+ maxClientsPerHost: 100, maxClientsPerIp: 30, maxPendingClients: 30, pendingMs: 15_000,
12
+ maxRawSockets: 4096, maxRawSocketsPerIp: 128,
13
+ graceMs: 30_000, timestampSkewMs: 60_000, replayMs: 120_000, maxReplayEntries: 10_000,
14
+ maxFrameBytes: 128 * 1024, maxQueuedBytesPerConnection: 2 * 1024 * 1024,
15
+ maxGlobalQueuedBytes: 32 * 1024 * 1024, maxBufferedAmount: 2 * 1024 * 1024,
16
+ maxControlQueueEntries: 256, maxControlQueuedBytes: 2 * 1024 * 1024,
17
+ pumpRetryMs: 25, heartbeatMs: 30_000, handshakeMs: 10_000, closeDeadlineMs: 5_000,
18
+ admissionWindowMs: 60_000, maxAdmissionsPerIp: 120, maxAdmissionEntries: 10_000,
19
+ idAttempts: 4,
20
+ };
21
+ const fields = {
22
+ client: new Set(['v', 'role', 'serverId', 'grant']),
23
+ 'host-control': new Set(['v', 'role', 'serverId', 'ts', 'sig', 'pk']),
24
+ 'host-data': new Set(['v', 'role', 'serverId', 'connectionId', 'ts', 'sig', 'pk']),
25
+ };
26
+ const bytes = (value) => Buffer.byteLength(value, 'utf8');
27
+ const canonical = (jwk) => JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
28
+ const b64 = (value, length) => {
29
+ if (typeof value !== 'string' || !B64.test(value)) return null;
30
+ try { const decoded = Buffer.from(value, 'base64url'); return decoded.length === length && decoded.toString('base64url') === value ? decoded : null; } catch { return null; }
31
+ };
32
+
33
+ export const resolveRelayClientIp = (request, trustProxy = false) => {
34
+ const remoteAddress = request.socket.remoteAddress ?? 'unknown';
35
+ if (!trustProxy) return remoteAddress;
36
+ const forwarded = request.headers['x-forwarded-for'];
37
+ const candidate = typeof forwarded === 'string' && !forwarded.includes(',') ? forwarded.trim() : '';
38
+ return isIP(candidate) ? candidate : remoteAddress;
39
+ };
40
+
41
+ export const formatRelayWsUrl = (host, port, relayPath) => `ws://${isIP(host) === 6 ? `[${host}]` : host}:${port}${relayPath}`;
42
+
43
+ /** @param {string} encoded */
44
+ const decodeJwk = (encoded) => {
45
+ const raw = b64(encoded, Buffer.byteLength(encoded, 'base64url'));
46
+ if (!raw) return null;
47
+ try {
48
+ const text = raw.toString('utf8'); const jwk = JSON.parse(text);
49
+ if (text !== canonical(jwk) || jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !b64(jwk.x, 32) || !b64(jwk.y, 32)) return null;
50
+ return { jwk, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) };
51
+ } catch { return null; }
52
+ };
53
+
54
+ /**
55
+ * Creates the Layer 1 self-hosted relay. It routes opaque Layer 2/3 frames.
56
+ * @param {{ host?: string, port?: number, path?: string, trustProxy?: boolean, requestHandler?: (request: http.IncomingMessage, response: http.ServerResponse) => boolean, clock?: Partial<typeof globalThis>, randomBytes?: (size: number) => Buffer, logger?: Pick<Console, 'info'|'warn'|'error'>, limits?: Partial<typeof DEFAULT_LIMITS>, resolveClientIp?: (request: http.IncomingMessage) => string, onSocketAccepted?: ({ socket: import('ws').WebSocket, role: string }) => void }} options
57
+ */
58
+ export const createPrivateRelayServer = (options = {}) => {
59
+ const limits = { ...DEFAULT_LIMITS, ...options.limits };
60
+ if (limits.replayMs < limits.timestampSkewMs * 2 || limits.maxReplayEntries < 1) throw new RangeError('invalid replay limits');
61
+ const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...options.clock };
62
+ const randomBytes = options.randomBytes ?? crypto.randomBytes;
63
+ const hosts = new Map(); const replay = new Map(); const admissions = new Map(); const clientIpCounts = new Map(); const rawIpCounts = new Map();
64
+ const accepted = new Set(); const rawSockets = new Set();
65
+ const counts = { hosts: 0, sockets: 0, rawSockets: 0, controls: 0, clients: 0, pairs: 0, pending: 0, queuedBytes: 0 };
66
+ const reasons = { authRejected: 0, policyRejected: 0, limited: 0, heartbeatReaped: 0, replayRejected: 0 };
67
+ let server = null; let wss = null; let heartbeat = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
68
+ const addReason = (key) => { reasons[key] += 1; };
69
+ const snapshot = () => ({ state, ...counts, reasons: { ...reasons } });
70
+ const resolveClientIp = options.resolveClientIp ?? ((request) => resolveRelayClientIp(request, options.trustProxy));
71
+ const relayPath = options.path ?? '/ws';
72
+ const decrement = (map, key) => { const value = map.get(key) ?? 0; if (value <= 1) map.delete(key); else map.set(key, value - 1); };
73
+ const safeClose = (socket, code, reason = '') => {
74
+ if (socket.readyState === socket.CLOSED) return;
75
+ try {
76
+ if (socket.readyState === socket.OPEN || socket.readyState === socket.CONNECTING) socket.close(code, reason);
77
+ if (socket.readyState === socket.CLOSED) return;
78
+ if (socket.readyState !== socket.OPEN && socket.readyState !== socket.CONNECTING && socket.readyState !== socket.CLOSING) return;
79
+ if (!socket._relayCloseDeadline) socket._relayCloseDeadline = clock.setTimeout(() => {
80
+ socket._relayCloseDeadline = null;
81
+ if (socket.readyState !== socket.CLOSED) socket.terminate();
82
+ }, limits.closeDeadlineMs);
83
+ } catch { socket.terminate(); }
84
+ };
85
+ const purge = () => {
86
+ const now = clock.now();
87
+ for (const [key, expiry] of replay) if (expiry <= now) replay.delete(key);
88
+ for (const [key, record] of admissions) if (record.until <= now) admissions.delete(key);
89
+ };
90
+ const admitIp = (ip, role) => {
91
+ purge(); const key = `${role}:${ip}`; let record = admissions.get(key);
92
+ if (!record) {
93
+ if (admissions.size >= limits.maxAdmissionEntries) { addReason('limited'); return false; }
94
+ record = { count: 0, until: clock.now() + limits.admissionWindowMs }; admissions.set(key, record);
95
+ }
96
+ record.count += 1;
97
+ if (record.count > limits.maxAdmissionsPerIp) { addReason('limited'); return false; }
98
+ return true;
99
+ };
100
+ const removeSocket = (socket) => {
101
+ if (!accepted.delete(socket)) return;
102
+ counts.sockets -= 1;
103
+ if (socket._relayHandshake) clock.clearTimeout(socket._relayHandshake);
104
+ if (socket._relayCloseDeadline) clock.clearTimeout(socket._relayCloseDeadline);
105
+ };
106
+ const detachControl = (host, control, { code = CLOSE.away, reason = 'control closed', grace = true } = {}) => {
107
+ if (!control || hosts.get(host.serverId) !== host || host.control !== control || control.epoch !== host.epoch) return;
108
+ host.control = null;
109
+ counts.controls -= 1;
110
+ const queued = control.queuedBytes;
111
+ control.queue = []; control.queuedBytes = 0; control.busy = false;
112
+ counts.queuedBytes = Math.max(0, counts.queuedBytes - queued);
113
+ safeClose(control.socket, code, reason);
114
+ if (grace) beginGrace(host, control.epoch);
115
+ };
116
+ const controlPump = (host, message) => {
117
+ if (!host.control || host.control.epoch !== host.epoch) return;
118
+ const control = host.control; const payload = Buffer.from(JSON.stringify(message));
119
+ if (control.queue.length >= limits.maxControlQueueEntries || control.queuedBytes + payload.length > limits.maxControlQueuedBytes || counts.queuedBytes + payload.length > limits.maxGlobalQueuedBytes) {
120
+ addReason('limited'); detachControl(host, control, { code: CLOSE.limit, reason: 'control queue limit' }); return;
121
+ }
122
+ control.queue.push(payload); control.queuedBytes += payload.length; counts.queuedBytes += payload.length;
123
+ const pump = () => {
124
+ const control = host.control;
125
+ if (!control || control.epoch !== host.epoch || control.busy || !control.queue.length) return;
126
+ if (control.socket.readyState !== control.socket.OPEN) return detachControl(host, control, { code: CLOSE.away, reason: 'control unavailable' });
127
+ control.busy = true; const item = control.queue[0];
128
+ control.socket.send(item, { binary: false }, (error) => {
129
+ if (!host.control || host.control !== control || control.epoch !== host.epoch) return;
130
+ control.busy = false;
131
+ if (error) { detachControl(host, control, { code: CLOSE.away, reason: 'control send failure' }); return; }
132
+ control.queue.shift(); control.queuedBytes -= item.length; counts.queuedBytes = Math.max(0, counts.queuedBytes - item.length); pump();
133
+ });
134
+ };
135
+ pump();
136
+ };
137
+ const releasePump = (entry, pump) => {
138
+ if (pump.retry) clock.clearTimeout(pump.retry);
139
+ const total = pump.bytes; pump.queue = []; pump.bytes = 0; pump.busy = false; pump.retry = null;
140
+ entry.queuedBytes = Math.max(0, entry.queuedBytes - total);
141
+ counts.queuedBytes = Math.max(0, counts.queuedBytes - total);
142
+ };
143
+ const removeClient = (host, entry, code, reason, notify = true) => {
144
+ if (host.clients.get(entry.id) !== entry) return;
145
+ host.clients.delete(entry.id); counts.clients -= 1; decrement(clientIpCounts, entry.ip); if (entry.data) counts.pairs -= 1; else counts.pending -= 1;
146
+ if (entry.pendingTimer) clock.clearTimeout(entry.pendingTimer);
147
+ releasePump(entry, entry.toData); releasePump(entry, entry.toClient);
148
+ setRelayPaused(entry.client, false); setRelayPaused(entry.data, false);
149
+ if (entry.client) safeClose(entry.client, code, reason); if (entry.data) safeClose(entry.data, code, reason);
150
+ if (notify) controlPump(host, { type: 'disconnected', connectionId: entry.id });
151
+ if (!host.control && host.clients.size === 0 && !host.graceTimer && hosts.delete(host.serverId)) counts.hosts -= 1;
152
+ };
153
+ const expireHost = (host, epoch) => {
154
+ if (hosts.get(host.serverId) !== host || host.epoch !== epoch || host.control) return;
155
+ host.graceTimer = null;
156
+ for (const entry of [...host.clients.values()]) removeClient(host, entry, CLOSE.away, 'host went away', false);
157
+ if (hosts.get(host.serverId) === host && hosts.delete(host.serverId)) counts.hosts -= 1;
158
+ };
159
+ const beginGrace = (host, epoch) => {
160
+ if (hosts.get(host.serverId) !== host || host.epoch !== epoch || host.graceTimer) return;
161
+ host.graceTimer = clock.setTimeout(() => expireHost(host, epoch), limits.graceMs);
162
+ };
163
+ const pauseHigh = Math.max(1, Math.min(limits.maxQueuedBytesPerConnection - 1, Math.floor(limits.maxQueuedBytesPerConnection / 2)));
164
+ const pauseLow = Math.min(pauseHigh, Math.floor(limits.maxQueuedBytesPerConnection / 4));
165
+ const sourceOf = (entry, direction) => (direction === 'toData' ? entry.client : entry.data);
166
+ // Pause the fast sender when this direction's queue is above the high watermark.
167
+ const setRelayPaused = (socket, paused) => {
168
+ if (!socket || Boolean(socket._relayPaused) === paused) return;
169
+ if (paused && socket.readyState !== socket.OPEN) return;
170
+ try {
171
+ if (typeof socket.pause === 'function' && typeof socket.resume === 'function') {
172
+ if (paused) socket.pause();
173
+ else socket.resume();
174
+ } else {
175
+ const stream = socket._socket;
176
+ if (stream && typeof stream.pause === 'function') {
177
+ if (paused) stream.pause();
178
+ else stream.resume();
179
+ }
180
+ }
181
+ } catch { /* pause/resume only apply while OPEN */ }
182
+ socket._relayPaused = paused;
183
+ };
184
+ const applyBackpressure = (entry, direction) => {
185
+ const source = sourceOf(entry, direction);
186
+ if (!source) return;
187
+ const queued = entry[direction].bytes;
188
+ if (queued >= pauseHigh) setRelayPaused(source, true);
189
+ else if (queued <= pauseLow) setRelayPaused(source, false);
190
+ };
191
+ const fairReady = [];
192
+ let fairScheduled = false;
193
+ const requestPump = (host, entry, direction) => {
194
+ const channel = entry[direction];
195
+ if (channel.scheduled) return;
196
+ channel.scheduled = true;
197
+ fairReady.push({ host, entry, direction });
198
+ if (fairScheduled) return;
199
+ fairScheduled = true;
200
+ clock.setImmediate(runFairPump);
201
+ };
202
+ // One frame per ready pair per tick so a single tunnel cannot monopolize the event loop.
203
+ const runFairPump = () => {
204
+ fairScheduled = false;
205
+ const pending = fairReady.length;
206
+ for (let index = 0; index < pending; index += 1) {
207
+ const job = fairReady.shift();
208
+ if (!job) break;
209
+ job.entry[job.direction].scheduled = false;
210
+ sendOne(job.host, job.entry, job.direction);
211
+ }
212
+ };
213
+ const sendOne = (host, entry, direction) => {
214
+ const channel = entry[direction]; const target = direction === 'toData' ? entry.data : entry.client;
215
+ if (host.clients.get(entry.id) !== entry || channel.busy || !channel.queue.length) return;
216
+ if (!target || target.readyState !== target.OPEN || target.bufferedAmount > limits.maxBufferedAmount) {
217
+ if (!channel.retry) channel.retry = clock.setTimeout(() => { channel.retry = null; requestPump(host, entry, direction); }, limits.pumpRetryMs);
218
+ return;
219
+ }
220
+ channel.busy = true; const item = channel.queue[0];
221
+ target.send(item.data, { binary: item.binary }, (error) => {
222
+ if (host.clients.get(entry.id) !== entry || entry[direction] !== channel) return;
223
+ channel.busy = false;
224
+ if (error) { removeClient(host, entry, CLOSE.limit, 'send failure'); return; }
225
+ channel.queue.shift(); channel.bytes = Math.max(0, channel.bytes - item.data.length); entry.queuedBytes = Math.max(0, entry.queuedBytes - item.data.length); counts.queuedBytes = Math.max(0, counts.queuedBytes - item.data.length);
226
+ applyBackpressure(entry, direction);
227
+ if (channel.queue.length) requestPump(host, entry, direction);
228
+ });
229
+ };
230
+ const enqueue = (host, entry, direction, data, binary) => {
231
+ const payload = Buffer.from(data); const channel = entry[direction];
232
+ if (payload.length > limits.maxFrameBytes || entry.queuedBytes + payload.length > limits.maxQueuedBytesPerConnection || counts.queuedBytes + payload.length > limits.maxGlobalQueuedBytes) { addReason('limited'); removeClient(host, entry, CLOSE.limit, 'queue limit'); return; }
233
+ channel.queue.push({ data: payload, binary }); channel.bytes += payload.length; entry.queuedBytes += payload.length; counts.queuedBytes += payload.length;
234
+ applyBackpressure(entry, direction);
235
+ requestPump(host, entry, direction);
236
+ };
237
+ const bindClient = (host, entry, socket) => {
238
+ entry.client = socket;
239
+ socket.on('message', (data, binary) => enqueue(host, entry, 'toData', data, binary));
240
+ socket.on('close', () => removeClient(host, entry, 1000, '', true)); socket.on('error', () => {});
241
+ };
242
+ const bindData = (host, entry, socket) => {
243
+ entry.data = socket; counts.pending -= 1; counts.pairs += 1; if (entry.pendingTimer) clock.clearTimeout(entry.pendingTimer);
244
+ socket.on('message', (data, binary) => enqueue(host, entry, 'toClient', data, binary));
245
+ socket.on('close', () => { if (host.clients.get(entry.id) === entry && entry.data === socket) removeClient(host, entry, CLOSE.away, 'host data closed'); }); socket.on('error', () => {});
246
+ requestPump(host, entry, 'toData');
247
+ };
248
+ const parse = (request) => {
249
+ if (!request.url || bytes(request.url) > limits.maxUrlBytes) return null;
250
+ let url; try { url = new URL(request.url, 'http://relay'); } catch { return null; }
251
+ if (url.pathname !== relayPath) return null;
252
+ const role = url.searchParams.get('role'); const allowed = fields[role];
253
+ if (!allowed || url.searchParams.get('v') !== '1') return null;
254
+ for (const [key, value] of url.searchParams) if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1 || bytes(value) > limits.maxFieldBytes) return null;
255
+ for (const key of allowed) if (key !== 'grant' && !url.searchParams.has(key)) return null;
256
+ const serverId = url.searchParams.get('serverId');
257
+ if (!b64(serverId, SERVER_ID_BYTES)) return null;
258
+ const connectionId = url.searchParams.get('connectionId');
259
+ if (connectionId && !b64(connectionId, 16)) return null;
260
+ return { role, serverId, connectionId, params: url.searchParams };
261
+ };
262
+ const authenticate = (parsed) => {
263
+ if (parsed.role === 'client') return true;
264
+ const ts = parsed.params.get('ts'); const sig = parsed.params.get('sig'); const pk = parsed.params.get('pk');
265
+ if (!/^(0|[1-9][0-9]{0,15})$/.test(ts ?? '')) return false;
266
+ const timestamp = Number(ts); if (!Number.isSafeInteger(timestamp) || Math.abs(clock.now() - timestamp) > limits.timestampSkewMs) return false;
267
+ const signature = b64(sig, 64); const publicKey = decodeJwk(pk);
268
+ if (!signature || !publicKey) return false;
269
+ const id = crypto.createHash('sha256').update(canonical(publicKey.jwk)).digest('base64url'); if (id !== parsed.serverId) return false;
270
+ const key = `${parsed.serverId}.${parsed.role}.${parsed.connectionId ?? ''}.${ts}`;
271
+ purge(); if (replay.has(key) || replay.size >= limits.maxReplayEntries) { addReason('replayRejected'); return false; }
272
+ const message = `${ts}.${parsed.serverId}.${parsed.role}.${parsed.connectionId ?? ''}`;
273
+ if (!crypto.verify('SHA256', Buffer.from(message), { key: publicKey.key, dsaEncoding: 'ieee-p1363' }, signature)) return false;
274
+ replay.set(key, clock.now() + limits.replayMs); return true;
275
+ };
276
+ const idFor = (host) => {
277
+ for (let attempt = 0; attempt < limits.idAttempts; attempt += 1) { const id = Buffer.from(randomBytes(16)).toString('base64url'); if (id.length === 22 && !host.clients.has(id)) return id; }
278
+ return null;
279
+ };
280
+ const attach = (socket, request, parsed) => {
281
+ const ip = resolveClientIp(request); socket._relayRole = parsed.role; socket._relayAlive = true;
282
+ socket.on('pong', () => { socket._relayAlive = true; });
283
+ if (parsed.role === 'host-control') {
284
+ let host = hosts.get(parsed.serverId);
285
+ if (!host && counts.hosts >= limits.maxHosts) { addReason('limited'); return safeClose(socket, CLOSE.limit, 'host limit'); }
286
+ if (!host) { host = { serverId: parsed.serverId, clients: new Map(), control: null, epoch: 0, graceTimer: null }; hosts.set(parsed.serverId, host); counts.hosts += 1; }
287
+ if (host.control) { const previous = host.control; host.epoch += 1; for (const entry of [...host.clients.values()]) removeClient(host, entry, CLOSE.replaced, 'control replaced', false); host.epoch -= 1; detachControl(host, previous, { code: CLOSE.replaced, reason: 'control replaced', grace: false }); }
288
+ if (host.graceTimer) { clock.clearTimeout(host.graceTimer); host.graceTimer = null; }
289
+ host.epoch += 1; const epoch = host.epoch; host.control = { socket, epoch, queue: [], queuedBytes: 0, busy: false }; counts.controls += 1;
290
+ socket.on('close', () => detachControl(host, host.control?.socket === socket ? host.control : null, { code: CLOSE.away, reason: 'control closed' })); socket.on('error', () => {});
291
+ controlPump(host, { type: 'sync', connectionIds: [...host.clients.keys()] }); return;
292
+ }
293
+ if (parsed.role === 'client') {
294
+ const host = hosts.get(parsed.serverId);
295
+ if (!host) return safeClose(socket, CLOSE.unavailable, 'host unavailable');
296
+ if (counts.clients >= limits.maxConnections || host.clients.size >= limits.maxClientsPerHost || counts.pending >= limits.maxPendingClients || (clientIpCounts.get(ip) ?? 0) >= limits.maxClientsPerIp) { addReason('limited'); return safeClose(socket, CLOSE.limit, 'client limit'); }
297
+ const id = idFor(host); if (!id) { addReason('limited'); return safeClose(socket, CLOSE.limit, 'id collision'); }
298
+ const entry = { id, ip, client: null, data: null, queuedBytes: 0, toData: { queue: [], bytes: 0, busy: false, retry: null, scheduled: false }, toClient: { queue: [], bytes: 0, busy: false, retry: null, scheduled: false }, pendingTimer: null };
299
+ host.clients.set(id, entry); counts.clients += 1; counts.pending += 1; clientIpCounts.set(ip, (clientIpCounts.get(ip) ?? 0) + 1); bindClient(host, entry, socket);
300
+ entry.pendingTimer = clock.setTimeout(() => { if (host.clients.get(id) === entry && !entry.data) removeClient(host, entry, CLOSE.limit, 'pending timeout'); }, limits.pendingMs);
301
+ controlPump(host, { type: 'connected', connectionId: id }); return;
302
+ }
303
+ const host = hosts.get(parsed.serverId); const entry = host?.clients.get(parsed.connectionId);
304
+ if (!host || !entry || entry.data) return safeClose(socket, CLOSE.duplicate, 'data attach');
305
+ bindData(host, entry, socket);
306
+ };
307
+ const reject = (request, socket, head, code) => wss.handleUpgrade(request, socket, head, (ws) => safeClose(ws, code, 'rejected'));
308
+ const start = () => {
309
+ if (state === 'running') return Promise.resolve(); if (state === 'stopping') return stopPromise.then(() => start()); if (startPromise) return startPromise;
310
+ state = 'starting'; const localGeneration = ++generation; const localServer = http.createServer((request, response) => {
311
+ if (options.requestHandler?.(request, response) || response.writableEnded) return;
312
+ const pathname = new URL(request.url ?? '/', 'http://relay').pathname;
313
+ const ready = pathname === '/readyz' && state === 'running';
314
+ const healthy = pathname === '/healthz';
315
+ response.setHeader('cache-control', 'no-store');
316
+ if ((healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
317
+ response.writeHead(200, { 'content-type': 'application/json' });
318
+ response.end(request.method === 'HEAD' ? undefined : '{"status":"ok"}');
319
+ return;
320
+ }
321
+ response.writeHead(404); response.end();
322
+ }); const localWss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: limits.maxFrameBytes }); server = localServer; wss = localWss;
323
+ localServer.on('connection', (socket) => {
324
+ const ip = socket.remoteAddress ?? 'unknown';
325
+ if (counts.rawSockets >= limits.maxRawSockets || (rawIpCounts.get(ip) ?? 0) >= limits.maxRawSocketsPerIp) { addReason('limited'); socket.destroy(); return; }
326
+ rawSockets.add(socket); counts.rawSockets += 1; rawIpCounts.set(ip, (rawIpCounts.get(ip) ?? 0) + 1); socket._relayRawIp = ip;
327
+ socket._relayTcpTimer = clock.setTimeout(() => socket.destroy(), limits.handshakeMs);
328
+ socket.on('close', () => { if (socket._relayTcpTimer) clock.clearTimeout(socket._relayTcpTimer); if (rawSockets.delete(socket)) { counts.rawSockets -= 1; decrement(rawIpCounts, ip); } });
329
+ });
330
+ localServer.on('upgrade', (request, socket, head) => {
331
+ if (socket._relayTcpTimer) { clock.clearTimeout(socket._relayTcpTimer); socket._relayTcpTimer = null; }
332
+ const parsed = parse(request); const ip = resolveClientIp(request);
333
+ if (!parsed) { addReason('policyRejected'); return reject(request, socket, head, CLOSE.malformed); }
334
+ if (!admitIp(ip, parsed.role)) return reject(request, socket, head, CLOSE.limit);
335
+ if (!authenticate(parsed)) { addReason('authRejected'); return reject(request, socket, head, CLOSE.auth); }
336
+ if (counts.sockets >= limits.maxSockets) { addReason('limited'); return reject(request, socket, head, CLOSE.limit); }
337
+ localWss.handleUpgrade(request, socket, head, (ws) => { accepted.add(ws); counts.sockets += 1; ws.on('close', () => removeSocket(ws)); ws._relayHandshake = clock.setTimeout(() => { if (!ws._relayRole) safeClose(ws, CLOSE.malformed, 'handshake timeout'); }, limits.handshakeMs); options.onSocketAccepted?.({ socket: ws, role: parsed.role }); attach(ws, request, parsed); if (ws._relayHandshake) { clock.clearTimeout(ws._relayHandshake); ws._relayHandshake = null; } });
338
+ });
339
+ heartbeat = clock.setInterval(() => { purge(); for (const socket of accepted) { if (socket.readyState !== socket.OPEN) continue; if (socket._relayPaused) socket._relayAlive = true; if (!socket._relayAlive) { addReason('heartbeatReaped'); safeClose(socket, socket._relayRole === 'host-control' ? CLOSE.stuck : CLOSE.away, 'heartbeat'); continue; } socket._relayAlive = false; try { socket.ping(); } catch { safeClose(socket, CLOSE.away, 'heartbeat'); } } }, limits.heartbeatMs);
340
+ startPromise = new Promise((resolve, rejectStart) => {
341
+ const fail = (error) => { if (localGeneration !== generation) return; localServer.off('listening', ready); cleanupStart(); rejectStart(error); };
342
+ const ready = () => { localServer.off('error', fail); if (localGeneration !== generation || state !== 'starting') return; state = 'running'; resolve(); };
343
+ const cleanupStart = () => { if (heartbeat) { clock.clearInterval(heartbeat); heartbeat = null; } localWss.close(); localServer.close(); if (server === localServer) { server = null; wss = null; } state = 'stopped'; };
344
+ abortStart = () => { if (state === 'starting') { cleanupStart(); rejectStart(new Error('relay stopped during start')); } };
345
+ localServer.once('error', fail); localServer.once('listening', ready); localServer.listen(options.port ?? 0, options.host ?? '127.0.0.1');
346
+ }).finally(() => { startPromise = null; abortStart = null; });
347
+ return startPromise;
348
+ };
349
+ const stop = () => {
350
+ if (stopPromise) return stopPromise;
351
+ if (state === 'idle' || state === 'stopped') { state = 'stopped'; return Promise.resolve(); }
352
+ if (state === 'starting') abortStart?.();
353
+ state = 'stopping'; generation += 1; const localServer = server; const localWss = wss;
354
+ stopPromise = new Promise((resolve) => {
355
+ if (heartbeat) { clock.clearInterval(heartbeat); heartbeat = null; }
356
+ for (const host of hosts.values()) { if (host.graceTimer) clock.clearTimeout(host.graceTimer); if (host.control) detachControl(host, host.control, { code: CLOSE.away, reason: 'server stopping', grace: false }); for (const entry of [...host.clients.values()]) removeClient(host, entry, CLOSE.away, 'server stopping', false); }
357
+ hosts.clear(); replay.clear(); admissions.clear(); clientIpCounts.clear(); counts.hosts = 0; counts.controls = 0; counts.clients = 0; counts.pairs = 0; counts.pending = 0; counts.queuedBytes = 0;
358
+ for (const socket of accepted) { if (socket._relayCloseDeadline) clock.clearTimeout(socket._relayCloseDeadline); socket.terminate(); } for (const socket of rawSockets) socket.destroy(); accepted.clear(); rawSockets.clear(); rawIpCounts.clear(); counts.sockets = 0; counts.rawSockets = 0;
359
+ if (!localServer) return resolve(); localWss?.close(); localServer.close(() => resolve()); clock.setTimeout(resolve, 100);
360
+ }).then(() => { if (server === localServer && wss === localWss) { server = null; wss = null; state = 'stopped'; } stopPromise = null; });
361
+ return stopPromise;
362
+ };
363
+ return { start, stop, address: () => server?.address(), get wsUrl() { const address = server?.address(); return address && typeof address === 'object' ? formatRelayWsUrl(options.host ?? '127.0.0.1', address.port, relayPath) : null; }, getSnapshot: snapshot };
364
+ };
365
+
366
+ export const startPrivateRelayServer = async (options) => { const relay = createPrivateRelayServer(options); await relay.start(); return relay; };