herdr-remote-relay 0.2.0 → 0.2.2
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/LICENSE +0 -0
- package/README.md +34 -70
- package/README.zh-CN.md +66 -0
- package/bin/herdr-remote-relay.js +12 -13
- package/config.example.json +0 -0
- package/deploy/cloudflared/config.yml.example +0 -0
- package/deploy/docker/compose.yaml +0 -0
- package/deploy/nginx/herdr-remote-relay.conf.example +0 -0
- package/deploy/systemd/herdr-remote-relay.service +0 -0
- package/deploy/systemd/relay.env.example +0 -0
- package/package.json +4 -3
- package/src/auth-store.js +63 -0
- package/src/metrics.js +7 -3
- package/src/relay-config.js +0 -0
- package/src/relay-server.js +305 -70
- package/src/scroll-input.js +0 -0
- package/src/state.js +0 -0
- package/src/stream-frame.js +0 -0
- package/web/dist/assets/index-D2ksY-SN.js +0 -39
- package/web/dist/assets/index-Zi2TgjUt.css +0 -32
- package/web/dist/assets/vendor-icons-VfRMalKD.js +0 -294
- package/web/dist/assets/vendor-react-B6szxdcU.js +0 -9
- package/web/dist/assets/vendor-xterm-Bldps5fz.js +0 -66
- package/web/dist/index.html +0 -38
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
|
}
|
|
@@ -311,7 +324,28 @@ class RelayServer {
|
|
|
311
324
|
this.sendJsonResponse(res, 401, { ok: false, code: 'admin_auth_required', message: 'a valid relay admin token is required' });
|
|
312
325
|
return;
|
|
313
326
|
}
|
|
314
|
-
this.sendJsonResponse(res, 200, this.statusSnapshot());
|
|
327
|
+
this.sendJsonResponse(res, 200, this.statusSnapshot({ includeDevices: true }));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (requestUrl.pathname.startsWith('/api/admin/devices/') && req.method === 'DELETE') {
|
|
331
|
+
if (!this.adminToken) {
|
|
332
|
+
this.sendJsonResponse(res, 503, { ok: false, code: 'admin_not_configured', message: 'configure RELAY_ADMIN_TOKEN to access the relay dashboard' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!this.authorizedAdmin(req)) {
|
|
336
|
+
this.sendJsonResponse(res, 401, { ok: false, code: 'admin_auth_required', message: 'a valid relay admin token is required' });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const deviceId = decodeURIComponent(requestUrl.pathname.slice('/api/admin/devices/'.length));
|
|
340
|
+
const revoked = this.auth.revokeDevice(deviceId);
|
|
341
|
+
if (!revoked) {
|
|
342
|
+
this.sendJsonResponse(res, 404, { ok: false, code: 'device_not_found', message: 'no such paired device' });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// Revocation has to take effect now, not at the next reconnect: drop any
|
|
346
|
+
// socket the device still holds so the terminal closes immediately.
|
|
347
|
+
const disconnected = this.detachDeviceSessions(deviceId);
|
|
348
|
+
this.sendJsonResponse(res, 200, { ok: true, deviceId: revoked.deviceId, disconnected });
|
|
315
349
|
return;
|
|
316
350
|
}
|
|
317
351
|
if (requestUrl.pathname === '/api/pair/start' && req.method === 'POST') {
|
|
@@ -412,6 +446,10 @@ class RelayServer {
|
|
|
412
446
|
arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
|
|
413
447
|
connectedAt: new Date(pending.connectedAt).toISOString(),
|
|
414
448
|
connectedAtMs: pending.connectedAt,
|
|
449
|
+
// One shared terminal per workstation. Every browser attached to this
|
|
450
|
+
// host reads and writes the same PTY, so what one window shows is
|
|
451
|
+
// what all of them show.
|
|
452
|
+
session: null,
|
|
415
453
|
// Colors are the workstation's to declare, but only in the one shape
|
|
416
454
|
// a browser renderer accepts.
|
|
417
455
|
terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
|
|
@@ -451,9 +489,14 @@ class RelayServer {
|
|
|
451
489
|
closeSocket(host.ws, 1003, error.message);
|
|
452
490
|
return;
|
|
453
491
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
492
|
+
// Output belongs to the workstation's one shared session, so it goes to
|
|
493
|
+
// every browser attached to it rather than to a single stream owner.
|
|
494
|
+
const session = host.session;
|
|
495
|
+
if (frame.type !== 'output' || !session || session.streamId !== frame.streamId) return;
|
|
496
|
+
this.rememberOutput(session, frame.payload);
|
|
497
|
+
for (const clientId of host.clients) {
|
|
498
|
+
const client = this.clients.get(clientId);
|
|
499
|
+
if (!client || !isOpen(client.ws)) continue;
|
|
457
500
|
client.ws.send(frame.payload);
|
|
458
501
|
client.bytesSent += frame.payload.length;
|
|
459
502
|
this.metrics.recordOut(frame.payload.length);
|
|
@@ -467,18 +510,170 @@ class RelayServer {
|
|
|
467
510
|
host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
|
|
468
511
|
return;
|
|
469
512
|
}
|
|
470
|
-
|
|
471
|
-
|
|
513
|
+
// Session-level news concerns the whole room: the host talks about the one
|
|
514
|
+
// shared stream, and every attached browser has to hear it.
|
|
515
|
+
const session = host.session;
|
|
516
|
+
const streamId = typeof message.clientId === 'string' ? message.clientId : message.streamId;
|
|
517
|
+
if (!session || (streamId && streamId !== session.streamId)) return;
|
|
472
518
|
if (message.type === 'session_ready') {
|
|
473
|
-
|
|
519
|
+
session.ready = true;
|
|
520
|
+
this.broadcastToClients(host, (client) => ({ type: 'session_ready', clientId: client.id }));
|
|
474
521
|
} else if (message.type === 'session_exit') {
|
|
475
|
-
|
|
476
|
-
|
|
522
|
+
const code = Number.isInteger(message.code) ? message.code : null;
|
|
523
|
+
host.session = null;
|
|
524
|
+
this.broadcastToClients(host, () => ({ type: 'exit', code }));
|
|
525
|
+
for (const clientId of [...host.clients]) {
|
|
526
|
+
const client = this.clients.get(clientId);
|
|
527
|
+
if (client) this.detachClient(client, { notify: false });
|
|
528
|
+
}
|
|
477
529
|
} else if (message.type === 'error') {
|
|
478
|
-
|
|
530
|
+
this.broadcastToClients(host, () => ({
|
|
531
|
+
type: 'error',
|
|
532
|
+
code: message.code || 'host_error',
|
|
533
|
+
message: String(message.message || 'Host connector error'),
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Send one JSON message to every browser attached to `host`. */
|
|
539
|
+
broadcastToClients(host, build) {
|
|
540
|
+
for (const clientId of [...host.clients]) {
|
|
541
|
+
const client = this.clients.get(clientId);
|
|
542
|
+
if (!client) continue;
|
|
543
|
+
const payload = build(client);
|
|
544
|
+
if (payload) jsonSend(client.ws, payload);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Keep the tail of the shared stream so a late joiner can be caught up. */
|
|
549
|
+
rememberOutput(session, payload) {
|
|
550
|
+
session.replay.push(Buffer.from(payload));
|
|
551
|
+
session.replayBytes += payload.length;
|
|
552
|
+
while (session.replayBytes > SESSION_REPLAY_BYTES && session.replay.length > 1) {
|
|
553
|
+
session.replayBytes -= session.replay.shift().length;
|
|
479
554
|
}
|
|
480
555
|
}
|
|
481
556
|
|
|
557
|
+
/**
|
|
558
|
+
* The grid the shared PTY runs at.
|
|
559
|
+
*
|
|
560
|
+
* The smallest attached window wins, exactly as it does in tmux: a column a
|
|
561
|
+
* phone cannot show is a column the program must not paint, or every other
|
|
562
|
+
* window sees wrapped rubbish. Nothing else keeps a shared terminal legible
|
|
563
|
+
* on two different screens at once.
|
|
564
|
+
*/
|
|
565
|
+
sharedDimensions(host) {
|
|
566
|
+
let cols = MAX_DIMENSION;
|
|
567
|
+
let rows = MAX_DIMENSION;
|
|
568
|
+
let found = false;
|
|
569
|
+
for (const clientId of host.clients) {
|
|
570
|
+
const client = this.clients.get(clientId);
|
|
571
|
+
if (!client) continue;
|
|
572
|
+
found = true;
|
|
573
|
+
cols = Math.min(cols, client.cols);
|
|
574
|
+
rows = Math.min(rows, client.rows);
|
|
575
|
+
}
|
|
576
|
+
if (!found) return null;
|
|
577
|
+
return {
|
|
578
|
+
cols: Math.max(MIN_SHARED_COLS, cols),
|
|
579
|
+
rows: Math.max(MIN_SHARED_ROWS, rows),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Attach `client` to the workstation's shared terminal, starting it if this
|
|
585
|
+
* is the first browser through the door.
|
|
586
|
+
*
|
|
587
|
+
* A later arrival does not get its own PTY: it is handed the stream already
|
|
588
|
+
* running, the output that has been printed so far, and — once the geometry
|
|
589
|
+
* has settled — a repaint, so it lands on the same screen everyone else is
|
|
590
|
+
* looking at.
|
|
591
|
+
*/
|
|
592
|
+
attachSession(host, client) {
|
|
593
|
+
if (!host.session) {
|
|
594
|
+
host.session = {
|
|
595
|
+
streamId: randomId('session'),
|
|
596
|
+
cols: client.cols,
|
|
597
|
+
rows: client.rows,
|
|
598
|
+
ready: false,
|
|
599
|
+
replay: [],
|
|
600
|
+
replayBytes: 0,
|
|
601
|
+
};
|
|
602
|
+
const dims = this.sharedDimensions(host) || { cols: client.cols, rows: client.rows };
|
|
603
|
+
host.session.cols = dims.cols;
|
|
604
|
+
host.session.rows = dims.rows;
|
|
605
|
+
jsonSend(host.ws, {
|
|
606
|
+
type: 'session_start',
|
|
607
|
+
clientId: host.session.streamId,
|
|
608
|
+
streamId: host.session.streamId,
|
|
609
|
+
cols: dims.cols,
|
|
610
|
+
rows: dims.rows,
|
|
611
|
+
role: 'controller',
|
|
612
|
+
});
|
|
613
|
+
// Said out loud even when this window is the only one, so a browser that
|
|
614
|
+
// reconnects is never left painting the grid of a session that has since
|
|
615
|
+
// been torn down and started again at a different size.
|
|
616
|
+
jsonSend(client.ws, { type: 'shared_resize', cols: dims.cols, rows: dims.rows });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const session = host.session;
|
|
621
|
+
if (session.ready) jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
622
|
+
for (const chunk of session.replay) {
|
|
623
|
+
if (!isOpen(client.ws)) break;
|
|
624
|
+
client.ws.send(chunk);
|
|
625
|
+
client.bytesSent += chunk.length;
|
|
626
|
+
}
|
|
627
|
+
// Geometry may now be smaller than it was; the resize doubles as the
|
|
628
|
+
// repaint that puts the newcomer on the same screen as everyone else.
|
|
629
|
+
this.syncDimensions(host, { force: true });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Push the shared grid to the workstation.
|
|
634
|
+
*
|
|
635
|
+
* `force` asks for a repaint even when the numbers did not move: a browser
|
|
636
|
+
* that just joined needs the program to draw itself again, and a resize is
|
|
637
|
+
* the only signal a PTY has for "paint everything".
|
|
638
|
+
*/
|
|
639
|
+
syncDimensions(host, { force = false } = {}) {
|
|
640
|
+
const session = host.session;
|
|
641
|
+
if (!session) return;
|
|
642
|
+
const dims = this.sharedDimensions(host);
|
|
643
|
+
if (!dims) return;
|
|
644
|
+
const changed = dims.cols !== session.cols || dims.rows !== session.rows;
|
|
645
|
+
session.cols = dims.cols;
|
|
646
|
+
session.rows = dims.rows;
|
|
647
|
+
if (!changed && !force) return;
|
|
648
|
+
// Every window has to be told what the shared grid became, not just the
|
|
649
|
+
// workstation. A browser that keeps rendering at its own width would wrap
|
|
650
|
+
// a stream written for a narrower terminal, which is the one thing a
|
|
651
|
+
// shared session must not do: the same bytes have to look the same in
|
|
652
|
+
// every window.
|
|
653
|
+
this.broadcastToClients(host, () => ({
|
|
654
|
+
type: 'shared_resize',
|
|
655
|
+
cols: dims.cols,
|
|
656
|
+
rows: dims.rows,
|
|
657
|
+
}));
|
|
658
|
+
if (!changed && force) {
|
|
659
|
+
// A no-op resize is ignored by the PTY, so bounce one row and come back.
|
|
660
|
+
jsonSend(host.ws, {
|
|
661
|
+
type: 'resize',
|
|
662
|
+
clientId: session.streamId,
|
|
663
|
+
streamId: session.streamId,
|
|
664
|
+
cols: dims.cols,
|
|
665
|
+
rows: Math.max(MIN_SHARED_ROWS, dims.rows - 1),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
jsonSend(host.ws, {
|
|
669
|
+
type: 'resize',
|
|
670
|
+
clientId: session.streamId,
|
|
671
|
+
streamId: session.streamId,
|
|
672
|
+
cols: dims.cols,
|
|
673
|
+
rows: dims.rows,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
482
677
|
handleClientConnection(ws, req) {
|
|
483
678
|
const pending = { ws, req, authenticated: false };
|
|
484
679
|
const deadline = setTimeout(() => {
|
|
@@ -500,6 +695,13 @@ class RelayServer {
|
|
|
500
695
|
if (!device) return this.rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
|
|
501
696
|
const host = this.hosts.get(device.hostId);
|
|
502
697
|
if (!host) return this.rejectHandshake(ws, 'paired Herdr host is offline', 'host_offline');
|
|
698
|
+
|
|
699
|
+
// Two tabs of one browser are two windows onto the same terminal, not
|
|
700
|
+
// rivals. Nothing is retired here: the relay used to close whichever
|
|
701
|
+
// session shared this browser's client id, which made two open tabs
|
|
702
|
+
// evict each other in a loop that never converged — each eviction
|
|
703
|
+
// triggered the other tab's auto-reconnect, which evicted this one
|
|
704
|
+
// back, forever.
|
|
503
705
|
if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
|
|
504
706
|
clearTimeout(deadline);
|
|
505
707
|
const clientId = randomId('client');
|
|
@@ -508,8 +710,14 @@ class RelayServer {
|
|
|
508
710
|
ws,
|
|
509
711
|
hostId: host.id,
|
|
510
712
|
deviceId: device.deviceId,
|
|
511
|
-
|
|
512
|
-
|
|
713
|
+
// Stable per browser profile; used to recognise a reconnect from the
|
|
714
|
+
// same browser rather than a genuinely separate viewer.
|
|
715
|
+
browserClientId: typeof message.clientId === 'string' ? message.clientId : null,
|
|
716
|
+
// Every paired window may type. Pairing is the permission boundary;
|
|
717
|
+
// once a device is through it, holding a second window read-only
|
|
718
|
+
// serves nobody — they are all views of one shared terminal.
|
|
719
|
+
role: 'controller',
|
|
720
|
+
controllerId: null,
|
|
513
721
|
connectedAt: new Date().toISOString(),
|
|
514
722
|
connectedAtMs: Date.now(),
|
|
515
723
|
lastSeenAt: Date.now(),
|
|
@@ -521,8 +729,10 @@ class RelayServer {
|
|
|
521
729
|
cols: clampDimension(message.cols, 80),
|
|
522
730
|
rows: clampDimension(message.rows, 24),
|
|
523
731
|
};
|
|
524
|
-
|
|
525
|
-
|
|
732
|
+
client.controllerId = null;
|
|
733
|
+
// Persist how this device identifies itself so the operator dashboard
|
|
734
|
+
// can name it in the revoke list instead of showing a bare device id.
|
|
735
|
+
this.auth.noteDeviceSeen(device.deviceId, { userAgent: client.userAgent, ip: client.ip });
|
|
526
736
|
pending.authenticated = true;
|
|
527
737
|
pending.client = client;
|
|
528
738
|
this.clients.set(client.id, client);
|
|
@@ -540,14 +750,19 @@ class RelayServer {
|
|
|
540
750
|
jsonSend(ws, {
|
|
541
751
|
type: 'ready',
|
|
542
752
|
role: client.role,
|
|
543
|
-
|
|
753
|
+
// There is no controller to name: every window has full input. The
|
|
754
|
+
// field stays in the message for clients built against protocol 1,
|
|
755
|
+
// which read it to decide whether somebody else held the lease — and
|
|
756
|
+
// `null` is exactly the answer that means "nobody does".
|
|
757
|
+
controllerId: null,
|
|
544
758
|
hostId: host.id,
|
|
545
759
|
clientId: client.id,
|
|
546
760
|
// Delivered with `ready`, before the first PTY byte, so the terminal
|
|
547
761
|
// is painted in the host's colors from its very first frame.
|
|
548
762
|
terminalPalette: host.terminalPalette || null,
|
|
763
|
+
clientCount: host.clients.size,
|
|
549
764
|
});
|
|
550
|
-
|
|
765
|
+
this.attachSession(host, client);
|
|
551
766
|
this.broadcastControlState(host);
|
|
552
767
|
return;
|
|
553
768
|
}
|
|
@@ -565,16 +780,12 @@ class RelayServer {
|
|
|
565
780
|
const host = this.hosts.get(client.hostId);
|
|
566
781
|
if (!host) return this.detachClient(client, { notify: true, reason: 'host_offline' });
|
|
567
782
|
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
783
|
if (raw.length > this.config.relay.maxPayloadBytes) return;
|
|
577
|
-
const
|
|
784
|
+
const session = host.session;
|
|
785
|
+
if (!session) return;
|
|
786
|
+
// Every window writes into the one shared terminal, so input is stamped
|
|
787
|
+
// with the session's stream id rather than the sender's.
|
|
788
|
+
const frame = packStreamFrame('input', session.streamId, raw);
|
|
578
789
|
if (isOpen(host.ws)) {
|
|
579
790
|
host.ws.send(frame);
|
|
580
791
|
client.bytesReceived += raw.length;
|
|
@@ -591,69 +802,81 @@ class RelayServer {
|
|
|
591
802
|
client.lastPingAt = new Date().toISOString();
|
|
592
803
|
jsonSend(client.ws, { type: 'pong' });
|
|
593
804
|
} else if (message.type === 'resize') {
|
|
594
|
-
//
|
|
595
|
-
//
|
|
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.
|
|
805
|
+
// The shared grid is the smallest attached window, so one client's resize
|
|
806
|
+
// is recomputed across the room rather than applied on its own.
|
|
601
807
|
client.cols = clampDimension(message.cols, client.cols);
|
|
602
808
|
client.rows = clampDimension(message.rows, client.rows);
|
|
603
|
-
|
|
809
|
+
this.syncDimensions(host);
|
|
604
810
|
} else if (message.type === 'claim_control') {
|
|
605
|
-
|
|
811
|
+
// Control is no longer a lease. Answering the old request keeps clients
|
|
812
|
+
// built against the previous protocol working.
|
|
813
|
+
client.role = 'controller';
|
|
814
|
+
jsonSend(client.ws, { type: 'control_granted' });
|
|
606
815
|
} else if (message.type === 'release_control') {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
816
|
+
// Nothing to release: the window keeps its input either way, and saying
|
|
817
|
+
// so beats a silence an older client would wait on.
|
|
818
|
+
jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null });
|
|
611
819
|
}
|
|
612
820
|
}
|
|
613
821
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
822
|
+
/**
|
|
823
|
+
* Tell every window who is attached.
|
|
824
|
+
*
|
|
825
|
+
* There is no controller to announce any more, so this carries the one fact
|
|
826
|
+
* that changed: how many windows now share this terminal.
|
|
827
|
+
*/
|
|
628
828
|
broadcastControlState(host) {
|
|
629
829
|
for (const clientId of host.clients) {
|
|
630
830
|
const client = this.clients.get(clientId);
|
|
631
831
|
if (!client) continue;
|
|
632
|
-
client.role =
|
|
633
|
-
client.controllerId =
|
|
634
|
-
jsonSend(client.ws, {
|
|
832
|
+
client.role = 'controller';
|
|
833
|
+
client.controllerId = null;
|
|
834
|
+
jsonSend(client.ws, {
|
|
835
|
+
type: 'control_state',
|
|
836
|
+
role: 'controller',
|
|
837
|
+
controllerId: null,
|
|
838
|
+
clientCount: host.clients.size,
|
|
839
|
+
});
|
|
635
840
|
}
|
|
636
841
|
}
|
|
637
842
|
|
|
843
|
+
/**
|
|
844
|
+
* Close every existing session belonging to `deviceId` on `host`.
|
|
845
|
+
*
|
|
846
|
+
* Called just before a freshly authenticated connection is registered, so the
|
|
847
|
+
* same physical device never occupies two client slots (and two PTYs) at once.
|
|
848
|
+
*/
|
|
849
|
+
detachDeviceSessions(deviceId) {
|
|
850
|
+
if (!deviceId) return 0;
|
|
851
|
+
let closed = 0;
|
|
852
|
+
for (const existing of [...this.clients.values()]) {
|
|
853
|
+
if (!existing || existing.deviceId !== deviceId) continue;
|
|
854
|
+
this.detachClient(existing, { notify: false, reason: 'device_revoked' });
|
|
855
|
+
closeSocket(existing.ws, 1000, 'this device has been revoked by the relay operator');
|
|
856
|
+
closed += 1;
|
|
857
|
+
}
|
|
858
|
+
return closed;
|
|
859
|
+
}
|
|
860
|
+
|
|
638
861
|
detachClient(client, { notify = true, reason = 'client_disconnected' } = {}) {
|
|
639
862
|
if (!client || !this.clients.has(client.id)) return;
|
|
640
863
|
this.clients.delete(client.id);
|
|
641
864
|
const host = this.hosts.get(client.hostId);
|
|
642
865
|
if (host) {
|
|
643
866
|
host.clients.delete(client.id);
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
host.controllerId = next.id;
|
|
654
|
-
next.role = 'controller';
|
|
655
|
-
jsonSend(next.ws, { type: 'control_granted' });
|
|
867
|
+
// The shared terminal outlives any one window: it is torn down only when
|
|
868
|
+
// the last of them has gone, so closing a tab never kills the session the
|
|
869
|
+
// other tabs are still watching.
|
|
870
|
+
if (host.clients.size === 0) {
|
|
871
|
+
if (host.session) {
|
|
872
|
+
if (isOpen(host.ws)) {
|
|
873
|
+
jsonSend(host.ws, { type: 'session_stop', clientId: host.session.streamId, streamId: host.session.streamId });
|
|
874
|
+
}
|
|
875
|
+
host.session = null;
|
|
656
876
|
}
|
|
877
|
+
host.controllerId = null;
|
|
878
|
+
} else {
|
|
879
|
+
this.syncDimensions(host);
|
|
657
880
|
this.broadcastControlState(host);
|
|
658
881
|
}
|
|
659
882
|
}
|
|
@@ -717,7 +940,7 @@ class RelayServer {
|
|
|
717
940
|
this.metrics.cleanup.lastCleanupAt = new Date(now).toISOString();
|
|
718
941
|
}
|
|
719
942
|
|
|
720
|
-
statusSnapshot({ sample = true } = {}) {
|
|
943
|
+
statusSnapshot({ sample = true, includeDevices = false } = {}) {
|
|
721
944
|
const clients = [...this.clients.values()].map((client) => ({
|
|
722
945
|
id: client.id,
|
|
723
946
|
role: client.role,
|
|
@@ -740,9 +963,21 @@ class RelayServer {
|
|
|
740
963
|
activePtyCount: host.ptys.length,
|
|
741
964
|
load: host.load,
|
|
742
965
|
}));
|
|
743
|
-
|
|
966
|
+
// The workstation counts one PTY per stream and cannot know how many
|
|
967
|
+
// windows are watching it; the relay does, and that is the number an
|
|
968
|
+
// operator needs when the session is shared.
|
|
969
|
+
const ptys = [...this.hosts.values()].flatMap((host) => host.ptys.map((pty) => ({
|
|
970
|
+
...pty,
|
|
971
|
+
hostId: host.id,
|
|
972
|
+
activeClients: host.clients.size,
|
|
973
|
+
})));
|
|
974
|
+
// The paired-device roster identifies people's hardware, so it is served to
|
|
975
|
+
// the relay operator only — never on /api/status, which any paired device
|
|
976
|
+
// may read.
|
|
977
|
+
const devices = includeDevices ? this.auth.listDevices() : undefined;
|
|
744
978
|
return {
|
|
745
979
|
...this.metrics.snapshot({ clients, hosts, ptys, sample }),
|
|
980
|
+
...(devices ? { devices } : {}),
|
|
746
981
|
relayMode: this.relayMode,
|
|
747
982
|
isRemoteRelay: this.relayMode === 'remote',
|
|
748
983
|
remoteAdminUrl: this.relayMode === 'remote'
|
package/src/scroll-input.js
CHANGED
|
File without changes
|
package/src/state.js
CHANGED
|
File without changes
|
package/src/stream-frame.js
CHANGED
|
File without changes
|