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