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