@openchambery/relay-server 1.19.17 → 1.19.19-beta.1

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.
package/DOCUMENTATION.md CHANGED
@@ -8,7 +8,7 @@ The Relay server brokers Layer 1 routing: Host control connections, client route
8
8
 
9
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
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.
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. Host-control upgrades are separately bounded per IP and per `serverId` so a Host reconnect loop cannot monopolize the listener; a limited Host-control upgrade is `4029` and does not replace a live control socket. Relay does not inspect Host or Client application versions — mixed app versions are a Host/Client concern, and unsupported Layer 1 fields are ignored rather than version-gated. 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
12
 
13
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
14
 
@@ -259,9 +259,11 @@ For an IPv6 literal in a public URL, enclose the host in brackets: `wss://[2001:
259
259
  | `OPENCHAMBER_RELAY_SERVER_HEARTBEAT_MS` | `30000` | ms WebSocket ping interval |
260
260
  | `OPENCHAMBER_RELAY_SERVER_HANDSHAKE_MS` | `10000` | ms for TCP and WebSocket admission |
261
261
  | `OPENCHAMBER_RELAY_SERVER_CLOSE_DEADLINE_MS` | `5000` | ms before forced socket close |
262
- | `OPENCHAMBER_RELAY_SERVER_ADMISSION_WINDOW_MS` | `60000` | ms per-IP admission window |
263
- | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSIONS_PER_IP` | `120` | upgrades per role and IP per admission window |
264
- | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
262
+ | `OPENCHAMBER_RELAY_SERVER_ADMISSION_WINDOW_MS` | `60000` | ms admission window |
263
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSIONS_PER_IP` | `120` | Client, Host-data, and malformed upgrades per role/IP per window |
264
+ | `OPENCHAMBER_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_IP` | `30` | Host-control upgrades per IP per window |
265
+ | `OPENCHAMBER_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_SERVER` | `12` | Host-control upgrades per `serverId` per window |
266
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked admission records |
265
267
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
266
268
 
267
269
  Host Push URL (OpenChamber Host, not the Push process):
package/README.md CHANGED
@@ -284,9 +284,11 @@ Trusted-proxy mode accepts exactly one valid IP address in `X-Forwarded-For`. Cl
284
284
  | `OPENCHAMBER_RELAY_SERVER_HEARTBEAT_MS` | `30000` | ms WebSocket ping interval |
285
285
  | `OPENCHAMBER_RELAY_SERVER_HANDSHAKE_MS` | `10000` | ms for TCP and WebSocket admission |
286
286
  | `OPENCHAMBER_RELAY_SERVER_CLOSE_DEADLINE_MS` | `5000` | ms before forced socket close |
287
- | `OPENCHAMBER_RELAY_SERVER_ADMISSION_WINDOW_MS` | `60000` | ms per-IP admission window |
288
- | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSIONS_PER_IP` | `120` | upgrades per role and IP per admission window |
289
- | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
287
+ | `OPENCHAMBER_RELAY_SERVER_ADMISSION_WINDOW_MS` | `60000` | ms admission window |
288
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSIONS_PER_IP` | `120` | Client, Host-data, and malformed upgrades per role/IP per window |
289
+ | `OPENCHAMBER_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_IP` | `30` | Host-control upgrades per IP per window |
290
+ | `OPENCHAMBER_RELAY_SERVER_MAX_HOST_CONTROL_ADMISSIONS_PER_SERVER` | `12` | Host-control upgrades per `serverId` per window |
291
+ | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked admission records |
290
292
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
291
293
 
292
294
  ### Host Push URL override
@@ -436,6 +438,7 @@ These assets define an optional follow-on deployment path. Validate the image, p
436
438
  | 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. |
437
439
  | `/healthz` succeeds and `/readyz` fails | Wait for the listener startup to complete, then inspect process stderr and service logs for bind errors. |
438
440
  | Clients receive admission or connection limits | Review `MAX_CONNECTIONS`, per-Host, per-Client-IP, pending, admission, and raw-socket limits against current traffic. |
441
+ | One Host reconnects host-control thousands of times per hour | Relay does not version-gate Hosts. A live control is kept when a later host-control admission is limited (`4029`). Review `MAX_HOST_CONTROL_ADMISSIONS_PER_SERVER` / `_PER_IP` and the Host's control backoff. |
439
442
  | Many Clients share a reverse proxy | Increase `MAX_RAW_SOCKETS_PER_IP` for aggregate proxy-peer concurrency. |
440
443
  | 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. |
441
444
  | Existing clients continue using an earlier endpoint | Refresh the candidate or create a new pairing link after changing `OPENCHAMBER_RELAY_URL`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/relay-server",
3
- "version": "1.19.17",
3
+ "version": "1.19.19-beta.1",
4
4
  "description": "Self-hosted private relay server for OpenChamber",
5
5
  "private": false,
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { startPrivateRelayServer } from './index.js';
2
2
  import { isIP } from 'node:net';
3
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'];
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', 'maxHostControlAdmissionsPerIp', 'maxHostControlAdmissionsPerServer', 'maxAdmissionEntries', 'idAttempts'];
5
5
  const upperSnake = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
6
6
  const envName = (key) => `OPENCHAMBER_RELAY_SERVER_${upperSnake(key)}`;
7
7
  const fail = (name) => { throw new Error(`Invalid ${name}`); };
package/src/index.d.ts CHANGED
@@ -31,6 +31,8 @@ export interface Limits {
31
31
  closeDeadlineMs: number;
32
32
  admissionWindowMs: number;
33
33
  maxAdmissionsPerIp: number;
34
+ maxHostControlAdmissionsPerIp: number;
35
+ maxHostControlAdmissionsPerServer: number;
34
36
  maxAdmissionEntries: number;
35
37
  idAttempts: number;
36
38
  }
package/src/index.js CHANGED
@@ -15,7 +15,8 @@ const DEFAULT_LIMITS = {
15
15
  maxGlobalQueuedBytes: 32 * 1024 * 1024, maxBufferedAmount: 2 * 1024 * 1024,
16
16
  maxControlQueueEntries: 256, maxControlQueuedBytes: 2 * 1024 * 1024,
17
17
  pumpRetryMs: 25, heartbeatMs: 30_000, handshakeMs: 10_000, closeDeadlineMs: 5_000,
18
- admissionWindowMs: 60_000, maxAdmissionsPerIp: 120, maxAdmissionEntries: 10_000,
18
+ admissionWindowMs: 60_000, maxAdmissionsPerIp: 120,
19
+ maxHostControlAdmissionsPerIp: 30, maxHostControlAdmissionsPerServer: 12, maxAdmissionEntries: 10_000,
19
20
  idAttempts: 4,
20
21
  };
21
22
  const fields = {
@@ -87,16 +88,23 @@ export const createPrivateRelayServer = (options = {}) => {
87
88
  for (const [key, expiry] of replay) if (expiry <= now) replay.delete(key);
88
89
  for (const [key, record] of admissions) if (record.until <= now) admissions.delete(key);
89
90
  };
90
- const admitIp = (ip, role) => {
91
- purge(); const key = `${role}:${ip}`; let record = admissions.get(key);
91
+ const admit = (key, cap) => {
92
+ purge(); let record = admissions.get(key);
92
93
  if (!record) {
93
94
  if (admissions.size >= limits.maxAdmissionEntries) { addReason('limited'); return false; }
94
95
  record = { count: 0, until: clock.now() + limits.admissionWindowMs }; admissions.set(key, record);
95
96
  }
96
97
  record.count += 1;
97
- if (record.count > limits.maxAdmissionsPerIp) { addReason('limited'); return false; }
98
+ if (record.count > cap) { addReason('limited'); return false; }
98
99
  return true;
99
100
  };
101
+ const admitUpgrade = (ip, parsed) => {
102
+ if (parsed.role === 'host-control') {
103
+ return admit(`host-control:${ip}`, limits.maxHostControlAdmissionsPerIp)
104
+ && admit(`host-control-server:${parsed.serverId}`, limits.maxHostControlAdmissionsPerServer);
105
+ }
106
+ return admit(`${parsed.role}:${ip}`, limits.maxAdmissionsPerIp);
107
+ };
100
108
  const removeSocket = (socket) => {
101
109
  if (!accepted.delete(socket)) return;
102
110
  counts.sockets -= 1;
@@ -330,8 +338,11 @@ export const createPrivateRelayServer = (options = {}) => {
330
338
  localServer.on('upgrade', (request, socket, head) => {
331
339
  if (socket._relayTcpTimer) { clock.clearTimeout(socket._relayTcpTimer); socket._relayTcpTimer = null; }
332
340
  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);
341
+ if (!parsed) {
342
+ if (!admit(`malformed:${ip}`, limits.maxAdmissionsPerIp)) return reject(request, socket, head, CLOSE.limit);
343
+ addReason('policyRejected'); return reject(request, socket, head, CLOSE.malformed);
344
+ }
345
+ if (!admitUpgrade(ip, parsed)) return reject(request, socket, head, CLOSE.limit);
335
346
  if (!authenticate(parsed)) { addReason('authRejected'); return reject(request, socket, head, CLOSE.auth); }
336
347
  if (counts.sockets >= limits.maxSockets) { addReason('limited'); return reject(request, socket, head, CLOSE.limit); }
337
348
  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; } });