herdr-remote-relay 0.3.12 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/bin/herdr-remote-relay.js +20 -20
  2. package/dist/auth-store.d.ts +114 -0
  3. package/dist/auth-store.js +276 -0
  4. package/dist/metrics.d.ts +76 -0
  5. package/dist/metrics.js +249 -0
  6. package/dist/protocol/frames.d.ts +27 -0
  7. package/dist/protocol/frames.js +110 -0
  8. package/dist/protocol/http.d.ts +161 -0
  9. package/dist/protocol/http.js +3 -0
  10. package/dist/protocol/index.d.ts +5 -0
  11. package/dist/protocol/index.js +7 -0
  12. package/dist/protocol/messages.d.ts +321 -0
  13. package/dist/protocol/messages.js +18 -0
  14. package/dist/protocol/paste.d.ts +14 -0
  15. package/dist/protocol/paste.js +50 -0
  16. package/dist/protocol/terminal.d.ts +79 -0
  17. package/dist/protocol/terminal.js +150 -0
  18. package/dist/relay-config.d.ts +74 -0
  19. package/dist/relay-config.js +316 -0
  20. package/dist/relay-server.d.ts +64 -0
  21. package/dist/relay-server.js +194 -0
  22. package/dist/scroll-input.d.ts +17 -0
  23. package/dist/scroll-input.js +124 -0
  24. package/dist/server/client-channel.d.ts +3 -0
  25. package/dist/server/client-channel.js +365 -0
  26. package/dist/server/font-proxy.d.ts +19 -0
  27. package/dist/server/font-proxy.js +155 -0
  28. package/dist/server/host-broadcasts.d.ts +25 -0
  29. package/dist/server/host-broadcasts.js +79 -0
  30. package/dist/server/host-channel.d.ts +3 -0
  31. package/dist/server/host-channel.js +266 -0
  32. package/dist/server/http-api.d.ts +3 -0
  33. package/dist/server/http-api.js +214 -0
  34. package/dist/server/maintenance.d.ts +5 -0
  35. package/dist/server/maintenance.js +64 -0
  36. package/dist/server/requests.d.ts +34 -0
  37. package/dist/server/requests.js +142 -0
  38. package/dist/server/sessions.d.ts +36 -0
  39. package/dist/server/sessions.js +225 -0
  40. package/dist/server/sockets.d.ts +12 -0
  41. package/dist/server/sockets.js +64 -0
  42. package/dist/server/status.d.ts +11 -0
  43. package/dist/server/status.js +95 -0
  44. package/dist/server/transport.d.ts +16 -0
  45. package/dist/server/transport.js +110 -0
  46. package/dist/server/types.d.ts +123 -0
  47. package/dist/server/types.js +3 -0
  48. package/dist/server/version.d.ts +2 -0
  49. package/dist/server/version.js +5 -0
  50. package/dist/state.d.ts +6 -0
  51. package/dist/state.js +47 -0
  52. package/package.json +32 -8
  53. package/web/dist/assets/index-B-5zRmL_.js +61 -0
  54. package/web/dist/assets/{index-CPVUfBt4.css → index-DKt8KLog.css} +1 -1
  55. package/web/dist/assets/{vendor-xterm-Bbu8R_5E.js → vendor-xterm-BhCxil_4.js} +2 -2
  56. package/web/dist/index.html +3 -3
  57. package/src/auth-store.js +0 -256
  58. package/src/metrics.js +0 -241
  59. package/src/relay-config.js +0 -312
  60. package/src/relay-server.js +0 -1799
  61. package/src/scroll-input.js +0 -139
  62. package/src/state.js +0 -47
  63. package/src/stream-frame.js +0 -238
  64. package/web/dist/assets/index-6WucmdNP.js +0 -61
