herdr-remote-relay 0.2.1 → 0.2.3

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.
@@ -11,13 +11,26 @@ const { loadRelayConfig, defaultStateDir, PACKAGE_ROOT } = require('./relay-conf
11
11
  const { AuthStore } = require('./auth-store');
12
12
  const { RelayMetrics } = require('./metrics');
13
13
  const { unpackStreamFrame, packStreamFrame, sanitizeTerminalPalette } = require('./stream-frame');
14
- const { isWheelOnlyInput } = require('./scroll-input');
15
14
  const { ensureDir } = require('./state');
16
15
 
17
16
  const VERSION = require('../package.json').version;
18
17
  const { PROTOCOL_VERSION } = require('./stream-frame');
19
18
  const MAX_DIMENSION = 500;
20
19
 
20
+ /**
21
+ * How much recent PTY output the relay keeps per shared session.
22
+ *
23
+ * A browser that joins a session already in progress has missed everything
24
+ * printed before it arrived. Replaying the tail of the stream is what makes
25
+ * "every window shows the same thing" true on the *first* frame rather than
26
+ * only after the next repaint.
27
+ */
28
+ const SESSION_REPLAY_BYTES = 512 * 1024;
29
+
30
+ /** Never shrink a shared grid below something a program can still draw in. */
31
+ const MIN_SHARED_COLS = 20;
32
+ const MIN_SHARED_ROWS = 6;
33
+
21
34
  function randomId(prefix) {
22
35
  return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
23
36
  }
@@ -70,11 +83,24 @@ function tokenMatches(candidate, expected) {
70
83
 
71
84
  class RelayServer {
72
85
  constructor(config = loadRelayConfig().config, options = {}) {
73
- this.config = config;
86
+ this.config = {
87
+ ...config,
88
+ relay: {
89
+ ...config.relay,
90
+ maxHosts: config.relay?.maxHosts ?? 1024,
91
+ maxPendingHandshakes: config.relay?.maxPendingHandshakes ?? 1024,
92
+ maxBufferedBytesPerClient: config.relay?.maxBufferedBytesPerClient ?? 4 * 1024 * 1024,
93
+ hostReconnectGraceMs: config.relay?.hostReconnectGraceMs ?? 30 * 1000,
94
+ },
95
+ };
96
+ config = this.config;
74
97
  this.relayMode = config.relay?.mode === 'local' ? 'local' : 'remote';
75
98
  this.hosts = new Map();
76
99
  this.clients = new Map();
77
100
  this.pairAttempts = new Map();
101
+ this.clientHandshakeAttempts = new Map();
102
+ this.hostHandshakeAttempts = new Map();
103
+ this.pendingHandshakes = new Set();
78
104
  this.startedAt = Date.now();
79
105
  this.metrics = options.metrics || new RelayMetrics({ version: VERSION, protocolVersion: PROTOCOL_VERSION });
80
106
  this.stateFile = options.stateFile || config.auth?.stateFile || path.join(defaultStateDir(), 'relay-auth.json');
@@ -89,7 +115,14 @@ class RelayServer {
89
115
  password: this.password,
90
116
  });
91
117
  this.server = http.createServer((req, res) => this.handleHttp(req, res));
92
- this.wss = new WebSocketServer({ noServer: true, clientTracking: false, maxPayload: config.relay.maxPayloadBytes });
118
+ this.wss = new WebSocketServer({
119
+ noServer: true,
120
+ clientTracking: false,
121
+ maxPayload: config.relay.maxPayloadBytes,
122
+ // Terminal data is already compact and latency-sensitive. Compression
123
+ // adds CPU and buffering without helping the usual ANSI payloads.
124
+ perMessageDeflate: false,
125
+ });
93
126
  this.heartbeatTimer = null;
94
127
  this.cleanupTimer = null;
95
128
  this.server.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
@@ -127,6 +160,10 @@ class RelayServer {
127
160
  this.cleanupTimer = null;
128
161
  for (const client of [...this.clients.values()]) this.detachClient(client, { notify: false });
129
162
  for (const host of [...this.hosts.values()]) this.detachHost(host, { notify: false });
163
+ this.pairAttempts.clear();
164
+ this.clientHandshakeAttempts.clear();
165
+ this.hostHandshakeAttempts.clear();
166
+ this.pendingHandshakes.clear();
130
167
  this.metrics.close();
131
168
  await new Promise((resolve) => {
132
169
  if (!this.server.listening) return resolve();
@@ -164,7 +201,21 @@ class RelayServer {
164
201
  socket.destroy();
165
202
  return;
166
203
  }
204
+ if (this.pendingHandshakes.size >= this.config.relay.maxPendingHandshakes) {
205
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
206
+ socket.destroy();
207
+ return;
208
+ }
209
+ // Small ANSI/input frames should not wait behind Nagle's timer. This is
210
+ // safe for both plain HTTP and TLS sockets and also benefits a relay behind
211
+ // a reverse proxy by keeping the relay leg immediately writable.
212
+ try {
213
+ socket.setNoDelay(true);
214
+ socket.setKeepAlive?.(true, this.config.cleanup.heartbeatIntervalMs);
215
+ } catch {}
167
216
  this.wss.handleUpgrade(req, socket, head, (ws) => {
217
+ this.pendingHandshakes.add(ws);
218
+ ws.once('close', () => this.finishHandshake(ws));
168
219
  if (pathname === '/ws/host') this.handleHostConnection(ws, req);
169
220
  else this.handleClientConnection(ws, req);
170
221
  });
@@ -176,7 +227,7 @@ class RelayServer {
176
227
  res.setHeader('X-Content-Type-Options', 'nosniff');
177
228
  res.setHeader('X-Frame-Options', 'DENY');
178
229
  res.setHeader('Referrer-Policy', 'no-referrer');
179
- res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'");
230
+ res.setHeader('Content-Security-Policy', "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'");
180
231
  }
181
232
 
182
233
  sendJsonResponse(res, status, payload) {
@@ -203,6 +254,27 @@ class RelayServer {
203
254
  return token ? this.auth.authenticateDevice(token) : null;
204
255
  }
205
256
 
257
+ /**
258
+ * Resolve the request to one tenant. Supplying both authentication schemes is
259
+ * allowed only when they identify the same host; otherwise a caller could
260
+ * accidentally combine credentials from two workstations and receive the
261
+ * result selected by whichever branch happened to run first.
262
+ */
263
+ authorizedSubject(req) {
264
+ const hostIdHeader = req.headers['x-herdr-host-id'];
265
+ const hostTokenHeader = req.headers['x-herdr-host-token'];
266
+ const hasHostCredentials = hostIdHeader !== undefined || hostTokenHeader !== undefined;
267
+ const hasBearer = req.headers.authorization !== undefined;
268
+ const hostId = this.authorizedHost(req);
269
+ const device = this.authorizedDevice(req);
270
+ if (hasHostCredentials && !hostId) return null;
271
+ if (hasBearer && !device) return null;
272
+ if (hostId && device && hostId !== device.hostId) return null;
273
+ if (hostId) return { kind: 'host', hostId };
274
+ if (device) return { kind: 'device', hostId: device.hostId, deviceId: device.deviceId };
275
+ return null;
276
+ }
277
+
206
278
  /** Authenticate the operator of this relay, not a workstation or device. */
207
279
  authorizedAdmin(req) {
208
280
  return tokenMatches(req.headers['x-relay-admin-token'], this.adminToken);
@@ -226,19 +298,42 @@ class RelayServer {
226
298
  return req.socket.remoteAddress || 'unknown';
227
299
  }
228
300
 
229
- allowPairAttempt(req) {
301
+ allowAttempt(store, req, limit = 20) {
230
302
  const key = this.rateLimitKey(req);
231
303
  const now = Date.now();
232
- const current = this.pairAttempts.get(key);
304
+ const current = store.get(key);
233
305
  if (!current || now - current.startedAt >= 60_000) {
234
- this.pairAttempts.set(key, { startedAt: now, count: 1 });
306
+ // Bound the map even when an attacker rotates source addresses. Expired
307
+ // entries are removed by sweep; the oldest live entry is the least
308
+ // useful one to retain when the cap is reached.
309
+ if (store.size >= 4096) {
310
+ const oldest = store.keys().next().value;
311
+ if (oldest !== undefined) store.delete(oldest);
312
+ }
313
+ store.set(key, { startedAt: now, count: 1 });
235
314
  return true;
236
315
  }
237
- if (current.count >= 20) return false;
316
+ if (current.count >= limit) return false;
238
317
  current.count += 1;
239
318
  return true;
240
319
  }
241
320
 
321
+ allowPairAttempt(req) {
322
+ return this.allowAttempt(this.pairAttempts, req, 20);
323
+ }
324
+
325
+ allowClientHandshake(req) {
326
+ return this.allowAttempt(this.clientHandshakeAttempts, req, 60);
327
+ }
328
+
329
+ allowHostHandshake(req) {
330
+ return this.allowAttempt(this.hostHandshakeAttempts, req, 60);
331
+ }
332
+
333
+ finishHandshake(ws) {
334
+ this.pendingHandshakes.delete(ws);
335
+ }
336
+
242
337
  handleHttp(req, res) {
243
338
  let requestUrl;
244
339
  try {
@@ -255,7 +350,7 @@ class RelayServer {
255
350
  res.writeHead(204, {
256
351
  'Access-Control-Allow-Origin': req.headers.origin || '*',
257
352
  'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Herdr-Host-Id, X-Herdr-Host-Token, X-Relay-Admin-Token',
258
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
353
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
259
354
  Vary: 'Origin',
260
355
  });
261
356
  res.end();
@@ -265,16 +360,21 @@ class RelayServer {
265
360
  this.sendJsonResponse(res, 403, { ok: false, code: 'origin_denied', message: 'origin is not allowed' });
266
361
  return;
267
362
  }
363
+ // Only echo an origin after the exact allowlist/same-host check above. This
364
+ // makes explicitly configured cross-origin WebUI profiles usable without
365
+ // reflecting an attacker-controlled Origin header.
366
+ if (req.headers.origin) {
367
+ res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
368
+ res.setHeader('Vary', 'Origin');
369
+ }
268
370
  if (requestUrl.pathname === '/healthz' && req.method === 'GET') {
269
- const snapshot = this.statusSnapshot({ sample: false });
371
+ // Liveness is intentionally tenant-blind. Host/client counts let an
372
+ // unauthenticated caller learn whether other workstations are present.
270
373
  this.sendJsonResponse(res, 200, {
271
374
  ok: true,
272
375
  version: VERSION,
273
376
  protocol: PROTOCOL_VERSION,
274
- hosts: snapshot.hostCount,
275
- clients: snapshot.clientCount,
276
- uptimeSeconds: snapshot.uptimeSeconds,
277
- load1m: snapshot.cpu.load1m,
377
+ uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
278
378
  });
279
379
  return;
280
380
  }
@@ -295,11 +395,12 @@ class RelayServer {
295
395
  return;
296
396
  }
297
397
  if (requestUrl.pathname === '/api/status' && req.method === 'GET') {
298
- if (!this.authorizedHost(req) && !this.authorizedDevice(req)) {
398
+ const subject = this.authorizedSubject(req);
399
+ if (!subject) {
299
400
  this.sendJsonResponse(res, 401, { ok: false, code: 'auth_required', message: 'an authorized device or host token is required' });
300
401
  return;
301
402
  }
302
- this.sendJsonResponse(res, 200, this.statusSnapshot());
403
+ this.sendJsonResponse(res, 200, this.statusSnapshot({ scopeHostId: subject.hostId }));
303
404
  return;
304
405
  }
305
406
  if (requestUrl.pathname === '/api/admin/status' && req.method === 'GET') {
@@ -345,7 +446,8 @@ class RelayServer {
345
446
  this.sendJsonResponse(res, 401, { ok: false, code: 'host_auth_required', message: 'a valid host id and token are required' });
346
447
  return;
347
448
  }
348
- if (!this.hosts.has(hostId)) {
449
+ const host = this.hosts.get(hostId);
450
+ if (!host || host.reconnecting || !isOpen(host.ws)) {
349
451
  this.sendJsonResponse(res, 409, { ok: false, code: 'host_offline', message: 'no Herdr host is connected' });
350
452
  return;
351
453
  }
@@ -404,6 +506,66 @@ class RelayServer {
404
506
  });
405
507
  }
406
508
 
509
+ createHostRecord(message, ws, pending, clients = new Set()) {
510
+ const capabilities = Array.isArray(message.capabilities) ? message.capabilities : [];
511
+ return {
512
+ id: message.hostId,
513
+ ws,
514
+ hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
515
+ platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
516
+ arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
517
+ connectedAt: new Date(pending.connectedAt).toISOString(),
518
+ connectedAtMs: pending.connectedAt,
519
+ // One shared terminal per workstation. Every browser attached to this
520
+ // host reads and writes the same PTY, so what one of them shows is what
521
+ // all of them show.
522
+ session: null,
523
+ terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
524
+ lastSeenAt: Date.now(),
525
+ clients,
526
+ controllerId: null,
527
+ load: {},
528
+ ptys: [],
529
+ reconnecting: false,
530
+ reconnectTimer: null,
531
+ connectionGeneration: randomId('host-connection'),
532
+ handoffCapable: capabilities.includes('host_handoff'),
533
+ shutdownRequested: false,
534
+ };
535
+ }
536
+
537
+ canHandoffHost(host) {
538
+ if (!host?.handoffCapable || host.clients.size === 0) return false;
539
+ for (const clientId of host.clients) {
540
+ const client = this.clients.get(clientId);
541
+ if (!client?.handoffCapable) return false;
542
+ }
543
+ return true;
544
+ }
545
+
546
+ beginHostReconnect(host, reason = 'host_disconnected') {
547
+ if (!host || this.hosts.get(host.id) !== host || host.reconnecting) return;
548
+ if (!this.canHandoffHost(host)) {
549
+ this.detachHost(host, { notify: true, reason });
550
+ return;
551
+ }
552
+ host.ws = null;
553
+ host.reconnecting = true;
554
+ host.reconnectStartedAt = Date.now();
555
+ host.lastSeenAt = Date.now();
556
+ host.load = {};
557
+ host.ptys = [];
558
+ host.session = null;
559
+ this.broadcastToClients(host, () => ({ type: 'host_reconnecting', code: reason }));
560
+ host.reconnectTimer = setTimeout(() => {
561
+ host.reconnectTimer = null;
562
+ if (this.hosts.get(host.id) === host && host.reconnecting) {
563
+ this.detachHost(host, { notify: true, reason: 'host_reconnect_timeout' });
564
+ }
565
+ }, this.config.relay.hostReconnectGraceMs);
566
+ host.reconnectTimer.unref?.();
567
+ }
568
+
407
569
  handleHostConnection(ws, req) {
408
570
  const pending = { ws, remoteAddress: req.socket.remoteAddress, connectedAt: Date.now(), authenticated: false };
409
571
  const deadline = setTimeout(() => {
@@ -419,39 +581,65 @@ class RelayServer {
419
581
  if (isBinary) return this.rejectHandshake(ws, 'host hello must be JSON');
420
582
  const message = parseJson(raw.toString());
421
583
  if (!message || message.type !== 'host_hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid host hello');
584
+ if (!this.allowHostHandshake(req)) return this.rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
585
+ const oldHost = this.hosts.get(message.hostId);
586
+ if (!oldHost && this.hosts.size >= this.config.relay.maxHosts) {
587
+ return this.rejectHandshake(ws, 'relay host limit reached', 'too_many_hosts');
588
+ }
422
589
  const registration = this.auth.registerHost(message.hostId, message.token, message.password ?? null);
423
590
  if (!registration.ok) return this.rejectHandshake(ws, registration.message, registration.code);
424
591
  clearTimeout(deadline);
425
592
  pending.authenticated = true;
426
- const oldHost = this.hosts.get(message.hostId);
427
- if (oldHost) this.detachHost(oldHost, { notify: true, reason: 'host_replaced' });
428
- const host = {
429
- id: message.hostId,
430
- ws,
431
- hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
432
- platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
433
- arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
434
- connectedAt: new Date(pending.connectedAt).toISOString(),
435
- connectedAtMs: pending.connectedAt,
436
- // Colors are the workstation's to declare, but only in the one shape
437
- // a browser renderer accepts.
438
- terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
439
- lastSeenAt: Date.now(),
440
- clients: new Set(),
441
- controllerId: null,
442
- load: {},
443
- ptys: [],
444
- };
593
+ this.finishHandshake(ws);
594
+
595
+ const handoff = this.canHandoffHost(oldHost);
596
+ const retainedClients = handoff ? oldHost.clients : new Set();
597
+ if (oldHost) {
598
+ if (oldHost.reconnectTimer) clearTimeout(oldHost.reconnectTimer);
599
+ oldHost.reconnectTimer = null;
600
+ if (handoff) {
601
+ if (oldHost.session && isOpen(oldHost.ws)) {
602
+ jsonSend(oldHost.ws, { type: 'session_stop', clientId: oldHost.session.streamId, streamId: oldHost.session.streamId });
603
+ }
604
+ oldHost.session = null;
605
+ oldHost.load = {};
606
+ oldHost.ptys = [];
607
+ this.broadcastToClients(oldHost, () => ({ type: 'host_reconnecting', code: 'host_replaced' }));
608
+ } else {
609
+ this.detachHost(oldHost, { notify: true, reason: 'host_replaced' });
610
+ }
611
+ }
612
+
613
+ const host = this.createHostRecord(message, ws, pending, retainedClients);
445
614
  pending.host = host;
615
+ // Install the new record before closing the old socket. Its delayed
616
+ // close handler then fails the identity check instead of detaching the
617
+ // freshly authenticated host.
446
618
  this.hosts.set(host.id, host);
447
- jsonSend(ws, { type: 'host_ready', protocol: PROTOCOL_VERSION, hostId: host.id });
619
+ if (oldHost && handoff) closeSocket(oldHost.ws, 1000, 'host_replaced');
620
+ jsonSend(ws, {
621
+ type: 'host_ready',
622
+ protocol: PROTOCOL_VERSION,
623
+ hostId: host.id,
624
+ clientCount: host.clients.size,
625
+ });
626
+ this.notifyHostClientCount(host);
627
+ if (handoff) this.startSession(host, { restarted: true });
448
628
  return;
449
629
  }
450
630
  this.handleHostMessage(pending.host, raw, isBinary);
451
631
  });
452
- ws.on('close', () => {
632
+ ws.on('close', (_code, rawReason) => {
453
633
  clearTimeout(deadline);
454
- if (pending.host) this.detachHost(pending.host, { notify: true, reason: 'host_disconnected' });
634
+ const host = pending.host;
635
+ if (!host || this.hosts.get(host.id) !== host || host.ws !== ws) return;
636
+ const reason = rawReason ? rawReason.toString() : '';
637
+ if (reason === 'host_shutdown') host.shutdownRequested = true;
638
+ if (host.shutdownRequested || !this.canHandoffHost(host)) {
639
+ this.detachHost(host, { notify: true, reason: host.shutdownRequested ? 'host_shutdown' : 'host_disconnected' });
640
+ return;
641
+ }
642
+ this.beginHostReconnect(host, 'host_disconnected');
455
643
  });
456
644
  ws.on('error', () => {});
457
645
  }
@@ -472,12 +660,15 @@ class RelayServer {
472
660
  closeSocket(host.ws, 1003, error.message);
473
661
  return;
474
662
  }
475
- const client = this.clients.get(frame.streamId);
476
- if (frame.type !== 'output' || !client || client.hostId !== host.id) return;
477
- if (isOpen(client.ws)) {
478
- client.ws.send(frame.payload);
479
- client.bytesSent += frame.payload.length;
480
- this.metrics.recordOut(frame.payload.length);
663
+ // Output belongs to the workstation's one shared session, so it goes to
664
+ // every browser attached to it rather than to a single stream owner.
665
+ const session = host.session;
666
+ if (frame.type !== 'output' || !session || session.streamId !== frame.streamId) return;
667
+ this.rememberOutput(session, frame.payload);
668
+ for (const clientId of [...host.clients]) {
669
+ const client = this.clients.get(clientId);
670
+ if (!client || !isOpen(client.ws)) continue;
671
+ this.sendClientBinary(host, client, frame.payload);
481
672
  }
482
673
  return;
483
674
  }
@@ -488,18 +679,219 @@ class RelayServer {
488
679
  host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
489
680
  return;
490
681
  }
491
- const client = typeof message.clientId === 'string' ? this.clients.get(message.clientId) : null;
492
- if (!client || client.hostId !== host.id) return;
682
+ if (message.type === 'host_shutdown') {
683
+ host.shutdownRequested = true;
684
+ if (this.hosts.get(host.id) === host) this.detachHost(host, { notify: true, reason: 'host_shutdown' });
685
+ return;
686
+ }
687
+ // Session-level news concerns the whole room: the host talks about the one
688
+ // shared stream, and every attached browser has to hear it.
689
+ const session = host.session;
690
+ const streamId = typeof message.clientId === 'string' ? message.clientId : message.streamId;
691
+ if (!session || (streamId && streamId !== session.streamId)) return;
493
692
  if (message.type === 'session_ready') {
494
- jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
693
+ session.ready = true;
694
+ this.broadcastToClients(host, (client) => ({ type: 'session_ready', clientId: client.id }));
495
695
  } else if (message.type === 'session_exit') {
496
- jsonSend(client.ws, { type: 'exit', code: Number.isInteger(message.code) ? message.code : null });
497
- this.detachClient(client, { notify: false });
696
+ const code = Number.isInteger(message.code) ? message.code : null;
697
+ host.session = null;
698
+ this.broadcastToClients(host, () => ({ type: 'exit', code }));
699
+ for (const clientId of [...host.clients]) {
700
+ const client = this.clients.get(clientId);
701
+ if (client) this.detachClient(client, { notify: false });
702
+ }
498
703
  } else if (message.type === 'error') {
499
- jsonSend(client.ws, { type: 'error', code: message.code || 'host_error', message: String(message.message || 'Host connector error') });
704
+ this.broadcastToClients(host, () => ({
705
+ type: 'error',
706
+ code: message.code || 'host_error',
707
+ message: String(message.message || 'Host connector error'),
708
+ }));
709
+ }
710
+ }
711
+
712
+ /** Notify the host whether any browser currently needs business telemetry. */
713
+ notifyHostClientCount(host) {
714
+ if (!host || !isOpen(host.ws)) return;
715
+ jsonSend(host.ws, { type: 'client_count', clientCount: host.clients.size });
716
+ }
717
+
718
+ /**
719
+ * Forward output without allowing one slow browser to grow an unbounded ws
720
+ * queue. Closing only that browser preserves low latency for the other views.
721
+ */
722
+ sendClientBinary(host, client, payload) {
723
+ if (!client || !isOpen(client.ws)) return false;
724
+ const limit = this.config.relay.maxBufferedBytesPerClient;
725
+ const buffered = Number(client.ws.bufferedAmount) || 0;
726
+ if (buffered + payload.length > limit) {
727
+ this.metrics.recordCleanup('slowClientsDropped');
728
+ this.detachClient(client, { notify: true, reason: 'slow_client', closeCode: 1013 });
729
+ return false;
730
+ }
731
+ try {
732
+ client.ws.send(payload);
733
+ client.bytesSent += payload.length;
734
+ this.metrics.recordOut(payload.length, host.id);
735
+ return true;
736
+ } catch {
737
+ this.detachClient(client, { notify: false, reason: 'client_send_failed', closeCode: 1011 });
738
+ return false;
500
739
  }
501
740
  }
502
741
 
742
+ /** Send one JSON message to every browser attached to `host`. */
743
+ broadcastToClients(host, build) {
744
+ for (const clientId of [...host.clients]) {
745
+ const client = this.clients.get(clientId);
746
+ if (!client) continue;
747
+ const payload = build(client);
748
+ if (payload) jsonSend(client.ws, payload);
749
+ }
750
+ }
751
+
752
+ /** Keep the tail of the shared stream so a late joiner can be caught up. */
753
+ rememberOutput(session, payload) {
754
+ session.replay.push(Buffer.from(payload));
755
+ session.replayBytes += payload.length;
756
+ while (session.replayBytes > SESSION_REPLAY_BYTES && session.replay.length > 1) {
757
+ session.replayBytes -= session.replay.shift().length;
758
+ }
759
+ }
760
+
761
+ /**
762
+ * The grid the shared PTY runs at.
763
+ *
764
+ * The smallest attached window wins, exactly as it does in tmux: a column a
765
+ * phone cannot show is a column the program must not paint, or every other
766
+ * window sees wrapped rubbish. Nothing else keeps a shared terminal legible
767
+ * on two different screens at once.
768
+ */
769
+ sharedDimensions(host) {
770
+ let cols = MAX_DIMENSION;
771
+ let rows = MAX_DIMENSION;
772
+ let found = false;
773
+ for (const clientId of host.clients) {
774
+ const client = this.clients.get(clientId);
775
+ if (!client) continue;
776
+ found = true;
777
+ cols = Math.min(cols, client.cols);
778
+ rows = Math.min(rows, client.rows);
779
+ }
780
+ if (!found) return null;
781
+ return {
782
+ cols: Math.max(MIN_SHARED_COLS, cols),
783
+ rows: Math.max(MIN_SHARED_ROWS, rows),
784
+ };
785
+ }
786
+
787
+ /** Start the one shared PTY for a host's currently attached clients. */
788
+ startSession(host, { restarted = false } = {}) {
789
+ const firstClientId = [...host.clients][0];
790
+ const firstClient = firstClientId ? this.clients.get(firstClientId) : null;
791
+ if (!firstClient || !isOpen(host.ws)) return;
792
+ host.session = {
793
+ streamId: randomId('session'),
794
+ cols: firstClient.cols,
795
+ rows: firstClient.rows,
796
+ ready: false,
797
+ replay: [],
798
+ replayBytes: 0,
799
+ };
800
+ const dims = this.sharedDimensions(host) || { cols: firstClient.cols, rows: firstClient.rows };
801
+ host.session.cols = dims.cols;
802
+ host.session.rows = dims.rows;
803
+ jsonSend(host.ws, {
804
+ type: 'session_start',
805
+ clientId: host.session.streamId,
806
+ streamId: host.session.streamId,
807
+ cols: dims.cols,
808
+ rows: dims.rows,
809
+ role: 'controller',
810
+ });
811
+ this.broadcastToClients(host, () => ({ type: 'shared_resize', cols: dims.cols, rows: dims.rows }));
812
+ if (restarted) {
813
+ this.broadcastToClients(host, () => ({
814
+ type: 'session_restarted',
815
+ streamId: host.session.streamId,
816
+ cols: dims.cols,
817
+ rows: dims.rows,
818
+ hostname: host.hostname,
819
+ terminalPalette: host.terminalPalette || null,
820
+ }));
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Attach `client` to the workstation's shared terminal, starting it if this
826
+ * is the first browser through the door.
827
+ *
828
+ * A later arrival does not get its own PTY: it is handed the stream already
829
+ * running, the output that has been printed so far, and — once the geometry
830
+ * has settled — a repaint, so it lands on the same screen everyone else is
831
+ * looking at.
832
+ */
833
+ attachSession(host, client) {
834
+ if (!host.session) {
835
+ this.startSession(host);
836
+ return;
837
+ }
838
+
839
+ const session = host.session;
840
+ if (session.ready) jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
841
+ for (const chunk of session.replay) {
842
+ if (!isOpen(client.ws)) break;
843
+ this.sendClientBinary(host, client, chunk);
844
+ }
845
+ // Geometry may now be smaller than it was; the resize doubles as the
846
+ // repaint that puts the newcomer on the same screen as everyone else.
847
+ this.syncDimensions(host, { force: true });
848
+ }
849
+
850
+ /**
851
+ * Push the shared grid to the workstation.
852
+ *
853
+ * `force` asks for a repaint even when the numbers did not move: a browser
854
+ * that just joined needs the program to draw itself again, and a resize is
855
+ * the only signal a PTY has for "paint everything".
856
+ */
857
+ syncDimensions(host, { force = false } = {}) {
858
+ const session = host.session;
859
+ if (!session) return;
860
+ const dims = this.sharedDimensions(host);
861
+ if (!dims) return;
862
+ const changed = dims.cols !== session.cols || dims.rows !== session.rows;
863
+ session.cols = dims.cols;
864
+ session.rows = dims.rows;
865
+ if (!changed && !force) return;
866
+ // Every window has to be told what the shared grid became, not just the
867
+ // workstation. A browser that keeps rendering at its own width would wrap
868
+ // a stream written for a narrower terminal, which is the one thing a
869
+ // shared session must not do: the same bytes have to look the same in
870
+ // every window.
871
+ this.broadcastToClients(host, () => ({
872
+ type: 'shared_resize',
873
+ cols: dims.cols,
874
+ rows: dims.rows,
875
+ }));
876
+ if (!changed && force) {
877
+ // A no-op resize is ignored by the PTY, so bounce one row and come back.
878
+ jsonSend(host.ws, {
879
+ type: 'resize',
880
+ clientId: session.streamId,
881
+ streamId: session.streamId,
882
+ cols: dims.cols,
883
+ rows: Math.max(MIN_SHARED_ROWS, dims.rows - 1),
884
+ });
885
+ }
886
+ jsonSend(host.ws, {
887
+ type: 'resize',
888
+ clientId: session.streamId,
889
+ streamId: session.streamId,
890
+ cols: dims.cols,
891
+ rows: dims.rows,
892
+ });
893
+ }
894
+
503
895
  handleClientConnection(ws, req) {
504
896
  const pending = { ws, req, authenticated: false };
505
897
  const deadline = setTimeout(() => {
@@ -510,6 +902,7 @@ class RelayServer {
510
902
  if (isBinary) return this.rejectHandshake(ws, 'client hello must be JSON');
511
903
  const message = parseJson(raw.toString());
512
904
  if (!message || message.type !== 'hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid client hello');
905
+ if (!this.allowClientHandshake(req)) return this.rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
513
906
  let device = null;
514
907
  let paired = null;
515
908
  if (message.pairCode) {
@@ -520,22 +913,17 @@ class RelayServer {
520
913
  else if (message.token) device = this.auth.authenticateDevice(message.token);
521
914
  if (!device) return this.rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
522
915
  const host = this.hosts.get(device.hostId);
523
- if (!host) return this.rejectHandshake(ws, 'paired Herdr host is offline', 'host_offline');
524
-
525
- // One live session per browser. A browser that reconnects before the
526
- // relay has noticed the old socket is gone would otherwise end up
527
- // holding two sessions: the stale one keeps the controller lease, so
528
- // the reconnected tab is stuck read-only against its own ghost. Retire
529
- // that session first; the controller lease is released with it and this
530
- // connection can take it again.
531
- //
532
- // Keyed on the browser-supplied client id, not on the device token:
533
- // sharing one token across two browsers is the supported multi-viewer
534
- // case and must keep working.
535
- this.detachSupersededSession(device.deviceId, message.clientId, host);
916
+ if (!host || host.reconnecting || !isOpen(host.ws)) return this.rejectHandshake(ws, 'paired Herdr host is offline', host?.reconnecting ? 'host_reconnecting' : 'host_offline');
536
917
 
918
+ // Two tabs of one browser are two windows onto the same terminal, not
919
+ // rivals. Nothing is retired here: the relay used to close whichever
920
+ // session shared this browser's client id, which made two open tabs
921
+ // evict each other in a loop that never converged — each eviction
922
+ // triggered the other tab's auto-reconnect, which evicted this one
923
+ // back, forever.
537
924
  if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
538
925
  clearTimeout(deadline);
926
+ this.finishHandshake(ws);
539
927
  const clientId = randomId('client');
540
928
  const client = {
541
929
  id: clientId,
@@ -544,9 +932,13 @@ class RelayServer {
544
932
  deviceId: device.deviceId,
545
933
  // Stable per browser profile; used to recognise a reconnect from the
546
934
  // same browser rather than a genuinely separate viewer.
547
- browserClientId: typeof message.clientId === 'string' ? message.clientId : null,
548
- role: host.controllerId ? 'viewer' : 'controller',
549
- controllerId: host.controllerId,
935
+ browserClientId: typeof message.clientId === 'string' ? message.clientId.slice(0, 128) : null,
936
+ handoffCapable: Array.isArray(message.capabilities) && message.capabilities.includes('host_handoff'),
937
+ // Every paired window may type. Pairing is the permission boundary;
938
+ // once a device is through it, holding a second window read-only
939
+ // serves nobody — they are all views of one shared terminal.
940
+ role: 'controller',
941
+ controllerId: null,
550
942
  connectedAt: new Date().toISOString(),
551
943
  connectedAtMs: Date.now(),
552
944
  lastSeenAt: Date.now(),
@@ -558,8 +950,7 @@ class RelayServer {
558
950
  cols: clampDimension(message.cols, 80),
559
951
  rows: clampDimension(message.rows, 24),
560
952
  };
561
- if (client.role === 'controller') host.controllerId = client.id;
562
- client.controllerId = host.controllerId;
953
+ client.controllerId = null;
563
954
  // Persist how this device identifies itself so the operator dashboard
564
955
  // can name it in the revoke list instead of showing a bare device id.
565
956
  this.auth.noteDeviceSeen(device.deviceId, { userAgent: client.userAgent, ip: client.ip });
@@ -567,6 +958,7 @@ class RelayServer {
567
958
  pending.client = client;
568
959
  this.clients.set(client.id, client);
569
960
  host.clients.add(client.id);
961
+ this.notifyHostClientCount(host);
570
962
  ws.isAlive = true;
571
963
  ws.on('pong', () => {
572
964
  ws.isAlive = true;
@@ -580,14 +972,20 @@ class RelayServer {
580
972
  jsonSend(ws, {
581
973
  type: 'ready',
582
974
  role: client.role,
583
- controllerId: host.controllerId,
975
+ // There is no controller to name: every window has full input. The
976
+ // field stays in the message for clients built against protocol 1,
977
+ // which read it to decide whether somebody else held the lease — and
978
+ // `null` is exactly the answer that means "nobody does".
979
+ controllerId: null,
584
980
  hostId: host.id,
981
+ hostname: host.hostname,
585
982
  clientId: client.id,
586
983
  // Delivered with `ready`, before the first PTY byte, so the terminal
587
984
  // is painted in the host's colors from its very first frame.
588
985
  terminalPalette: host.terminalPalette || null,
986
+ clientCount: host.clients.size,
589
987
  });
590
- jsonSend(host.ws, { type: 'session_start', clientId: client.id, streamId: client.id, cols: client.cols, rows: client.rows, role: client.role });
988
+ this.attachSession(host, client);
591
989
  this.broadcastControlState(host);
592
990
  return;
593
991
  }
@@ -605,20 +1003,20 @@ class RelayServer {
605
1003
  const host = this.hosts.get(client.hostId);
606
1004
  if (!host) return this.detachClient(client, { notify: true, reason: 'host_offline' });
607
1005
  if (isBinary) {
608
- // A read-only device may still scroll. Like `resize` below, scrolling is
609
- // not a shared-terminal action: each client drives its own PTY stream, so
610
- // a wheel report moves only that viewer's own screen. Everything else —
611
- // keystrokes, clicks, drags — stays behind the control lease.
612
- if (client.role !== 'controller' && !isWheelOnlyInput(raw)) {
613
- jsonSend(client.ws, { type: 'control_denied', message: 'this device is read-only' });
614
- return;
615
- }
616
1006
  if (raw.length > this.config.relay.maxPayloadBytes) return;
617
- const frame = packStreamFrame('input', client.id, raw);
1007
+ const session = host.session;
1008
+ if (!session) return;
1009
+ // Every window writes into the one shared terminal, so input is stamped
1010
+ // with the session's stream id rather than the sender's.
1011
+ const frame = packStreamFrame('input', session.streamId, raw);
618
1012
  if (isOpen(host.ws)) {
619
- host.ws.send(frame);
620
- client.bytesReceived += raw.length;
621
- this.metrics.recordIn(raw.length);
1013
+ try {
1014
+ host.ws.send(frame);
1015
+ client.bytesReceived += raw.length;
1016
+ this.metrics.recordIn(raw.length, host.id);
1017
+ } catch {
1018
+ this.beginHostReconnect(host, 'host_send_failed');
1019
+ }
622
1020
  }
623
1021
  return;
624
1022
  }
@@ -631,47 +1029,41 @@ class RelayServer {
631
1029
  client.lastPingAt = new Date().toISOString();
632
1030
  jsonSend(client.ws, { type: 'pong' });
633
1031
  } else if (message.type === 'resize') {
634
- // Every client drives its *own* PTY stream — `session_start` is emitted
635
- // per client with `streamId: client.id`, and the host resizes only that
636
- // session — so geometry is not a shared-terminal action and must not
637
- // require the control lease. Gating it here left a viewer's PTY at the
638
- // 80x24 it was opened with: the agent then painted into a grid the
639
- // terminal did not have, leaving blank rows under the content and
640
- // columns clipped off the right edge.
1032
+ // The shared grid is the smallest attached window, so one client's resize
1033
+ // is recomputed across the room rather than applied on its own.
641
1034
  client.cols = clampDimension(message.cols, client.cols);
642
1035
  client.rows = clampDimension(message.rows, client.rows);
643
- jsonSend(host.ws, { type: 'resize', clientId: client.id, cols: client.cols, rows: client.rows });
1036
+ this.syncDimensions(host);
644
1037
  } else if (message.type === 'claim_control') {
645
- this.claimControl(host, client, Boolean(message.force));
1038
+ // Control is no longer a lease. Answering the old request keeps clients
1039
+ // built against the previous protocol working.
1040
+ client.role = 'controller';
1041
+ jsonSend(client.ws, { type: 'control_granted' });
646
1042
  } else if (message.type === 'release_control') {
647
- if (host.controllerId === client.id) {
648
- host.controllerId = null;
649
- this.broadcastControlState(host);
650
- }
1043
+ // Nothing to release: the window keeps its input either way, and saying
1044
+ // so beats a silence an older client would wait on.
1045
+ jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null });
651
1046
  }
652
1047
  }
653
1048
 
654
- claimControl(host, client, force) {
655
- if (host.controllerId === client.id) return jsonSend(client.ws, { type: 'control_granted' });
656
- if (host.controllerId && !force) return jsonSend(client.ws, { type: 'control_denied', message: 'another device currently controls this Herdr' });
657
- const previous = host.controllerId ? this.clients.get(host.controllerId) : null;
658
- if (previous) {
659
- previous.role = 'viewer';
660
- jsonSend(previous.ws, { type: 'control_revoked', controllerId: client.id });
661
- }
662
- host.controllerId = client.id;
663
- client.role = 'controller';
664
- jsonSend(client.ws, { type: 'control_granted' });
665
- this.broadcastControlState(host);
666
- }
667
-
1049
+ /**
1050
+ * Tell every window who is attached.
1051
+ *
1052
+ * There is no controller to announce any more, so this carries the one fact
1053
+ * that changed: how many windows now share this terminal.
1054
+ */
668
1055
  broadcastControlState(host) {
669
1056
  for (const clientId of host.clients) {
670
1057
  const client = this.clients.get(clientId);
671
1058
  if (!client) continue;
672
- client.role = host.controllerId === client.id ? 'controller' : 'viewer';
673
- client.controllerId = host.controllerId;
674
- jsonSend(client.ws, { type: 'control_state', role: client.role, controllerId: host.controllerId || null });
1059
+ client.role = 'controller';
1060
+ client.controllerId = null;
1061
+ jsonSend(client.ws, {
1062
+ type: 'control_state',
1063
+ role: 'controller',
1064
+ controllerId: null,
1065
+ clientCount: host.clients.size,
1066
+ });
675
1067
  }
676
1068
  }
677
1069
 
@@ -693,57 +1085,42 @@ class RelayServer {
693
1085
  return closed;
694
1086
  }
695
1087
 
696
- /**
697
- * Retire the session the same browser already holds on this host, if any.
698
- *
699
- * `browserClientId` is the identifier the browser persists for itself, so two
700
- * tabs of one browser collapse to a single session while two genuinely
701
- * different browsers sharing a device token stay independent viewers. A
702
- * client that sends no id cannot be matched and is left alone.
703
- */
704
- detachSupersededSession(deviceId, browserClientId, host) {
705
- if (!deviceId || typeof browserClientId !== 'string' || !browserClientId) return 0;
706
- let closed = 0;
707
- for (const clientId of [...host.clients]) {
708
- const existing = this.clients.get(clientId);
709
- if (!existing || existing.deviceId !== deviceId) continue;
710
- if (existing.browserClientId !== browserClientId) continue;
711
- this.detachClient(existing, { notify: false, reason: 'superseded_by_new_session' });
712
- closeSocket(existing.ws, 1000, 'replaced by a newer session from the same browser');
713
- closed += 1;
714
- }
715
- return closed;
716
- }
717
-
718
- detachClient(client, { notify = true, reason = 'client_disconnected' } = {}) {
1088
+ detachClient(client, { notify = true, reason = 'client_disconnected', closeCode = 1000 } = {}) {
719
1089
  if (!client || !this.clients.has(client.id)) return;
720
1090
  this.clients.delete(client.id);
721
1091
  const host = this.hosts.get(client.hostId);
722
1092
  if (host) {
723
1093
  host.clients.delete(client.id);
724
- if (isOpen(host.ws)) jsonSend(host.ws, { type: 'session_stop', clientId: client.id });
725
- const wasController = host.controllerId === client.id;
726
- if (wasController) {
727
- host.controllerId = null;
728
- const next = [...host.clients]
729
- .map((id) => this.clients.get(id))
730
- .filter(Boolean)
731
- .sort((a, b) => a.connectedAtMs - b.connectedAtMs)[0];
732
- if (next) {
733
- host.controllerId = next.id;
734
- next.role = 'controller';
735
- jsonSend(next.ws, { type: 'control_granted' });
1094
+ // The shared terminal outlives any one window: it is torn down only when
1095
+ // the last of them has gone, so closing a tab never kills the session the
1096
+ // other tabs are still watching.
1097
+ if (host.clients.size === 0) {
1098
+ if (host.session) {
1099
+ if (isOpen(host.ws)) {
1100
+ jsonSend(host.ws, { type: 'session_stop', clientId: host.session.streamId, streamId: host.session.streamId });
1101
+ }
1102
+ host.session = null;
736
1103
  }
1104
+ host.controllerId = null;
1105
+ } else {
1106
+ this.notifyHostClientCount(host);
1107
+ this.syncDimensions(host);
737
1108
  this.broadcastControlState(host);
738
1109
  }
1110
+ if (host.clients.size === 0) {
1111
+ this.notifyHostClientCount(host);
1112
+ if (host.reconnecting) this.detachHost(host, { notify: false, reason: 'no_clients' });
1113
+ }
739
1114
  }
740
1115
  if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: reason === 'host_offline' ? 'Herdr host is offline' : 'connection closed' });
741
- closeSocket(client.ws, 1000, reason);
1116
+ closeSocket(client.ws, closeCode, reason);
742
1117
  this.metrics.recordCleanup('closedPtysCleaned');
743
1118
  }
744
1119
 
745
1120
  detachHost(host, { notify = true, reason = 'host_disconnected' } = {}) {
746
1121
  if (!host || this.hosts.get(host.id) !== host) return;
1122
+ if (host.reconnectTimer) clearTimeout(host.reconnectTimer);
1123
+ host.reconnectTimer = null;
747
1124
  this.hosts.delete(host.id);
748
1125
  for (const clientId of [...host.clients]) {
749
1126
  const client = this.clients.get(clientId);
@@ -753,14 +1130,17 @@ class RelayServer {
753
1130
  closeSocket(client.ws, 1012, reason);
754
1131
  }
755
1132
  host.clients.clear();
756
- closeSocket(host.ws, 1000, reason);
1133
+ this.metrics.forgetHost(host.id);
1134
+ const hostSocket = host.ws;
1135
+ host.ws = null;
1136
+ closeSocket(hostSocket, 1000, reason);
757
1137
  }
758
1138
 
759
1139
  heartbeat() {
760
1140
  const sockets = [
761
1141
  ...[...this.hosts.values()].map((host) => host.ws),
762
1142
  ...[...this.clients.values()].map((client) => client.ws),
763
- ];
1143
+ ].filter(isOpen);
764
1144
  for (const socket of sockets) {
765
1145
  if (!socket.isAlive) {
766
1146
  this.metrics.recordCleanup('deadConnectionsClosed');
@@ -780,6 +1160,12 @@ class RelayServer {
780
1160
  for (const [key, attempt] of this.pairAttempts.entries()) {
781
1161
  if (now - attempt.startedAt >= 60_000) this.pairAttempts.delete(key);
782
1162
  }
1163
+ for (const [key, attempt] of this.clientHandshakeAttempts.entries()) {
1164
+ if (now - attempt.startedAt >= 60_000) this.clientHandshakeAttempts.delete(key);
1165
+ }
1166
+ for (const [key, attempt] of this.hostHandshakeAttempts.entries()) {
1167
+ if (now - attempt.startedAt >= 60_000) this.hostHandshakeAttempts.delete(key);
1168
+ }
783
1169
  for (const client of [...this.clients.values()]) {
784
1170
  if (now - client.lastSeenAt > staleAfter) {
785
1171
  this.metrics.recordCleanup('staleClientsPurged');
@@ -787,6 +1173,7 @@ class RelayServer {
787
1173
  }
788
1174
  }
789
1175
  for (const host of [...this.hosts.values()]) {
1176
+ if (host.reconnecting) continue;
790
1177
  if (now - host.lastSeenAt > staleAfter) {
791
1178
  this.metrics.recordCleanup('deadConnectionsClosed');
792
1179
  this.detachHost(host, { notify: true, reason: 'stale_host' });
@@ -797,12 +1184,18 @@ class RelayServer {
797
1184
  this.metrics.cleanup.lastCleanupAt = new Date(now).toISOString();
798
1185
  }
799
1186
 
800
- statusSnapshot({ sample = true, includeDevices = false } = {}) {
801
- const clients = [...this.clients.values()].map((client) => ({
1187
+ statusSnapshot({ sample = true, includeDevices = false, scopeHostId = null } = {}) {
1188
+ const scopedClients = scopeHostId
1189
+ ? [...this.clients.values()].filter((client) => client.hostId === scopeHostId)
1190
+ : [...this.clients.values()];
1191
+ const scopedHosts = scopeHostId
1192
+ ? [...this.hosts.values()].filter((host) => host.id === scopeHostId)
1193
+ : [...this.hosts.values()];
1194
+ const clients = scopedClients.map((client) => ({
802
1195
  id: client.id,
803
1196
  role: client.role,
804
- hostId: client.hostId,
805
- deviceId: client.deviceId,
1197
+ ...(scopeHostId ? {} : { hostId: client.hostId }),
1198
+ ...(includeDevices ? { deviceId: client.deviceId } : {}),
806
1199
  userAgent: client.userAgent,
807
1200
  connectedAt: client.connectedAt,
808
1201
  lastPingAt: client.lastPingAt,
@@ -810,23 +1203,31 @@ class RelayServer {
810
1203
  bytesSent: client.bytesSent,
811
1204
  ip: client.ip,
812
1205
  }));
813
- const hosts = [...this.hosts.values()].map((host) => ({
1206
+ const hosts = scopedHosts.map((host) => ({
814
1207
  id: host.id,
815
1208
  hostname: host.hostname,
816
1209
  platform: host.platform,
817
1210
  arch: host.arch,
818
- status: host.clients.size ? 'busy' : 'online',
1211
+ status: host.reconnecting ? 'reconnecting' : host.clients.size ? 'busy' : 'online',
819
1212
  connectedAt: host.connectedAt,
820
1213
  activePtyCount: host.ptys.length,
821
1214
  load: host.load,
822
1215
  }));
823
- const ptys = [...this.hosts.values()].flatMap((host) => host.ptys.map((pty) => ({ ...pty, hostId: host.id })));
1216
+ // The workstation counts one PTY per stream and cannot know how many
1217
+ // windows are watching it; the relay does, and that is the number an
1218
+ // operator needs when the session is shared. Scoped callers only receive
1219
+ // the PTYs belonging to their authenticated host.
1220
+ const ptys = scopedHosts.flatMap((host) => host.ptys.map((pty) => ({
1221
+ ...pty,
1222
+ ...(scopeHostId ? {} : { hostId: host.id }),
1223
+ activeClients: host.clients.size,
1224
+ })));
824
1225
  // The paired-device roster identifies people's hardware, so it is served to
825
1226
  // the relay operator only — never on /api/status, which any paired device
826
1227
  // may read.
827
1228
  const devices = includeDevices ? this.auth.listDevices() : undefined;
828
1229
  return {
829
- ...this.metrics.snapshot({ clients, hosts, ptys, sample }),
1230
+ ...this.metrics.snapshot({ clients, hosts, ptys, sample, scopeHostId }),
830
1231
  ...(devices ? { devices } : {}),
831
1232
  relayMode: this.relayMode,
832
1233
  isRemoteRelay: this.relayMode === 'remote',
@@ -839,6 +1240,10 @@ class RelayServer {
839
1240
  bind: this.config.relay.host,
840
1241
  port: this.address()?.port || this.config.relay.port,
841
1242
  maxClientsPerHost: this.config.relay.maxClientsPerHost,
1243
+ maxHosts: this.config.relay.maxHosts,
1244
+ maxPendingHandshakes: this.config.relay.maxPendingHandshakes,
1245
+ maxBufferedBytesPerClient: this.config.relay.maxBufferedBytesPerClient,
1246
+ hostReconnectGraceMs: this.config.relay.hostReconnectGraceMs,
842
1247
  adminConfigured: Boolean(this.adminToken),
843
1248
  adminStatusPath: '/api/admin/status',
844
1249
  dashboardPath: '/admin',