herdr-remote-relay 0.3.12 → 0.3.13

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.
Files changed (64) hide show
  1. package/bin/herdr-remote-relay.js +20 -20
  2. package/dist/auth-store.d.ts +114 -0
  3. package/dist/auth-store.js +276 -0
  4. package/dist/metrics.d.ts +76 -0
  5. package/dist/metrics.js +249 -0
  6. package/dist/protocol/frames.d.ts +27 -0
  7. package/dist/protocol/frames.js +110 -0
  8. package/dist/protocol/http.d.ts +161 -0
  9. package/dist/protocol/http.js +3 -0
  10. package/dist/protocol/index.d.ts +5 -0
  11. package/dist/protocol/index.js +7 -0
  12. package/dist/protocol/messages.d.ts +321 -0
  13. package/dist/protocol/messages.js +18 -0
  14. package/dist/protocol/paste.d.ts +14 -0
  15. package/dist/protocol/paste.js +50 -0
  16. package/dist/protocol/terminal.d.ts +79 -0
  17. package/dist/protocol/terminal.js +150 -0
  18. package/dist/relay-config.d.ts +74 -0
  19. package/dist/relay-config.js +316 -0
  20. package/dist/relay-server.d.ts +64 -0
  21. package/dist/relay-server.js +194 -0
  22. package/dist/scroll-input.d.ts +17 -0
  23. package/dist/scroll-input.js +124 -0
  24. package/dist/server/client-channel.d.ts +3 -0
  25. package/dist/server/client-channel.js +365 -0
  26. package/dist/server/font-proxy.d.ts +19 -0
  27. package/dist/server/font-proxy.js +155 -0
  28. package/dist/server/host-broadcasts.d.ts +25 -0
  29. package/dist/server/host-broadcasts.js +79 -0
  30. package/dist/server/host-channel.d.ts +3 -0
  31. package/dist/server/host-channel.js +266 -0
  32. package/dist/server/http-api.d.ts +3 -0
  33. package/dist/server/http-api.js +214 -0
  34. package/dist/server/maintenance.d.ts +5 -0
  35. package/dist/server/maintenance.js +64 -0
  36. package/dist/server/requests.d.ts +34 -0
  37. package/dist/server/requests.js +142 -0
  38. package/dist/server/sessions.d.ts +36 -0
  39. package/dist/server/sessions.js +225 -0
  40. package/dist/server/sockets.d.ts +12 -0
  41. package/dist/server/sockets.js +64 -0
  42. package/dist/server/status.d.ts +11 -0
  43. package/dist/server/status.js +95 -0
  44. package/dist/server/transport.d.ts +16 -0
  45. package/dist/server/transport.js +110 -0
  46. package/dist/server/types.d.ts +123 -0
  47. package/dist/server/types.js +3 -0
  48. package/dist/server/version.d.ts +2 -0
  49. package/dist/server/version.js +5 -0
  50. package/dist/state.d.ts +6 -0
  51. package/dist/state.js +47 -0
  52. package/package.json +32 -8
  53. package/web/dist/assets/index-B-5zRmL_.js +61 -0
  54. package/web/dist/assets/{index-CPVUfBt4.css → index-DKt8KLog.css} +1 -1
  55. package/web/dist/assets/{vendor-xterm-Bbu8R_5E.js → vendor-xterm-BhCxil_4.js} +2 -2
  56. package/web/dist/index.html +3 -3
  57. package/src/auth-store.js +0 -256
  58. package/src/metrics.js +0 -241
  59. package/src/relay-config.js +0 -312
  60. package/src/relay-server.js +0 -1799
  61. package/src/scroll-input.js +0 -139
  62. package/src/state.js +0 -47
  63. package/src/stream-frame.js +0 -238
  64. package/web/dist/assets/index-6WucmdNP.js +0 -61
