herdr-remote-relay 0.2.0

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.
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wheel-report recognition for read-only clients.
5
+ *
6
+ * A viewer holds no control lease and may not type into the terminal. It may
7
+ * still scroll, for the same reason `resize` does not require the lease: every
8
+ * client drives its *own* PTY stream (`session_start` is emitted per client
9
+ * with `streamId: client.id`), so a wheel report moves only that viewer's own
10
+ * screen and cannot disturb the controller.
11
+ *
12
+ * This module is the trust boundary for that allowance, so the match is exact
13
+ * rather than heuristic. A frame is forwarded only when it consists entirely
14
+ * of wheel reports in the encodings a terminal emits; a single stray byte —
15
+ * a keystroke, a click, a drag — rejects the whole frame.
16
+ */
17
+
18
+ const ESC = 0x1b;
19
+ const LEFT_BRACKET = 0x5b; // [
20
+ const LESS_THAN = 0x3c; // <
21
+ const SEMICOLON = 0x3b; // ;
22
+ const UPPER_M = 0x4d; // M
23
+ const ZERO = 0x30;
24
+ const NINE = 0x39;
25
+
26
+ /**
27
+ * The wheel sets bit 64 of the event code; bit 1 is up/down and bits 4/8/16
28
+ * are shift/alt/ctrl. Every other bit belongs to something that is not a
29
+ * wheel: bit 32 marks motion, bits 2 and 128 select other buttons.
30
+ */
31
+ const WHEEL_BASE = 64;
32
+ const WHEEL_CODE_MASK = ~(1 | 4 | 8 | 16);
33
+
34
+ /** Single-byte (X10) parameters are biased by 32 and cannot exceed one byte. */
35
+ const X10_PARAM_BIAS = 32;
36
+ const X10_PARAM_MAX = 255;
37
+
38
+ function isWheelCode(code) {
39
+ return Number.isInteger(code) && (code & WHEEL_CODE_MASK) === WHEEL_BASE;
40
+ }
41
+
42
+ function isDigit(byte) {
43
+ return byte >= ZERO && byte <= NINE;
44
+ }
45
+
46
+ /**
47
+ * Reads one decimal parameter.
48
+ * @returns {{ value: number, next: number } | null}
49
+ */
50
+ function readNumber(bytes, start) {
51
+ let index = start;
52
+ let value = 0;
53
+ while (index < bytes.length && isDigit(bytes[index])) {
54
+ value = value * 10 + (bytes[index] - ZERO);
55
+ // A parameter long enough to overflow is not something a terminal emits.
56
+ if (value > 0xffff) return null;
57
+ index += 1;
58
+ }
59
+ if (index === start) return null;
60
+ return { value, next: index };
61
+ }
62
+
63
+ /**
64
+ * Matches `ESC [ < Pb ; Px ; Py M`, the SGR and SGR-pixels encodings.
65
+ * @returns {number} offset after the report, or -1
66
+ */
67
+ function matchSgrWheel(bytes, start) {
68
+ if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== LESS_THAN) {
69
+ return -1;
70
+ }
71
+
72
+ const code = readNumber(bytes, start + 3);
73
+ if (!code || !isWheelCode(code.value) || bytes[code.next] !== SEMICOLON) return -1;
74
+
75
+ const first = readNumber(bytes, code.next + 1);
76
+ if (!first || bytes[first.next] !== SEMICOLON) return -1;
77
+
78
+ const second = readNumber(bytes, first.next + 1);
79
+ if (!second) return -1;
80
+
81
+ // A wheel is never released, so the terminator is always `M`. Accepting `m`
82
+ // here would admit button releases, which are not scrolling.
83
+ if (bytes[second.next] !== UPPER_M) return -1;
84
+ return second.next + 1;
85
+ }
86
+
87
+ /**
88
+ * Matches `ESC [ M Pb Px Py`, the default single-byte encoding.
89
+ * @returns {number} offset after the report, or -1
90
+ */
91
+ function matchX10Wheel(bytes, start) {
92
+ if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== UPPER_M) {
93
+ return -1;
94
+ }
95
+ if (start + 6 > bytes.length) return -1;
96
+
97
+ const code = bytes[start + 3] - X10_PARAM_BIAS;
98
+ const column = bytes[start + 4];
99
+ const row = bytes[start + 5];
100
+ if (!isWheelCode(code)) return -1;
101
+ if (column < X10_PARAM_BIAS || column > X10_PARAM_MAX) return -1;
102
+ if (row < X10_PARAM_BIAS || row > X10_PARAM_MAX) return -1;
103
+ return start + 6;
104
+ }
105
+
106
+ /**
107
+ * Whether every byte of `payload` belongs to a wheel report.
108
+ *
109
+ * @param {Buffer | Uint8Array | ArrayBuffer} payload raw client input frame
110
+ * @returns {boolean}
111
+ */
112
+ function isWheelOnlyInput(payload) {
113
+ if (!payload) return false;
114
+ const bytes =
115
+ payload instanceof Uint8Array
116
+ ? payload
117
+ : payload instanceof ArrayBuffer
118
+ ? new Uint8Array(payload)
119
+ : null;
120
+ if (!bytes || bytes.length === 0) return false;
121
+
122
+ let offset = 0;
123
+ while (offset < bytes.length) {
124
+ const sgr = matchSgrWheel(bytes, offset);
125
+ if (sgr > offset) {
126
+ offset = sgr;
127
+ continue;
128
+ }
129
+ const x10 = matchX10Wheel(bytes, offset);
130
+ if (x10 > offset) {
131
+ offset = x10;
132
+ continue;
133
+ }
134
+ return false;
135
+ }
136
+ return true;
137
+ }
138
+
139
+ module.exports = { isWheelOnlyInput };
package/src/state.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ function ensureDir(dirPath) {
8
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
9
+ try {
10
+ fs.chmodSync(dirPath, 0o700);
11
+ } catch {}
12
+ }
13
+
14
+ function writeJsonAtomic(filePath, value) {
15
+ ensureDir(path.dirname(filePath));
16
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
17
+ fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
18
+ try {
19
+ fs.chmodSync(tempPath, 0o600);
20
+ } catch {}
21
+ fs.renameSync(tempPath, filePath);
22
+ try {
23
+ fs.chmodSync(filePath, 0o600);
24
+ } catch {}
25
+ }
26
+
27
+ function readJson(filePath, fallback) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
30
+ } catch (error) {
31
+ if (error.code !== 'ENOENT') {
32
+ process.stderr.write(`herdr-remote: invalid state at ${filePath}: ${error.message}\n`);
33
+ }
34
+ return fallback;
35
+ }
36
+ }
37
+
38
+ function randomToken(bytes = 32) {
39
+ return crypto.randomBytes(bytes).toString('base64url');
40
+ }
41
+
42
+ module.exports = {
43
+ ensureDir,
44
+ writeJsonAtomic,
45
+ readJson,
46
+ randomToken,
47
+ };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ // Wire protocol shared by the relay and the herdr-remote host connector. This
4
+ // module is the single source of truth for both sides: the CLI package imports
5
+ // it as `herdr-remote-relay/protocol` rather than keeping its own copy, so the
6
+ // framing and the version can never drift apart.
7
+
8
+ const PROTOCOL_VERSION = 1;
9
+ const MAX_HEADER_BYTES = 8 * 1024;
10
+
11
+ /**
12
+ * The sixteen ANSI slots a host may report, in index order. A palette is
13
+ * all-or-nothing: half the host's colors mixed with half the browser's would
14
+ * look worse than either set on its own.
15
+ */
16
+ const ANSI_PALETTE_KEYS = [
17
+ 'black',
18
+ 'red',
19
+ 'green',
20
+ 'yellow',
21
+ 'blue',
22
+ 'magenta',
23
+ 'cyan',
24
+ 'white',
25
+ 'brightBlack',
26
+ 'brightRed',
27
+ 'brightGreen',
28
+ 'brightYellow',
29
+ 'brightBlue',
30
+ 'brightMagenta',
31
+ 'brightCyan',
32
+ 'brightWhite',
33
+ ];
34
+
35
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
36
+
37
+ /**
38
+ * Validates a terminal palette crossing the wire.
39
+ *
40
+ * The host reports what its own terminal answered to the OSC color queries,
41
+ * and the browser paints with it. Anything that is not a plain `#rrggbb`
42
+ * string is dropped here, so a compromised or buggy host cannot push arbitrary
43
+ * data into a browser's renderer options.
44
+ */
45
+ function sanitizeTerminalPalette(value) {
46
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
47
+ const palette = {};
48
+ for (const key of ['background', 'foreground', 'cursor']) {
49
+ const color = value[key];
50
+ if (typeof color === 'string' && HEX_COLOR.test(color)) palette[key] = color.toLowerCase();
51
+ }
52
+ const ansi = value.ansi;
53
+ if (ansi && typeof ansi === 'object' && !Array.isArray(ansi)) {
54
+ const collected = {};
55
+ for (const key of ANSI_PALETTE_KEYS) {
56
+ const color = ansi[key];
57
+ if (typeof color === 'string' && HEX_COLOR.test(color)) collected[key] = color.toLowerCase();
58
+ }
59
+ if (Object.keys(collected).length === ANSI_PALETTE_KEYS.length) palette.ansi = collected;
60
+ }
61
+ return Object.keys(palette).length > 0 ? palette : null;
62
+ }
63
+
64
+ function packStreamFrame(type, streamId, payload = Buffer.alloc(0)) {
65
+ if (typeof type !== 'string' || !type || typeof streamId !== 'string' || !streamId) {
66
+ throw new TypeError('type and streamId must be non-empty strings');
67
+ }
68
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
69
+ const header = Buffer.from(JSON.stringify({ type, streamId }), 'utf8');
70
+ if (header.length > MAX_HEADER_BYTES) throw new RangeError('stream frame header is too large');
71
+ const length = Buffer.allocUnsafe(4);
72
+ length.writeUInt32BE(header.length, 0);
73
+ return Buffer.concat([length, header, body]);
74
+ }
75
+
76
+ function unpackStreamFrame(value) {
77
+ const frame = Buffer.isBuffer(value) ? value : Buffer.from(value);
78
+ if (frame.length < 4) throw new Error('stream frame is truncated');
79
+ const headerLength = frame.readUInt32BE(0);
80
+ if (headerLength === 0 || headerLength > MAX_HEADER_BYTES || frame.length < 4 + headerLength) {
81
+ throw new Error('stream frame header is invalid');
82
+ }
83
+ let header;
84
+ try {
85
+ header = JSON.parse(frame.subarray(4, 4 + headerLength).toString('utf8'));
86
+ } catch (error) {
87
+ throw new Error(`stream frame header is not JSON: ${error.message}`);
88
+ }
89
+ if (!header || typeof header.type !== 'string' || typeof header.streamId !== 'string') {
90
+ throw new Error('stream frame header is missing routing fields');
91
+ }
92
+ return {
93
+ type: header.type,
94
+ streamId: header.streamId,
95
+ payload: frame.subarray(4 + headerLength),
96
+ };
97
+ }
98
+
99
+ module.exports = {
100
+ PROTOCOL_VERSION,
101
+ MAX_HEADER_BYTES,
102
+ ANSI_PALETTE_KEYS,
103
+ packStreamFrame,
104
+ unpackStreamFrame,
105
+ sanitizeTerminalPalette,
106
+ };