herdr-remote-relay 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,776 @@
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 } = require('./metrics');
13
+ const { unpackStreamFrame, packStreamFrame, sanitizeTerminalPalette } = require('./stream-frame');
14
+ const { isWheelOnlyInput } = require('./scroll-input');
15
+ const { ensureDir } = require('./state');
16
+
17
+ const VERSION = require('../package.json').version;
18
+ const { PROTOCOL_VERSION } = require('./stream-frame');
19
+ const MAX_DIMENSION = 500;
20
+
21
+ function randomId(prefix) {
22
+ return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
23
+ }
24
+
25
+ function clampDimension(value, fallback) {
26
+ const numeric = Number(value);
27
+ if (!Number.isInteger(numeric)) return fallback;
28
+ return Math.min(MAX_DIMENSION, Math.max(2, numeric));
29
+ }
30
+
31
+ function isOpen(socket) {
32
+ return socket && socket.readyState === WebSocket.OPEN;
33
+ }
34
+
35
+ function jsonSend(socket, payload) {
36
+ if (isOpen(socket)) socket.send(JSON.stringify(payload));
37
+ }
38
+
39
+ function closeSocket(socket, code = 1000, reason = '') {
40
+ if (!socket || socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) return;
41
+ try {
42
+ socket.close(code, reason.slice(0, 120));
43
+ } catch {}
44
+ }
45
+
46
+ function parseJson(data) {
47
+ if (typeof data !== 'string') return null;
48
+ try {
49
+ return JSON.parse(data);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function bearerToken(req) {
56
+ const value = req.headers.authorization;
57
+ if (typeof value !== 'string') return null;
58
+ const match = /^Bearer\s+(.+)$/i.exec(value.trim());
59
+ return match ? match[1] : null;
60
+ }
61
+
62
+ function tokenMatches(candidate, expected) {
63
+ if (typeof candidate !== 'string' || candidate.length === 0 || typeof expected !== 'string' || expected.length === 0) return false;
64
+ // Hashing first gives timingSafeEqual fixed-size buffers, without leaking a
65
+ // length mismatch through the comparison itself.
66
+ const candidateHash = crypto.createHash('sha256').update(candidate, 'utf8').digest();
67
+ const expectedHash = crypto.createHash('sha256').update(expected, 'utf8').digest();
68
+ return crypto.timingSafeEqual(candidateHash, expectedHash);
69
+ }
70
+
71
+ class RelayServer {
72
+ constructor(config = loadRelayConfig().config, options = {}) {
73
+ this.config = config;
74
+ this.relayMode = config.relay?.mode === 'local' ? 'local' : 'remote';
75
+ this.hosts = new Map();
76
+ this.clients = new Map();
77
+ this.pairAttempts = new Map();
78
+ this.startedAt = Date.now();
79
+ this.metrics = options.metrics || new RelayMetrics({ version: VERSION, protocolVersion: PROTOCOL_VERSION });
80
+ this.stateFile = options.stateFile || config.auth?.stateFile || path.join(defaultStateDir(), 'relay-auth.json');
81
+ this.password = options.password ?? config.auth?.password ?? null;
82
+ this.adminToken = options.adminToken ?? config.auth?.adminToken ?? null;
83
+ this.trustProxy = Boolean(options.trustProxy ?? config.relay.trustProxy);
84
+ this.auth = options.auth || new AuthStore({
85
+ stateFile: this.stateFile,
86
+ pairingTtlMs: config.auth.pairingTtlMs,
87
+ deviceTtlMs: config.auth.deviceTtlMs,
88
+ maxDevices: config.auth.maxDevices,
89
+ password: this.password,
90
+ });
91
+ this.server = http.createServer((req, res) => this.handleHttp(req, res));
92
+ this.wss = new WebSocketServer({ noServer: true, clientTracking: false, maxPayload: config.relay.maxPayloadBytes });
93
+ this.heartbeatTimer = null;
94
+ this.cleanupTimer = null;
95
+ this.server.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
96
+ }
97
+
98
+ listen(port = this.config.relay.port, host = this.config.relay.host) {
99
+ ensureDir(path.dirname(this.stateFile));
100
+ return new Promise((resolve, reject) => {
101
+ const onError = (error) => {
102
+ this.server.off('listening', onListening);
103
+ reject(error);
104
+ };
105
+ const onListening = () => {
106
+ this.server.off('error', onError);
107
+ this.heartbeatTimer = setInterval(() => this.heartbeat(), this.config.cleanup.heartbeatIntervalMs);
108
+ this.cleanupTimer = setInterval(() => this.sweep(), this.config.cleanup.intervalMs);
109
+ resolve(this.address());
110
+ };
111
+ this.server.once('error', onError);
112
+ this.server.once('listening', onListening);
113
+ this.server.listen(port, host);
114
+ });
115
+ }
116
+
117
+ address() {
118
+ const address = this.server.address();
119
+ if (!address) return null;
120
+ return typeof address === 'string' ? { path: address } : { host: address.address, port: address.port };
121
+ }
122
+
123
+ async close() {
124
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
125
+ if (this.cleanupTimer) clearInterval(this.cleanupTimer);
126
+ this.heartbeatTimer = null;
127
+ this.cleanupTimer = null;
128
+ for (const client of [...this.clients.values()]) this.detachClient(client, { notify: false });
129
+ for (const host of [...this.hosts.values()]) this.detachHost(host, { notify: false });
130
+ this.metrics.close();
131
+ await new Promise((resolve) => {
132
+ if (!this.server.listening) return resolve();
133
+ this.server.close(() => resolve());
134
+ });
135
+ }
136
+
137
+ isAllowedOrigin(origin, req) {
138
+ if (!origin) return true;
139
+ const allowed = this.config.relay.allowedOrigins || [];
140
+ if (allowed.includes(origin)) return true;
141
+ try {
142
+ return new URL(origin).host === req.headers.host;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ requestOriginAllowed(req) {
149
+ const origin = req.headers.origin;
150
+ if (!origin) return true;
151
+ return this.isAllowedOrigin(origin, req);
152
+ }
153
+
154
+ handleUpgrade(req, socket, head) {
155
+ const pathname = (() => {
156
+ try {
157
+ return new URL(req.url, 'http://localhost').pathname;
158
+ } catch {
159
+ return '';
160
+ }
161
+ })();
162
+ if (!['/ws/host', '/ws/client'].includes(pathname) || !this.isAllowedOrigin(req.headers.origin, req)) {
163
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
164
+ socket.destroy();
165
+ return;
166
+ }
167
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
168
+ if (pathname === '/ws/host') this.handleHostConnection(ws, req);
169
+ else this.handleClientConnection(ws, req);
170
+ });
171
+ }
172
+
173
+ setResponseHeaders(res, contentType = 'application/json') {
174
+ res.setHeader('Content-Type', contentType);
175
+ res.setHeader('Cache-Control', 'no-store');
176
+ res.setHeader('X-Content-Type-Options', 'nosniff');
177
+ res.setHeader('X-Frame-Options', 'DENY');
178
+ 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'");
180
+ }
181
+
182
+ sendJsonResponse(res, status, payload) {
183
+ this.setResponseHeaders(res);
184
+ res.writeHead(status);
185
+ res.end(JSON.stringify(payload));
186
+ }
187
+
188
+ /**
189
+ * Identify the workstation making a request by its own host token.
190
+ * Ownership of a workstation is exactly what the host token proves, so a
191
+ * public relay serving many workstations stays safe: nobody can mint a pairing
192
+ * code for a host whose token they do not hold.
193
+ */
194
+ authorizedHost(req) {
195
+ const hostId = req.headers['x-herdr-host-id'];
196
+ const token = req.headers['x-herdr-host-token'];
197
+ if (typeof hostId !== 'string' || typeof token !== 'string') return null;
198
+ return this.auth.authenticateHost(hostId, token) ? hostId : null;
199
+ }
200
+
201
+ authorizedDevice(req) {
202
+ const token = bearerToken(req);
203
+ return token ? this.auth.authenticateDevice(token) : null;
204
+ }
205
+
206
+ /** Authenticate the operator of this relay, not a workstation or device. */
207
+ authorizedAdmin(req) {
208
+ return tokenMatches(req.headers['x-relay-admin-token'], this.adminToken);
209
+ }
210
+
211
+ /**
212
+ * Rate-limit key for a request. Behind a TLS reverse proxy every connection
213
+ * arrives from the proxy itself, so keying on the socket address would put
214
+ * every device in the world into one bucket and let a single attacker lock
215
+ * everyone out. `trustProxy` switches to the left-most X-Forwarded-For entry,
216
+ * which is only trustworthy when a proxy is actually in front of the relay.
217
+ */
218
+ rateLimitKey(req) {
219
+ if (this.trustProxy) {
220
+ const forwarded = req.headers['x-forwarded-for'];
221
+ if (typeof forwarded === 'string' && forwarded.length > 0) {
222
+ const first = forwarded.split(',')[0].trim();
223
+ if (first) return first;
224
+ }
225
+ }
226
+ return req.socket.remoteAddress || 'unknown';
227
+ }
228
+
229
+ allowPairAttempt(req) {
230
+ const key = this.rateLimitKey(req);
231
+ const now = Date.now();
232
+ const current = this.pairAttempts.get(key);
233
+ if (!current || now - current.startedAt >= 60_000) {
234
+ this.pairAttempts.set(key, { startedAt: now, count: 1 });
235
+ return true;
236
+ }
237
+ if (current.count >= 20) return false;
238
+ current.count += 1;
239
+ return true;
240
+ }
241
+
242
+ handleHttp(req, res) {
243
+ let requestUrl;
244
+ try {
245
+ requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
246
+ } catch {
247
+ this.sendJsonResponse(res, 400, { ok: false, code: 'bad_request', message: 'invalid URL' });
248
+ return;
249
+ }
250
+ if (req.method === 'OPTIONS') {
251
+ if (!this.requestOriginAllowed(req)) {
252
+ this.sendJsonResponse(res, 403, { ok: false, code: 'origin_denied', message: 'origin is not allowed' });
253
+ return;
254
+ }
255
+ res.writeHead(204, {
256
+ 'Access-Control-Allow-Origin': req.headers.origin || '*',
257
+ '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',
259
+ Vary: 'Origin',
260
+ });
261
+ res.end();
262
+ return;
263
+ }
264
+ if (!this.requestOriginAllowed(req)) {
265
+ this.sendJsonResponse(res, 403, { ok: false, code: 'origin_denied', message: 'origin is not allowed' });
266
+ return;
267
+ }
268
+ if (requestUrl.pathname === '/healthz' && req.method === 'GET') {
269
+ const snapshot = this.statusSnapshot({ sample: false });
270
+ this.sendJsonResponse(res, 200, {
271
+ ok: true,
272
+ version: VERSION,
273
+ protocol: PROTOCOL_VERSION,
274
+ hosts: snapshot.hostCount,
275
+ clients: snapshot.clientCount,
276
+ uptimeSeconds: snapshot.uptimeSeconds,
277
+ load1m: snapshot.cpu.load1m,
278
+ });
279
+ return;
280
+ }
281
+ if (requestUrl.pathname === '/api/info' && req.method === 'GET') {
282
+ const publicUrl = String(this.config.relay.publicUrl || '').replace(/\/+$/, '');
283
+ this.sendJsonResponse(res, 200, {
284
+ ok: true,
285
+ version: VERSION,
286
+ protocol: PROTOCOL_VERSION,
287
+ relayMode: this.relayMode,
288
+ isRemoteRelay: this.relayMode === 'remote',
289
+ adminConfigured: Boolean(this.adminToken),
290
+ adminPath: '/admin',
291
+ adminStatusPath: '/api/admin/status',
292
+ publicUrl,
293
+ remoteAdminUrl: this.relayMode === 'remote' ? `${publicUrl}/admin` : null,
294
+ });
295
+ return;
296
+ }
297
+ if (requestUrl.pathname === '/api/status' && req.method === 'GET') {
298
+ if (!this.authorizedHost(req) && !this.authorizedDevice(req)) {
299
+ this.sendJsonResponse(res, 401, { ok: false, code: 'auth_required', message: 'an authorized device or host token is required' });
300
+ return;
301
+ }
302
+ this.sendJsonResponse(res, 200, this.statusSnapshot());
303
+ return;
304
+ }
305
+ if (requestUrl.pathname === '/api/admin/status' && req.method === 'GET') {
306
+ if (!this.adminToken) {
307
+ this.sendJsonResponse(res, 503, { ok: false, code: 'admin_not_configured', message: 'configure RELAY_ADMIN_TOKEN to access the relay dashboard' });
308
+ return;
309
+ }
310
+ if (!this.authorizedAdmin(req)) {
311
+ this.sendJsonResponse(res, 401, { ok: false, code: 'admin_auth_required', message: 'a valid relay admin token is required' });
312
+ return;
313
+ }
314
+ this.sendJsonResponse(res, 200, this.statusSnapshot());
315
+ return;
316
+ }
317
+ if (requestUrl.pathname === '/api/pair/start' && req.method === 'POST') {
318
+ if (!this.allowPairAttempt(req)) {
319
+ this.sendJsonResponse(res, 429, { ok: false, code: 'rate_limited', message: 'too many pairing attempts' });
320
+ return;
321
+ }
322
+ const hostId = this.authorizedHost(req);
323
+ if (!hostId) {
324
+ this.sendJsonResponse(res, 401, { ok: false, code: 'host_auth_required', message: 'a valid host id and token are required' });
325
+ return;
326
+ }
327
+ if (!this.hosts.has(hostId)) {
328
+ this.sendJsonResponse(res, 409, { ok: false, code: 'host_offline', message: 'no Herdr host is connected' });
329
+ return;
330
+ }
331
+ try {
332
+ const pairing = this.auth.startPairing(hostId, String(this.config.relay.publicUrl).replace(/\/$/, ''));
333
+ this.sendJsonResponse(res, 200, { ok: true, ...pairing, pairUrl: `${pairing.publicUrl}/?pairCode=${encodeURIComponent(pairing.code)}` });
334
+ } catch (error) {
335
+ this.sendJsonResponse(res, 409, { ok: false, code: error.code || 'pairing_failed', message: error.message });
336
+ }
337
+ return;
338
+ }
339
+ if (requestUrl.pathname.startsWith('/api/')) {
340
+ this.sendJsonResponse(res, 404, { ok: false, code: 'not_found', message: 'API route not found' });
341
+ return;
342
+ }
343
+ this.serveStatic(requestUrl.pathname, res);
344
+ }
345
+
346
+ serveStatic(requestPath, res) {
347
+ const publicDir = path.join(PACKAGE_ROOT, 'web', 'dist');
348
+ const relative = requestPath === '/' || requestPath === '/admin' ? 'index.html' : requestPath.replace(/^\/+/, '');
349
+ const candidate = path.resolve(publicDir, relative);
350
+ if (!candidate.startsWith(`${publicDir}${path.sep}`) && candidate !== path.join(publicDir, 'index.html')) {
351
+ this.sendJsonResponse(res, 403, { ok: false, code: 'forbidden', message: 'path is not allowed' });
352
+ return;
353
+ }
354
+ fs.readFile(candidate, (error, data) => {
355
+ if (error && !path.extname(relative)) {
356
+ fs.readFile(path.join(publicDir, 'index.html'), (fallbackError, fallbackData) => {
357
+ if (fallbackError) {
358
+ this.sendJsonResponse(res, 503, { ok: false, code: 'web_not_built', message: 'frontend has not been built' });
359
+ return;
360
+ }
361
+ this.setResponseHeaders(res, 'text/html; charset=utf-8');
362
+ res.end(fallbackData);
363
+ });
364
+ return;
365
+ }
366
+ if (error) {
367
+ this.sendJsonResponse(res, 404, { ok: false, code: 'not_found', message: 'file not found' });
368
+ return;
369
+ }
370
+ const extension = path.extname(candidate).toLowerCase();
371
+ const mime = {
372
+ '.html': 'text/html; charset=utf-8',
373
+ '.js': 'text/javascript; charset=utf-8',
374
+ '.css': 'text/css; charset=utf-8',
375
+ '.json': 'application/json',
376
+ '.svg': 'image/svg+xml',
377
+ '.png': 'image/png',
378
+ '.ico': 'image/x-icon',
379
+ '.woff2': 'font/woff2',
380
+ }[extension] || 'application/octet-stream';
381
+ this.setResponseHeaders(res, mime);
382
+ res.end(data);
383
+ });
384
+ }
385
+
386
+ handleHostConnection(ws, req) {
387
+ const pending = { ws, remoteAddress: req.socket.remoteAddress, connectedAt: Date.now(), authenticated: false };
388
+ const deadline = setTimeout(() => {
389
+ if (!pending.authenticated) closeSocket(ws, 1008, 'host hello timeout');
390
+ }, 10000);
391
+ ws.isAlive = true;
392
+ ws.on('pong', () => {
393
+ ws.isAlive = true;
394
+ if (pending.host) pending.host.lastSeenAt = Date.now();
395
+ });
396
+ ws.on('message', (raw, isBinary) => {
397
+ if (!pending.authenticated) {
398
+ if (isBinary) return this.rejectHandshake(ws, 'host hello must be JSON');
399
+ const message = parseJson(raw.toString());
400
+ if (!message || message.type !== 'host_hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid host hello');
401
+ const registration = this.auth.registerHost(message.hostId, message.token, message.password ?? null);
402
+ if (!registration.ok) return this.rejectHandshake(ws, registration.message, registration.code);
403
+ clearTimeout(deadline);
404
+ pending.authenticated = true;
405
+ const oldHost = this.hosts.get(message.hostId);
406
+ if (oldHost) this.detachHost(oldHost, { notify: true, reason: 'host_replaced' });
407
+ const host = {
408
+ id: message.hostId,
409
+ ws,
410
+ hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
411
+ platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
412
+ arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
413
+ connectedAt: new Date(pending.connectedAt).toISOString(),
414
+ connectedAtMs: pending.connectedAt,
415
+ // Colors are the workstation's to declare, but only in the one shape
416
+ // a browser renderer accepts.
417
+ terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
418
+ lastSeenAt: Date.now(),
419
+ clients: new Set(),
420
+ controllerId: null,
421
+ load: {},
422
+ ptys: [],
423
+ };
424
+ pending.host = host;
425
+ this.hosts.set(host.id, host);
426
+ jsonSend(ws, { type: 'host_ready', protocol: PROTOCOL_VERSION, hostId: host.id });
427
+ return;
428
+ }
429
+ this.handleHostMessage(pending.host, raw, isBinary);
430
+ });
431
+ ws.on('close', () => {
432
+ clearTimeout(deadline);
433
+ if (pending.host) this.detachHost(pending.host, { notify: true, reason: 'host_disconnected' });
434
+ });
435
+ ws.on('error', () => {});
436
+ }
437
+
438
+ rejectHandshake(ws, message, code = 'invalid_handshake') {
439
+ jsonSend(ws, { type: 'error', code, message });
440
+ closeSocket(ws, 1008, message);
441
+ }
442
+
443
+ handleHostMessage(host, raw, isBinary) {
444
+ host.lastSeenAt = Date.now();
445
+ if (isBinary) {
446
+ let frame;
447
+ try {
448
+ frame = unpackStreamFrame(raw);
449
+ } catch (error) {
450
+ this.metrics.recordCleanup('deadConnectionsClosed');
451
+ closeSocket(host.ws, 1003, error.message);
452
+ return;
453
+ }
454
+ const client = this.clients.get(frame.streamId);
455
+ if (frame.type !== 'output' || !client || client.hostId !== host.id) return;
456
+ if (isOpen(client.ws)) {
457
+ client.ws.send(frame.payload);
458
+ client.bytesSent += frame.payload.length;
459
+ this.metrics.recordOut(frame.payload.length);
460
+ }
461
+ return;
462
+ }
463
+ const message = parseJson(raw.toString());
464
+ if (!message) return;
465
+ if (message.type === 'heartbeat') {
466
+ host.load = message.load && typeof message.load === 'object' ? message.load : {};
467
+ host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
468
+ return;
469
+ }
470
+ const client = typeof message.clientId === 'string' ? this.clients.get(message.clientId) : null;
471
+ if (!client || client.hostId !== host.id) return;
472
+ if (message.type === 'session_ready') {
473
+ jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
474
+ } else if (message.type === 'session_exit') {
475
+ jsonSend(client.ws, { type: 'exit', code: Number.isInteger(message.code) ? message.code : null });
476
+ this.detachClient(client, { notify: false });
477
+ } else if (message.type === 'error') {
478
+ jsonSend(client.ws, { type: 'error', code: message.code || 'host_error', message: String(message.message || 'Host connector error') });
479
+ }
480
+ }
481
+
482
+ handleClientConnection(ws, req) {
483
+ const pending = { ws, req, authenticated: false };
484
+ const deadline = setTimeout(() => {
485
+ if (!pending.authenticated) closeSocket(ws, 1008, 'client hello timeout');
486
+ }, 10000);
487
+ ws.on('message', (raw, isBinary) => {
488
+ if (!pending.authenticated) {
489
+ if (isBinary) return this.rejectHandshake(ws, 'client hello must be JSON');
490
+ const message = parseJson(raw.toString());
491
+ if (!message || message.type !== 'hello' || message.protocol !== PROTOCOL_VERSION) return this.rejectHandshake(ws, 'invalid client hello');
492
+ let device = null;
493
+ let paired = null;
494
+ if (message.pairCode) {
495
+ if (!this.allowPairAttempt(req)) return this.rejectHandshake(ws, 'too many pairing attempts', 'rate_limited');
496
+ paired = this.auth.completePairing(message.pairCode);
497
+ }
498
+ if (paired) device = paired;
499
+ else if (message.token) device = this.auth.authenticateDevice(message.token);
500
+ if (!device) return this.rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
501
+ const host = this.hosts.get(device.hostId);
502
+ if (!host) return this.rejectHandshake(ws, 'paired Herdr host is offline', 'host_offline');
503
+ if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
504
+ clearTimeout(deadline);
505
+ const clientId = randomId('client');
506
+ const client = {
507
+ id: clientId,
508
+ ws,
509
+ hostId: host.id,
510
+ deviceId: device.deviceId,
511
+ role: host.controllerId ? 'viewer' : 'controller',
512
+ controllerId: host.controllerId,
513
+ connectedAt: new Date().toISOString(),
514
+ connectedAtMs: Date.now(),
515
+ lastSeenAt: Date.now(),
516
+ lastPingAt: null,
517
+ bytesReceived: 0,
518
+ bytesSent: 0,
519
+ ip: this.clientAddress(req),
520
+ userAgent: String(req.headers['user-agent'] || '').slice(0, 256),
521
+ cols: clampDimension(message.cols, 80),
522
+ rows: clampDimension(message.rows, 24),
523
+ };
524
+ if (client.role === 'controller') host.controllerId = client.id;
525
+ client.controllerId = host.controllerId;
526
+ pending.authenticated = true;
527
+ pending.client = client;
528
+ this.clients.set(client.id, client);
529
+ host.clients.add(client.id);
530
+ ws.isAlive = true;
531
+ ws.on('pong', () => {
532
+ ws.isAlive = true;
533
+ client.lastSeenAt = Date.now();
534
+ client.lastPingAt = new Date().toISOString();
535
+ });
536
+ if (paired) jsonSend(ws, { type: 'paired', token: paired.token, deviceId: paired.deviceId, hostId: paired.hostId, expiresAt: paired.expiresAtIso });
537
+ // Expose the relay-assigned connection id so clients can distinguish
538
+ // their own controller lease from another device's lease. The browser
539
+ // supplied clientId identifies a device, not this live WebSocket.
540
+ jsonSend(ws, {
541
+ type: 'ready',
542
+ role: client.role,
543
+ controllerId: host.controllerId,
544
+ hostId: host.id,
545
+ clientId: client.id,
546
+ // Delivered with `ready`, before the first PTY byte, so the terminal
547
+ // is painted in the host's colors from its very first frame.
548
+ terminalPalette: host.terminalPalette || null,
549
+ });
550
+ jsonSend(host.ws, { type: 'session_start', clientId: client.id, streamId: client.id, cols: client.cols, rows: client.rows, role: client.role });
551
+ this.broadcastControlState(host);
552
+ return;
553
+ }
554
+ this.handleClientMessage(pending.client, raw, isBinary);
555
+ });
556
+ ws.on('close', () => {
557
+ clearTimeout(deadline);
558
+ if (pending.client) this.detachClient(pending.client, { notify: true });
559
+ });
560
+ ws.on('error', () => {});
561
+ }
562
+
563
+ handleClientMessage(client, raw, isBinary) {
564
+ client.lastSeenAt = Date.now();
565
+ const host = this.hosts.get(client.hostId);
566
+ if (!host) return this.detachClient(client, { notify: true, reason: 'host_offline' });
567
+ if (isBinary) {
568
+ // A read-only device may still scroll. Like `resize` below, scrolling is
569
+ // not a shared-terminal action: each client drives its own PTY stream, so
570
+ // a wheel report moves only that viewer's own screen. Everything else —
571
+ // keystrokes, clicks, drags — stays behind the control lease.
572
+ if (client.role !== 'controller' && !isWheelOnlyInput(raw)) {
573
+ jsonSend(client.ws, { type: 'control_denied', message: 'this device is read-only' });
574
+ return;
575
+ }
576
+ if (raw.length > this.config.relay.maxPayloadBytes) return;
577
+ const frame = packStreamFrame('input', client.id, raw);
578
+ if (isOpen(host.ws)) {
579
+ host.ws.send(frame);
580
+ client.bytesReceived += raw.length;
581
+ this.metrics.recordIn(raw.length);
582
+ }
583
+ return;
584
+ }
585
+ const message = parseJson(raw.toString());
586
+ if (!message) {
587
+ jsonSend(client.ws, { type: 'error', code: 'invalid_json', message: 'message must be JSON' });
588
+ return;
589
+ }
590
+ if (message.type === 'ping') {
591
+ client.lastPingAt = new Date().toISOString();
592
+ jsonSend(client.ws, { type: 'pong' });
593
+ } else if (message.type === 'resize') {
594
+ // Every client drives its *own* PTY stream — `session_start` is emitted
595
+ // per client with `streamId: client.id`, and the host resizes only that
596
+ // session — so geometry is not a shared-terminal action and must not
597
+ // require the control lease. Gating it here left a viewer's PTY at the
598
+ // 80x24 it was opened with: the agent then painted into a grid the
599
+ // terminal did not have, leaving blank rows under the content and
600
+ // columns clipped off the right edge.
601
+ client.cols = clampDimension(message.cols, client.cols);
602
+ client.rows = clampDimension(message.rows, client.rows);
603
+ jsonSend(host.ws, { type: 'resize', clientId: client.id, cols: client.cols, rows: client.rows });
604
+ } else if (message.type === 'claim_control') {
605
+ this.claimControl(host, client, Boolean(message.force));
606
+ } else if (message.type === 'release_control') {
607
+ if (host.controllerId === client.id) {
608
+ host.controllerId = null;
609
+ this.broadcastControlState(host);
610
+ }
611
+ }
612
+ }
613
+
614
+ claimControl(host, client, force) {
615
+ if (host.controllerId === client.id) return jsonSend(client.ws, { type: 'control_granted' });
616
+ if (host.controllerId && !force) return jsonSend(client.ws, { type: 'control_denied', message: 'another device currently controls this Herdr' });
617
+ const previous = host.controllerId ? this.clients.get(host.controllerId) : null;
618
+ if (previous) {
619
+ previous.role = 'viewer';
620
+ jsonSend(previous.ws, { type: 'control_revoked', controllerId: client.id });
621
+ }
622
+ host.controllerId = client.id;
623
+ client.role = 'controller';
624
+ jsonSend(client.ws, { type: 'control_granted' });
625
+ this.broadcastControlState(host);
626
+ }
627
+
628
+ broadcastControlState(host) {
629
+ for (const clientId of host.clients) {
630
+ const client = this.clients.get(clientId);
631
+ if (!client) continue;
632
+ client.role = host.controllerId === client.id ? 'controller' : 'viewer';
633
+ client.controllerId = host.controllerId;
634
+ jsonSend(client.ws, { type: 'control_state', role: client.role, controllerId: host.controllerId || null });
635
+ }
636
+ }
637
+
638
+ detachClient(client, { notify = true, reason = 'client_disconnected' } = {}) {
639
+ if (!client || !this.clients.has(client.id)) return;
640
+ this.clients.delete(client.id);
641
+ const host = this.hosts.get(client.hostId);
642
+ if (host) {
643
+ host.clients.delete(client.id);
644
+ if (isOpen(host.ws)) jsonSend(host.ws, { type: 'session_stop', clientId: client.id });
645
+ const wasController = host.controllerId === client.id;
646
+ if (wasController) {
647
+ host.controllerId = null;
648
+ const next = [...host.clients]
649
+ .map((id) => this.clients.get(id))
650
+ .filter(Boolean)
651
+ .sort((a, b) => a.connectedAtMs - b.connectedAtMs)[0];
652
+ if (next) {
653
+ host.controllerId = next.id;
654
+ next.role = 'controller';
655
+ jsonSend(next.ws, { type: 'control_granted' });
656
+ }
657
+ this.broadcastControlState(host);
658
+ }
659
+ }
660
+ if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: reason === 'host_offline' ? 'Herdr host is offline' : 'connection closed' });
661
+ closeSocket(client.ws, 1000, reason);
662
+ this.metrics.recordCleanup('closedPtysCleaned');
663
+ }
664
+
665
+ detachHost(host, { notify = true, reason = 'host_disconnected' } = {}) {
666
+ if (!host || this.hosts.get(host.id) !== host) return;
667
+ this.hosts.delete(host.id);
668
+ for (const clientId of [...host.clients]) {
669
+ const client = this.clients.get(clientId);
670
+ if (!client) continue;
671
+ this.clients.delete(client.id);
672
+ if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: 'Herdr host disconnected' });
673
+ closeSocket(client.ws, 1012, reason);
674
+ }
675
+ host.clients.clear();
676
+ closeSocket(host.ws, 1000, reason);
677
+ }
678
+
679
+ heartbeat() {
680
+ const sockets = [
681
+ ...[...this.hosts.values()].map((host) => host.ws),
682
+ ...[...this.clients.values()].map((client) => client.ws),
683
+ ];
684
+ for (const socket of sockets) {
685
+ if (!socket.isAlive) {
686
+ this.metrics.recordCleanup('deadConnectionsClosed');
687
+ closeSocket(socket, 1001, 'heartbeat timeout');
688
+ continue;
689
+ }
690
+ socket.isAlive = false;
691
+ try {
692
+ socket.ping();
693
+ } catch {}
694
+ }
695
+ }
696
+
697
+ sweep() {
698
+ const now = Date.now();
699
+ const staleAfter = this.config.cleanup.staleAfterMs;
700
+ for (const [key, attempt] of this.pairAttempts.entries()) {
701
+ if (now - attempt.startedAt >= 60_000) this.pairAttempts.delete(key);
702
+ }
703
+ for (const client of [...this.clients.values()]) {
704
+ if (now - client.lastSeenAt > staleAfter) {
705
+ this.metrics.recordCleanup('staleClientsPurged');
706
+ this.detachClient(client, { notify: false, reason: 'stale_client' });
707
+ }
708
+ }
709
+ for (const host of [...this.hosts.values()]) {
710
+ if (now - host.lastSeenAt > staleAfter) {
711
+ this.metrics.recordCleanup('deadConnectionsClosed');
712
+ this.detachHost(host, { notify: true, reason: 'stale_host' });
713
+ }
714
+ }
715
+ const authCleanup = this.auth.cleanup(now);
716
+ if (authCleanup.removedDevices || authCleanup.removedPairings) this.metrics.recordCleanup('staleClientsPurged', authCleanup.removedDevices);
717
+ this.metrics.cleanup.lastCleanupAt = new Date(now).toISOString();
718
+ }
719
+
720
+ statusSnapshot({ sample = true } = {}) {
721
+ const clients = [...this.clients.values()].map((client) => ({
722
+ id: client.id,
723
+ role: client.role,
724
+ hostId: client.hostId,
725
+ deviceId: client.deviceId,
726
+ userAgent: client.userAgent,
727
+ connectedAt: client.connectedAt,
728
+ lastPingAt: client.lastPingAt,
729
+ bytesReceived: client.bytesReceived,
730
+ bytesSent: client.bytesSent,
731
+ ip: client.ip,
732
+ }));
733
+ const hosts = [...this.hosts.values()].map((host) => ({
734
+ id: host.id,
735
+ hostname: host.hostname,
736
+ platform: host.platform,
737
+ arch: host.arch,
738
+ status: host.clients.size ? 'busy' : 'online',
739
+ connectedAt: host.connectedAt,
740
+ activePtyCount: host.ptys.length,
741
+ load: host.load,
742
+ }));
743
+ const ptys = [...this.hosts.values()].flatMap((host) => host.ptys.map((pty) => ({ ...pty, hostId: host.id })));
744
+ return {
745
+ ...this.metrics.snapshot({ clients, hosts, ptys, sample }),
746
+ relayMode: this.relayMode,
747
+ isRemoteRelay: this.relayMode === 'remote',
748
+ remoteAdminUrl: this.relayMode === 'remote'
749
+ ? `${String(this.config.relay.publicUrl || '').replace(/\/+$/, '')}/admin`
750
+ : undefined,
751
+ relay: {
752
+ mode: this.relayMode,
753
+ publicUrl: this.config.relay.publicUrl,
754
+ bind: this.config.relay.host,
755
+ port: this.address()?.port || this.config.relay.port,
756
+ maxClientsPerHost: this.config.relay.maxClientsPerHost,
757
+ adminConfigured: Boolean(this.adminToken),
758
+ adminStatusPath: '/api/admin/status',
759
+ dashboardPath: '/admin',
760
+ },
761
+ };
762
+ }
763
+
764
+ clientAddress(req) {
765
+ if (this.trustProxy) {
766
+ const forwarded = req.headers['x-forwarded-for'];
767
+ if (typeof forwarded === 'string' && forwarded.length > 0) {
768
+ const first = forwarded.split(',')[0].trim();
769
+ if (first) return first;
770
+ }
771
+ }
772
+ return req.socket.remoteAddress || null;
773
+ }
774
+ }
775
+
776
+ module.exports = { RelayServer, PROTOCOL_VERSION, VERSION };