@@ -1,1799 +0,0 @@
1
- 'use strict';
2
-
3
- const crypto = require('node:crypto');
4
- const fs = require('node:fs');
5
- const http = require('node:http');
6
- const os = require('node:os');
7
- const path = require('node:path');
8
- const { URL } = require('node:url');
9
- const { WebSocketServer, WebSocket } = require('ws');
10
- const { loadRelayConfig, defaultStateDir, PACKAGE_ROOT } = require('./relay-config');
11
- const { AuthStore } = require('./auth-store');
12
- const { RelayMetrics, countActiveUsers } = require('./metrics');
13
- const {
14
- unpackStreamFrame,
15
- packStreamFrame,
16
- packStreamFrameV2,
17
- FRAME_TYPE_INPUT,
18
- sanitizeTerminalPalette,
19
- sanitizeTerminalFont,
20
- TERMINAL_FONT_CHUNK_BYTES,
21
- } = require('./stream-frame');
22
- const { ensureDir } = require('./state');
23
-
24
- const VERSION = require('../package.json').version;
25
- const { PROTOCOL_VERSION } = require('./stream-frame');
26
- const MAX_DIMENSION = 500;
27
-
28
- /** Never shrink an individual session grid below something a program can still draw in. */
29
- const MIN_SESSION_COLS = 20;
30
- const MIN_SESSION_ROWS = 6;
31
-
32
- /** A release version as npm writes it; anything else is not forwarded. */
33
- const RELEASE_VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,32})?$/;
34
-
35
- /** A window asks to start its workstation's Herdr at most this often. */
36
- const HERDR_START_REPEAT_MS = 3_000;
37
-
38
- /** A window asks its workstation to re-read the terminal font at most this often. */
39
- const FONT_REFRESH_REPEAT_MS = 2_000;
40
- /**
41
- * Font chunks a window may have outstanding. Fonts are pulled a slice at a
42
- * time so megabytes of font never queue ahead of terminal output; an answer
43
- * that never comes stops counting after the timeout.
44
- */
45
- const MAX_FONT_CHUNKS_IN_FLIGHT = 4;
46
- const FONT_CHUNK_TIMEOUT_MS = 30_000;
47
- /** Base64 of one full chunk, the most a `host_font_chunk` may carry. */
48
- const MAX_FONT_CHUNK_BASE64 = Math.ceil(TERMINAL_FONT_CHUNK_BYTES / 3) * 4;
49
- const SHA256_HEX = /^[0-9a-f]{64}$/;
50
- /** Characters one subset request may ask for (UTF-16 units of `text`). */
51
- const MAX_FONT_SUBSET_TEXT = 32 * 1024;
52
- /** Cut fonts a window may pull by hash; older ones stop being fetchable. */
53
- const MAX_FONT_SUBSETS_PER_CLIENT = 64;
54
- const MAX_FONT_SUBSET_BYTES = 16 * 1024 * 1024;
55
- const REQUEST_ID = /^[A-Za-z0-9_-]{1,32}$/;
56
-
57
- /** Matches the host's own cap; the relay re-applies it rather than trusting it. */
58
- const MAX_AGENT_STATUS_ENTRIES = 16;
59
-
60
- function randomId(prefix) {
61
- return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
62
- }
63
-
64
- /** A bounded string, or null. For fields whose contents a pane decided. */
65
- function text(value, limit) {
66
- return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : null;
67
- }
68
-
69
- function clampDimension(value, fallback) {
70
- const numeric = Number(value);
71
- if (!Number.isInteger(numeric)) return fallback;
72
- return Math.min(MAX_DIMENSION, Math.max(2, numeric));
73
- }
74
-
75
- function isOpen(socket) {
76
- return socket && socket.readyState === WebSocket.OPEN;
77
- }
78
-
79
- function jsonSend(socket, payload) {
80
- if (isOpen(socket)) socket.send(JSON.stringify(payload));
81
- }
82
-
83
- /** One outstanding font chunk request of this window was answered. */
84
- function settleFontChunk(client) {
85
- if (Array.isArray(client.fontChunkRequests) && client.fontChunkRequests.length) client.fontChunkRequests.shift();
86
- }
87
-
88
- /** One outstanding font subset request of this window was answered. */
89
- function settleFontSubset(client) {
90
- if (Array.isArray(client.fontSubsetRequests) && client.fontSubsetRequests.length) client.fontSubsetRequests.shift();
91
- }
92
-
93
- function closeSocket(socket, code = 1000, reason = '') {
94
- if (!socket || socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) return;
95
- try {
96
- socket.close(code, reason.slice(0, 120));
97
- } catch {}
98
- }
99
-
100
- function terminateSocket(socket) {
101
- if (!socket || socket.readyState === WebSocket.CLOSED) return;
102
- try {
103
- if (typeof socket.terminate === 'function') {
104
- socket.terminate();
105
- } else if (typeof socket.destroy === 'function') {
106
- socket.destroy();
107
- }
108
- } catch {}
109
- }
110
-
111
- function parseJson(data) {
112
- if (typeof data !== 'string') return null;
113
- try {
114
- return JSON.parse(data);
115
- } catch {
116
- return null;
117
- }
118
- }
119
-
120
- function bearerToken(req) {
121
- const value = req.headers.authorization;
122
- if (typeof value !== 'string') return null;
123
- const match = /^Bearer\s+(.+)$/i.exec(value.trim());
124
- return match ? match[1] : null;
125
- }
126
-
127
- function tokenMatches(candidate, expected) {
128
- if (typeof candidate !== 'string' || candidate.length === 0 || typeof expected !== 'string' || expected.length === 0) return false;
129
- // Hashing first gives timingSafeEqual fixed-size buffers, without leaking a
130
- // length mismatch through the comparison itself.
131
- const candidateHash = crypto.createHash('sha256').update(candidate, 'utf8').digest();
132
- const expectedHash = crypto.createHash('sha256').update(expected, 'utf8').digest();
133
- return crypto.timingSafeEqual(candidateHash, expectedHash);
134
- }
135
-
136
- function verifyImageMagicBytes(mime, dataBase64) {
137
- if (typeof dataBase64 !== 'string' || dataBase64.length === 0) return false;
138
- let headerBuf;
139
- try {
140
- headerBuf = Buffer.from(dataBase64.slice(0, 32), 'base64');
141
- } catch {
142
- return false;
143
- }
144
- if (headerBuf.length === 0) return false;
145
-
146
- switch (mime) {
147
- case 'image/png':
148
- return (
149
- headerBuf.length >= 8 &&
150
- headerBuf[0] === 0x89 &&
151
- headerBuf[1] === 0x50 &&
152
- headerBuf[2] === 0x4e &&
153
- headerBuf[3] === 0x47
154
- );
155
- case 'image/jpeg':
156
- return (
157
- headerBuf.length >= 3 &&
158
- headerBuf[0] === 0xff &&
159
- headerBuf[1] === 0xd8 &&
160
- headerBuf[2] === 0xff
161
- );
162
- case 'image/webp':
163
- return (
164
- headerBuf.length >= 12 &&
165
- headerBuf[0] === 0x52 &&
166
- headerBuf[1] === 0x49 &&
167
- headerBuf[2] === 0x46 &&
168
- headerBuf[3] === 0x46 &&
169
- headerBuf[8] === 0x57 &&
170
- headerBuf[9] === 0x45 &&
171
- headerBuf[10] === 0x42 &&
172
- headerBuf[11] === 0x50
173
- );
174
- case 'image/gif':
175
- return (
176
- headerBuf.length >= 6 &&
177
- headerBuf[0] === 0x47 &&
178
- headerBuf[1] === 0x49 &&
179
- headerBuf[2] === 0x46 &&
180
- headerBuf[3] === 0x38
181
- );
182
- default:
183
- return false;
184
- }
185
- }
186
-
187
- class RelayServer {
188
- constructor(config = loadRelayConfig().config, options = {}) {
189
- this.config = {
190
- ...config,
191
- relay: {
192
- ...config.relay,
193
- maxHosts: config.relay?.maxHosts ?? 1024,
194
- maxPendingHandshakes: config.relay?.maxPendingHandshakes ?? 1024,
195
- maxBufferedBytesPerClient: config.relay?.maxBufferedBytesPerClient ?? 4 * 1024 * 1024,
196
- hostReconnectGraceMs: config.relay?.hostReconnectGraceMs ?? 30 * 1000,
197
- },
198
- };
199
- config = this.config;
200
- this.relayMode = config.relay?.mode === 'local' ? 'local' : 'remote';
201
- this.hosts = new Map();
202
- this.clients = new Map();
203
- this.streams = new Map();
204
- this.pairAttempts = new Map();
205
- this.clientHandshakeAttempts = new Map();
206
- this.hostHandshakeAttempts = new Map();
207
- this.pendingHandshakes = new Set();
208
- this.startedAt = Date.now();
209
- this.metrics = options.metrics || new RelayMetrics({ version: VERSION, protocolVersion: PROTOCOL_VERSION });
210
- this.stateFile = options.stateFile || config.auth?.stateFile || path.join(defaultStateDir(), 'relay-auth.json');
211
- this.password = options.password ?? config.auth?.password ?? null;
212
- this.adminToken = options.adminToken ?? config.auth?.adminToken ?? null;
213
- this.trustProxy = Boolean(options.trustProxy ?? config.relay.trustProxy);
214
- // Development-only artificial latency switch for local responsiveness profiling.
215
- // When unset or 0, this incurs zero overhead and avoids entering the delayed path.
216
- // RELAY_DEV_LATENCY_MS specifies round-trip delay, so each one-way leg
217
- // (host -> browser and browser -> host) is delayed by half.
218
- const devLatencyRaw = options.devLatencyMs ?? config.relay?.devLatencyMs ?? process.env.RELAY_DEV_LATENCY_MS;
219
- this.devLatencyMs = devLatencyRaw ? Math.max(0, parseInt(devLatencyRaw, 10) || 0) : 0;
220
- this.devDelayMs = this.devLatencyMs > 0 ? Math.round(this.devLatencyMs / 2) : 0;
221
- this.sendQueues = new WeakMap();
222
- this.activeDelayTimers = new Set();
223
- this.auth = options.auth || new AuthStore({
224
- stateFile: this.stateFile,
225
- pairingTtlMs: config.auth.pairingTtlMs,
226
- deviceTtlMs: config.auth.deviceTtlMs,
227
- maxDevices: config.auth.maxDevices,
228
- password: this.password,
229
- });
230
- this.server = http.createServer((req, res) => this.handleHttp(req, res));
231
- this.wss = new WebSocketServer({
232
- noServer: true,
233
- clientTracking: false,
234
- maxPayload: config.relay.maxPayloadBytes,
235
- // Frames below 1024 bytes bypass compression completely, ensuring single
236
- // keystrokes and small echoes incur zero CPU and zero buffering delay.
237
- // Keeping context across messages (NoContextTakeover=false) maximizes
238
- // compression ratios on highly repetitive full-screen ratatui/ANSI redraws;
239
- // level 3 provides low CPU cost and low latency over peak compression.
240
- perMessageDeflate: {
241
- threshold: 1024,
242
- zlibDeflateOptions: { level: 3 },
243
- serverNoContextTakeover: false,
244
- clientNoContextTakeover: false,
245
- concurrencyLimit: 10,
246
- },
247
- });
248
- this.heartbeatTimer = null;
249
- this.cleanupTimer = null;
250
- this.server.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
251
- }
252
-
253
- listen(port = this.config.relay.port, host = this.config.relay.host) {
254
- ensureDir(path.dirname(this.stateFile));
255
- return new Promise((resolve, reject) => {
256
- const onError = (error) => {
257
- this.server.off('listening', onListening);
258
- reject(error);
259
- };
260
- const onListening = () => {
261
- this.server.off('error', onError);
262
- this.heartbeatTimer = setInterval(() => this.heartbeat(), this.config.cleanup.heartbeatIntervalMs);
263
- this.cleanupTimer = setInterval(() => this.sweep(), this.config.cleanup.intervalMs);
264
- resolve(this.address());
265
- };
266
- this.server.once('error', onError);
267
- this.server.once('listening', onListening);
268
- this.server.listen(port, host);
269
- });
270
- }
271
-
272
- address() {
273
- const address = this.server.address();
274
- if (!address) return null;
275
- return typeof address === 'string' ? { path: address } : { host: address.address, port: address.port };
276
- }
277
-
278
- async close() {
279
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
280
- if (this.cleanupTimer) clearInterval(this.cleanupTimer);
281
- this.heartbeatTimer = null;
282
- this.cleanupTimer = null;
283
- if (this.activeDelayTimers) {
284
- for (const timer of this.activeDelayTimers) clearTimeout(timer);
285
- this.activeDelayTimers.clear();
286
- }
287
- for (const client of [...this.clients.values()]) this.detachClient(client, { notify: false });
288
- for (const host of [...this.hosts.values()]) this.detachHost(host, { notify: false });
289
- this.streams.clear();
290
- this.pairAttempts.clear();
291
- this.clientHandshakeAttempts.clear();
292
- this.hostHandshakeAttempts.clear();
293
- this.pendingHandshakes.clear();
294
- this.metrics.close();
295
- await new Promise((resolve) => {
296
- if (!this.server.listening) return resolve();
297
- this.server.close(() => resolve());
298
- });
299
- }
300
-
301
- isAllowedOrigin(origin, req) {
302
- if (!origin) return true;
303
- const allowed = this.config.relay.allowedOrigins || [];
304
- if (allowed.includes(origin)) return true;
305
- try {
306
- return new URL(origin).host === req.headers.host;
307
- } catch {
308
- return false;
309
- }
310
- }
311
-
312
- requestOriginAllowed(req) {
313
- const origin = req.headers.origin;
314
- if (!origin) return true;
315
- return this.isAllowedOrigin(origin, req);
316
- }
317
-
318
- handleUpgrade(req, socket, head) {
319
- const pathname = (() => {
320
- try {
321
- return new URL(req.url, 'http://localhost').pathname;
322
- } catch {
323
- return '';
324
- }
325
- })();
326
- if (!['/ws/host', '/ws/client'].includes(pathname) || !this.isAllowedOrigin(req.headers.origin, req)) {
327
- socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
328
- socket.destroy();
329
- return;
330
- }
331
- if (this.pendingHandshakes.size >= this.config.relay.maxPendingHandshakes) {
332
- socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
333
- socket.destroy();
334
- return;
335
- }
336
- // Small ANSI/input frames should not wait behind Nagle's timer. This is
337
- // safe for both plain HTTP and TLS sockets and also benefits a relay behind
338
- // a reverse proxy by keeping the relay leg immediately writable.
339
- try {
340
- socket.setNoDelay(true);
341
- socket.setKeepAlive?.(true, this.config.cleanup.heartbeatIntervalMs);
342
- } catch {}
343
- this.wss.handleUpgrade(req, socket, head, (ws) => {
344
- this.pendingHandshakes.add(ws);
345
- ws.once('close', () => this.finishHandshake(ws));
346
- if (pathname === '/ws/host') this.handleHostConnection(ws, req);
347
- else this.handleClientConnection(ws, req);
348
- });
349
- }
350
-
351
- setResponseHeaders(res, contentType = 'application/json') {
352
- res.setHeader('Content-Type', contentType);
353
- res.setHeader('Cache-Control', 'no-store');
354
- res.setHeader('X-Content-Type-Options', 'nosniff');
355
- res.setHeader('X-Frame-Options', 'DENY');
356
- res.setHeader('Referrer-Policy', 'no-referrer');
357
- 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'");
358
- }
359
-
360
- sendJsonResponse(res, status, payload) {
361
- this.setResponseHeaders(res);
362
- res.writeHead(status);
363
- res.end(JSON.stringify(payload));
364
- }
365
-
366
- /**
367
- * Identify the workstation making a request by its own host token.
368
- * Ownership of a workstation is exactly what the host token proves, so a
369
- * public relay serving many workstations stays safe: nobody can mint a pairing
370
- * code for a host whose token they do not hold.
371
- */
372
- authorizedHost(req) {
373
- const hostId = req.headers['x-herdr-host-id'];
374
- const token = req.headers['x-herdr-host-token'];
375
- if (typeof hostId !== 'string' || typeof token !== 'string') return null;
376
- return this.auth.authenticateHost(hostId, token) ? hostId : null;
377
- }
378
-
379
- authorizedDevice(req) {
380
- const token = bearerToken(req);
381
- return token ? this.auth.authenticateDevice(token) : null;
382
- }
383
-
384
- /**
385
- * Resolve the request to one tenant. Supplying both authentication schemes is
386
- * allowed only when they identify the same host; otherwise a caller could
387
- * accidentally combine credentials from two workstations and receive the
388
- * result selected by whichever branch happened to run first.
389
- */
390
- authorizedSubject(req) {
391
- const hostIdHeader = req.headers['x-herdr-host-id'];
392
- const hostTokenHeader = req.headers['x-herdr-host-token'];
393
- const hasHostCredentials = hostIdHeader !== undefined || hostTokenHeader !== undefined;
394
- const hasBearer = req.headers.authorization !== undefined;
395
- const hostId = this.authorizedHost(req);
396
- const device = this.authorizedDevice(req);
397
- if (hasHostCredentials && !hostId) return null;
398
- if (hasBearer && !device) return null;
399
- if (hostId && device && hostId !== device.hostId) return null;
400
- if (hostId) return { kind: 'host', hostId };
401
- if (device) return { kind: 'device', hostId: device.hostId, deviceId: device.deviceId };
402
- return null;
403
- }
404
-
405
- /** Authenticate the operator of this relay, not a workstation or device. */
406
- authorizedAdmin(req) {
407
- return tokenMatches(req.headers['x-relay-admin-token'], this.adminToken);
408
- }
409
-
410
- /**
411
- * Rate-limit key for a request. Behind a TLS reverse proxy every connection
412
- * arrives from the proxy itself, so keying on the socket address would put
413
- * every device in the world into one bucket and let a single attacker lock
414
- * everyone out. `trustProxy` switches to the left-most X-Forwarded-For entry,
415
- * which is only trustworthy when a proxy is actually in front of the relay.
416
- */
417
- rateLimitKey(req) {
418
- if (this.trustProxy) {
419
- const forwarded = req.headers['x-forwarded-for'];
420
- if (typeof forwarded === 'string' && forwarded.length > 0) {
421
- const first = forwarded.split(',')[0].trim();
422
- if (first) return first;
423
- }
424
- }
425
- return req.socket.remoteAddress || 'unknown';
426
- }
427
-
428
- allowAttempt(store, req, limit = 20) {
429
- const key = this.rateLimitKey(req);
430
- const now = Date.now();
431
- const current = store.get(key);
432
- if (!current || now - current.startedAt >= 60_000) {
433
- // Bound the map even when an attacker rotates source addresses. Expired
434
- // entries are removed by sweep; the oldest live entry is the least
435
- // useful one to retain when the cap is reached.
436
- if (store.size >= 4096) {
437
- const oldest = store.keys().next().value;
438
- if (oldest !== undefined) store.delete(oldest);
439
- }
440
- store.set(key, { startedAt: now, count: 1 });
441
- return true;
442
- }
443
- if (current.count >= limit) return false;
444
- current.count += 1;
445
- return true;
446
- }
447
-
448
- allowPairAttempt(req) {
449
- return this.allowAttempt(this.pairAttempts, req, 20);
450
- }
451
-
452
- allowClientHandshake(req) {
453
- return this.allowAttempt(this.clientHandshakeAttempts, req, 60);
454
- }
455
-
456
- allowHostHandshake(req) {
457
- return this.allowAttempt(this.hostHandshakeAttempts, req, 60);
458
- }
459
-
460
- finishHandshake(ws) {
461
- this.pendingHandshakes.delete(ws);
462
- }
463
-
464
- handleHttp(req, res) {
465
- let requestUrl;
466
- try {
467
- requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
468
- } catch {
469
- this.sendJsonResponse(res, 400, { ok: false, code: 'bad_request', message: 'invalid URL' });
470
- return;
471
- }
472
- if (req.method === 'OPTIONS') {
473
- if (!this.requestOriginAllowed(req)) {
474
- this.sendJsonResponse(res, 403, { ok: false, code: 'origin_denied', message: 'origin is not allowed' });
475
- return;
476
- }
477
- res.writeHead(204, {
478
- 'Access-Control-Allow-Origin': req.headers.origin || '*',
479
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Herdr-Host-Id, X-Herdr-Host-Token, X-Relay-Admin-Token',
480
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
481
- Vary: 'Origin',
482
- });
483
- res.end();
484
- return;
485
- }
486
- if (!this.requestOriginAllowed(req)) {
487
- this.sendJsonResponse(res, 403, { ok: false, code: 'origin_denied', message: 'origin is not allowed' });
488
- return;
489
- }
490
- // Only echo an origin after the exact allowlist/same-host check above. This
491
- // makes explicitly configured cross-origin WebUI profiles usable without
492
- // reflecting an attacker-controlled Origin header.
493
- if (req.headers.origin) {
494
- res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
495
- res.setHeader('Vary', 'Origin');
496
- }
497
- if (requestUrl.pathname === '/healthz' && req.method === 'GET') {
498
- // Liveness is intentionally tenant-blind. Host/client counts let an
499
- // unauthenticated caller learn whether other workstations are present.
500
- this.sendJsonResponse(res, 200, {
501
- ok: true,
502
- version: VERSION,
503
- protocol: PROTOCOL_VERSION,
504
- uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
505
- });
506
- return;
507
- }
508
- if (requestUrl.pathname === '/api/info' && req.method === 'GET') {
509
- const publicUrl = String(this.config.relay.publicUrl || '').replace(/\/+$/, '');
510
- this.sendJsonResponse(res, 200, {
511
- ok: true,
512
- version: VERSION,
513
- protocol: PROTOCOL_VERSION,
514
- relayMode: this.relayMode,
515
- isRemoteRelay: this.relayMode === 'remote',
516
- adminConfigured: Boolean(this.adminToken),
517
- adminPath: '/admin',
518
- adminStatusPath: '/api/admin/status',
519
- publicUrl,
520
- remoteAdminUrl: this.relayMode === 'remote' ? `${publicUrl}/admin` : null,
521
- });
522
- return;
523
- }
524
- if (requestUrl.pathname === '/api/status' && req.method === 'GET') {
525
- const subject = this.authorizedSubject(req);
526
- if (!subject) {
527
- this.sendJsonResponse(res, 401, { ok: false, code: 'auth_required', message: 'an authorized device or host token is required' });
528
- return;
529
- }
530
- this.sendJsonResponse(res, 200, this.statusSnapshot({ scopeHostId: subject.hostId }));
531
- return;
532
- }
533
- if (requestUrl.pathname === '/api/admin/status' && req.method === 'GET') {
534
- if (!this.adminToken) {
535
- this.sendJsonResponse(res, 503, { ok: false, code: 'admin_not_configured', message: 'configure RELAY_ADMIN_TOKEN to access the relay dashboard' });
536
- return;
537
- }
538
- if (!this.authorizedAdmin(req)) {
539
- this.sendJsonResponse(res, 401, { ok: false, code: 'admin_auth_required', message: 'a valid relay admin token is required' });
540
- return;
541
- }
542
- this.sendJsonResponse(res, 200, this.statusSnapshot({ includeDevices: true }));
543
- return;
544
- }
545
- if (requestUrl.pathname.startsWith('/api/admin/devices/') && req.method === 'DELETE') {
546
- if (!this.adminToken) {
547
- this.sendJsonResponse(res, 503, { ok: false, code: 'admin_not_configured', message: 'configure RELAY_ADMIN_TOKEN to access the relay dashboard' });
548
- return;
549
- }
550
- if (!this.authorizedAdmin(req)) {
551
- this.sendJsonResponse(res, 401, { ok: false, code: 'admin_auth_required', message: 'a valid relay admin token is required' });
552
- return;
553
- }
554
- const deviceId = decodeURIComponent(requestUrl.pathname.slice('/api/admin/devices/'.length));
555
- const revoked = this.auth.revokeDevice(deviceId);
556
- if (!revoked) {
557
- this.sendJsonResponse(res, 404, { ok: false, code: 'device_not_found', message: 'no such paired device' });
558
- return;
559
- }
560
- // Revocation has to take effect now, not at the next reconnect: drop any
561
- // socket the device still holds so the terminal closes immediately.
562
- const disconnected = this.detachDeviceSessions(deviceId);
563
- this.sendJsonResponse(res, 200, { ok: true, deviceId: revoked.deviceId, disconnected });
564
- return;
565
- }
566
- if (requestUrl.pathname === '/api/pair/start' && req.method === 'POST') {
567
- if (!this.allowPairAttempt(req)) {
568
- this.sendJsonResponse(res, 429, { ok: false, code: 'rate_limited', message: 'too many pairing attempts' });
569
- return;
570
- }
571
- const hostId = this.authorizedHost(req);
572
- if (!hostId) {
573
- this.sendJsonResponse(res, 401, { ok: false, code: 'host_auth_required', message: 'a valid host id and token are required' });
574
- return;
575
- }
576
- const host = this.hosts.get(hostId);
577
- if (!host || host.reconnecting || !isOpen(host.ws)) {
578
- this.sendJsonResponse(res, 409, { ok: false, code: 'host_offline', message: 'no Herdr host is connected' });
579
- return;
580
- }
581
- try {
582
- const pairing = this.auth.startPairing(hostId, String(this.config.relay.publicUrl).replace(/\/$/, ''));
583
- this.sendJsonResponse(res, 200, { ok: true, ...pairing, pairUrl: `${pairing.publicUrl}/?pairCode=${encodeURIComponent(pairing.code)}` });
584
- } catch (error) {
585
- this.sendJsonResponse(res, 409, { ok: false, code: error.code || 'pairing_failed', message: error.message });
586
- }
587
- return;
588
- }
589
- if (requestUrl.pathname.startsWith('/api/')) {
590
- this.sendJsonResponse(res, 404, { ok: false, code: 'not_found', message: 'API route not found' });
591
- return;
592
- }
593
- this.serveStatic(requestUrl.pathname, res);
594
- }
595
-
596
- serveStatic(requestPath, res) {
597
- const publicDir = path.join(PACKAGE_ROOT, 'web', 'dist');
598
- const relative = requestPath === '/' || requestPath === '/admin' ? 'index.html' : requestPath.replace(/^\/+/, '');
599
- const candidate = path.resolve(publicDir, relative);
600
- if (!candidate.startsWith(`${publicDir}${path.sep}`) && candidate !== path.join(publicDir, 'index.html')) {
601
- this.sendJsonResponse(res, 403, { ok: false, code: 'forbidden', message: 'path is not allowed' });
602
- return;
603
- }
604
- fs.readFile(candidate, (error, data) => {
605
- if (error && !path.extname(relative)) {
606
- fs.readFile(path.join(publicDir, 'index.html'), (fallbackError, fallbackData) => {
607
- if (fallbackError) {
608
- this.sendJsonResponse(res, 503, { ok: false, code: 'web_not_built', message: 'frontend has not been built' });
609
- return;
610
- }
611
- this.setResponseHeaders(res, 'text/html; charset=utf-8');
612
- res.end(fallbackData);
613
- });
614
- return;
615
- }
616
- if (error) {
617
- this.sendJsonResponse(res, 404, { ok: false, code: 'not_found', message: 'file not found' });
618
- return;
619
- }
620
- const extension = path.extname(candidate).toLowerCase();
621
- const mime = {
622
- '.html': 'text/html; charset=utf-8',
623
- '.js': 'text/javascript; charset=utf-8',
624
- '.css': 'text/css; charset=utf-8',
625
- '.json': 'application/json',
626
- '.svg': 'image/svg+xml',
627
- '.png': 'image/png',
628
- '.ico': 'image/x-icon',
629
- '.woff2': 'font/woff2',
630
- }[extension] || 'application/octet-stream';
631
- this.setResponseHeaders(res, mime);
632
- res.end(data);
633
- });
634
- }
635
-
636
- createHostRecord(message, ws, pending, clients = new Set()) {
637
- const capabilities = Array.isArray(message.capabilities) ? message.capabilities : [];
638
- return {
639
- id: message.hostId,
640
- ws,
641
- hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
642
- platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
643
- arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
644
- connectedAt: new Date(pending.connectedAt).toISOString(),
645
- connectedAtMs: pending.connectedAt,
646
- terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
647
- /** Family, size and fetchable files of the workstation's terminal font. */
648
- terminalFont: sanitizeTerminalFont(message.terminalFont),
649
- /** The latest workstation snapshot is replayed when a browser joins late. */
650
- agentStatus: null,
651
- /** Whether the workstation's herdr-remote is behind; replayed the same way. */
652
- updateStatus: null,
653
- lastSeenAt: Date.now(),
654
- clients,
655
- controllerId: null,
656
- load: {},
657
- ptys: [],
658
- reconnecting: false,
659
- reconnectTimer: null,
660
- connectionGeneration: randomId('host-connection'),
661
- handoffCapable: capabilities.includes('host_handoff'),
662
- binaryFrameV2: capabilities.includes('binary_frame_v2'),
663
- streamIndices: new Map(),
664
- nextStreamIndex: 0,
665
- shutdownRequested: false,
666
- };
667
- }
668
-
669
- allocateStreamIndex(host) {
670
- if (!host) return null;
671
- const totalPossible = 65536;
672
- for (let i = 0; i < totalPossible; i++) {
673
- const candidate = host.nextStreamIndex;
674
- host.nextStreamIndex = (host.nextStreamIndex + 1) & 0xffff;
675
- if (!host.streamIndices.has(candidate)) {
676
- return candidate;
677
- }
678
- }
679
- return null;
680
- }
681
-
682
- canHandoffHost(host) {
683
- if (!host?.handoffCapable || host.clients.size === 0) return false;
684
- for (const clientId of host.clients) {
685
- const client = this.clients.get(clientId);
686
- if (!client?.handoffCapable) return false;
687
- }
688
- return true;
689
- }
690
-
691
- beginHostReconnect(host, reason = 'host_disconnected') {
692
- if (!host || this.hosts.get(host.id) !== host || host.reconnecting) return;
693
- if (!this.canHandoffHost(host)) {
694
- this.detachHost(host, { notify: true, reason });
695
- return;
696
- }
697
- host.ws = null;
698
- host.reconnecting = true;
699
- host.reconnectStartedAt = Date.now();
700
- host.lastSeenAt = Date.now();
701
- host.load = {};
702
- host.ptys = [];
703
- for (const clientId of host.clients) {
704
- const client = this.clients.get(clientId);
705
- if (client?.session) {
706
- if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
707
- host.streamIndices.delete(client.session.streamIndex);
708
- }
709
- this.streams.delete(client.session.streamId);
710
- client.session = null;
711
- }
712
- }
713
- this.broadcastToClients(host, () => ({ type: 'host_reconnecting', code: reason }));
714
- host.reconnectTimer = setTimeout(() => {
715
- host.reconnectTimer = null;
716
- if (this.hosts.get(host.id) === host && host.reconnecting) {
717
- this.detachHost(host, { notify: true, reason: 'host_reconnect_timeout' });
718
- }
719
- }, this.config.relay.hostReconnectGraceMs);
720
- host.reconnectTimer.unref?.();
721
- }
722
-
723
- handleHostConnection(ws, req) {
724
- const pending = { ws, remoteAddress: req.socket.remoteAddress, connectedAt: Date.now(), authenticated: false };
725
- const deadline = setTimeout(() => {
726
- if (!pending.authenticated) closeSocket(ws, 1008, 'host hello timeout');
727
- }, 10000);
728
- ws.isAlive = true;
729
- ws.on('pong', () => {
730
- ws.isAlive = true;
731
- if (pending.host) pending.host.lastSeenAt = Date.now();
732
- });
733
- ws.on('ping', () => {
734
- ws.isAlive = true;
735
- if (pending.host) pending.host.lastSeenAt = Date.now();
736
- });
737
- ws.on('message', (raw, isBinary) => {
738
- if (!pending.authenticated) {
739
- if (isBinary) return this.rejectHandshake(ws, 'host hello must be JSON');
740
- const message = parseJson(raw.toString());
741
- if (!message || message.type !== 'host_hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid host hello');
742
- if (!this.allowHostHandshake(req)) return this.rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
743
- const oldHost = this.hosts.get(message.hostId);
744
- if (!oldHost && this.hosts.size >= this.config.relay.maxHosts) {
745
- return this.rejectHandshake(ws, 'relay host limit reached', 'too_many_hosts');
746
- }
747
- const registration = this.auth.registerHost(message.hostId, message.token, message.password ?? null);
748
- if (!registration.ok) return this.rejectHandshake(ws, registration.message, registration.code);
749
- clearTimeout(deadline);
750
- pending.authenticated = true;
751
- this.finishHandshake(ws);
752
-
753
- const handoff = this.canHandoffHost(oldHost);
754
- const retainedClients = handoff ? oldHost.clients : new Set();
755
- if (oldHost) {
756
- if (oldHost.reconnectTimer) clearTimeout(oldHost.reconnectTimer);
757
- oldHost.reconnectTimer = null;
758
- if (handoff) {
759
- for (const clientId of oldHost.clients) {
760
- const client = this.clients.get(clientId);
761
- if (client?.session) {
762
- if (isOpen(oldHost.ws)) {
763
- jsonSend(oldHost.ws, {
764
- type: 'session_stop',
765
- clientId: client.session.streamId,
766
- streamId: client.session.streamId,
767
- });
768
- }
769
- if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
770
- oldHost.streamIndices.delete(client.session.streamIndex);
771
- }
772
- this.streams.delete(client.session.streamId);
773
- client.session = null;
774
- }
775
- }
776
- oldHost.load = {};
777
- oldHost.ptys = [];
778
- this.broadcastToClients(oldHost, () => ({ type: 'host_reconnecting', code: 'host_replaced' }));
779
- } else {
780
- this.detachHost(oldHost, { notify: true, reason: 'host_replaced' });
781
- }
782
- }
783
-
784
- const host = this.createHostRecord(message, ws, pending, retainedClients);
785
- pending.host = host;
786
- // Install the new record before closing the old socket. Its delayed
787
- // close handler then fails the identity check instead of detaching the
788
- // freshly authenticated host.
789
- this.hosts.set(host.id, host);
790
- if (oldHost && handoff) closeSocket(oldHost.ws, 1000, 'host_replaced');
791
- jsonSend(ws, {
792
- type: 'host_ready',
793
- protocol: PROTOCOL_VERSION,
794
- hostId: host.id,
795
- clientCount: host.clients.size,
796
- });
797
- this.notifyHostClientCount(host);
798
- if (handoff) {
799
- for (const clientId of host.clients) {
800
- const client = this.clients.get(clientId);
801
- if (client) this.startSession(host, client, { restarted: true });
802
- }
803
- }
804
- return;
805
- }
806
- this.handleHostMessage(pending.host, raw, isBinary);
807
- });
808
- ws.on('close', (_code, rawReason) => {
809
- clearTimeout(deadline);
810
- const host = pending.host;
811
- if (!host || this.hosts.get(host.id) !== host || host.ws !== ws) return;
812
- const reason = rawReason ? rawReason.toString() : '';
813
- if (reason === 'host_shutdown') host.shutdownRequested = true;
814
- if (host.shutdownRequested || !this.canHandoffHost(host)) {
815
- this.detachHost(host, { notify: true, reason: host.shutdownRequested ? 'host_shutdown' : 'host_disconnected' });
816
- return;
817
- }
818
- this.beginHostReconnect(host, 'host_disconnected');
819
- });
820
- ws.on('error', () => {});
821
- }
822
-
823
- rejectHandshake(ws, message, code = 'invalid_handshake') {
824
- jsonSend(ws, { type: 'error', code, message });
825
- closeSocket(ws, 1008, message);
826
- }
827
-
828
- handleHostMessage(host, raw, isBinary) {
829
- host.lastSeenAt = Date.now();
830
- if (isBinary) {
831
- let frame;
832
- try {
833
- frame = unpackStreamFrame(raw);
834
- } catch (error) {
835
- this.metrics.recordCleanup('deadConnectionsClosed');
836
- closeSocket(host.ws, 1003, error.message);
837
- return;
838
- }
839
- // Postel's law: be conservative in what you send, liberal in what you accept.
840
- // We strictly send v2 only to hosts that negotiated binary_frame_v2, but we accept
841
- // both v1 and v2 on receipt: a v2-capable host falls back to v1 if stream indices
842
- // were exhausted for a session, and an unnegotiated host sending v2 simply finds
843
- // no routed client instead of having its entire connection severed.
844
- // Output is routed to the single client owning the stream rather than broadcast.
845
- if (frame.type !== 'output') return;
846
- const clientId = frame.version === 2
847
- ? host.streamIndices.get(frame.streamIndex)
848
- : this.streams.get(frame.streamId);
849
- if (!clientId) return;
850
- const client = this.clients.get(clientId);
851
- if (!client || !isOpen(client.ws)) return;
852
- this.sendClientBinary(host, client, frame.payload);
853
- return;
854
- }
855
- const message = parseJson(raw.toString());
856
- if (!message) return;
857
- if (message.type === 'heartbeat') {
858
- host.load = message.load && typeof message.load === 'object' ? message.load : {};
859
- host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
860
- return;
861
- }
862
- if (message.type === 'host_shutdown') {
863
- host.shutdownRequested = true;
864
- if (this.hosts.get(host.id) === host) this.detachHost(host, { notify: true, reason: 'host_shutdown' });
865
- return;
866
- }
867
- // What the workstation's agents are doing belongs to the workstation, not
868
- // to one stream: every window watching it gets the same answer.
869
- if (message.type === 'agent_status') {
870
- this.broadcastAgentStatus(host, message);
871
- return;
872
- }
873
- if (message.type === 'update_status') {
874
- this.broadcastUpdateStatus(host, message);
875
- return;
876
- }
877
- if (message.type === 'terminal_font') {
878
- this.broadcastTerminalFont(host, message);
879
- return;
880
- }
881
- // Session events target the single client that owns this stream.
882
- const streamId = typeof message.clientId === 'string' ? message.clientId : message.streamId;
883
- const clientId = streamId ? this.streams.get(streamId) : null;
884
- const client = clientId ? this.clients.get(clientId) : null;
885
- if (!client) return;
886
-
887
- if (message.type === 'session_ready') {
888
- if (client.session) client.session.ready = true;
889
- jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
890
- } else if (message.type === 'session_exit') {
891
- const code = Number.isInteger(message.code) ? message.code : null;
892
- jsonSend(client.ws, { type: 'exit', code });
893
- if (client.session) {
894
- if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
895
- host.streamIndices.delete(client.session.streamIndex);
896
- }
897
- this.streams.delete(client.session.streamId);
898
- client.session = null;
899
- }
900
- this.detachClient(client, { notify: false });
901
- } else if (message.type === 'host_font_chunk') {
902
- this.forwardFontChunk(host, client, message);
903
- } else if (message.type === 'host_font_subset_ready') {
904
- this.forwardFontSubset(client, message);
905
- } else if (message.type === 'paste_file_ready') {
906
- jsonSend(client.ws, {
907
- type: 'paste_file_ready',
908
- path: message.path,
909
- });
910
- } else if (message.type === 'error') {
911
- if (message.code === 'host_font_unavailable') {
912
- settleFontChunk(client);
913
- settleFontSubset(client);
914
- }
915
- if (message.code === 'host_font_subset_failed') settleFontSubset(client);
916
- jsonSend(client.ws, {
917
- type: 'error',
918
- code: message.code || 'host_error',
919
- message: String(message.message || 'Host connector error'),
920
- });
921
- }
922
- }
923
-
924
- /** Notify the host whether any browser currently needs business telemetry. */
925
- notifyHostClientCount(host) {
926
- if (!host || !isOpen(host.ws)) return;
927
- jsonSend(host.ws, { type: 'client_count', clientCount: host.clients.size });
928
- }
929
-
930
- /**
931
- * Forward output without allowing one slow browser to grow an unbounded ws
932
- * queue. Closing only that browser preserves low latency for the other views.
933
- */
934
- sendClientBinary(host, client, payload) {
935
- if (!client || !isOpen(client.ws)) return false;
936
- const limit = this.config.relay.maxBufferedBytesPerClient;
937
- const buffered = Number(client.ws.bufferedAmount) || 0;
938
- if (buffered + payload.length > limit) {
939
- this.metrics.recordCleanup('slowClientsDropped');
940
- this.detachClient(client, { notify: true, reason: 'slow_client', closeCode: 1013 });
941
- return false;
942
- }
943
- let sent = false;
944
- const doSend = () => {
945
- if (!isOpen(client.ws)) return;
946
- try {
947
- client.ws.send(payload);
948
- client.bytesSent += payload.length;
949
- this.metrics.recordOut(payload.length, host.id);
950
- sent = true;
951
- } catch {
952
- this.detachClient(client, { notify: false, reason: 'client_send_failed', closeCode: 1011 });
953
- }
954
- };
955
- if (this.devLatencyMs > 0) this.enqueueDelayedSend(client.ws, doSend);
956
- else doSend();
957
- return this.devLatencyMs > 0 ? true : sent;
958
- }
959
-
960
- /**
961
- * Queue artificial delay per-socket rather than using naked setTimeout calls.
962
- * Concurrent timers experience event loop jitter that can deliver frames out of order;
963
- * a single FIFO queue per socket guarantees strict in-order delivery of terminal frames.
964
- */
965
- enqueueDelayedSend(socket, task) {
966
- let queue = this.sendQueues.get(socket);
967
- if (!queue) {
968
- queue = { items: [], timer: null };
969
- this.sendQueues.set(socket, queue);
970
- socket.once('close', () => {
971
- if (queue.timer) {
972
- clearTimeout(queue.timer);
973
- this.activeDelayTimers.delete(queue.timer);
974
- queue.timer = null;
975
- }
976
- queue.items = [];
977
- });
978
- }
979
- const sendAt = Date.now() + this.devDelayMs;
980
- queue.items.push({ sendAt, task });
981
- if (!queue.timer) {
982
- const timer = setTimeout(() => this.flushSendQueue(socket, queue), this.devDelayMs);
983
- queue.timer = timer;
984
- this.activeDelayTimers.add(timer);
985
- }
986
- }
987
-
988
- /**
989
- * Drain ready frames in FIFO order up to the current timestamp, then schedule the
990
- * single next timer if items remain. Ensures only one timer runs per socket at a time.
991
- */
992
- flushSendQueue(socket, queue) {
993
- if (queue.timer) {
994
- this.activeDelayTimers.delete(queue.timer);
995
- queue.timer = null;
996
- }
997
- const now = Date.now();
998
- while (queue.items.length > 0 && queue.items[0].sendAt <= now) {
999
- const item = queue.items.shift();
1000
- try {
1001
- item.task();
1002
- } catch {}
1003
- }
1004
- if (queue.items.length > 0 && !queue.timer) {
1005
- const nextDelay = Math.max(0, queue.items[0].sendAt - Date.now());
1006
- const timer = setTimeout(() => this.flushSendQueue(socket, queue), nextDelay);
1007
- queue.timer = timer;
1008
- this.activeDelayTimers.add(timer);
1009
- }
1010
- }
1011
-
1012
- /** Send one JSON message to every browser attached to `host`. */
1013
- broadcastToClients(host, build) {
1014
- for (const clientId of [...host.clients]) {
1015
- const client = this.clients.get(clientId);
1016
- if (!client) continue;
1017
- const payload = build(client);
1018
- if (payload) jsonSend(client.ws, payload);
1019
- }
1020
- }
1021
-
1022
- /** Start a dedicated PTY session for one attached client. */
1023
- startSession(host, client, { restarted = false } = {}) {
1024
- if (!client || !isOpen(host.ws)) return;
1025
- const streamId = randomId('session');
1026
- let streamIndex = null;
1027
- if (host.binaryFrameV2) {
1028
- streamIndex = this.allocateStreamIndex(host);
1029
- if (streamIndex !== null) {
1030
- host.streamIndices.set(streamIndex, client.id);
1031
- }
1032
- }
1033
- client.session = {
1034
- streamId,
1035
- streamIndex,
1036
- cols: client.cols,
1037
- rows: client.rows,
1038
- ready: false,
1039
- };
1040
- this.streams.set(streamId, client.id);
1041
- jsonSend(host.ws, {
1042
- type: 'session_start',
1043
- clientId: streamId,
1044
- streamId,
1045
- cols: Math.max(MIN_SESSION_COLS, client.cols),
1046
- rows: Math.max(MIN_SESSION_ROWS, client.rows),
1047
- role: 'controller',
1048
- ...(streamIndex !== null ? { streamIndex } : {}),
1049
- });
1050
- if (restarted) {
1051
- jsonSend(client.ws, {
1052
- type: 'session_restarted',
1053
- streamId,
1054
- cols: client.cols,
1055
- rows: client.rows,
1056
- hostname: host.hostname,
1057
- terminalPalette: host.terminalPalette || null,
1058
- terminalFont: host.terminalFont || null,
1059
- });
1060
- }
1061
- }
1062
-
1063
- handleClientConnection(ws, req) {
1064
- const pending = { ws, req, authenticated: false };
1065
- const deadline = setTimeout(() => {
1066
- if (!pending.authenticated) closeSocket(ws, 1008, 'client hello timeout');
1067
- }, 10000);
1068
- ws.isAlive = true;
1069
- ws.on('pong', () => {
1070
- ws.isAlive = true;
1071
- if (pending.client) {
1072
- pending.client.lastSeenAt = Date.now();
1073
- pending.client.lastPingAt = new Date().toISOString();
1074
- }
1075
- });
1076
- ws.on('ping', () => {
1077
- ws.isAlive = true;
1078
- if (pending.client) {
1079
- pending.client.lastSeenAt = Date.now();
1080
- pending.client.lastPingAt = new Date().toISOString();
1081
- }
1082
- });
1083
- ws.on('message', (raw, isBinary) => {
1084
- if (!pending.authenticated) {
1085
- if (isBinary) return this.rejectHandshake(ws, 'client hello must be JSON');
1086
- const message = parseJson(raw.toString());
1087
- if (!message || message.type !== 'hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid client hello');
1088
- if (!this.allowClientHandshake(req)) return this.rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
1089
- let device = null;
1090
- let paired = null;
1091
- if (message.pairCode) {
1092
- if (!this.allowPairAttempt(req)) return this.rejectHandshake(ws, 'too many pairing attempts', 'rate_limited');
1093
- paired = this.auth.completePairing(message.pairCode);
1094
- }
1095
- if (paired) device = paired;
1096
- else if (message.token) device = this.auth.authenticateDevice(message.token);
1097
- if (!device) return this.rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
1098
- const host = this.hosts.get(device.hostId);
1099
- if (!host || host.reconnecting || !isOpen(host.ws)) return this.rejectHandshake(ws, 'paired Herdr host is offline', host?.reconnecting ? 'host_reconnecting' : 'host_offline');
1100
-
1101
- // Two tabs of one browser are separate windows with their own PTY sessions,
1102
- // not rivals. Nothing is retired here: each connection gets its own stream
1103
- // without evicting existing clients.
1104
- if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
1105
- clearTimeout(deadline);
1106
- this.finishHandshake(ws);
1107
- const clientId = randomId('client');
1108
- const client = {
1109
- id: clientId,
1110
- ws,
1111
- hostId: host.id,
1112
- deviceId: device.deviceId,
1113
- // Stable per browser profile; used to recognise a reconnect from the
1114
- // same browser rather than a genuinely separate viewer.
1115
- browserClientId: typeof message.clientId === 'string' ? message.clientId.slice(0, 128) : null,
1116
- handoffCapable: Array.isArray(message.capabilities) && message.capabilities.includes('host_handoff'),
1117
- session: null,
1118
- // Every paired window gets its own interactive PTY session. Pairing
1119
- // is the permission boundary; once a device is through it, every
1120
- // window can type into its own terminal.
1121
- role: 'controller',
1122
- controllerId: null,
1123
- connectedAt: new Date().toISOString(),
1124
- connectedAtMs: Date.now(),
1125
- lastSeenAt: Date.now(),
1126
- lastPingAt: null,
1127
- bytesReceived: 0,
1128
- bytesSent: 0,
1129
- ip: this.clientAddress(req),
1130
- userAgent: String(req.headers['user-agent'] || '').slice(0, 256),
1131
- cols: clampDimension(message.cols, 80),
1132
- rows: clampDimension(message.rows, 24),
1133
- };
1134
- client.controllerId = null;
1135
- // Persist how this device identifies itself so the operator dashboard
1136
- // can name it in the revoke list instead of showing a bare device id.
1137
- this.auth.noteDeviceSeen(device.deviceId, { userAgent: client.userAgent, ip: client.ip });
1138
- pending.authenticated = true;
1139
- pending.client = client;
1140
- this.clients.set(client.id, client);
1141
- host.clients.add(client.id);
1142
- this.notifyHostClientCount(host);
1143
- ws.isAlive = true;
1144
- if (paired) jsonSend(ws, { type: 'paired', token: paired.token, deviceId: paired.deviceId, hostId: paired.hostId, expiresAt: paired.expiresAtIso });
1145
- // Expose the relay-assigned connection id so clients can distinguish
1146
- // their own controller lease from another device's lease. The browser
1147
- // supplied clientId identifies a device, not this live WebSocket.
1148
- jsonSend(ws, {
1149
- type: 'ready',
1150
- role: client.role,
1151
- // There is no controller to name: every window has full input. The
1152
- // field stays in the message for clients built against protocol 1,
1153
- // which read it to decide whether somebody else held the lease — and
1154
- // `null` is exactly the answer that means "nobody does".
1155
- controllerId: null,
1156
- hostId: host.id,
1157
- hostname: host.hostname,
1158
- clientId: client.id,
1159
- // Delivered with `ready`, before the first PTY byte, so the terminal
1160
- // is painted in the host's colors from its very first frame.
1161
- terminalPalette: host.terminalPalette || null,
1162
- terminalFont: host.terminalFont || null,
1163
- clientCount: host.clients.size,
1164
- });
1165
- if (host.agentStatus) jsonSend(ws, host.agentStatus);
1166
- if (host.updateStatus) jsonSend(ws, host.updateStatus);
1167
- this.startSession(host, client);
1168
- this.broadcastControlState(host);
1169
- return;
1170
- }
1171
- this.handleClientMessage(pending.client, raw, isBinary);
1172
- });
1173
- ws.on('close', () => {
1174
- clearTimeout(deadline);
1175
- if (pending.client) this.detachClient(pending.client, { notify: true });
1176
- });
1177
- ws.on('error', () => {});
1178
- }
1179
-
1180
- handleClientMessage(client, raw, isBinary) {
1181
- client.lastSeenAt = Date.now();
1182
- const host = this.hosts.get(client.hostId);
1183
- if (!host) return this.detachClient(client, { notify: true, reason: 'host_offline' });
1184
- if (isBinary) {
1185
- if (raw.length > this.config.relay.maxPayloadBytes) return;
1186
- if (!client.session) return;
1187
- // Each window owns its own PTY session, so input is stamped with the
1188
- // client's dedicated stream id or streamIndex.
1189
- const frame = host.binaryFrameV2 && typeof client.session.streamIndex === 'number'
1190
- ? packStreamFrameV2(FRAME_TYPE_INPUT, client.session.streamIndex, raw)
1191
- : packStreamFrame('input', client.session.streamId, raw);
1192
- if (isOpen(host.ws)) {
1193
- const doSend = () => {
1194
- if (!isOpen(host.ws)) return;
1195
- try {
1196
- host.ws.send(frame);
1197
- client.bytesReceived += raw.length;
1198
- this.metrics.recordIn(raw.length, host.id);
1199
- } catch {
1200
- this.beginHostReconnect(host, 'host_send_failed');
1201
- }
1202
- };
1203
- if (this.devLatencyMs > 0) this.enqueueDelayedSend(host.ws, doSend);
1204
- else doSend();
1205
- }
1206
- return;
1207
- }
1208
- const message = parseJson(raw.toString());
1209
- if (!message) {
1210
- jsonSend(client.ws, { type: 'error', code: 'invalid_json', message: 'message must be JSON' });
1211
- return;
1212
- }
1213
- if (message.type === 'ping') {
1214
- client.lastPingAt = new Date().toISOString();
1215
- jsonSend(client.ws, { type: 'pong' });
1216
- } else if (message.type === 'resize') {
1217
- // Each client owns its own PTY session, so resizing directly forwards
1218
- // the new geometry for this client's stream to the host.
1219
- client.cols = clampDimension(message.cols, client.cols);
1220
- client.rows = clampDimension(message.rows, client.rows);
1221
- if (client.session) {
1222
- client.session.cols = client.cols;
1223
- client.session.rows = client.rows;
1224
- if (isOpen(host.ws)) {
1225
- jsonSend(host.ws, {
1226
- type: 'resize',
1227
- clientId: client.session.streamId,
1228
- streamId: client.session.streamId,
1229
- cols: Math.max(MIN_SESSION_COLS, client.cols),
1230
- rows: Math.max(MIN_SESSION_ROWS, client.rows),
1231
- });
1232
- }
1233
- }
1234
- } else if (message.type === 'herdr_start') {
1235
- // The window's own workstation, and nobody else's: `host` was fixed by
1236
- // the device token at handshake, and nothing in this message can name
1237
- // another one. The host starts Herdr as its own user, at its own socket.
1238
- // Repeats are dropped — one click is enough, and the host shares a
1239
- // start between windows anyway.
1240
- if (!client.session) {
1241
- jsonSend(client.ws, { type: 'error', code: 'no_session', message: 'terminal session is not ready' });
1242
- return;
1243
- }
1244
- const now = Date.now();
1245
- if (client.herdrStartAt && now - client.herdrStartAt < HERDR_START_REPEAT_MS) return;
1246
- client.herdrStartAt = now;
1247
- if (isOpen(host.ws)) {
1248
- jsonSend(host.ws, { type: 'herdr_start', clientId: client.session.streamId, streamId: client.session.streamId });
1249
- }
1250
- } else if (message.type === 'host_font_chunk_request') {
1251
- this.requestFontChunk(host, client, message);
1252
- } else if (message.type === 'host_font_subset_request') {
1253
- this.requestFontSubset(host, client, message);
1254
- } else if (message.type === 'host_font_refresh') {
1255
- // Same routing as herdr_start: only the workstation this window is
1256
- // bound to, and only what it offered. Repeats inside the window are
1257
- // dropped; the answer is broadcast to every window anyway.
1258
- if (!client.session || !isOpen(host.ws)) return;
1259
- const now = Date.now();
1260
- if (client.fontRefreshAt && now - client.fontRefreshAt < FONT_REFRESH_REPEAT_MS) return;
1261
- client.fontRefreshAt = now;
1262
- jsonSend(host.ws, { type: 'host_font_refresh', clientId: client.session.streamId, streamId: client.session.streamId });
1263
- } else if (message.type === 'claim_control') {
1264
- // Control is no longer a lease. Answering the old request keeps clients
1265
- // built against the previous protocol working.
1266
- client.role = 'controller';
1267
- jsonSend(client.ws, { type: 'control_granted' });
1268
- } else if (message.type === 'release_control') {
1269
- // Nothing to release: the window keeps its input either way, and saying
1270
- // so beats a silence an older client would wait on.
1271
- jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null });
1272
- }
1273
- else if (message.type === 'paste_file') {
1274
- if (client.role === 'viewer') {
1275
- jsonSend(client.ws, {
1276
- type: 'error',
1277
- code: 'viewer_mode',
1278
- message: 'Viewer mode cannot paste to terminal',
1279
- });
1280
- return;
1281
- }
1282
- const allowedMimes = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
1283
- if (typeof message.mime !== 'string' || !allowedMimes.has(message.mime)) {
1284
- jsonSend(client.ws, {
1285
- type: 'error',
1286
- code: 'paste_file_unsupported',
1287
- message: 'Unsupported paste image format',
1288
- });
1289
- return;
1290
- }
1291
- if (typeof message.dataBase64 !== 'string') {
1292
- jsonSend(client.ws, {
1293
- type: 'error',
1294
- code: 'paste_file_unsupported',
1295
- message: 'Invalid paste image payload',
1296
- });
1297
- return;
1298
- }
1299
- const MAX_PASTE_BYTES = 3 * 1024 * 1024;
1300
- const rawLength = Buffer.byteLength(message.dataBase64, 'base64');
1301
- if (rawLength === 0) {
1302
- jsonSend(client.ws, {
1303
- type: 'error',
1304
- code: 'paste_file_empty',
1305
- message: 'Pasted image payload is empty',
1306
- });
1307
- return;
1308
- }
1309
- if (rawLength > MAX_PASTE_BYTES) {
1310
- jsonSend(client.ws, {
1311
- type: 'error',
1312
- code: 'paste_file_too_large',
1313
- message: 'Pasted image exceeds 3 MB limit',
1314
- });
1315
- return;
1316
- }
1317
- if (!verifyImageMagicBytes(message.mime, message.dataBase64)) {
1318
- jsonSend(client.ws, {
1319
- type: 'error',
1320
- code: 'paste_file_unsupported',
1321
- message: 'Pasted image content does not match declared MIME type',
1322
- });
1323
- return;
1324
- }
1325
- if (!isOpen(host?.ws)) {
1326
- jsonSend(client.ws, {
1327
- type: 'error',
1328
- code: 'host_offline',
1329
- message: 'Host is offline or disconnected',
1330
- });
1331
- return;
1332
- }
1333
- if (!client.session) {
1334
- jsonSend(client.ws, {
1335
- type: 'error',
1336
- code: 'no_session',
1337
- message: 'Terminal session is not ready',
1338
- });
1339
- return;
1340
- }
1341
- jsonSend(host.ws, {
1342
- type: 'paste_file',
1343
- clientId: client.session.streamId,
1344
- streamId: client.session.streamId,
1345
- mime: message.mime,
1346
- dataBase64: message.dataBase64,
1347
- });
1348
- }
1349
- }
1350
-
1351
- /**
1352
- * Tell every window who is attached.
1353
- *
1354
- * There is no controller to announce any more, so this carries the one fact
1355
- * that changed: how many windows now share this terminal.
1356
- */
1357
- broadcastControlState(host) {
1358
- for (const clientId of host.clients) {
1359
- const client = this.clients.get(clientId);
1360
- if (!client) continue;
1361
- client.role = 'controller';
1362
- client.controllerId = null;
1363
- jsonSend(client.ws, {
1364
- type: 'control_state',
1365
- role: 'controller',
1366
- controllerId: null,
1367
- clientCount: host.clients.size,
1368
- });
1369
- }
1370
- }
1371
-
1372
- /**
1373
- * Pass on what the workstation's agents are doing.
1374
- *
1375
- * Sanitised rather than forwarded: the host is trusted, but this is the one
1376
- * message whose contents come from terminal titles, and it lands in a status
1377
- * bar on every attached window. The counts are numbers and the list is
1378
- * bounded, so a workstation cannot grow a browser's memory by naming things
1379
- * carefully.
1380
- */
1381
- broadcastAgentStatus(host, message) {
1382
- const counts = {};
1383
- if (message.counts && typeof message.counts === 'object') {
1384
- for (const [status, value] of Object.entries(message.counts)) {
1385
- if (typeof status === 'string' && Number.isFinite(value)) {
1386
- counts[status.slice(0, 32)] = Math.max(0, Math.min(9999, Math.trunc(value)));
1387
- }
1388
- }
1389
- }
1390
- const agents = (Array.isArray(message.agents) ? message.agents : [])
1391
- .slice(0, MAX_AGENT_STATUS_ENTRIES)
1392
- .map((agent) => ({
1393
- paneId: text(agent?.paneId, 64),
1394
- workspaceId: text(agent?.workspaceId, 64),
1395
- agent: text(agent?.agent, 32),
1396
- title: text(agent?.title, 64),
1397
- status: text(agent?.status, 16),
1398
- focused: agent?.focused === true,
1399
- }));
1400
- const payload = {
1401
- type: 'agent_status',
1402
- focusedPaneId: text(message.focusedPaneId, 64),
1403
- focusedAgent: text(message.focusedAgent, 32),
1404
- counts,
1405
- total: Number.isFinite(message.total) ? Math.max(0, Math.trunc(message.total)) : agents.length,
1406
- agents,
1407
- };
1408
- host.agentStatus = payload;
1409
- for (const clientId of host.clients) {
1410
- const client = this.clients.get(clientId);
1411
- if (client) jsonSend(client.ws, payload);
1412
- }
1413
- }
1414
-
1415
- /**
1416
- * Whether the workstation runs the newest herdr-remote, for every window on
1417
- * it. Only version strings and two flags pass: this ends up as text in the
1418
- * browser, and the host is not the relay's to trust.
1419
- */
1420
- /** The workstation re-read its terminal font; every window draws with it. */
1421
- broadcastTerminalFont(host, message) {
1422
- host.terminalFont = sanitizeTerminalFont(message.terminalFont);
1423
- const payload = { type: 'terminal_font', terminalFont: host.terminalFont || null };
1424
- for (const clientId of host.clients) {
1425
- const client = this.clients.get(clientId);
1426
- if (client) jsonSend(client.ws, payload);
1427
- }
1428
- }
1429
-
1430
- /**
1431
- * Pass a browser's request for one slice of a font file to its workstation.
1432
- * Only a file the workstation announced can be asked for, by its hash, and
1433
- * only a few slices at a time.
1434
- */
1435
- requestFontChunk(host, client, message) {
1436
- if (!client.session || !isOpen(host.ws)) return;
1437
- const face = host.terminalFont?.faces?.find((candidate) => candidate.sha256 === message.sha256);
1438
- // A whole font file the workstation announced, or a cut it made for this window.
1439
- const bytes = face ? face.bytes : client.fontSubsets?.get(message.sha256) || 0;
1440
- const total = Math.ceil(bytes / TERMINAL_FONT_CHUNK_BYTES);
1441
- if (!bytes || !Number.isInteger(message.index) || message.index < 0 || message.index >= total) {
1442
- jsonSend(client.ws, { type: 'error', code: 'host_font_unavailable', message: 'The workstation did not offer this font file' });
1443
- return;
1444
- }
1445
- const now = Date.now();
1446
- client.fontChunkRequests = (client.fontChunkRequests || []).filter((at) => now - at < FONT_CHUNK_TIMEOUT_MS);
1447
- if (client.fontChunkRequests.length >= MAX_FONT_CHUNKS_IN_FLIGHT) return;
1448
- client.fontChunkRequests.push(now);
1449
- jsonSend(host.ws, {
1450
- type: 'host_font_chunk_request',
1451
- clientId: client.session.streamId,
1452
- streamId: client.session.streamId,
1453
- sha256: message.sha256,
1454
- index: message.index,
1455
- });
1456
- }
1457
-
1458
- /**
1459
- * Ask the workstation to cut characters out of a large font it announced.
1460
- * The text is only characters to look up; a few requests at a time.
1461
- */
1462
- requestFontSubset(host, client, message) {
1463
- if (!client.session || !isOpen(host.ws)) return;
1464
- const known = host.terminalFont?.subsets?.some((source) => source.sha256 === message.sha256);
1465
- const valid = known && typeof message.text === 'string' && message.text.length > 0
1466
- && message.text.length <= MAX_FONT_SUBSET_TEXT
1467
- && typeof message.requestId === 'string' && REQUEST_ID.test(message.requestId);
1468
- if (!valid) {
1469
- jsonSend(client.ws, { type: 'error', code: 'host_font_unavailable', message: 'The workstation did not offer this font' });
1470
- return;
1471
- }
1472
- const now = Date.now();
1473
- client.fontSubsetRequests = (client.fontSubsetRequests || []).filter((at) => now - at < FONT_CHUNK_TIMEOUT_MS);
1474
- if (client.fontSubsetRequests.length >= MAX_FONT_CHUNKS_IN_FLIGHT) return;
1475
- client.fontSubsetRequests.push(now);
1476
- jsonSend(host.ws, {
1477
- type: 'host_font_subset_request',
1478
- clientId: client.session.streamId,
1479
- streamId: client.session.streamId,
1480
- requestId: message.requestId,
1481
- sha256: message.sha256,
1482
- text: message.text,
1483
- });
1484
- }
1485
-
1486
- forwardFontSubset(client, message) {
1487
- settleFontSubset(client);
1488
- const valid = typeof message.sha256 === 'string' && SHA256_HEX.test(message.sha256)
1489
- && typeof message.subsetSha === 'string' && SHA256_HEX.test(message.subsetSha)
1490
- && typeof message.requestId === 'string' && REQUEST_ID.test(message.requestId)
1491
- && Number.isInteger(message.bytes) && message.bytes > 0 && message.bytes <= MAX_FONT_SUBSET_BYTES
1492
- && (message.dataBase64 === undefined
1493
- || (typeof message.dataBase64 === 'string' && message.dataBase64.length <= MAX_FONT_CHUNK_BASE64));
1494
- if (!valid) return;
1495
- if (message.dataBase64 === undefined) {
1496
- // Pulled in slices next: make this hash fetchable for this window only.
1497
- client.fontSubsets ||= new Map();
1498
- client.fontSubsets.delete(message.subsetSha);
1499
- client.fontSubsets.set(message.subsetSha, message.bytes);
1500
- while (client.fontSubsets.size > MAX_FONT_SUBSETS_PER_CLIENT) {
1501
- client.fontSubsets.delete(client.fontSubsets.keys().next().value);
1502
- }
1503
- }
1504
- jsonSend(client.ws, {
1505
- type: 'host_font_subset_ready',
1506
- requestId: message.requestId,
1507
- sha256: message.sha256,
1508
- subsetSha: message.subsetSha,
1509
- bytes: message.bytes,
1510
- ...(message.dataBase64 !== undefined ? { dataBase64: message.dataBase64 } : {}),
1511
- });
1512
- }
1513
-
1514
- forwardFontChunk(host, client, message) {
1515
- settleFontChunk(client);
1516
- const valid = typeof message.sha256 === 'string' && SHA256_HEX.test(message.sha256)
1517
- && Number.isInteger(message.index) && message.index >= 0
1518
- && Number.isInteger(message.total) && message.total > message.index
1519
- && typeof message.dataBase64 === 'string' && message.dataBase64.length <= MAX_FONT_CHUNK_BASE64;
1520
- if (!valid) return;
1521
- jsonSend(client.ws, {
1522
- type: 'host_font_chunk',
1523
- sha256: message.sha256,
1524
- index: message.index,
1525
- total: message.total,
1526
- dataBase64: message.dataBase64,
1527
- });
1528
- }
1529
-
1530
- broadcastUpdateStatus(host, message) {
1531
- const version = (value) => (typeof value === 'string' && RELEASE_VERSION.test(value) ? value : null);
1532
- const current = version(message.current);
1533
- const latest = version(message.latest);
1534
- if (!current || !latest) return;
1535
- const payload = {
1536
- type: 'update_status',
1537
- current,
1538
- installed: version(message.installed) || current,
1539
- latest,
1540
- updateAvailable: message.updateAvailable === true,
1541
- restartPending: message.restartPending === true,
1542
- };
1543
- host.updateStatus = payload;
1544
- for (const clientId of host.clients) {
1545
- const client = this.clients.get(clientId);
1546
- if (client) jsonSend(client.ws, payload);
1547
- }
1548
- }
1549
-
1550
- /**
1551
- * Close every existing session belonging to `deviceId` on `host`.
1552
- *
1553
- * Called just before a freshly authenticated connection is registered, so the
1554
- * same physical device never occupies two client slots (and two PTYs) at once.
1555
- */
1556
- detachDeviceSessions(deviceId) {
1557
- if (!deviceId) return 0;
1558
- let closed = 0;
1559
- for (const existing of [...this.clients.values()]) {
1560
- if (!existing || existing.deviceId !== deviceId) continue;
1561
- this.detachClient(existing, { notify: false, reason: 'device_revoked' });
1562
- closeSocket(existing.ws, 1000, 'this device has been revoked by the relay operator');
1563
- closed += 1;
1564
- }
1565
- return closed;
1566
- }
1567
-
1568
- detachClient(client, { notify = true, reason = 'client_disconnected', closeCode = 1000, terminate = false } = {}) {
1569
- if (!client || !this.clients.has(client.id)) return;
1570
- this.clients.delete(client.id);
1571
- const host = this.hosts.get(client.hostId);
1572
- // Each window has its own PTY session. Tearing down the client immediately
1573
- // stops its backing session on the host and cleans up its stream mapping.
1574
- if (client.session) {
1575
- const streamId = client.session.streamId;
1576
- if (client.session.streamIndex !== null && client.session.streamIndex !== undefined && host) {
1577
- host.streamIndices.delete(client.session.streamIndex);
1578
- }
1579
- this.streams.delete(streamId);
1580
- if (host && isOpen(host.ws)) {
1581
- jsonSend(host.ws, { type: 'session_stop', clientId: streamId, streamId });
1582
- }
1583
- client.session = null;
1584
- }
1585
- if (host) {
1586
- host.clients.delete(client.id);
1587
- this.notifyHostClientCount(host);
1588
- this.broadcastControlState(host);
1589
- if (host.clients.size === 0) {
1590
- host.controllerId = null;
1591
- if (host.reconnecting) this.detachHost(host, { notify: false, reason: 'no_clients' });
1592
- }
1593
- }
1594
- const clientSocket = client.ws;
1595
- client.ws = null;
1596
- if (notify) jsonSend(clientSocket, { type: 'error', code: reason, message: reason === 'host_offline' ? 'Herdr host is offline' : 'connection closed' });
1597
- if (terminate || clientSocket?.readyState === WebSocket.CLOSING) {
1598
- terminateSocket(clientSocket);
1599
- } else {
1600
- closeSocket(clientSocket, closeCode, reason);
1601
- }
1602
- this.metrics.recordCleanup('closedPtysCleaned');
1603
- }
1604
-
1605
- detachHost(host, { notify = true, reason = 'host_disconnected', terminate = false } = {}) {
1606
- if (!host || this.hosts.get(host.id) !== host) return;
1607
- if (host.reconnectTimer) clearTimeout(host.reconnectTimer);
1608
- host.reconnectTimer = null;
1609
- this.hosts.delete(host.id);
1610
- for (const clientId of [...host.clients]) {
1611
- const client = this.clients.get(clientId);
1612
- if (!client) continue;
1613
- this.clients.delete(client.id);
1614
- if (client.session) {
1615
- if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
1616
- host.streamIndices.delete(client.session.streamIndex);
1617
- }
1618
- this.streams.delete(client.session.streamId);
1619
- client.session = null;
1620
- }
1621
- const clientSocket = client.ws;
1622
- client.ws = null;
1623
- if (notify) jsonSend(clientSocket, { type: 'error', code: reason, message: 'Herdr host disconnected' });
1624
- if (terminate || clientSocket?.readyState === WebSocket.CLOSING) {
1625
- terminateSocket(clientSocket);
1626
- } else {
1627
- closeSocket(clientSocket, 1012, reason);
1628
- }
1629
- }
1630
- host.clients.clear();
1631
- this.metrics.forgetHost(host.id);
1632
- const hostSocket = host.ws;
1633
- host.ws = null;
1634
- if (terminate || hostSocket?.readyState === WebSocket.CLOSING) {
1635
- terminateSocket(hostSocket);
1636
- } else {
1637
- closeSocket(hostSocket, 1000, reason);
1638
- }
1639
- }
1640
-
1641
- heartbeat() {
1642
- const sockets = [
1643
- ...[...this.hosts.values()].map((host) => host.ws),
1644
- ...[...this.clients.values()].map((client) => client.ws),
1645
- ].filter((socket) => socket && socket.readyState !== WebSocket.CLOSED);
1646
- for (const socket of sockets) {
1647
- if (socket.readyState === WebSocket.CLOSING) {
1648
- this.metrics.recordCleanup('deadConnectionsClosed');
1649
- terminateSocket(socket);
1650
- continue;
1651
- }
1652
- if (socket.readyState !== WebSocket.OPEN) continue;
1653
- if (!socket.isAlive) {
1654
- this.metrics.recordCleanup('deadConnectionsClosed');
1655
- terminateSocket(socket);
1656
- continue;
1657
- }
1658
- socket.isAlive = false;
1659
- try {
1660
- socket.ping();
1661
- } catch {}
1662
- }
1663
- }
1664
-
1665
- sweep() {
1666
- const now = Date.now();
1667
- const staleAfter = this.config.cleanup.staleAfterMs;
1668
- for (const [key, attempt] of this.pairAttempts.entries()) {
1669
- if (now - attempt.startedAt >= 60_000) this.pairAttempts.delete(key);
1670
- }
1671
- for (const [key, attempt] of this.clientHandshakeAttempts.entries()) {
1672
- if (now - attempt.startedAt >= 60_000) this.clientHandshakeAttempts.delete(key);
1673
- }
1674
- for (const [key, attempt] of this.hostHandshakeAttempts.entries()) {
1675
- if (now - attempt.startedAt >= 60_000) this.hostHandshakeAttempts.delete(key);
1676
- }
1677
- for (const client of [...this.clients.values()]) {
1678
- if (now - client.lastSeenAt > staleAfter) {
1679
- this.metrics.recordCleanup('staleClientsPurged');
1680
- this.detachClient(client, { notify: false, reason: 'stale_client', terminate: true });
1681
- }
1682
- }
1683
- for (const host of [...this.hosts.values()]) {
1684
- if (host.reconnecting) continue;
1685
- if (now - host.lastSeenAt > staleAfter) {
1686
- this.metrics.recordCleanup('deadConnectionsClosed');
1687
- this.detachHost(host, { notify: true, reason: 'stale_host', terminate: true });
1688
- }
1689
- }
1690
- const authCleanup = this.auth.cleanup(now);
1691
- if (authCleanup.removedDevices || authCleanup.removedPairings) this.metrics.recordCleanup('staleClientsPurged', authCleanup.removedDevices);
1692
- this.metrics.cleanup.lastCleanupAt = new Date(now).toISOString();
1693
- }
1694
-
1695
- statusSnapshot({ sample = true, includeDevices = false, scopeHostId = null } = {}) {
1696
- const scopedClients = scopeHostId
1697
- ? [...this.clients.values()].filter((client) => client.hostId === scopeHostId)
1698
- : [...this.clients.values()];
1699
- const scopedHosts = scopeHostId
1700
- ? [...this.hosts.values()].filter((host) => host.id === scopeHostId)
1701
- : [...this.hosts.values()];
1702
- const clients = scopedClients.map((client) => ({
1703
- id: client.id,
1704
- role: client.role,
1705
- ...(scopeHostId ? {} : { hostId: client.hostId }),
1706
- ...(includeDevices ? { deviceId: client.deviceId } : {}),
1707
- userAgent: client.userAgent,
1708
- connectedAt: client.connectedAt,
1709
- lastPingAt: client.lastPingAt,
1710
- bytesReceived: client.bytesReceived,
1711
- bytesSent: client.bytesSent,
1712
- ip: client.ip,
1713
- }));
1714
- // The roster is read once and used twice: the operator response carries it
1715
- // whole, and every response carries the per-host tally derived from it. A
1716
- // public caller learns how many devices a workstation has paired, never
1717
- // which ones.
1718
- const pairedDevices = this.auth.listDevices();
1719
- const pairedPerHost = new Map();
1720
- for (const device of pairedDevices) {
1721
- pairedPerHost.set(device.hostId, (pairedPerHost.get(device.hostId) || 0) + 1);
1722
- }
1723
- const hosts = scopedHosts.map((host) => ({
1724
- id: host.id,
1725
- hostname: host.hostname,
1726
- platform: host.platform,
1727
- arch: host.arch,
1728
- status: host.reconnecting ? 'reconnecting' : host.clients.size ? 'busy' : 'online',
1729
- connectedAt: host.connectedAt,
1730
- activePtyCount: host.ptys.length,
1731
- // Distinct devices attached to this workstation, not open sockets: a
1732
- // phone with two tabs open is one device on the operator's board.
1733
- connectedDeviceCount: countActiveUsers(
1734
- [...host.clients].map((id) => this.clients.get(id)).filter(Boolean)
1735
- ),
1736
- pairedDeviceCount: pairedPerHost.get(host.id) || 0,
1737
- load: host.load,
1738
- }));
1739
- // The workstation counts one PTY per stream and cannot know how many
1740
- // windows are watching it; the relay does, and that is the number an
1741
- // operator needs when the session is shared. Scoped callers only receive
1742
- // the PTYs belonging to their authenticated host.
1743
- const ptys = scopedHosts.flatMap((host) => host.ptys.map((pty) => ({
1744
- ...pty,
1745
- ...(scopeHostId ? {} : { hostId: host.id }),
1746
- activeClients: host.clients.size,
1747
- })));
1748
- // The paired-device roster identifies people's hardware, so it is served to
1749
- // the relay operator only — never on /api/status, which any paired device
1750
- // may read.
1751
- const devices = includeDevices ? pairedDevices : undefined;
1752
- return {
1753
- ...this.metrics.snapshot({
1754
- clients,
1755
- hosts,
1756
- ptys,
1757
- sample,
1758
- scopeHostId,
1759
- // Counted from the unredacted records: the mapped rows above only carry
1760
- // `deviceId` for the operator, and a public caller must still see the
1761
- // same number of users the operator does.
1762
- activeUserCount: countActiveUsers(scopedClients),
1763
- }),
1764
- ...(devices ? { devices } : {}),
1765
- relayMode: this.relayMode,
1766
- isRemoteRelay: this.relayMode === 'remote',
1767
- remoteAdminUrl: this.relayMode === 'remote'
1768
- ? `${String(this.config.relay.publicUrl || '').replace(/\/+$/, '')}/admin`
1769
- : undefined,
1770
- relay: {
1771
- mode: this.relayMode,
1772
- publicUrl: this.config.relay.publicUrl,
1773
- bind: this.config.relay.host,
1774
- port: this.address()?.port || this.config.relay.port,
1775
- maxClientsPerHost: this.config.relay.maxClientsPerHost,
1776
- maxHosts: this.config.relay.maxHosts,
1777
- maxPendingHandshakes: this.config.relay.maxPendingHandshakes,
1778
- maxBufferedBytesPerClient: this.config.relay.maxBufferedBytesPerClient,
1779
- hostReconnectGraceMs: this.config.relay.hostReconnectGraceMs,
1780
- adminConfigured: Boolean(this.adminToken),
1781
- adminStatusPath: '/api/admin/status',
1782
- dashboardPath: '/admin',
1783
- },
1784
- };
1785
- }
1786
-
1787
- clientAddress(req) {
1788
- if (this.trustProxy) {
1789
- const forwarded = req.headers['x-forwarded-for'];
1790
- if (typeof forwarded === 'string' && forwarded.length > 0) {
1791
- const first = forwarded.split(',')[0].trim();
1792
- if (first) return first;
1793
- }
1794
- }
1795
- return req.socket.remoteAddress || null;
1796
- }
1797
- }
1798
-
1799
- module.exports = { RelayServer, PROTOCOL_VERSION, VERSION };