@@ -0,0 +1,34 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { RelayContext } from './types.js';
3
+ /** Who an authenticated request speaks for. */
4
+ export type RequestSubject = {
5
+ kind: 'host';
6
+ hostId: string;
7
+ } | {
8
+ kind: 'device';
9
+ hostId: string;
10
+ deviceId: string;
11
+ };
12
+ export declare function isAllowedOrigin(relay: RelayContext, origin: string | undefined, req: IncomingMessage): boolean;
13
+ export declare function requestOriginAllowed(relay: RelayContext, req: IncomingMessage): boolean;
14
+ /**
15
+ * Identify the workstation making a request by its own host token.
16
+ * Ownership of a workstation is exactly what the host token proves, so a
17
+ * public relay serving many workstations stays safe: nobody can mint a pairing
18
+ * code for a host whose token they do not hold.
19
+ */
20
+ export declare function authorizedHost(relay: RelayContext, req: IncomingMessage): string | null;
21
+ /**
22
+ * Resolve the request to one tenant. Supplying both authentication schemes is
23
+ * allowed only when they identify the same host; otherwise a caller could
24
+ * accidentally combine credentials from two workstations and receive the
25
+ * result selected by whichever branch happened to run first.
26
+ */
27
+ export declare function authorizedSubject(relay: RelayContext, req: IncomingMessage): RequestSubject | null;
28
+ /** Authenticate the operator of this relay, not a workstation or device. */
29
+ export declare function authorizedAdmin(relay: RelayContext, req: IncomingMessage): boolean;
30
+ /** The address a window connected from, as the operator dashboard shows it. */
31
+ export declare function clientAddress(relay: RelayContext, req: IncomingMessage): string | null;
32
+ export declare function allowPairAttempt(relay: RelayContext, req: IncomingMessage): boolean;
33
+ export declare function allowClientHandshake(relay: RelayContext, req: IncomingMessage): boolean;
34
+ export declare function allowHostHandshake(relay: RelayContext, req: IncomingMessage): boolean;
@@ -0,0 +1,142 @@
1
+ // Who is asking, and whether to let them: origins, credentials and rate limits
2
+ // for HTTP requests and WebSocket handshakes.
3
+ import crypto from 'node:crypto';
4
+ function bearerToken(req) {
5
+ const value = req.headers.authorization;
6
+ if (typeof value !== 'string')
7
+ return null;
8
+ const match = /^Bearer\s+(.+)$/i.exec(value.trim());
9
+ return match ? match[1] : null;
10
+ }
11
+ function tokenMatches(candidate, expected) {
12
+ if (typeof candidate !== 'string' ||
13
+ candidate.length === 0 ||
14
+ typeof expected !== 'string' ||
15
+ expected.length === 0)
16
+ return false;
17
+ // Hashing first gives timingSafeEqual fixed-size buffers, without leaking a
18
+ // length mismatch through the comparison itself.
19
+ const candidateHash = crypto.createHash('sha256').update(candidate, 'utf8').digest();
20
+ const expectedHash = crypto.createHash('sha256').update(expected, 'utf8').digest();
21
+ return crypto.timingSafeEqual(candidateHash, expectedHash);
22
+ }
23
+ export function isAllowedOrigin(relay, origin, req) {
24
+ if (!origin)
25
+ return true;
26
+ const allowed = relay.config.relay.allowedOrigins || [];
27
+ if (allowed.includes(origin))
28
+ return true;
29
+ try {
30
+ return new URL(origin).host === req.headers.host;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ export function requestOriginAllowed(relay, req) {
37
+ const origin = req.headers.origin;
38
+ if (!origin)
39
+ return true;
40
+ return isAllowedOrigin(relay, origin, req);
41
+ }
42
+ /**
43
+ * Identify the workstation making a request by its own host token.
44
+ * Ownership of a workstation is exactly what the host token proves, so a
45
+ * public relay serving many workstations stays safe: nobody can mint a pairing
46
+ * code for a host whose token they do not hold.
47
+ */
48
+ export function authorizedHost(relay, req) {
49
+ const hostId = req.headers['x-herdr-host-id'];
50
+ const token = req.headers['x-herdr-host-token'];
51
+ if (typeof hostId !== 'string' || typeof token !== 'string')
52
+ return null;
53
+ return relay.auth.authenticateHost(hostId, token) ? hostId : null;
54
+ }
55
+ function authorizedDevice(relay, req) {
56
+ const token = bearerToken(req);
57
+ return token ? relay.auth.authenticateDevice(token) : null;
58
+ }
59
+ /**
60
+ * Resolve the request to one tenant. Supplying both authentication schemes is
61
+ * allowed only when they identify the same host; otherwise a caller could
62
+ * accidentally combine credentials from two workstations and receive the
63
+ * result selected by whichever branch happened to run first.
64
+ */
65
+ export function authorizedSubject(relay, req) {
66
+ const hostIdHeader = req.headers['x-herdr-host-id'];
67
+ const hostTokenHeader = req.headers['x-herdr-host-token'];
68
+ const hasHostCredentials = hostIdHeader !== undefined || hostTokenHeader !== undefined;
69
+ const hasBearer = req.headers.authorization !== undefined;
70
+ const hostId = authorizedHost(relay, req);
71
+ const device = authorizedDevice(relay, req);
72
+ if (hasHostCredentials && !hostId)
73
+ return null;
74
+ if (hasBearer && !device)
75
+ return null;
76
+ if (hostId && device && hostId !== device.hostId)
77
+ return null;
78
+ if (hostId)
79
+ return { kind: 'host', hostId };
80
+ if (device)
81
+ return { kind: 'device', hostId: device.hostId, deviceId: device.deviceId };
82
+ return null;
83
+ }
84
+ /** Authenticate the operator of this relay, not a workstation or device. */
85
+ export function authorizedAdmin(relay, req) {
86
+ return tokenMatches(req.headers['x-relay-admin-token'], relay.adminToken);
87
+ }
88
+ /**
89
+ * The left-most X-Forwarded-For entry, but only when `trustProxy` says a proxy
90
+ * sits in front of the relay: otherwise any caller could write the header.
91
+ */
92
+ function forwardedFor(relay, req) {
93
+ if (!relay.trustProxy)
94
+ return null;
95
+ const forwarded = req.headers['x-forwarded-for'];
96
+ if (typeof forwarded !== 'string' || forwarded.length === 0)
97
+ return null;
98
+ return forwarded.split(',')[0].trim() || null;
99
+ }
100
+ /**
101
+ * Rate-limit key for a request. Behind a TLS reverse proxy every connection
102
+ * arrives from the proxy itself, so keying on the socket address would put
103
+ * every device in the world into one bucket and let a single attacker lock
104
+ * everyone out.
105
+ */
106
+ function rateLimitKey(relay, req) {
107
+ return forwardedFor(relay, req) || req.socket.remoteAddress || 'unknown';
108
+ }
109
+ /** The address a window connected from, as the operator dashboard shows it. */
110
+ export function clientAddress(relay, req) {
111
+ return forwardedFor(relay, req) || req.socket.remoteAddress || null;
112
+ }
113
+ function allowAttempt(relay, store, req, limit = 20) {
114
+ const key = rateLimitKey(relay, req);
115
+ const now = Date.now();
116
+ const current = store.get(key);
117
+ if (!current || now - current.startedAt >= 60_000) {
118
+ // Bound the map even when an attacker rotates source addresses. Expired
119
+ // entries are removed by sweep; the oldest live entry is the least
120
+ // useful one to retain when the cap is reached.
121
+ if (store.size >= 4096) {
122
+ const oldest = store.keys().next().value;
123
+ if (oldest !== undefined)
124
+ store.delete(oldest);
125
+ }
126
+ store.set(key, { startedAt: now, count: 1 });
127
+ return true;
128
+ }
129
+ if (current.count >= limit)
130
+ return false;
131
+ current.count += 1;
132
+ return true;
133
+ }
134
+ export function allowPairAttempt(relay, req) {
135
+ return allowAttempt(relay, relay.pairAttempts, req, 20);
136
+ }
137
+ export function allowClientHandshake(relay, req) {
138
+ return allowAttempt(relay, relay.clientHandshakeAttempts, req, 60);
139
+ }
140
+ export function allowHostHandshake(relay, req) {
141
+ return allowAttempt(relay, relay.hostHandshakeAttempts, req, 60);
142
+ }
@@ -0,0 +1,36 @@
1
+ import type { RelayClient, RelayContext, RelayHost } from './types.js';
2
+ /** Never shrink an individual session grid below something a program can still draw in. */
3
+ export declare const MIN_SESSION_COLS = 20;
4
+ export declare const MIN_SESSION_ROWS = 6;
5
+ export interface DetachOptions {
6
+ /** Tell the window why, before closing it. */
7
+ notify?: boolean;
8
+ reason?: string;
9
+ closeCode?: number;
10
+ /** Drop the socket at once instead of closing it politely. */
11
+ terminate?: boolean;
12
+ }
13
+ export declare function allocateStreamIndex(host: RelayHost | null | undefined): number | null;
14
+ /** Forget `client`'s stream: its id, its v2 index on `host`, and the session itself. */
15
+ export declare function releaseSession(relay: RelayContext, host: RelayHost | null | undefined, client: RelayClient): void;
16
+ /** Start a dedicated PTY session for one attached client. */
17
+ export declare function startSession(relay: RelayContext, host: RelayHost, client: RelayClient | null | undefined, { restarted }?: {
18
+ restarted?: boolean | undefined;
19
+ }): void;
20
+ /**
21
+ * Tell every window who is attached.
22
+ *
23
+ * There is no controller to announce any more, so this carries the one fact
24
+ * that changed: how many windows now share this terminal.
25
+ */
26
+ export declare function broadcastControlState(relay: RelayContext, host: RelayHost): void;
27
+ /**
28
+ * Close every window a revoked device still holds, on any host, so revoking
29
+ * takes effect now rather than at its next reconnect. Returns how many.
30
+ */
31
+ export declare function detachDeviceSessions(relay: RelayContext, deviceId: string): number;
32
+ export declare function detachClient(relay: RelayContext, client: RelayClient | null | undefined, { notify, reason, closeCode, terminate, }?: DetachOptions): void;
33
+ export declare function detachHost(relay: RelayContext, host: RelayHost | null | undefined, { notify, reason, terminate }?: DetachOptions): void;
34
+ /** Whether `host`'s windows can keep their sessions across a host reconnect. */
35
+ export declare function canHandoffHost(relay: RelayContext, host: RelayHost | null | undefined): boolean;
36
+ export declare function beginHostReconnect(relay: RelayContext, host: RelayHost | null | undefined, reason?: string): void;
@@ -0,0 +1,225 @@
1
+ // A window's PTY session on its host, and taking windows and hosts off the
2
+ // relay: detaching, host hand-off and the reconnect grace period.
3
+ import { WebSocket } from 'ws';
4
+ import { closeSocket, isOpen, jsonSend, randomId, terminateSocket } from './sockets.js';
5
+ import { broadcastToClients, notifyHostClientCount } from './transport.js';
6
+ /** Never shrink an individual session grid below something a program can still draw in. */
7
+ export const MIN_SESSION_COLS = 20;
8
+ export const MIN_SESSION_ROWS = 6;
9
+ export function allocateStreamIndex(host) {
10
+ if (!host)
11
+ return null;
12
+ const totalPossible = 65536;
13
+ for (let i = 0; i < totalPossible; i++) {
14
+ const candidate = host.nextStreamIndex;
15
+ host.nextStreamIndex = (host.nextStreamIndex + 1) & 0xffff;
16
+ if (!host.streamIndices.has(candidate)) {
17
+ return candidate;
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ /** Forget `client`'s stream: its id, its v2 index on `host`, and the session itself. */
23
+ export function releaseSession(relay, host, client) {
24
+ const session = client.session;
25
+ if (!session)
26
+ return;
27
+ if (session.streamIndex !== null && session.streamIndex !== undefined && host) {
28
+ host.streamIndices.delete(session.streamIndex);
29
+ }
30
+ relay.streams.delete(session.streamId);
31
+ client.session = null;
32
+ }
33
+ /** Start a dedicated PTY session for one attached client. */
34
+ export function startSession(relay, host, client, { restarted = false } = {}) {
35
+ if (!client || !isOpen(host.ws))
36
+ return;
37
+ const streamId = randomId('session');
38
+ let streamIndex = null;
39
+ if (host.binaryFrameV2) {
40
+ streamIndex = allocateStreamIndex(host);
41
+ if (streamIndex !== null) {
42
+ host.streamIndices.set(streamIndex, client.id);
43
+ }
44
+ }
45
+ client.session = {
46
+ streamId,
47
+ streamIndex,
48
+ cols: client.cols,
49
+ rows: client.rows,
50
+ ready: false,
51
+ };
52
+ relay.streams.set(streamId, client.id);
53
+ jsonSend(host.ws, {
54
+ type: 'session_start',
55
+ clientId: streamId,
56
+ streamId,
57
+ cols: Math.max(MIN_SESSION_COLS, client.cols),
58
+ rows: Math.max(MIN_SESSION_ROWS, client.rows),
59
+ role: 'controller',
60
+ ...(streamIndex !== null ? { streamIndex } : {}),
61
+ });
62
+ if (restarted) {
63
+ jsonSend(client.ws, {
64
+ type: 'session_restarted',
65
+ streamId,
66
+ cols: client.cols,
67
+ rows: client.rows,
68
+ hostname: host.hostname,
69
+ terminalPalette: host.terminalPalette || null,
70
+ terminalFont: host.terminalFont || null,
71
+ });
72
+ }
73
+ }
74
+ /**
75
+ * Tell every window who is attached.
76
+ *
77
+ * There is no controller to announce any more, so this carries the one fact
78
+ * that changed: how many windows now share this terminal.
79
+ */
80
+ export function broadcastControlState(relay, host) {
81
+ for (const clientId of host.clients) {
82
+ const client = relay.clients.get(clientId);
83
+ if (!client)
84
+ continue;
85
+ client.role = 'controller';
86
+ client.controllerId = null;
87
+ jsonSend(client.ws, {
88
+ type: 'control_state',
89
+ role: 'controller',
90
+ controllerId: null,
91
+ clientCount: host.clients.size,
92
+ });
93
+ }
94
+ }
95
+ /**
96
+ * Close every window a revoked device still holds, on any host, so revoking
97
+ * takes effect now rather than at its next reconnect. Returns how many.
98
+ */
99
+ export function detachDeviceSessions(relay, deviceId) {
100
+ if (!deviceId)
101
+ return 0;
102
+ let closed = 0;
103
+ for (const existing of [...relay.clients.values()]) {
104
+ if (!existing || existing.deviceId !== deviceId)
105
+ continue;
106
+ detachClient(relay, existing, { notify: false, reason: 'device_revoked' });
107
+ closeSocket(existing.ws, 1000, 'this device has been revoked by the relay operator');
108
+ closed += 1;
109
+ }
110
+ return closed;
111
+ }
112
+ export function detachClient(relay, client, { notify = true, reason = 'client_disconnected', closeCode = 1000, terminate = false, } = {}) {
113
+ if (!client || !relay.clients.has(client.id))
114
+ return;
115
+ relay.clients.delete(client.id);
116
+ const host = relay.hosts.get(client.hostId);
117
+ // Each window has its own PTY session. Tearing down the client immediately
118
+ // stops its backing session on the host and cleans up its stream mapping.
119
+ if (client.session) {
120
+ const streamId = client.session.streamId;
121
+ releaseSession(relay, host, client);
122
+ if (host && isOpen(host.ws)) {
123
+ jsonSend(host.ws, { type: 'session_stop', clientId: streamId, streamId });
124
+ }
125
+ }
126
+ if (host) {
127
+ host.clients.delete(client.id);
128
+ notifyHostClientCount(host);
129
+ broadcastControlState(relay, host);
130
+ if (host.clients.size === 0) {
131
+ host.controllerId = null;
132
+ if (host.reconnecting)
133
+ detachHost(relay, host, { notify: false, reason: 'no_clients' });
134
+ }
135
+ }
136
+ const clientSocket = client.ws;
137
+ client.ws = null;
138
+ if (notify)
139
+ jsonSend(clientSocket, {
140
+ type: 'error',
141
+ code: reason,
142
+ message: reason === 'host_offline' ? 'Herdr host is offline' : 'connection closed',
143
+ });
144
+ if (terminate || clientSocket?.readyState === WebSocket.CLOSING) {
145
+ terminateSocket(clientSocket);
146
+ }
147
+ else {
148
+ closeSocket(clientSocket, closeCode, reason);
149
+ }
150
+ relay.metrics.recordCleanup('closedPtysCleaned');
151
+ }
152
+ export function detachHost(relay, host, { notify = true, reason = 'host_disconnected', terminate = false } = {}) {
153
+ if (!host || relay.hosts.get(host.id) !== host)
154
+ return;
155
+ if (host.reconnectTimer)
156
+ clearTimeout(host.reconnectTimer);
157
+ host.reconnectTimer = null;
158
+ relay.hosts.delete(host.id);
159
+ for (const clientId of [...host.clients]) {
160
+ const client = relay.clients.get(clientId);
161
+ if (!client)
162
+ continue;
163
+ relay.clients.delete(client.id);
164
+ releaseSession(relay, host, client);
165
+ const clientSocket = client.ws;
166
+ client.ws = null;
167
+ if (notify)
168
+ jsonSend(clientSocket, { type: 'error', code: reason, message: 'Herdr host disconnected' });
169
+ if (terminate || clientSocket?.readyState === WebSocket.CLOSING) {
170
+ terminateSocket(clientSocket);
171
+ }
172
+ else {
173
+ closeSocket(clientSocket, 1012, reason);
174
+ }
175
+ }
176
+ host.clients.clear();
177
+ relay.metrics.forgetHost(host.id);
178
+ const hostSocket = host.ws;
179
+ host.ws = null;
180
+ if (terminate || hostSocket?.readyState === WebSocket.CLOSING) {
181
+ terminateSocket(hostSocket);
182
+ }
183
+ else {
184
+ closeSocket(hostSocket, 1000, reason);
185
+ }
186
+ }
187
+ /** Whether `host`'s windows can keep their sessions across a host reconnect. */
188
+ export function canHandoffHost(relay, host) {
189
+ if (!host?.handoffCapable || host.clients.size === 0)
190
+ return false;
191
+ for (const clientId of host.clients) {
192
+ const client = relay.clients.get(clientId);
193
+ if (!client?.handoffCapable)
194
+ return false;
195
+ }
196
+ return true;
197
+ }
198
+ export function beginHostReconnect(relay, host, reason = 'host_disconnected') {
199
+ if (!host || relay.hosts.get(host.id) !== host || host.reconnecting)
200
+ return;
201
+ if (!canHandoffHost(relay, host)) {
202
+ detachHost(relay, host, { notify: true, reason });
203
+ return;
204
+ }
205
+ host.ws = null;
206
+ host.reconnecting = true;
207
+ host.reconnectStartedAt = Date.now();
208
+ host.lastSeenAt = Date.now();
209
+ host.load = {};
210
+ host.ptys = [];
211
+ for (const clientId of host.clients) {
212
+ const client = relay.clients.get(clientId);
213
+ if (client)
214
+ releaseSession(relay, host, client);
215
+ }
216
+ broadcastToClients(relay, host, () => ({ type: 'host_reconnecting', code: reason }));
217
+ const timer = setTimeout(() => {
218
+ host.reconnectTimer = null;
219
+ if (relay.hosts.get(host.id) === host && host.reconnecting) {
220
+ detachHost(relay, host, { notify: true, reason: 'host_reconnect_timeout' });
221
+ }
222
+ }, relay.config.relay.hostReconnectGraceMs);
223
+ host.reconnectTimer = timer;
224
+ timer.unref?.();
225
+ }
@@ -0,0 +1,12 @@
1
+ import type { RelaySocket } from './types.js';
2
+ export declare function randomId(prefix: string): string;
3
+ /** A bounded string, or null. For fields whose contents a pane decided. */
4
+ export declare function text(value: unknown, limit: number): string | null;
5
+ export declare function clampDimension(value: unknown, fallback: number): number;
6
+ export declare function isOpen(socket: RelaySocket | null | undefined): socket is RelaySocket;
7
+ export declare function jsonSend(socket: RelaySocket | null | undefined, payload: unknown): void;
8
+ export declare function closeSocket(socket: RelaySocket | null | undefined, code?: number, reason?: string): void;
9
+ export declare function terminateSocket(socket: RelaySocket | null | undefined): void;
10
+ /** Parsed JSON, or null for anything that is not a string of valid JSON. */
11
+ export declare function parseJson<T = Record<string, unknown>>(data: unknown): T | null;
12
+ export declare function rejectHandshake(ws: RelaySocket, message: string, code?: string): void;
@@ -0,0 +1,64 @@
1
+ // Small helpers for the relay's WebSockets and the untrusted data read from them.
2
+ import crypto from 'node:crypto';
3
+ import { WebSocket } from 'ws';
4
+ const MAX_DIMENSION = 500;
5
+ export function randomId(prefix) {
6
+ return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
7
+ }
8
+ /** A bounded string, or null. For fields whose contents a pane decided. */
9
+ export function text(value, limit) {
10
+ return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : null;
11
+ }
12
+ export function clampDimension(value, fallback) {
13
+ const numeric = Number(value);
14
+ if (!Number.isInteger(numeric))
15
+ return fallback;
16
+ return Math.min(MAX_DIMENSION, Math.max(2, numeric));
17
+ }
18
+ export function isOpen(socket) {
19
+ return Boolean(socket) && socket?.readyState === WebSocket.OPEN;
20
+ }
21
+ export function jsonSend(socket, payload) {
22
+ if (isOpen(socket))
23
+ socket.send(JSON.stringify(payload));
24
+ }
25
+ export function closeSocket(socket, code = 1000, reason = '') {
26
+ if (!socket || socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING)
27
+ return;
28
+ try {
29
+ socket.close(code, reason.slice(0, 120));
30
+ }
31
+ catch {
32
+ // Closing a socket that is already failing needs no further handling.
33
+ }
34
+ }
35
+ export function terminateSocket(socket) {
36
+ if (!socket || socket.readyState === WebSocket.CLOSED)
37
+ return;
38
+ try {
39
+ if (typeof socket.terminate === 'function') {
40
+ socket.terminate();
41
+ }
42
+ else if (typeof socket.destroy === 'function') {
43
+ socket.destroy();
44
+ }
45
+ }
46
+ catch {
47
+ // Tearing down a dead socket; there is nothing left to recover.
48
+ }
49
+ }
50
+ /** Parsed JSON, or null for anything that is not a string of valid JSON. */
51
+ export function parseJson(data) {
52
+ if (typeof data !== 'string')
53
+ return null;
54
+ try {
55
+ return JSON.parse(data);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function rejectHandshake(ws, message, code = 'invalid_handshake') {
62
+ jsonSend(ws, { type: 'error', code, message });
63
+ closeSocket(ws, 1008, message);
64
+ }
@@ -0,0 +1,11 @@
1
+ import type { AdminStatusResponse } from '../protocol/http.js';
2
+ import type { RelayContext } from './types.js';
3
+ export interface StatusOptions {
4
+ /** Advance the throughput sample; off for reads that must not skew the rates. */
5
+ sample?: boolean;
6
+ /** Include the paired-device roster, which only the relay operator may see. */
7
+ includeDevices?: boolean;
8
+ /** Report only this workstation and its windows. */
9
+ scopeHostId?: string | null;
10
+ }
11
+ export declare function statusSnapshot(relay: RelayContext, { sample, includeDevices, scopeHostId }?: StatusOptions): AdminStatusResponse;
@@ -0,0 +1,95 @@
1
+ // What `/api/status` and `/api/admin/status` report: the relay's metrics plus
2
+ // the hosts, windows and PTYs it can see, scoped to one tenant when asked.
3
+ import { countActiveUsers } from '../metrics.js';
4
+ export function statusSnapshot(relay, { sample = true, includeDevices = false, scopeHostId = null } = {}) {
5
+ const scopedClients = scopeHostId
6
+ ? [...relay.clients.values()].filter((client) => client.hostId === scopeHostId)
7
+ : [...relay.clients.values()];
8
+ const scopedHosts = scopeHostId
9
+ ? [...relay.hosts.values()].filter((host) => host.id === scopeHostId)
10
+ : [...relay.hosts.values()];
11
+ const clients = scopedClients.map((client) => ({
12
+ id: client.id,
13
+ role: client.role,
14
+ ...(scopeHostId ? {} : { hostId: client.hostId }),
15
+ ...(includeDevices ? { deviceId: client.deviceId } : {}),
16
+ userAgent: client.userAgent,
17
+ connectedAt: client.connectedAt,
18
+ lastPingAt: client.lastPingAt,
19
+ bytesReceived: client.bytesReceived,
20
+ bytesSent: client.bytesSent,
21
+ ip: client.ip,
22
+ }));
23
+ // The roster is read once and used twice: the operator response carries it
24
+ // whole, and every response carries the per-host tally derived from it. A
25
+ // public caller learns how many devices a workstation has paired, never
26
+ // which ones.
27
+ const pairedDevices = relay.auth.listDevices();
28
+ const pairedPerHost = new Map();
29
+ for (const device of pairedDevices) {
30
+ pairedPerHost.set(device.hostId, (pairedPerHost.get(device.hostId) || 0) + 1);
31
+ }
32
+ const hosts = scopedHosts.map((host) => ({
33
+ id: host.id,
34
+ hostname: host.hostname,
35
+ platform: host.platform,
36
+ arch: host.arch,
37
+ status: host.reconnecting ? 'reconnecting' : host.clients.size ? 'busy' : 'online',
38
+ connectedAt: host.connectedAt,
39
+ activePtyCount: host.ptys.length,
40
+ // Distinct devices attached to this workstation, not open sockets: a
41
+ // phone with two tabs open is one device on the operator's board.
42
+ connectedDeviceCount: countActiveUsers([...host.clients]
43
+ .map((id) => relay.clients.get(id))
44
+ .filter((client) => Boolean(client))),
45
+ pairedDeviceCount: pairedPerHost.get(host.id) || 0,
46
+ load: host.load,
47
+ }));
48
+ // The workstation counts one PTY per stream and cannot know how many
49
+ // windows are watching it; the relay does, and that is the number an
50
+ // operator needs when the session is shared. Scoped callers only receive
51
+ // the PTYs belonging to their authenticated host.
52
+ const ptys = scopedHosts.flatMap((host) => host.ptys.map((pty) => ({
53
+ ...pty,
54
+ ...(scopeHostId ? {} : { hostId: host.id }),
55
+ activeClients: host.clients.size,
56
+ })));
57
+ // The paired-device roster identifies people's hardware, so it is served to
58
+ // the relay operator only — never on /api/status, which any paired device
59
+ // may read.
60
+ const devices = includeDevices ? pairedDevices : undefined;
61
+ const address = relay.address();
62
+ return {
63
+ ...relay.metrics.snapshot({
64
+ clients,
65
+ hosts,
66
+ ptys,
67
+ sample,
68
+ scopeHostId,
69
+ // Counted from the unredacted records: the mapped rows above only carry
70
+ // `deviceId` for the operator, and a public caller must still see the
71
+ // same number of users the operator does.
72
+ activeUserCount: countActiveUsers(scopedClients),
73
+ }),
74
+ ...(devices ? { devices } : {}),
75
+ relayMode: relay.relayMode,
76
+ isRemoteRelay: relay.relayMode === 'remote',
77
+ remoteAdminUrl: relay.relayMode === 'remote'
78
+ ? `${String(relay.config.relay.publicUrl || '').replace(/\/+$/, '')}/admin`
79
+ : undefined,
80
+ relay: {
81
+ mode: relay.relayMode,
82
+ publicUrl: relay.config.relay.publicUrl,
83
+ bind: relay.config.relay.host,
84
+ port: (address && 'port' in address ? address.port : undefined) || relay.config.relay.port,
85
+ maxClientsPerHost: relay.config.relay.maxClientsPerHost,
86
+ maxHosts: relay.config.relay.maxHosts,
87
+ maxPendingHandshakes: relay.config.relay.maxPendingHandshakes,
88
+ maxBufferedBytesPerClient: relay.config.relay.maxBufferedBytesPerClient,
89
+ hostReconnectGraceMs: relay.config.relay.hostReconnectGraceMs,
90
+ adminConfigured: Boolean(relay.adminToken),
91
+ adminStatusPath: '/api/admin/status',
92
+ dashboardPath: '/admin',
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,16 @@
1
+ import type { RelayClient, RelayContext, RelayHost, RelaySocket } from './types.js';
2
+ /** Notify the host whether any browser currently needs business telemetry. */
3
+ export declare function notifyHostClientCount(host: RelayHost | null | undefined): void;
4
+ /**
5
+ * Forward output without allowing one slow browser to grow an unbounded ws
6
+ * queue. Closing only that browser preserves low latency for the other views.
7
+ */
8
+ export declare function sendClientBinary(relay: RelayContext, host: RelayHost, client: RelayClient | null | undefined, payload: Buffer): boolean;
9
+ /**
10
+ * Queue artificial delay per-socket rather than using naked setTimeout calls.
11
+ * Concurrent timers experience event loop jitter that can deliver frames out of order;
12
+ * a single FIFO queue per socket guarantees strict in-order delivery of terminal frames.
13
+ */
14
+ export declare function enqueueDelayedSend(relay: RelayContext, socket: RelaySocket, task: () => void): void;
15
+ /** Send one JSON message to every browser attached to `host`. */
16
+ export declare function broadcastToClients(relay: RelayContext, host: RelayHost, build: (client: RelayClient) => unknown): void;