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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
declare const PACKAGE_ROOT: string;
|
|
2
|
+
export type RelayMode = 'local' | 'remote';
|
|
3
|
+
/** A relay configuration after `validate`: every field present and in range. */
|
|
4
|
+
export interface RelayConfig {
|
|
5
|
+
relay: {
|
|
6
|
+
mode: RelayMode;
|
|
7
|
+
host: string;
|
|
8
|
+
port: number;
|
|
9
|
+
publicUrl: string;
|
|
10
|
+
maxPayloadBytes: number;
|
|
11
|
+
maxClientsPerHost: number;
|
|
12
|
+
maxHosts: number;
|
|
13
|
+
maxPendingHandshakes: number;
|
|
14
|
+
maxBufferedBytesPerClient: number;
|
|
15
|
+
allowedOrigins: string[];
|
|
16
|
+
trustProxy: boolean;
|
|
17
|
+
hostReconnectGraceMs: number;
|
|
18
|
+
/** Development only: simulated round-trip latency. */
|
|
19
|
+
devLatencyMs?: number;
|
|
20
|
+
};
|
|
21
|
+
auth: {
|
|
22
|
+
pairingTtlMs: number;
|
|
23
|
+
deviceTtlMs: number;
|
|
24
|
+
maxDevices: number;
|
|
25
|
+
password: string | null;
|
|
26
|
+
adminToken: string | null;
|
|
27
|
+
stateFile: string | null;
|
|
28
|
+
};
|
|
29
|
+
cleanup: {
|
|
30
|
+
intervalMs: number;
|
|
31
|
+
heartbeatIntervalMs: number;
|
|
32
|
+
staleAfterMs: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A configuration while it is being assembled: file, environment and flags
|
|
37
|
+
* may each put a string, or anything else, where `validate` expects a number.
|
|
38
|
+
*/
|
|
39
|
+
type RelayConfigDraft = {
|
|
40
|
+
[Section in keyof RelayConfig]: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
/** Flags given as `--name value` or `--name=value`, by name. */
|
|
43
|
+
export type RelayOptions = Record<string, string>;
|
|
44
|
+
declare function defaultStateDir(): string;
|
|
45
|
+
declare const DEFAULTS: RelayConfig;
|
|
46
|
+
/**
|
|
47
|
+
* Parse `--flag value` / `--flag=value` pairs plus the standalone flags the
|
|
48
|
+
* relay binary understands. Returns { options, help, version, errors }.
|
|
49
|
+
*/
|
|
50
|
+
declare function parseArgv(argv?: readonly string[]): {
|
|
51
|
+
options: RelayOptions;
|
|
52
|
+
help: boolean;
|
|
53
|
+
version: boolean;
|
|
54
|
+
errors: string[];
|
|
55
|
+
};
|
|
56
|
+
declare function validate(config: RelayConfigDraft): RelayConfig;
|
|
57
|
+
/**
|
|
58
|
+
* Warnings a standalone operator should see at startup. These are advisory —
|
|
59
|
+
* the relay still starts, because a loopback-only development run legitimately
|
|
60
|
+
* needs neither TLS nor tokens.
|
|
61
|
+
*/
|
|
62
|
+
declare function configWarnings(config: RelayConfig): string[];
|
|
63
|
+
declare function loadRelayConfig({ argv, env, }?: {
|
|
64
|
+
argv?: readonly string[];
|
|
65
|
+
env?: NodeJS.ProcessEnv;
|
|
66
|
+
}): {
|
|
67
|
+
config: RelayConfig;
|
|
68
|
+
help: boolean;
|
|
69
|
+
version: boolean;
|
|
70
|
+
errors: string[];
|
|
71
|
+
configFile: string | null;
|
|
72
|
+
warnings: string[];
|
|
73
|
+
};
|
|
74
|
+
export { PACKAGE_ROOT, DEFAULTS, loadRelayConfig, parseArgv, validate, configWarnings, defaultStateDir, };
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Standalone relay configuration.
|
|
2
|
+
//
|
|
3
|
+
// The relay is deliberately decoupled from the herdr-remote plugin: it never
|
|
4
|
+
// reads the plugin's config directory and never imports plugin code. Settings
|
|
5
|
+
// come from (lowest to highest precedence) built-in defaults, an optional JSON
|
|
6
|
+
// config file, environment variables, then command line flags.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
const PACKAGE_ROOT = path.resolve(import.meta.dirname, '..');
|
|
11
|
+
function defaultStateDir() {
|
|
12
|
+
if (process.env.RELAY_STATE_DIR)
|
|
13
|
+
return process.env.RELAY_STATE_DIR;
|
|
14
|
+
const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
|
|
15
|
+
return path.join(stateHome, 'herdr-remote-relay');
|
|
16
|
+
}
|
|
17
|
+
const DEFAULTS = {
|
|
18
|
+
relay: {
|
|
19
|
+
// A relay package is remote/operator-facing by default. The workstation
|
|
20
|
+
// service sets this to local for the private relay it starts itself so the
|
|
21
|
+
// web client can distinguish its own status page from the operator console.
|
|
22
|
+
mode: 'remote',
|
|
23
|
+
// Bind to loopback by default: the common production shape is a TLS
|
|
24
|
+
// reverse proxy on the same machine. Containers and LAN deployments set
|
|
25
|
+
// RELAY_BIND=0.0.0.0 explicitly.
|
|
26
|
+
host: '127.0.0.1',
|
|
27
|
+
port: 8787,
|
|
28
|
+
publicUrl: 'http://127.0.0.1:8787',
|
|
29
|
+
// Sized for a pasted image, not for keystrokes. `ws` closes a connection
|
|
30
|
+
// that receives an oversized frame (1009) rather than dropping the frame,
|
|
31
|
+
// so this ceiling is what a client's own upload cap has to stay under.
|
|
32
|
+
maxPayloadBytes: 5 * 1024 * 1024,
|
|
33
|
+
maxClientsPerHost: 16,
|
|
34
|
+
maxHosts: 1024,
|
|
35
|
+
maxPendingHandshakes: 1024,
|
|
36
|
+
maxBufferedBytesPerClient: 4 * 1024 * 1024,
|
|
37
|
+
allowedOrigins: [],
|
|
38
|
+
trustProxy: false,
|
|
39
|
+
hostReconnectGraceMs: 30 * 1000,
|
|
40
|
+
},
|
|
41
|
+
auth: {
|
|
42
|
+
pairingTtlMs: 10 * 60 * 1000,
|
|
43
|
+
deviceTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
44
|
+
maxDevices: 32,
|
|
45
|
+
// Optional shared password. Unset means a public relay: anyone may connect
|
|
46
|
+
// a workstation, and each one is still reachable only through its own host
|
|
47
|
+
// token.
|
|
48
|
+
password: null,
|
|
49
|
+
// Optional operator credential for the standalone relay dashboard. This is
|
|
50
|
+
// deliberately separate from the join password and from device tokens:
|
|
51
|
+
// the former is shared with workstations, while the latter is scoped to a
|
|
52
|
+
// single workstation.
|
|
53
|
+
adminToken: null,
|
|
54
|
+
stateFile: null,
|
|
55
|
+
},
|
|
56
|
+
cleanup: {
|
|
57
|
+
intervalMs: 60 * 1000,
|
|
58
|
+
heartbeatIntervalMs: 30 * 1000,
|
|
59
|
+
staleAfterMs: 90 * 1000,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
function clone(value) {
|
|
63
|
+
return JSON.parse(JSON.stringify(value));
|
|
64
|
+
}
|
|
65
|
+
function readJsonFile(filePath) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const { code, message } = error;
|
|
71
|
+
if (code === 'ENOENT')
|
|
72
|
+
return null;
|
|
73
|
+
throw new Error(`cannot read relay config ${filePath}: ${message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function parseBoolean(value, fallback) {
|
|
77
|
+
if (value === undefined || value === null || value === '')
|
|
78
|
+
return fallback;
|
|
79
|
+
if (value === true || value === false)
|
|
80
|
+
return value;
|
|
81
|
+
const normalized = String(value).trim().toLowerCase();
|
|
82
|
+
if (['true', '1', 'yes', 'on'].includes(normalized))
|
|
83
|
+
return true;
|
|
84
|
+
if (['false', '0', 'no', 'off'].includes(normalized))
|
|
85
|
+
return false;
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
function parseInteger(value, fallback, min, max) {
|
|
89
|
+
const numeric = Number(value);
|
|
90
|
+
if (!Number.isInteger(numeric) || numeric < min || numeric > max)
|
|
91
|
+
return fallback;
|
|
92
|
+
return numeric;
|
|
93
|
+
}
|
|
94
|
+
function parseOriginList(value) {
|
|
95
|
+
if (Array.isArray(value))
|
|
96
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
97
|
+
if (typeof value !== 'string')
|
|
98
|
+
return null;
|
|
99
|
+
return value
|
|
100
|
+
.split(',')
|
|
101
|
+
.map((entry) => entry.trim())
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
}
|
|
104
|
+
function mergeSection(target, source) {
|
|
105
|
+
if (!source || typeof source !== 'object')
|
|
106
|
+
return;
|
|
107
|
+
const values = source;
|
|
108
|
+
for (const key of Object.keys(target)) {
|
|
109
|
+
if (values[key] !== undefined)
|
|
110
|
+
target[key] = values[key];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Parse `--flag value` / `--flag=value` pairs plus the standalone flags the
|
|
115
|
+
* relay binary understands. Returns { options, help, version, errors }.
|
|
116
|
+
*/
|
|
117
|
+
function parseArgv(argv = []) {
|
|
118
|
+
const options = {};
|
|
119
|
+
const errors = [];
|
|
120
|
+
let help = false;
|
|
121
|
+
let version = false;
|
|
122
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
123
|
+
const arg = argv[index];
|
|
124
|
+
if (arg === '--help' || arg === '-h') {
|
|
125
|
+
help = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (arg === '--version' || arg === '-v') {
|
|
129
|
+
version = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!arg.startsWith('--')) {
|
|
133
|
+
errors.push(`unexpected argument: ${arg}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const equals = arg.indexOf('=');
|
|
137
|
+
const name = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
|
|
138
|
+
let value = equals === -1 ? undefined : arg.slice(equals + 1);
|
|
139
|
+
if (value === undefined) {
|
|
140
|
+
const next = argv[index + 1];
|
|
141
|
+
if (next === undefined || next.startsWith('--')) {
|
|
142
|
+
// Bare boolean flag.
|
|
143
|
+
value = 'true';
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
value = next;
|
|
147
|
+
index += 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
options[name] = value;
|
|
151
|
+
}
|
|
152
|
+
return { options, help, version, errors };
|
|
153
|
+
}
|
|
154
|
+
function applyFile(config, fileConfig) {
|
|
155
|
+
if (!fileConfig || typeof fileConfig !== 'object')
|
|
156
|
+
return;
|
|
157
|
+
const sections = fileConfig;
|
|
158
|
+
mergeSection(config.relay, sections.relay);
|
|
159
|
+
mergeSection(config.auth, sections.auth);
|
|
160
|
+
mergeSection(config.cleanup, sections.cleanup);
|
|
161
|
+
}
|
|
162
|
+
function applyEnvironment(config, env) {
|
|
163
|
+
if (env.RELAY_DEPLOYMENT_MODE === 'local' || env.RELAY_DEPLOYMENT_MODE === 'remote') {
|
|
164
|
+
config.relay.mode = env.RELAY_DEPLOYMENT_MODE;
|
|
165
|
+
}
|
|
166
|
+
if (env.RELAY_BIND)
|
|
167
|
+
config.relay.host = env.RELAY_BIND;
|
|
168
|
+
if (env.RELAY_PORT)
|
|
169
|
+
config.relay.port = env.RELAY_PORT;
|
|
170
|
+
if (env.RELAY_PUBLIC_URL)
|
|
171
|
+
config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
172
|
+
if (env.RELAY_MAX_PAYLOAD_BYTES)
|
|
173
|
+
config.relay.maxPayloadBytes = env.RELAY_MAX_PAYLOAD_BYTES;
|
|
174
|
+
if (env.RELAY_MAX_CLIENTS_PER_HOST)
|
|
175
|
+
config.relay.maxClientsPerHost = env.RELAY_MAX_CLIENTS_PER_HOST;
|
|
176
|
+
if (env.RELAY_MAX_HOSTS)
|
|
177
|
+
config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
178
|
+
if (env.RELAY_MAX_PENDING_HANDSHAKES)
|
|
179
|
+
config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
180
|
+
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT)
|
|
181
|
+
config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
182
|
+
if (env.RELAY_HOST_RECONNECT_GRACE_MS)
|
|
183
|
+
config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
184
|
+
if (env.RELAY_ALLOWED_ORIGINS !== undefined) {
|
|
185
|
+
const origins = parseOriginList(env.RELAY_ALLOWED_ORIGINS);
|
|
186
|
+
if (origins)
|
|
187
|
+
config.relay.allowedOrigins = origins;
|
|
188
|
+
}
|
|
189
|
+
if (env.RELAY_TRUST_PROXY !== undefined) {
|
|
190
|
+
config.relay.trustProxy = parseBoolean(env.RELAY_TRUST_PROXY, config.relay.trustProxy);
|
|
191
|
+
}
|
|
192
|
+
if (env.RELAY_PASSWORD)
|
|
193
|
+
config.auth.password = env.RELAY_PASSWORD;
|
|
194
|
+
if (env.RELAY_ADMIN_TOKEN)
|
|
195
|
+
config.auth.adminToken = env.RELAY_ADMIN_TOKEN;
|
|
196
|
+
if (env.RELAY_AUTH_STATE_FILE)
|
|
197
|
+
config.auth.stateFile = env.RELAY_AUTH_STATE_FILE;
|
|
198
|
+
if (env.RELAY_PAIRING_TTL_MS)
|
|
199
|
+
config.auth.pairingTtlMs = env.RELAY_PAIRING_TTL_MS;
|
|
200
|
+
if (env.RELAY_DEVICE_TTL_MS)
|
|
201
|
+
config.auth.deviceTtlMs = env.RELAY_DEVICE_TTL_MS;
|
|
202
|
+
if (env.RELAY_MAX_DEVICES)
|
|
203
|
+
config.auth.maxDevices = env.RELAY_MAX_DEVICES;
|
|
204
|
+
}
|
|
205
|
+
function applyOptions(config, options) {
|
|
206
|
+
if (options['deployment-mode'] === 'local' || options['deployment-mode'] === 'remote') {
|
|
207
|
+
config.relay.mode = options['deployment-mode'];
|
|
208
|
+
}
|
|
209
|
+
if (options.bind)
|
|
210
|
+
config.relay.host = options.bind;
|
|
211
|
+
if (options.port)
|
|
212
|
+
config.relay.port = options.port;
|
|
213
|
+
if (options['public-url'])
|
|
214
|
+
config.relay.publicUrl = options['public-url'];
|
|
215
|
+
if (options['allowed-origins'] !== undefined) {
|
|
216
|
+
const origins = parseOriginList(options['allowed-origins']);
|
|
217
|
+
if (origins)
|
|
218
|
+
config.relay.allowedOrigins = origins;
|
|
219
|
+
}
|
|
220
|
+
if (options['trust-proxy'] !== undefined) {
|
|
221
|
+
config.relay.trustProxy = parseBoolean(options['trust-proxy'], config.relay.trustProxy);
|
|
222
|
+
}
|
|
223
|
+
if (options.password)
|
|
224
|
+
config.auth.password = options.password;
|
|
225
|
+
if (options['admin-token'])
|
|
226
|
+
config.auth.adminToken = options['admin-token'];
|
|
227
|
+
if (options['state-file'])
|
|
228
|
+
config.auth.stateFile = options['state-file'];
|
|
229
|
+
if (options['max-clients'])
|
|
230
|
+
config.relay.maxClientsPerHost = options['max-clients'];
|
|
231
|
+
if (options['max-hosts'])
|
|
232
|
+
config.relay.maxHosts = options['max-hosts'];
|
|
233
|
+
if (options['max-pending-handshakes'])
|
|
234
|
+
config.relay.maxPendingHandshakes = options['max-pending-handshakes'];
|
|
235
|
+
if (options['max-buffered-bytes'])
|
|
236
|
+
config.relay.maxBufferedBytesPerClient = options['max-buffered-bytes'];
|
|
237
|
+
if (options['host-reconnect-grace-ms'])
|
|
238
|
+
config.relay.hostReconnectGraceMs = options['host-reconnect-grace-ms'];
|
|
239
|
+
}
|
|
240
|
+
function validate(config) {
|
|
241
|
+
if (config.relay.mode !== 'local' && config.relay.mode !== 'remote')
|
|
242
|
+
config.relay.mode = DEFAULTS.relay.mode;
|
|
243
|
+
config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 0, 65535);
|
|
244
|
+
config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
|
|
245
|
+
config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
|
|
246
|
+
config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
|
|
247
|
+
config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
|
|
248
|
+
config.relay.maxBufferedBytesPerClient = parseInteger(config.relay.maxBufferedBytesPerClient, DEFAULTS.relay.maxBufferedBytesPerClient, 64 * 1024, 256 * 1024 * 1024);
|
|
249
|
+
config.relay.hostReconnectGraceMs = parseInteger(config.relay.hostReconnectGraceMs, DEFAULTS.relay.hostReconnectGraceMs, 1000, 24 * 60 * 60 * 1000);
|
|
250
|
+
config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
|
|
251
|
+
config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
|
|
252
|
+
config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
|
|
253
|
+
config.cleanup.intervalMs = parseInteger(config.cleanup.intervalMs, DEFAULTS.cleanup.intervalMs, 1000, 24 * 60 * 60 * 1000);
|
|
254
|
+
config.cleanup.heartbeatIntervalMs = parseInteger(config.cleanup.heartbeatIntervalMs, DEFAULTS.cleanup.heartbeatIntervalMs, 1000, 10 * 60 * 1000);
|
|
255
|
+
config.cleanup.staleAfterMs = parseInteger(config.cleanup.staleAfterMs, DEFAULTS.cleanup.staleAfterMs, config.cleanup.heartbeatIntervalMs * 2, 24 * 60 * 60 * 1000);
|
|
256
|
+
if (typeof config.relay.host !== 'string' || config.relay.host.length === 0) {
|
|
257
|
+
config.relay.host = DEFAULTS.relay.host;
|
|
258
|
+
}
|
|
259
|
+
if (typeof config.relay.publicUrl !== 'string' || config.relay.publicUrl.length === 0) {
|
|
260
|
+
config.relay.publicUrl = `http://${config.relay.host}:${config.relay.port}`;
|
|
261
|
+
}
|
|
262
|
+
config.relay.publicUrl = String(config.relay.publicUrl).replace(/\/+$/, '');
|
|
263
|
+
if (!Array.isArray(config.relay.allowedOrigins))
|
|
264
|
+
config.relay.allowedOrigins = [];
|
|
265
|
+
config.relay.trustProxy = parseBoolean(config.relay.trustProxy, false);
|
|
266
|
+
if (!config.auth.stateFile) {
|
|
267
|
+
config.auth.stateFile = path.join(defaultStateDir(), 'relay-auth.json');
|
|
268
|
+
}
|
|
269
|
+
// Every field `RelayConfig` promises has been normalised above.
|
|
270
|
+
return config;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Warnings a standalone operator should see at startup. These are advisory —
|
|
274
|
+
* the relay still starts, because a loopback-only development run legitimately
|
|
275
|
+
* needs neither TLS nor tokens.
|
|
276
|
+
*/
|
|
277
|
+
function configWarnings(config) {
|
|
278
|
+
const warnings = [];
|
|
279
|
+
const isLoopbackBind = ['127.0.0.1', 'localhost', '::1'].includes(config.relay.host);
|
|
280
|
+
let publicUrl;
|
|
281
|
+
try {
|
|
282
|
+
publicUrl = new URL(config.relay.publicUrl);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
warnings.push(`publicUrl is not a valid URL: ${config.relay.publicUrl}`);
|
|
286
|
+
}
|
|
287
|
+
if (!config.auth.password) {
|
|
288
|
+
warnings.push('no password set (RELAY_PASSWORD): this is a public relay, anyone may connect a workstation to it');
|
|
289
|
+
}
|
|
290
|
+
if (config.relay.mode === 'remote' && !config.auth.adminToken) {
|
|
291
|
+
warnings.push('no admin token set (RELAY_ADMIN_TOKEN): the relay operator dashboard is unavailable');
|
|
292
|
+
}
|
|
293
|
+
if (!isLoopbackBind &&
|
|
294
|
+
publicUrl &&
|
|
295
|
+
publicUrl.protocol === 'http:' &&
|
|
296
|
+
!['127.0.0.1', 'localhost'].includes(publicUrl.hostname)) {
|
|
297
|
+
warnings.push(`publicUrl uses plain http on a non-loopback address (${config.relay.publicUrl}); terminate TLS in front of the relay`);
|
|
298
|
+
}
|
|
299
|
+
return warnings;
|
|
300
|
+
}
|
|
301
|
+
function loadRelayConfig({ argv = [], env = process.env, } = {}) {
|
|
302
|
+
const { options, help, version, errors } = parseArgv(argv);
|
|
303
|
+
const draft = clone(DEFAULTS);
|
|
304
|
+
const configFile = options.config || env.HERDR_RELAY_CONFIG || null;
|
|
305
|
+
if (configFile) {
|
|
306
|
+
const fileConfig = readJsonFile(path.resolve(configFile));
|
|
307
|
+
if (fileConfig === null)
|
|
308
|
+
throw new Error(`relay config not found: ${configFile}`);
|
|
309
|
+
applyFile(draft, fileConfig);
|
|
310
|
+
}
|
|
311
|
+
applyEnvironment(draft, env);
|
|
312
|
+
applyOptions(draft, options);
|
|
313
|
+
const config = validate(draft);
|
|
314
|
+
return { config, help, version, errors, configFile, warnings: configWarnings(config) };
|
|
315
|
+
}
|
|
316
|
+
export { PACKAGE_ROOT, DEFAULTS, loadRelayConfig, parseArgv, validate, configWarnings, defaultStateDir, };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import type { Duplex } from 'node:stream';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { AuthStore } from './auth-store.js';
|
|
5
|
+
import { RelayMetrics } from './metrics.js';
|
|
6
|
+
import { PROTOCOL_VERSION } from './protocol/index.js';
|
|
7
|
+
import { type RelayConfig } from './relay-config.js';
|
|
8
|
+
import type { AttemptWindow, RelayClient, RelayContext, RelayHost, RelaySocket, SendQueue } from './server/types.js';
|
|
9
|
+
import { VERSION } from './server/version.js';
|
|
10
|
+
/** A configuration with any field left out; the defaults fill the rest. */
|
|
11
|
+
export type RelayConfigInput = {
|
|
12
|
+
[Section in keyof RelayConfig]?: Partial<RelayConfig[Section]>;
|
|
13
|
+
};
|
|
14
|
+
/** Overrides for embedding the relay, mostly in tests. */
|
|
15
|
+
export interface RelayServerOptions {
|
|
16
|
+
stateFile?: string;
|
|
17
|
+
password?: string | null;
|
|
18
|
+
adminToken?: string | null;
|
|
19
|
+
trustProxy?: boolean;
|
|
20
|
+
/** Simulated round-trip latency; each direction is delayed by half. */
|
|
21
|
+
devLatencyMs?: number | string;
|
|
22
|
+
metrics?: RelayMetrics;
|
|
23
|
+
auth?: AuthStore;
|
|
24
|
+
}
|
|
25
|
+
export type RelayAddress = {
|
|
26
|
+
host: string;
|
|
27
|
+
port: number;
|
|
28
|
+
} | {
|
|
29
|
+
path: string;
|
|
30
|
+
};
|
|
31
|
+
declare class RelayServer implements RelayContext {
|
|
32
|
+
config: RelayConfig;
|
|
33
|
+
relayMode: 'local' | 'remote';
|
|
34
|
+
hosts: Map<string, RelayHost>;
|
|
35
|
+
clients: Map<string, RelayClient>;
|
|
36
|
+
streams: Map<string, string>;
|
|
37
|
+
pairAttempts: Map<string, AttemptWindow>;
|
|
38
|
+
clientHandshakeAttempts: Map<string, AttemptWindow>;
|
|
39
|
+
hostHandshakeAttempts: Map<string, AttemptWindow>;
|
|
40
|
+
/** Sockets that are upgraded but have not finished their hello. */
|
|
41
|
+
pendingHandshakes: Set<RelaySocket>;
|
|
42
|
+
startedAt: number;
|
|
43
|
+
metrics: RelayMetrics;
|
|
44
|
+
stateFile: string;
|
|
45
|
+
password: string | null;
|
|
46
|
+
adminToken: string | null;
|
|
47
|
+
trustProxy: boolean;
|
|
48
|
+
devLatencyMs: number;
|
|
49
|
+
devDelayMs: number;
|
|
50
|
+
sendQueues: WeakMap<RelaySocket, SendQueue>;
|
|
51
|
+
activeDelayTimers: Set<NodeJS.Timeout>;
|
|
52
|
+
auth: AuthStore;
|
|
53
|
+
server: http.Server;
|
|
54
|
+
wss: WebSocketServer;
|
|
55
|
+
heartbeatTimer: NodeJS.Timeout | null;
|
|
56
|
+
cleanupTimer: NodeJS.Timeout | null;
|
|
57
|
+
constructor(config?: RelayConfigInput, options?: RelayServerOptions);
|
|
58
|
+
listen(port?: number, host?: string): Promise<RelayAddress>;
|
|
59
|
+
address(): RelayAddress | null;
|
|
60
|
+
close(): Promise<void>;
|
|
61
|
+
handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): void;
|
|
62
|
+
finishHandshake(ws: RelaySocket): void;
|
|
63
|
+
}
|
|
64
|
+
export { RelayServer, PROTOCOL_VERSION, VERSION };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The relay: one HTTP server that answers the API, serves the web app and
|
|
2
|
+
// upgrades /ws/host and /ws/client. The work itself lives in ./server/.
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { URL } from 'node:url';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
7
|
+
import { AuthStore } from './auth-store.js';
|
|
8
|
+
import { RelayMetrics } from './metrics.js';
|
|
9
|
+
import { PROTOCOL_VERSION, WS_CLIENT_PATH, WS_HOST_PATH } from './protocol/index.js';
|
|
10
|
+
import { DEFAULTS, defaultStateDir, loadRelayConfig } from './relay-config.js';
|
|
11
|
+
import { handleClientConnection } from './server/client-channel.js';
|
|
12
|
+
import { handleHostConnection } from './server/host-channel.js';
|
|
13
|
+
import { handleHttp } from './server/http-api.js';
|
|
14
|
+
import { heartbeat, sweep } from './server/maintenance.js';
|
|
15
|
+
import { isAllowedOrigin } from './server/requests.js';
|
|
16
|
+
import { detachClient, detachHost } from './server/sessions.js';
|
|
17
|
+
import { VERSION } from './server/version.js';
|
|
18
|
+
import { ensureDir } from './state.js';
|
|
19
|
+
class RelayServer {
|
|
20
|
+
config;
|
|
21
|
+
relayMode;
|
|
22
|
+
hosts = new Map();
|
|
23
|
+
clients = new Map();
|
|
24
|
+
streams = new Map();
|
|
25
|
+
pairAttempts = new Map();
|
|
26
|
+
clientHandshakeAttempts = new Map();
|
|
27
|
+
hostHandshakeAttempts = new Map();
|
|
28
|
+
/** Sockets that are upgraded but have not finished their hello. */
|
|
29
|
+
pendingHandshakes = new Set();
|
|
30
|
+
startedAt = Date.now();
|
|
31
|
+
metrics;
|
|
32
|
+
stateFile;
|
|
33
|
+
password;
|
|
34
|
+
adminToken;
|
|
35
|
+
trustProxy;
|
|
36
|
+
devLatencyMs;
|
|
37
|
+
devDelayMs;
|
|
38
|
+
sendQueues = new WeakMap();
|
|
39
|
+
activeDelayTimers = new Set();
|
|
40
|
+
auth;
|
|
41
|
+
server;
|
|
42
|
+
wss;
|
|
43
|
+
heartbeatTimer = null;
|
|
44
|
+
cleanupTimer = null;
|
|
45
|
+
constructor(config = loadRelayConfig().config, options = {}) {
|
|
46
|
+
this.config = {
|
|
47
|
+
relay: { ...DEFAULTS.relay, ...config.relay },
|
|
48
|
+
auth: { ...DEFAULTS.auth, ...config.auth },
|
|
49
|
+
cleanup: { ...DEFAULTS.cleanup, ...config.cleanup },
|
|
50
|
+
};
|
|
51
|
+
const { relay, auth } = this.config;
|
|
52
|
+
this.relayMode = relay.mode === 'local' ? 'local' : 'remote';
|
|
53
|
+
this.metrics =
|
|
54
|
+
options.metrics || new RelayMetrics({ version: VERSION, protocolVersion: PROTOCOL_VERSION });
|
|
55
|
+
this.stateFile =
|
|
56
|
+
options.stateFile || auth.stateFile || path.join(defaultStateDir(), 'relay-auth.json');
|
|
57
|
+
this.password = options.password ?? auth.password ?? null;
|
|
58
|
+
this.adminToken = options.adminToken ?? auth.adminToken ?? null;
|
|
59
|
+
this.trustProxy = Boolean(options.trustProxy ?? relay.trustProxy);
|
|
60
|
+
// Development-only artificial latency switch for local responsiveness profiling.
|
|
61
|
+
// When unset or 0, this incurs zero overhead and avoids entering the delayed path.
|
|
62
|
+
// RELAY_DEV_LATENCY_MS specifies round-trip delay, so each one-way leg
|
|
63
|
+
// (host -> browser and browser -> host) is delayed by half.
|
|
64
|
+
const devLatencyRaw = options.devLatencyMs ?? relay.devLatencyMs ?? process.env.RELAY_DEV_LATENCY_MS;
|
|
65
|
+
this.devLatencyMs = devLatencyRaw
|
|
66
|
+
? Math.max(0, Number.parseInt(String(devLatencyRaw), 10) || 0)
|
|
67
|
+
: 0;
|
|
68
|
+
this.devDelayMs = this.devLatencyMs > 0 ? Math.round(this.devLatencyMs / 2) : 0;
|
|
69
|
+
this.auth =
|
|
70
|
+
options.auth ||
|
|
71
|
+
new AuthStore({
|
|
72
|
+
stateFile: this.stateFile,
|
|
73
|
+
pairingTtlMs: auth.pairingTtlMs,
|
|
74
|
+
deviceTtlMs: auth.deviceTtlMs,
|
|
75
|
+
maxDevices: auth.maxDevices,
|
|
76
|
+
password: this.password,
|
|
77
|
+
});
|
|
78
|
+
this.server = http.createServer((req, res) => handleHttp(this, req, res));
|
|
79
|
+
this.wss = new WebSocketServer({
|
|
80
|
+
noServer: true,
|
|
81
|
+
clientTracking: false,
|
|
82
|
+
maxPayload: relay.maxPayloadBytes,
|
|
83
|
+
// Frames below 1024 bytes bypass compression completely, ensuring single
|
|
84
|
+
// keystrokes and small echoes incur zero CPU and zero buffering delay.
|
|
85
|
+
// Keeping context across messages (NoContextTakeover=false) maximizes
|
|
86
|
+
// compression ratios on highly repetitive full-screen ratatui/ANSI redraws;
|
|
87
|
+
// level 3 provides low CPU cost and low latency over peak compression.
|
|
88
|
+
perMessageDeflate: {
|
|
89
|
+
threshold: 1024,
|
|
90
|
+
zlibDeflateOptions: { level: 3 },
|
|
91
|
+
serverNoContextTakeover: false,
|
|
92
|
+
clientNoContextTakeover: false,
|
|
93
|
+
concurrencyLimit: 10,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
this.server.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
97
|
+
}
|
|
98
|
+
listen(port = this.config.relay.port, host = this.config.relay.host) {
|
|
99
|
+
ensureDir(path.dirname(this.stateFile));
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const onError = (error) => {
|
|
102
|
+
this.server.off('listening', onListening);
|
|
103
|
+
reject(error);
|
|
104
|
+
};
|
|
105
|
+
const onListening = () => {
|
|
106
|
+
this.server.off('error', onError);
|
|
107
|
+
this.heartbeatTimer = setInterval(() => heartbeat(this), this.config.cleanup.heartbeatIntervalMs);
|
|
108
|
+
this.cleanupTimer = setInterval(() => sweep(this), this.config.cleanup.intervalMs);
|
|
109
|
+
resolve(this.address());
|
|
110
|
+
};
|
|
111
|
+
this.server.once('error', onError);
|
|
112
|
+
this.server.once('listening', onListening);
|
|
113
|
+
this.server.listen(port, host);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
address() {
|
|
117
|
+
const address = this.server.address();
|
|
118
|
+
if (!address)
|
|
119
|
+
return null;
|
|
120
|
+
return typeof address === 'string'
|
|
121
|
+
? { path: address }
|
|
122
|
+
: { host: address.address, port: address.port };
|
|
123
|
+
}
|
|
124
|
+
async close() {
|
|
125
|
+
if (this.heartbeatTimer)
|
|
126
|
+
clearInterval(this.heartbeatTimer);
|
|
127
|
+
if (this.cleanupTimer)
|
|
128
|
+
clearInterval(this.cleanupTimer);
|
|
129
|
+
this.heartbeatTimer = null;
|
|
130
|
+
this.cleanupTimer = null;
|
|
131
|
+
for (const timer of this.activeDelayTimers)
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
this.activeDelayTimers.clear();
|
|
134
|
+
for (const client of [...this.clients.values()])
|
|
135
|
+
detachClient(this, client, { notify: false });
|
|
136
|
+
for (const host of [...this.hosts.values()])
|
|
137
|
+
detachHost(this, host, { notify: false });
|
|
138
|
+
this.streams.clear();
|
|
139
|
+
this.pairAttempts.clear();
|
|
140
|
+
this.clientHandshakeAttempts.clear();
|
|
141
|
+
this.hostHandshakeAttempts.clear();
|
|
142
|
+
this.pendingHandshakes.clear();
|
|
143
|
+
this.metrics.close();
|
|
144
|
+
await new Promise((resolve) => {
|
|
145
|
+
if (!this.server.listening)
|
|
146
|
+
return resolve();
|
|
147
|
+
this.server.close(() => resolve());
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
handleUpgrade(req, socket, head) {
|
|
151
|
+
const pathname = (() => {
|
|
152
|
+
try {
|
|
153
|
+
return new URL(req.url || '', 'http://localhost').pathname;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
if (![WS_HOST_PATH, WS_CLIENT_PATH].includes(pathname) ||
|
|
160
|
+
!isAllowedOrigin(this, req.headers.origin, req)) {
|
|
161
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
162
|
+
socket.destroy();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (this.pendingHandshakes.size >= this.config.relay.maxPendingHandshakes) {
|
|
166
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
167
|
+
socket.destroy();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Small ANSI/input frames should not wait behind Nagle's timer. This is
|
|
171
|
+
// safe for both plain HTTP and TLS sockets and also benefits a relay behind
|
|
172
|
+
// a reverse proxy by keeping the relay leg immediately writable.
|
|
173
|
+
try {
|
|
174
|
+
const tcp = socket;
|
|
175
|
+
tcp.setNoDelay(true);
|
|
176
|
+
tcp.setKeepAlive?.(true, this.config.cleanup.heartbeatIntervalMs);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Not a TCP socket (tests, proxies); these are only latency and liveness tweaks.
|
|
180
|
+
}
|
|
181
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
182
|
+
this.pendingHandshakes.add(ws);
|
|
183
|
+
ws.once('close', () => this.finishHandshake(ws));
|
|
184
|
+
if (pathname === WS_HOST_PATH)
|
|
185
|
+
handleHostConnection(this, ws, req);
|
|
186
|
+
else
|
|
187
|
+
handleClientConnection(this, ws, req);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
finishHandshake(ws) {
|
|
191
|
+
this.pendingHandshakes.delete(ws);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export { RelayServer, PROTOCOL_VERSION, VERSION };
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
/** Whether every byte of `payload`, a raw client input frame, belongs to a wheel report. */
|
|
16
|
+
declare function isWheelOnlyInput(payload: Uint8Array | ArrayBuffer | null | undefined): boolean;
|
|
17
|
+
export { isWheelOnlyInput };
|