herdr-remote-relay 0.3.11 → 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-DKt8KLog.css +32 -0
- 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-2ut7QeyF.css +0 -32
- package/web/dist/assets/index-cIIMc3UE.js +0 -61
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel-report recognition for read-only clients.
|
|
3
|
+
*
|
|
4
|
+
* A viewer holds no control lease and may not type into the terminal. It may
|
|
5
|
+
* still scroll, for the same reason `resize` does not require the lease: every
|
|
6
|
+
* client drives its *own* PTY stream (`session_start` is emitted per client
|
|
7
|
+
* with `streamId: client.id`), so a wheel report moves only that viewer's own
|
|
8
|
+
* screen and cannot disturb the controller.
|
|
9
|
+
*
|
|
10
|
+
* This module is the trust boundary for that allowance, so the match is exact
|
|
11
|
+
* rather than heuristic. A frame is forwarded only when it consists entirely
|
|
12
|
+
* of wheel reports in the encodings a terminal emits; a single stray byte —
|
|
13
|
+
* a keystroke, a click, a drag — rejects the whole frame.
|
|
14
|
+
*/
|
|
15
|
+
const ESC = 0x1b;
|
|
16
|
+
const LEFT_BRACKET = 0x5b; // [
|
|
17
|
+
const LESS_THAN = 0x3c; // <
|
|
18
|
+
const SEMICOLON = 0x3b; // ;
|
|
19
|
+
const UPPER_M = 0x4d; // M
|
|
20
|
+
const ZERO = 0x30;
|
|
21
|
+
const NINE = 0x39;
|
|
22
|
+
/**
|
|
23
|
+
* The wheel sets bit 64 of the event code; bit 1 is up/down and bits 4/8/16
|
|
24
|
+
* are shift/alt/ctrl. Every other bit belongs to something that is not a
|
|
25
|
+
* wheel: bit 32 marks motion, bits 2 and 128 select other buttons.
|
|
26
|
+
*/
|
|
27
|
+
const WHEEL_BASE = 64;
|
|
28
|
+
const WHEEL_CODE_MASK = ~(1 | 4 | 8 | 16);
|
|
29
|
+
/** Single-byte (X10) parameters are biased by 32 and cannot exceed one byte. */
|
|
30
|
+
const X10_PARAM_BIAS = 32;
|
|
31
|
+
const X10_PARAM_MAX = 255;
|
|
32
|
+
function isWheelCode(code) {
|
|
33
|
+
return Number.isInteger(code) && (code & WHEEL_CODE_MASK) === WHEEL_BASE;
|
|
34
|
+
}
|
|
35
|
+
function isDigit(byte) {
|
|
36
|
+
return byte >= ZERO && byte <= NINE;
|
|
37
|
+
}
|
|
38
|
+
/** Reads one decimal parameter. */
|
|
39
|
+
function readNumber(bytes, start) {
|
|
40
|
+
let index = start;
|
|
41
|
+
let value = 0;
|
|
42
|
+
while (index < bytes.length && isDigit(bytes[index])) {
|
|
43
|
+
value = value * 10 + (bytes[index] - ZERO);
|
|
44
|
+
// A parameter long enough to overflow is not something a terminal emits.
|
|
45
|
+
if (value > 0xffff)
|
|
46
|
+
return null;
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
if (index === start)
|
|
50
|
+
return null;
|
|
51
|
+
return { value, next: index };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Matches `ESC [ < Pb ; Px ; Py M`, the SGR and SGR-pixels encodings.
|
|
55
|
+
* @returns offset after the report, or -1
|
|
56
|
+
*/
|
|
57
|
+
function matchSgrWheel(bytes, start) {
|
|
58
|
+
if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== LESS_THAN) {
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
const code = readNumber(bytes, start + 3);
|
|
62
|
+
if (!code || !isWheelCode(code.value) || bytes[code.next] !== SEMICOLON)
|
|
63
|
+
return -1;
|
|
64
|
+
const first = readNumber(bytes, code.next + 1);
|
|
65
|
+
if (!first || bytes[first.next] !== SEMICOLON)
|
|
66
|
+
return -1;
|
|
67
|
+
const second = readNumber(bytes, first.next + 1);
|
|
68
|
+
if (!second)
|
|
69
|
+
return -1;
|
|
70
|
+
// A wheel is never released, so the terminator is always `M`. Accepting `m`
|
|
71
|
+
// here would admit button releases, which are not scrolling.
|
|
72
|
+
if (bytes[second.next] !== UPPER_M)
|
|
73
|
+
return -1;
|
|
74
|
+
return second.next + 1;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Matches `ESC [ M Pb Px Py`, the default single-byte encoding.
|
|
78
|
+
* @returns offset after the report, or -1
|
|
79
|
+
*/
|
|
80
|
+
function matchX10Wheel(bytes, start) {
|
|
81
|
+
if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== UPPER_M) {
|
|
82
|
+
return -1;
|
|
83
|
+
}
|
|
84
|
+
if (start + 6 > bytes.length)
|
|
85
|
+
return -1;
|
|
86
|
+
const code = bytes[start + 3] - X10_PARAM_BIAS;
|
|
87
|
+
const column = bytes[start + 4];
|
|
88
|
+
const row = bytes[start + 5];
|
|
89
|
+
if (!isWheelCode(code))
|
|
90
|
+
return -1;
|
|
91
|
+
if (column < X10_PARAM_BIAS || column > X10_PARAM_MAX)
|
|
92
|
+
return -1;
|
|
93
|
+
if (row < X10_PARAM_BIAS || row > X10_PARAM_MAX)
|
|
94
|
+
return -1;
|
|
95
|
+
return start + 6;
|
|
96
|
+
}
|
|
97
|
+
/** Whether every byte of `payload`, a raw client input frame, belongs to a wheel report. */
|
|
98
|
+
function isWheelOnlyInput(payload) {
|
|
99
|
+
if (!payload)
|
|
100
|
+
return false;
|
|
101
|
+
const bytes = payload instanceof Uint8Array
|
|
102
|
+
? payload
|
|
103
|
+
: payload instanceof ArrayBuffer
|
|
104
|
+
? new Uint8Array(payload)
|
|
105
|
+
: null;
|
|
106
|
+
if (!bytes || bytes.length === 0)
|
|
107
|
+
return false;
|
|
108
|
+
let offset = 0;
|
|
109
|
+
while (offset < bytes.length) {
|
|
110
|
+
const sgr = matchSgrWheel(bytes, offset);
|
|
111
|
+
if (sgr > offset) {
|
|
112
|
+
offset = sgr;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const x10 = matchX10Wheel(bytes, offset);
|
|
116
|
+
if (x10 > offset) {
|
|
117
|
+
offset = x10;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
export { isWheelOnlyInput };
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// The browser side of the relay: a window's WebSocket, from its hello and
|
|
2
|
+
// pairing to every message and keystroke it sends after.
|
|
3
|
+
import { CAPABILITY, FRAME_TYPE_INPUT, PASTE_MAX_BYTES, PROTOCOL_VERSION, hasImageSignature, isPasteImageMime, packStreamFrame, packStreamFrameV2, } from '../protocol/index.js';
|
|
4
|
+
import { requestFontChunk, requestFontSubset } from './font-proxy.js';
|
|
5
|
+
import { allowClientHandshake, allowPairAttempt, clientAddress } from './requests.js';
|
|
6
|
+
import { MIN_SESSION_COLS, MIN_SESSION_ROWS, beginHostReconnect, broadcastControlState, detachClient, startSession, } from './sessions.js';
|
|
7
|
+
import { clampDimension, closeSocket, isOpen, jsonSend, parseJson, randomId, rejectHandshake, } from './sockets.js';
|
|
8
|
+
import { enqueueDelayedSend, notifyHostClientCount } from './transport.js';
|
|
9
|
+
/** A window asks to start its workstation's Herdr at most this often. */
|
|
10
|
+
const HERDR_START_REPEAT_MS = 3_000;
|
|
11
|
+
/** A window asks its workstation to re-read the terminal font at most this often. */
|
|
12
|
+
const FONT_REFRESH_REPEAT_MS = 2_000;
|
|
13
|
+
function verifyImageMagicBytes(mime, dataBase64) {
|
|
14
|
+
if (typeof dataBase64 !== 'string' || dataBase64.length === 0)
|
|
15
|
+
return false;
|
|
16
|
+
let headerBuf;
|
|
17
|
+
try {
|
|
18
|
+
headerBuf = Buffer.from(dataBase64.slice(0, 32), 'base64');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (headerBuf.length === 0)
|
|
24
|
+
return false;
|
|
25
|
+
return hasImageSignature(mime, headerBuf);
|
|
26
|
+
}
|
|
27
|
+
export function handleClientConnection(relay, ws, req) {
|
|
28
|
+
const pending = { ws, req, authenticated: false };
|
|
29
|
+
const deadline = setTimeout(() => {
|
|
30
|
+
if (!pending.authenticated)
|
|
31
|
+
closeSocket(ws, 1008, 'client hello timeout');
|
|
32
|
+
}, 10000);
|
|
33
|
+
ws.isAlive = true;
|
|
34
|
+
ws.on('pong', () => {
|
|
35
|
+
ws.isAlive = true;
|
|
36
|
+
if (pending.client) {
|
|
37
|
+
pending.client.lastSeenAt = Date.now();
|
|
38
|
+
pending.client.lastPingAt = new Date().toISOString();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
ws.on('ping', () => {
|
|
42
|
+
ws.isAlive = true;
|
|
43
|
+
if (pending.client) {
|
|
44
|
+
pending.client.lastSeenAt = Date.now();
|
|
45
|
+
pending.client.lastPingAt = new Date().toISOString();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
ws.on('message', (raw, isBinary) => {
|
|
49
|
+
if (!pending.authenticated) {
|
|
50
|
+
if (isBinary)
|
|
51
|
+
return rejectHandshake(ws, 'client hello must be JSON');
|
|
52
|
+
const message = parseJson(raw.toString());
|
|
53
|
+
if (message?.type !== 'hello' || message.protocol !== PROTOCOL_VERSION)
|
|
54
|
+
return rejectHandshake(ws, 'invalid client hello');
|
|
55
|
+
if (!allowClientHandshake(relay, req))
|
|
56
|
+
return rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
|
|
57
|
+
let device = null;
|
|
58
|
+
let paired = null;
|
|
59
|
+
if (message.pairCode) {
|
|
60
|
+
if (!allowPairAttempt(relay, req))
|
|
61
|
+
return rejectHandshake(ws, 'too many pairing attempts', 'rate_limited');
|
|
62
|
+
paired = relay.auth.completePairing(message.pairCode);
|
|
63
|
+
}
|
|
64
|
+
if (paired)
|
|
65
|
+
device = paired;
|
|
66
|
+
else if (message.token)
|
|
67
|
+
device = relay.auth.authenticateDevice(message.token);
|
|
68
|
+
if (!device)
|
|
69
|
+
return rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
|
|
70
|
+
const host = relay.hosts.get(device.hostId);
|
|
71
|
+
if (!host || host.reconnecting || !isOpen(host.ws))
|
|
72
|
+
return rejectHandshake(ws, 'paired Herdr host is offline', host?.reconnecting ? 'host_reconnecting' : 'host_offline');
|
|
73
|
+
// Two tabs of one browser are separate windows with their own PTY sessions,
|
|
74
|
+
// not rivals. Nothing is retired here: each connection gets its own stream
|
|
75
|
+
// without evicting existing clients.
|
|
76
|
+
if (host.clients.size >= relay.config.relay.maxClientsPerHost)
|
|
77
|
+
return rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
|
|
78
|
+
clearTimeout(deadline);
|
|
79
|
+
relay.finishHandshake(ws);
|
|
80
|
+
const clientId = randomId('client');
|
|
81
|
+
const client = {
|
|
82
|
+
id: clientId,
|
|
83
|
+
ws,
|
|
84
|
+
hostId: host.id,
|
|
85
|
+
deviceId: device.deviceId,
|
|
86
|
+
// Stable per browser profile; used to recognise a reconnect from the
|
|
87
|
+
// same browser rather than a genuinely separate viewer.
|
|
88
|
+
browserClientId: typeof message.clientId === 'string' ? message.clientId.slice(0, 128) : null,
|
|
89
|
+
handoffCapable: Array.isArray(message.capabilities) &&
|
|
90
|
+
message.capabilities.includes(CAPABILITY.hostHandoff),
|
|
91
|
+
session: null,
|
|
92
|
+
// Every paired window gets its own interactive PTY session. Pairing
|
|
93
|
+
// is the permission boundary; once a device is through it, every
|
|
94
|
+
// window can type into its own terminal.
|
|
95
|
+
role: 'controller',
|
|
96
|
+
controllerId: null,
|
|
97
|
+
connectedAt: new Date().toISOString(),
|
|
98
|
+
connectedAtMs: Date.now(),
|
|
99
|
+
lastSeenAt: Date.now(),
|
|
100
|
+
lastPingAt: null,
|
|
101
|
+
bytesReceived: 0,
|
|
102
|
+
bytesSent: 0,
|
|
103
|
+
ip: clientAddress(relay, req),
|
|
104
|
+
userAgent: String(req.headers['user-agent'] || '').slice(0, 256),
|
|
105
|
+
cols: clampDimension(message.cols, 80),
|
|
106
|
+
rows: clampDimension(message.rows, 24),
|
|
107
|
+
};
|
|
108
|
+
client.controllerId = null;
|
|
109
|
+
// Persist how this device identifies itself so the operator dashboard
|
|
110
|
+
// can name it in the revoke list instead of showing a bare device id.
|
|
111
|
+
relay.auth.noteDeviceSeen(device.deviceId, { userAgent: client.userAgent, ip: client.ip });
|
|
112
|
+
pending.authenticated = true;
|
|
113
|
+
pending.client = client;
|
|
114
|
+
relay.clients.set(client.id, client);
|
|
115
|
+
host.clients.add(client.id);
|
|
116
|
+
notifyHostClientCount(host);
|
|
117
|
+
ws.isAlive = true;
|
|
118
|
+
if (paired)
|
|
119
|
+
jsonSend(ws, {
|
|
120
|
+
type: 'paired',
|
|
121
|
+
token: paired.token,
|
|
122
|
+
deviceId: paired.deviceId,
|
|
123
|
+
hostId: paired.hostId,
|
|
124
|
+
expiresAt: paired.expiresAtIso,
|
|
125
|
+
});
|
|
126
|
+
// Expose the relay-assigned connection id so clients can distinguish
|
|
127
|
+
// their own controller lease from another device's lease. The browser
|
|
128
|
+
// supplied clientId identifies a device, not this live WebSocket.
|
|
129
|
+
jsonSend(ws, {
|
|
130
|
+
type: 'ready',
|
|
131
|
+
role: client.role,
|
|
132
|
+
// There is no controller to name: every window has full input. The
|
|
133
|
+
// field stays in the message for clients built against protocol 1,
|
|
134
|
+
// which read it to decide whether somebody else held the lease — and
|
|
135
|
+
// `null` is exactly the answer that means "nobody does".
|
|
136
|
+
controllerId: null,
|
|
137
|
+
hostId: host.id,
|
|
138
|
+
hostname: host.hostname,
|
|
139
|
+
clientId: client.id,
|
|
140
|
+
// Delivered with `ready`, before the first PTY byte, so the terminal
|
|
141
|
+
// is painted in the host's colors from its very first frame.
|
|
142
|
+
terminalPalette: host.terminalPalette || null,
|
|
143
|
+
terminalFont: host.terminalFont || null,
|
|
144
|
+
clientCount: host.clients.size,
|
|
145
|
+
});
|
|
146
|
+
if (host.agentStatus)
|
|
147
|
+
jsonSend(ws, host.agentStatus);
|
|
148
|
+
if (host.updateStatus)
|
|
149
|
+
jsonSend(ws, host.updateStatus);
|
|
150
|
+
startSession(relay, host, client);
|
|
151
|
+
broadcastControlState(relay, host);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (pending.client)
|
|
155
|
+
handleClientMessage(relay, pending.client, raw, isBinary);
|
|
156
|
+
});
|
|
157
|
+
ws.on('close', () => {
|
|
158
|
+
clearTimeout(deadline);
|
|
159
|
+
if (pending.client)
|
|
160
|
+
detachClient(relay, pending.client, { notify: true });
|
|
161
|
+
});
|
|
162
|
+
ws.on('error', () => { });
|
|
163
|
+
}
|
|
164
|
+
function handleClientMessage(relay, client, raw, isBinary) {
|
|
165
|
+
client.lastSeenAt = Date.now();
|
|
166
|
+
const host = relay.hosts.get(client.hostId);
|
|
167
|
+
if (!host) {
|
|
168
|
+
detachClient(relay, client, { notify: true, reason: 'host_offline' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (isBinary) {
|
|
172
|
+
// The server keeps ws's default binaryType, so a binary frame is one Buffer.
|
|
173
|
+
const bytes = raw;
|
|
174
|
+
if (bytes.length > relay.config.relay.maxPayloadBytes)
|
|
175
|
+
return;
|
|
176
|
+
if (!client.session)
|
|
177
|
+
return;
|
|
178
|
+
// Each window owns its own PTY session, so input is stamped with the
|
|
179
|
+
// client's dedicated stream id or streamIndex.
|
|
180
|
+
const frame = host.binaryFrameV2 && typeof client.session.streamIndex === 'number'
|
|
181
|
+
? packStreamFrameV2(FRAME_TYPE_INPUT, client.session.streamIndex, bytes)
|
|
182
|
+
: packStreamFrame('input', client.session.streamId, bytes);
|
|
183
|
+
if (isOpen(host.ws)) {
|
|
184
|
+
const doSend = () => {
|
|
185
|
+
if (!isOpen(host.ws))
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
host.ws.send(frame);
|
|
189
|
+
client.bytesReceived += bytes.length;
|
|
190
|
+
relay.metrics.recordIn(bytes.length, host.id);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
beginHostReconnect(relay, host, 'host_send_failed');
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
if (relay.devLatencyMs > 0)
|
|
197
|
+
enqueueDelayedSend(relay, host.ws, doSend);
|
|
198
|
+
else
|
|
199
|
+
doSend();
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Untrusted: each branch reads only the fields its message type defines.
|
|
204
|
+
const message = parseJson(raw.toString());
|
|
205
|
+
if (!message) {
|
|
206
|
+
jsonSend(client.ws, { type: 'error', code: 'invalid_json', message: 'message must be JSON' });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (message.type === 'ping') {
|
|
210
|
+
client.lastPingAt = new Date().toISOString();
|
|
211
|
+
jsonSend(client.ws, { type: 'pong' });
|
|
212
|
+
}
|
|
213
|
+
else if (message.type === 'resize') {
|
|
214
|
+
// Each client owns its own PTY session, so resizing directly forwards
|
|
215
|
+
// the new geometry for this client's stream to the host.
|
|
216
|
+
client.cols = clampDimension(message.cols, client.cols);
|
|
217
|
+
client.rows = clampDimension(message.rows, client.rows);
|
|
218
|
+
if (client.session) {
|
|
219
|
+
client.session.cols = client.cols;
|
|
220
|
+
client.session.rows = client.rows;
|
|
221
|
+
if (isOpen(host.ws)) {
|
|
222
|
+
jsonSend(host.ws, {
|
|
223
|
+
type: 'resize',
|
|
224
|
+
clientId: client.session.streamId,
|
|
225
|
+
streamId: client.session.streamId,
|
|
226
|
+
cols: Math.max(MIN_SESSION_COLS, client.cols),
|
|
227
|
+
rows: Math.max(MIN_SESSION_ROWS, client.rows),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (message.type === 'herdr_start') {
|
|
233
|
+
// The window's own workstation, and nobody else's: `host` was fixed by
|
|
234
|
+
// the device token at handshake, and nothing in this message can name
|
|
235
|
+
// another one. The host starts Herdr as its own user, at its own socket.
|
|
236
|
+
// Repeats are dropped — one click is enough, and the host shares a
|
|
237
|
+
// start between windows anyway.
|
|
238
|
+
if (!client.session) {
|
|
239
|
+
jsonSend(client.ws, {
|
|
240
|
+
type: 'error',
|
|
241
|
+
code: 'no_session',
|
|
242
|
+
message: 'terminal session is not ready',
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
if (client.herdrStartAt && now - client.herdrStartAt < HERDR_START_REPEAT_MS)
|
|
248
|
+
return;
|
|
249
|
+
client.herdrStartAt = now;
|
|
250
|
+
if (isOpen(host.ws)) {
|
|
251
|
+
jsonSend(host.ws, {
|
|
252
|
+
type: 'herdr_start',
|
|
253
|
+
clientId: client.session.streamId,
|
|
254
|
+
streamId: client.session.streamId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (message.type === 'host_font_chunk_request') {
|
|
259
|
+
requestFontChunk(host, client, message);
|
|
260
|
+
}
|
|
261
|
+
else if (message.type === 'host_font_subset_request') {
|
|
262
|
+
requestFontSubset(host, client, message);
|
|
263
|
+
}
|
|
264
|
+
else if (message.type === 'host_font_refresh') {
|
|
265
|
+
// Same routing as herdr_start: only the workstation this window is
|
|
266
|
+
// bound to, and only what it offered. Repeats inside the window are
|
|
267
|
+
// dropped; the answer is broadcast to every window anyway.
|
|
268
|
+
if (!client.session || !isOpen(host.ws))
|
|
269
|
+
return;
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
if (client.fontRefreshAt && now - client.fontRefreshAt < FONT_REFRESH_REPEAT_MS)
|
|
272
|
+
return;
|
|
273
|
+
client.fontRefreshAt = now;
|
|
274
|
+
jsonSend(host.ws, {
|
|
275
|
+
type: 'host_font_refresh',
|
|
276
|
+
clientId: client.session.streamId,
|
|
277
|
+
streamId: client.session.streamId,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
else if (message.type === 'claim_control') {
|
|
281
|
+
// Control is no longer a lease. Answering the old request keeps clients
|
|
282
|
+
// built against the previous protocol working.
|
|
283
|
+
client.role = 'controller';
|
|
284
|
+
jsonSend(client.ws, { type: 'control_granted' });
|
|
285
|
+
}
|
|
286
|
+
else if (message.type === 'release_control') {
|
|
287
|
+
// Nothing to release: the window keeps its input either way, and saying
|
|
288
|
+
// so beats a silence an older client would wait on.
|
|
289
|
+
jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null });
|
|
290
|
+
}
|
|
291
|
+
else if (message.type === 'paste_file') {
|
|
292
|
+
if (client.role === 'viewer') {
|
|
293
|
+
jsonSend(client.ws, {
|
|
294
|
+
type: 'error',
|
|
295
|
+
code: 'viewer_mode',
|
|
296
|
+
message: 'Viewer mode cannot paste to terminal',
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!isPasteImageMime(message.mime)) {
|
|
301
|
+
jsonSend(client.ws, {
|
|
302
|
+
type: 'error',
|
|
303
|
+
code: 'paste_file_unsupported',
|
|
304
|
+
message: 'Unsupported paste image format',
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (typeof message.dataBase64 !== 'string') {
|
|
309
|
+
jsonSend(client.ws, {
|
|
310
|
+
type: 'error',
|
|
311
|
+
code: 'paste_file_unsupported',
|
|
312
|
+
message: 'Invalid paste image payload',
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const rawLength = Buffer.byteLength(message.dataBase64, 'base64');
|
|
317
|
+
if (rawLength === 0) {
|
|
318
|
+
jsonSend(client.ws, {
|
|
319
|
+
type: 'error',
|
|
320
|
+
code: 'paste_file_empty',
|
|
321
|
+
message: 'Pasted image payload is empty',
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (rawLength > PASTE_MAX_BYTES) {
|
|
326
|
+
jsonSend(client.ws, {
|
|
327
|
+
type: 'error',
|
|
328
|
+
code: 'paste_file_too_large',
|
|
329
|
+
message: 'Pasted image exceeds 3 MB limit',
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!verifyImageMagicBytes(message.mime, message.dataBase64)) {
|
|
334
|
+
jsonSend(client.ws, {
|
|
335
|
+
type: 'error',
|
|
336
|
+
code: 'paste_file_unsupported',
|
|
337
|
+
message: 'Pasted image content does not match declared MIME type',
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!isOpen(host?.ws)) {
|
|
342
|
+
jsonSend(client.ws, {
|
|
343
|
+
type: 'error',
|
|
344
|
+
code: 'host_offline',
|
|
345
|
+
message: 'Host is offline or disconnected',
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!client.session) {
|
|
350
|
+
jsonSend(client.ws, {
|
|
351
|
+
type: 'error',
|
|
352
|
+
code: 'no_session',
|
|
353
|
+
message: 'Terminal session is not ready',
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
jsonSend(host.ws, {
|
|
358
|
+
type: 'paste_file',
|
|
359
|
+
clientId: client.session.streamId,
|
|
360
|
+
streamId: client.session.streamId,
|
|
361
|
+
mime: message.mime,
|
|
362
|
+
dataBase64: message.dataBase64,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ClientHostFontChunkRequestMessage, type ClientHostFontSubsetRequestMessage, type HostFontChunkMessage, type HostFontSubsetMessage } from '../protocol/index.js';
|
|
2
|
+
import type { RelayClient, RelayHost } from './types.js';
|
|
3
|
+
/** One outstanding font chunk request of this window was answered. */
|
|
4
|
+
export declare function settleFontChunk(client: RelayClient): void;
|
|
5
|
+
/** One outstanding font subset request of this window was answered. */
|
|
6
|
+
export declare function settleFontSubset(client: RelayClient): void;
|
|
7
|
+
/**
|
|
8
|
+
* Pass a browser's request for one slice of a font file to its workstation.
|
|
9
|
+
* Only a file the workstation announced can be asked for, by its hash, and
|
|
10
|
+
* only a few slices at a time.
|
|
11
|
+
*/
|
|
12
|
+
export declare function requestFontChunk(host: RelayHost, client: RelayClient, message: ClientHostFontChunkRequestMessage): void;
|
|
13
|
+
/**
|
|
14
|
+
* Ask the workstation to cut characters out of a large font it announced.
|
|
15
|
+
* The text is only characters to look up; a few requests at a time.
|
|
16
|
+
*/
|
|
17
|
+
export declare function requestFontSubset(host: RelayHost, client: RelayClient, message: ClientHostFontSubsetRequestMessage): void;
|
|
18
|
+
export declare function forwardFontSubset(client: RelayClient, message: HostFontSubsetMessage): void;
|
|
19
|
+
export declare function forwardFontChunk(client: RelayClient, message: HostFontChunkMessage): void;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Terminal font files and cuts, passed between a window and its workstation a
|
|
2
|
+
// slice at a time, and only for what the workstation announced.
|
|
3
|
+
import { TERMINAL_FONT_CHUNK_BYTES, } from '../protocol/index.js';
|
|
4
|
+
import { isOpen, jsonSend } from './sockets.js';
|
|
5
|
+
/**
|
|
6
|
+
* Font chunks a window may have outstanding. Fonts are pulled a slice at a
|
|
7
|
+
* time so megabytes of font never queue ahead of terminal output; an answer
|
|
8
|
+
* that never comes stops counting after the timeout.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_FONT_CHUNKS_IN_FLIGHT = 4;
|
|
11
|
+
const FONT_CHUNK_TIMEOUT_MS = 30_000;
|
|
12
|
+
/** Base64 of one full chunk, the most a `host_font_chunk` may carry. */
|
|
13
|
+
const MAX_FONT_CHUNK_BASE64 = Math.ceil(TERMINAL_FONT_CHUNK_BYTES / 3) * 4;
|
|
14
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
15
|
+
/** Characters one subset request may ask for (UTF-16 units of `text`). */
|
|
16
|
+
const MAX_FONT_SUBSET_TEXT = 32 * 1024;
|
|
17
|
+
/** Cut fonts a window may pull by hash; older ones stop being fetchable. */
|
|
18
|
+
const MAX_FONT_SUBSETS_PER_CLIENT = 64;
|
|
19
|
+
const MAX_FONT_SUBSET_BYTES = 16 * 1024 * 1024;
|
|
20
|
+
const REQUEST_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
21
|
+
/** One outstanding font chunk request of this window was answered. */
|
|
22
|
+
export function settleFontChunk(client) {
|
|
23
|
+
if (Array.isArray(client.fontChunkRequests) && client.fontChunkRequests.length)
|
|
24
|
+
client.fontChunkRequests.shift();
|
|
25
|
+
}
|
|
26
|
+
/** One outstanding font subset request of this window was answered. */
|
|
27
|
+
export function settleFontSubset(client) {
|
|
28
|
+
if (Array.isArray(client.fontSubsetRequests) && client.fontSubsetRequests.length)
|
|
29
|
+
client.fontSubsetRequests.shift();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Pass a browser's request for one slice of a font file to its workstation.
|
|
33
|
+
* Only a file the workstation announced can be asked for, by its hash, and
|
|
34
|
+
* only a few slices at a time.
|
|
35
|
+
*/
|
|
36
|
+
export function requestFontChunk(host, client, message) {
|
|
37
|
+
if (!client.session || !isOpen(host.ws))
|
|
38
|
+
return;
|
|
39
|
+
const face = host.terminalFont?.faces?.find((candidate) => candidate.sha256 === message.sha256);
|
|
40
|
+
// A whole font file the workstation announced, or a cut it made for this window.
|
|
41
|
+
const bytes = face ? face.bytes : client.fontSubsets?.get(message.sha256) || 0;
|
|
42
|
+
const total = Math.ceil(bytes / TERMINAL_FONT_CHUNK_BYTES);
|
|
43
|
+
if (!bytes || !Number.isInteger(message.index) || message.index < 0 || message.index >= total) {
|
|
44
|
+
jsonSend(client.ws, {
|
|
45
|
+
type: 'error',
|
|
46
|
+
code: 'host_font_unavailable',
|
|
47
|
+
message: 'The workstation did not offer this font file',
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
client.fontChunkRequests = (client.fontChunkRequests || []).filter((at) => now - at < FONT_CHUNK_TIMEOUT_MS);
|
|
53
|
+
if (client.fontChunkRequests.length >= MAX_FONT_CHUNKS_IN_FLIGHT)
|
|
54
|
+
return;
|
|
55
|
+
client.fontChunkRequests.push(now);
|
|
56
|
+
jsonSend(host.ws, {
|
|
57
|
+
type: 'host_font_chunk_request',
|
|
58
|
+
clientId: client.session.streamId,
|
|
59
|
+
streamId: client.session.streamId,
|
|
60
|
+
sha256: message.sha256,
|
|
61
|
+
index: message.index,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Ask the workstation to cut characters out of a large font it announced.
|
|
66
|
+
* The text is only characters to look up; a few requests at a time.
|
|
67
|
+
*/
|
|
68
|
+
export function requestFontSubset(host, client, message) {
|
|
69
|
+
if (!client.session || !isOpen(host.ws))
|
|
70
|
+
return;
|
|
71
|
+
const known = host.terminalFont?.subsets?.some((source) => source.sha256 === message.sha256);
|
|
72
|
+
const valid = known &&
|
|
73
|
+
typeof message.text === 'string' &&
|
|
74
|
+
message.text.length > 0 &&
|
|
75
|
+
message.text.length <= MAX_FONT_SUBSET_TEXT &&
|
|
76
|
+
typeof message.requestId === 'string' &&
|
|
77
|
+
REQUEST_ID.test(message.requestId);
|
|
78
|
+
if (!valid) {
|
|
79
|
+
jsonSend(client.ws, {
|
|
80
|
+
type: 'error',
|
|
81
|
+
code: 'host_font_unavailable',
|
|
82
|
+
message: 'The workstation did not offer this font',
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
client.fontSubsetRequests = (client.fontSubsetRequests || []).filter((at) => now - at < FONT_CHUNK_TIMEOUT_MS);
|
|
88
|
+
if (client.fontSubsetRequests.length >= MAX_FONT_CHUNKS_IN_FLIGHT)
|
|
89
|
+
return;
|
|
90
|
+
client.fontSubsetRequests.push(now);
|
|
91
|
+
jsonSend(host.ws, {
|
|
92
|
+
type: 'host_font_subset_request',
|
|
93
|
+
clientId: client.session.streamId,
|
|
94
|
+
streamId: client.session.streamId,
|
|
95
|
+
requestId: message.requestId,
|
|
96
|
+
sha256: message.sha256,
|
|
97
|
+
text: message.text,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export function forwardFontSubset(client, message) {
|
|
101
|
+
settleFontSubset(client);
|
|
102
|
+
const valid = typeof message.sha256 === 'string' &&
|
|
103
|
+
SHA256_HEX.test(message.sha256) &&
|
|
104
|
+
typeof message.subsetSha === 'string' &&
|
|
105
|
+
SHA256_HEX.test(message.subsetSha) &&
|
|
106
|
+
typeof message.requestId === 'string' &&
|
|
107
|
+
REQUEST_ID.test(message.requestId) &&
|
|
108
|
+
Number.isInteger(message.bytes) &&
|
|
109
|
+
message.bytes > 0 &&
|
|
110
|
+
message.bytes <= MAX_FONT_SUBSET_BYTES &&
|
|
111
|
+
(message.dataBase64 === undefined ||
|
|
112
|
+
(typeof message.dataBase64 === 'string' &&
|
|
113
|
+
message.dataBase64.length <= MAX_FONT_CHUNK_BASE64));
|
|
114
|
+
if (!valid)
|
|
115
|
+
return;
|
|
116
|
+
if (message.dataBase64 === undefined) {
|
|
117
|
+
// Pulled in slices next: make this hash fetchable for this window only.
|
|
118
|
+
client.fontSubsets ||= new Map();
|
|
119
|
+
client.fontSubsets.delete(message.subsetSha);
|
|
120
|
+
client.fontSubsets.set(message.subsetSha, message.bytes);
|
|
121
|
+
while (client.fontSubsets.size > MAX_FONT_SUBSETS_PER_CLIENT) {
|
|
122
|
+
const oldest = client.fontSubsets.keys().next().value;
|
|
123
|
+
if (oldest !== undefined)
|
|
124
|
+
client.fontSubsets.delete(oldest);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
jsonSend(client.ws, {
|
|
128
|
+
type: 'host_font_subset_ready',
|
|
129
|
+
requestId: message.requestId,
|
|
130
|
+
sha256: message.sha256,
|
|
131
|
+
subsetSha: message.subsetSha,
|
|
132
|
+
bytes: message.bytes,
|
|
133
|
+
...(message.dataBase64 !== undefined ? { dataBase64: message.dataBase64 } : {}),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export function forwardFontChunk(client, message) {
|
|
137
|
+
settleFontChunk(client);
|
|
138
|
+
const valid = typeof message.sha256 === 'string' &&
|
|
139
|
+
SHA256_HEX.test(message.sha256) &&
|
|
140
|
+
Number.isInteger(message.index) &&
|
|
141
|
+
message.index >= 0 &&
|
|
142
|
+
Number.isInteger(message.total) &&
|
|
143
|
+
message.total > message.index &&
|
|
144
|
+
typeof message.dataBase64 === 'string' &&
|
|
145
|
+
message.dataBase64.length <= MAX_FONT_CHUNK_BASE64;
|
|
146
|
+
if (!valid)
|
|
147
|
+
return;
|
|
148
|
+
jsonSend(client.ws, {
|
|
149
|
+
type: 'host_font_chunk',
|
|
150
|
+
sha256: message.sha256,
|
|
151
|
+
index: message.index,
|
|
152
|
+
total: message.total,
|
|
153
|
+
dataBase64: message.dataBase64,
|
|
154
|
+
});
|
|
155
|
+
}
|