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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/herdr-remote-relay.js +113 -0
- package/config.example.json +14 -0
- package/deploy/cloudflared/config.yml.example +9 -0
- package/deploy/docker/compose.yaml +23 -0
- package/deploy/nginx/herdr-remote-relay.conf.example +49 -0
- package/deploy/systemd/herdr-remote-relay.service +41 -0
- package/deploy/systemd/relay.env.example +20 -0
- package/package.json +50 -0
- package/src/auth-store.js +188 -0
- package/src/metrics.js +136 -0
- package/src/relay-config.js +278 -0
- package/src/relay-server.js +776 -0
- package/src/scroll-input.js +139 -0
- package/src/state.js +47 -0
- package/src/stream-frame.js +106 -0
- package/web/dist/assets/index-D2ksY-SN.js +39 -0
- package/web/dist/assets/index-Zi2TgjUt.css +32 -0
- package/web/dist/assets/vendor-icons-VfRMalKD.js +294 -0
- package/web/dist/assets/vendor-react-B6szxdcU.js +9 -0
- package/web/dist/assets/vendor-xterm-Bldps5fz.js +66 -0
- package/web/dist/index.html +38 -0
package/src/metrics.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const { monitorEventLoopDelay } = require('node:perf_hooks');
|
|
5
|
+
|
|
6
|
+
function finite(value, fallback = 0) {
|
|
7
|
+
return Number.isFinite(value) ? value : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function bytesPerSecond(current, previous, elapsedMs) {
|
|
11
|
+
if (!elapsedMs || elapsedMs <= 0) return 0;
|
|
12
|
+
return Math.max(0, (current - previous) * 1000 / elapsedMs);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class RelayMetrics {
|
|
16
|
+
constructor({ version = '0.1.0', protocolVersion = 1 } = {}) {
|
|
17
|
+
this.version = version;
|
|
18
|
+
this.protocolVersion = protocolVersion;
|
|
19
|
+
this.startedAt = Date.now();
|
|
20
|
+
this.bytesIn = 0;
|
|
21
|
+
this.bytesOut = 0;
|
|
22
|
+
this.framesIn = 0;
|
|
23
|
+
this.framesOut = 0;
|
|
24
|
+
this.cleanup = {
|
|
25
|
+
staleClientsPurged: 0,
|
|
26
|
+
closedPtysCleaned: 0,
|
|
27
|
+
deadConnectionsClosed: 0,
|
|
28
|
+
idleHostsTerminated: 0,
|
|
29
|
+
lastCleanupAt: null,
|
|
30
|
+
};
|
|
31
|
+
this.lastSample = { at: this.startedAt, bytesIn: 0, bytesOut: 0, framesIn: 0, framesOut: 0 };
|
|
32
|
+
this.cpuSampleAt = process.hrtime.bigint();
|
|
33
|
+
this.cpuSample = process.cpuUsage();
|
|
34
|
+
this.eventLoop = monitorEventLoopDelay({ resolution: 20 });
|
|
35
|
+
this.eventLoop.enable();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
recordIn(bytes) {
|
|
39
|
+
this.bytesIn += bytes;
|
|
40
|
+
this.framesIn += 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
recordOut(bytes) {
|
|
44
|
+
this.bytesOut += bytes;
|
|
45
|
+
this.framesOut += 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
recordCleanup(name, amount = 1) {
|
|
49
|
+
if (Object.prototype.hasOwnProperty.call(this.cleanup, name) && typeof this.cleanup[name] === 'number') {
|
|
50
|
+
this.cleanup[name] += amount;
|
|
51
|
+
}
|
|
52
|
+
this.cleanup.lastCleanupAt = new Date().toISOString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
cpuPercent() {
|
|
56
|
+
const now = process.hrtime.bigint();
|
|
57
|
+
const elapsedNs = Number(now - this.cpuSampleAt);
|
|
58
|
+
const usage = process.cpuUsage(this.cpuSample);
|
|
59
|
+
this.cpuSampleAt = now;
|
|
60
|
+
this.cpuSample = process.cpuUsage();
|
|
61
|
+
if (elapsedNs <= 0) return 0;
|
|
62
|
+
const percent = ((usage.user + usage.system) * 100000) / elapsedNs;
|
|
63
|
+
return Math.max(0, Math.round(percent * 100) / 100);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
eventLoopDelay() {
|
|
67
|
+
const percentile = (value) => Math.round(finite(value / 1e6) * 100) / 100;
|
|
68
|
+
return {
|
|
69
|
+
p50Ms: percentile(this.eventLoop.percentile(50)),
|
|
70
|
+
p90Ms: percentile(this.eventLoop.percentile(90)),
|
|
71
|
+
p99Ms: percentile(this.eventLoop.percentile(99)),
|
|
72
|
+
maxMs: percentile(this.eventLoop.max),
|
|
73
|
+
meanMs: percentile(this.eventLoop.mean),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
snapshot({ clients = [], hosts = [], ptys = [], sample = true } = {}) {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const elapsedMs = now - this.lastSample.at;
|
|
80
|
+
const throughput = {
|
|
81
|
+
bytesIn: this.bytesIn,
|
|
82
|
+
bytesOut: this.bytesOut,
|
|
83
|
+
bytesInPerSec: bytesPerSecond(this.bytesIn, this.lastSample.bytesIn, elapsedMs),
|
|
84
|
+
bytesOutPerSec: bytesPerSecond(this.bytesOut, this.lastSample.bytesOut, elapsedMs),
|
|
85
|
+
framesIn: this.framesIn,
|
|
86
|
+
framesOut: this.framesOut,
|
|
87
|
+
framesInPerSec: bytesPerSecond(this.framesIn, this.lastSample.framesIn, elapsedMs),
|
|
88
|
+
framesOutPerSec: bytesPerSecond(this.framesOut, this.lastSample.framesOut, elapsedMs),
|
|
89
|
+
};
|
|
90
|
+
if (sample) {
|
|
91
|
+
this.lastSample = { at: now, bytesIn: this.bytesIn, bytesOut: this.bytesOut, framesIn: this.framesIn, framesOut: this.framesOut };
|
|
92
|
+
}
|
|
93
|
+
const memory = process.memoryUsage();
|
|
94
|
+
const load = os.loadavg();
|
|
95
|
+
const controller = clients.find((client) => client.role === 'controller');
|
|
96
|
+
return {
|
|
97
|
+
version: this.version,
|
|
98
|
+
protocolVersion: this.protocolVersion,
|
|
99
|
+
uptimeSeconds: Math.floor((now - this.startedAt) / 1000),
|
|
100
|
+
startTime: new Date(this.startedAt).toISOString(),
|
|
101
|
+
serverTime: new Date(now).toISOString(),
|
|
102
|
+
activeControllerId: controller?.id || null,
|
|
103
|
+
activeHostId: controller?.hostId || hosts[0]?.id || null,
|
|
104
|
+
clients,
|
|
105
|
+
hosts,
|
|
106
|
+
ptys,
|
|
107
|
+
clientCount: clients.length,
|
|
108
|
+
hostCount: hosts.length,
|
|
109
|
+
ptyCount: ptys.length,
|
|
110
|
+
throughput,
|
|
111
|
+
cpu: {
|
|
112
|
+
load1m: finite(load[0]),
|
|
113
|
+
load5m: finite(load[1]),
|
|
114
|
+
load15m: finite(load[2]),
|
|
115
|
+
cpuPercent: this.cpuPercent(),
|
|
116
|
+
cores: os.cpus().length,
|
|
117
|
+
},
|
|
118
|
+
memory: {
|
|
119
|
+
rssBytes: memory.rss,
|
|
120
|
+
heapUsedBytes: memory.heapUsed,
|
|
121
|
+
heapTotalBytes: memory.heapTotal,
|
|
122
|
+
externalBytes: memory.external,
|
|
123
|
+
systemTotalBytes: os.totalmem(),
|
|
124
|
+
systemFreeBytes: os.freemem(),
|
|
125
|
+
},
|
|
126
|
+
eventLoopDelay: this.eventLoopDelay(),
|
|
127
|
+
cleanup: { ...this.cleanup },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
close() {
|
|
132
|
+
this.eventLoop.disable();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { RelayMetrics };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Standalone relay configuration.
|
|
4
|
+
//
|
|
5
|
+
// The relay is deliberately decoupled from the herdr-remote plugin: it never
|
|
6
|
+
// reads the plugin's config directory and never imports plugin code. Settings
|
|
7
|
+
// come from (lowest to highest precedence) built-in defaults, an optional JSON
|
|
8
|
+
// config file, environment variables, then command line flags.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
15
|
+
|
|
16
|
+
function defaultStateDir() {
|
|
17
|
+
if (process.env.RELAY_STATE_DIR) return process.env.RELAY_STATE_DIR;
|
|
18
|
+
const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
|
|
19
|
+
return path.join(stateHome, 'herdr-remote-relay');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
relay: {
|
|
24
|
+
// A relay package is remote/operator-facing by default. The workstation
|
|
25
|
+
// service sets this to local for the private relay it starts itself so the
|
|
26
|
+
// web client can distinguish its own status page from the operator console.
|
|
27
|
+
mode: 'remote',
|
|
28
|
+
// Bind to loopback by default: the common production shape is a TLS
|
|
29
|
+
// reverse proxy on the same machine. Containers and LAN deployments set
|
|
30
|
+
// RELAY_BIND=0.0.0.0 explicitly.
|
|
31
|
+
host: '127.0.0.1',
|
|
32
|
+
port: 8787,
|
|
33
|
+
publicUrl: 'http://127.0.0.1:8787',
|
|
34
|
+
maxPayloadBytes: 1024 * 1024,
|
|
35
|
+
maxClientsPerHost: 16,
|
|
36
|
+
allowedOrigins: [],
|
|
37
|
+
trustProxy: false,
|
|
38
|
+
},
|
|
39
|
+
auth: {
|
|
40
|
+
pairingTtlMs: 10 * 60 * 1000,
|
|
41
|
+
deviceTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
42
|
+
maxDevices: 32,
|
|
43
|
+
// Optional shared password. Unset means a public relay: anyone may connect
|
|
44
|
+
// a workstation, and each one is still reachable only through its own host
|
|
45
|
+
// token.
|
|
46
|
+
password: null,
|
|
47
|
+
// Optional operator credential for the standalone relay dashboard. This is
|
|
48
|
+
// deliberately separate from the join password and from device tokens:
|
|
49
|
+
// the former is shared with workstations, while the latter is scoped to a
|
|
50
|
+
// single workstation.
|
|
51
|
+
adminToken: null,
|
|
52
|
+
stateFile: null,
|
|
53
|
+
},
|
|
54
|
+
cleanup: {
|
|
55
|
+
intervalMs: 60 * 1000,
|
|
56
|
+
heartbeatIntervalMs: 30 * 1000,
|
|
57
|
+
staleAfterMs: 90 * 1000,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function clone(value) {
|
|
62
|
+
return JSON.parse(JSON.stringify(value));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readJsonFile(filePath) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === 'ENOENT') return null;
|
|
70
|
+
throw new Error(`cannot read relay config ${filePath}: ${error.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseBoolean(value, fallback) {
|
|
75
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
76
|
+
if (value === true || value === false) return value;
|
|
77
|
+
const normalized = String(value).trim().toLowerCase();
|
|
78
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
|
79
|
+
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseInteger(value, fallback, min, max) {
|
|
84
|
+
const numeric = Number(value);
|
|
85
|
+
if (!Number.isInteger(numeric) || numeric < min || numeric > max) return fallback;
|
|
86
|
+
return numeric;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseOriginList(value) {
|
|
90
|
+
if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
91
|
+
if (typeof value !== 'string') return null;
|
|
92
|
+
return value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mergeSection(target, source) {
|
|
96
|
+
if (!source || typeof source !== 'object') return;
|
|
97
|
+
for (const key of Object.keys(target)) {
|
|
98
|
+
if (source[key] !== undefined) target[key] = source[key];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse `--flag value` / `--flag=value` pairs plus the standalone flags the
|
|
104
|
+
* relay binary understands. Returns { options, help, version, errors }.
|
|
105
|
+
*/
|
|
106
|
+
function parseArgv(argv = []) {
|
|
107
|
+
const options = {};
|
|
108
|
+
const errors = [];
|
|
109
|
+
let help = false;
|
|
110
|
+
let version = false;
|
|
111
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
112
|
+
const arg = argv[index];
|
|
113
|
+
if (arg === '--help' || arg === '-h') { help = true; continue; }
|
|
114
|
+
if (arg === '--version' || arg === '-v') { version = true; continue; }
|
|
115
|
+
if (!arg.startsWith('--')) { errors.push(`unexpected argument: ${arg}`); continue; }
|
|
116
|
+
const equals = arg.indexOf('=');
|
|
117
|
+
const name = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
|
|
118
|
+
let value = equals === -1 ? undefined : arg.slice(equals + 1);
|
|
119
|
+
if (value === undefined) {
|
|
120
|
+
const next = argv[index + 1];
|
|
121
|
+
if (next === undefined || next.startsWith('--')) {
|
|
122
|
+
// Bare boolean flag.
|
|
123
|
+
value = 'true';
|
|
124
|
+
} else {
|
|
125
|
+
value = next;
|
|
126
|
+
index += 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
options[name] = value;
|
|
130
|
+
}
|
|
131
|
+
return { options, help, version, errors };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function applyFile(config, fileConfig) {
|
|
135
|
+
if (!fileConfig || typeof fileConfig !== 'object') return;
|
|
136
|
+
mergeSection(config.relay, fileConfig.relay);
|
|
137
|
+
mergeSection(config.auth, fileConfig.auth);
|
|
138
|
+
mergeSection(config.cleanup, fileConfig.cleanup);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applyEnvironment(config, env) {
|
|
142
|
+
if (env.RELAY_DEPLOYMENT_MODE === 'local' || env.RELAY_DEPLOYMENT_MODE === 'remote') {
|
|
143
|
+
config.relay.mode = env.RELAY_DEPLOYMENT_MODE;
|
|
144
|
+
}
|
|
145
|
+
if (env.RELAY_BIND) config.relay.host = env.RELAY_BIND;
|
|
146
|
+
if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
|
|
147
|
+
if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
148
|
+
if (env.RELAY_MAX_PAYLOAD_BYTES) config.relay.maxPayloadBytes = env.RELAY_MAX_PAYLOAD_BYTES;
|
|
149
|
+
if (env.RELAY_MAX_CLIENTS_PER_HOST) config.relay.maxClientsPerHost = env.RELAY_MAX_CLIENTS_PER_HOST;
|
|
150
|
+
if (env.RELAY_ALLOWED_ORIGINS !== undefined) {
|
|
151
|
+
const origins = parseOriginList(env.RELAY_ALLOWED_ORIGINS);
|
|
152
|
+
if (origins) config.relay.allowedOrigins = origins;
|
|
153
|
+
}
|
|
154
|
+
if (env.RELAY_TRUST_PROXY !== undefined) {
|
|
155
|
+
config.relay.trustProxy = parseBoolean(env.RELAY_TRUST_PROXY, config.relay.trustProxy);
|
|
156
|
+
}
|
|
157
|
+
if (env.RELAY_PASSWORD) config.auth.password = env.RELAY_PASSWORD;
|
|
158
|
+
if (env.RELAY_ADMIN_TOKEN) config.auth.adminToken = env.RELAY_ADMIN_TOKEN;
|
|
159
|
+
if (env.RELAY_AUTH_STATE_FILE) config.auth.stateFile = env.RELAY_AUTH_STATE_FILE;
|
|
160
|
+
if (env.RELAY_PAIRING_TTL_MS) config.auth.pairingTtlMs = env.RELAY_PAIRING_TTL_MS;
|
|
161
|
+
if (env.RELAY_DEVICE_TTL_MS) config.auth.deviceTtlMs = env.RELAY_DEVICE_TTL_MS;
|
|
162
|
+
if (env.RELAY_MAX_DEVICES) config.auth.maxDevices = env.RELAY_MAX_DEVICES;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function applyOptions(config, options) {
|
|
166
|
+
if (options['deployment-mode'] === 'local' || options['deployment-mode'] === 'remote') {
|
|
167
|
+
config.relay.mode = options['deployment-mode'];
|
|
168
|
+
}
|
|
169
|
+
if (options.bind) config.relay.host = options.bind;
|
|
170
|
+
if (options.port) config.relay.port = options.port;
|
|
171
|
+
if (options['public-url']) config.relay.publicUrl = options['public-url'];
|
|
172
|
+
if (options['allowed-origins'] !== undefined) {
|
|
173
|
+
const origins = parseOriginList(options['allowed-origins']);
|
|
174
|
+
if (origins) config.relay.allowedOrigins = origins;
|
|
175
|
+
}
|
|
176
|
+
if (options['trust-proxy'] !== undefined) {
|
|
177
|
+
config.relay.trustProxy = parseBoolean(options['trust-proxy'], config.relay.trustProxy);
|
|
178
|
+
}
|
|
179
|
+
if (options.password) config.auth.password = options.password;
|
|
180
|
+
if (options['admin-token']) config.auth.adminToken = options['admin-token'];
|
|
181
|
+
if (options['state-file']) config.auth.stateFile = options['state-file'];
|
|
182
|
+
if (options['max-clients']) config.relay.maxClientsPerHost = options['max-clients'];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validate(config) {
|
|
186
|
+
if (config.relay.mode !== 'local' && config.relay.mode !== 'remote') config.relay.mode = DEFAULTS.relay.mode;
|
|
187
|
+
config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 0, 65535);
|
|
188
|
+
config.relay.maxPayloadBytes = parseInteger(
|
|
189
|
+
config.relay.maxPayloadBytes,
|
|
190
|
+
DEFAULTS.relay.maxPayloadBytes,
|
|
191
|
+
4096,
|
|
192
|
+
16 * 1024 * 1024,
|
|
193
|
+
);
|
|
194
|
+
config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
|
|
195
|
+
config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
|
|
196
|
+
config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
|
|
197
|
+
config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
|
|
198
|
+
config.cleanup.intervalMs = parseInteger(config.cleanup.intervalMs, DEFAULTS.cleanup.intervalMs, 1000, 24 * 60 * 60 * 1000);
|
|
199
|
+
config.cleanup.heartbeatIntervalMs = parseInteger(
|
|
200
|
+
config.cleanup.heartbeatIntervalMs,
|
|
201
|
+
DEFAULTS.cleanup.heartbeatIntervalMs,
|
|
202
|
+
1000,
|
|
203
|
+
10 * 60 * 1000,
|
|
204
|
+
);
|
|
205
|
+
config.cleanup.staleAfterMs = parseInteger(
|
|
206
|
+
config.cleanup.staleAfterMs,
|
|
207
|
+
DEFAULTS.cleanup.staleAfterMs,
|
|
208
|
+
config.cleanup.heartbeatIntervalMs * 2,
|
|
209
|
+
24 * 60 * 60 * 1000,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (typeof config.relay.host !== 'string' || config.relay.host.length === 0) {
|
|
213
|
+
config.relay.host = DEFAULTS.relay.host;
|
|
214
|
+
}
|
|
215
|
+
if (typeof config.relay.publicUrl !== 'string' || config.relay.publicUrl.length === 0) {
|
|
216
|
+
config.relay.publicUrl = `http://${config.relay.host}:${config.relay.port}`;
|
|
217
|
+
}
|
|
218
|
+
config.relay.publicUrl = String(config.relay.publicUrl).replace(/\/+$/, '');
|
|
219
|
+
if (!Array.isArray(config.relay.allowedOrigins)) config.relay.allowedOrigins = [];
|
|
220
|
+
config.relay.trustProxy = parseBoolean(config.relay.trustProxy, false);
|
|
221
|
+
if (!config.auth.stateFile) {
|
|
222
|
+
config.auth.stateFile = path.join(defaultStateDir(), 'relay-auth.json');
|
|
223
|
+
}
|
|
224
|
+
return config;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Warnings a standalone operator should see at startup. These are advisory —
|
|
229
|
+
* the relay still starts, because a loopback-only development run legitimately
|
|
230
|
+
* needs neither TLS nor tokens.
|
|
231
|
+
*/
|
|
232
|
+
function configWarnings(config) {
|
|
233
|
+
const warnings = [];
|
|
234
|
+
const isLoopbackBind = ['127.0.0.1', 'localhost', '::1'].includes(config.relay.host);
|
|
235
|
+
let publicUrl;
|
|
236
|
+
try {
|
|
237
|
+
publicUrl = new URL(config.relay.publicUrl);
|
|
238
|
+
} catch {
|
|
239
|
+
warnings.push(`publicUrl is not a valid URL: ${config.relay.publicUrl}`);
|
|
240
|
+
}
|
|
241
|
+
if (!config.auth.password) {
|
|
242
|
+
warnings.push('no password set (RELAY_PASSWORD): this is a public relay, anyone may connect a workstation to it');
|
|
243
|
+
}
|
|
244
|
+
if (config.relay.mode === 'remote' && !config.auth.adminToken) {
|
|
245
|
+
warnings.push('no admin token set (RELAY_ADMIN_TOKEN): the relay operator dashboard is unavailable');
|
|
246
|
+
}
|
|
247
|
+
if (!isLoopbackBind && publicUrl && publicUrl.protocol === 'http:' && !['127.0.0.1', 'localhost'].includes(publicUrl.hostname)) {
|
|
248
|
+
warnings.push(`publicUrl uses plain http on a non-loopback address (${config.relay.publicUrl}); terminate TLS in front of the relay`);
|
|
249
|
+
}
|
|
250
|
+
return warnings;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function loadRelayConfig({ argv = [], env = process.env } = {}) {
|
|
254
|
+
const { options, help, version, errors } = parseArgv(argv);
|
|
255
|
+
const config = clone(DEFAULTS);
|
|
256
|
+
|
|
257
|
+
const configFile = options.config || env.HERDR_RELAY_CONFIG || null;
|
|
258
|
+
if (configFile) {
|
|
259
|
+
const fileConfig = readJsonFile(path.resolve(configFile));
|
|
260
|
+
if (fileConfig === null) throw new Error(`relay config not found: ${configFile}`);
|
|
261
|
+
applyFile(config, fileConfig);
|
|
262
|
+
}
|
|
263
|
+
applyEnvironment(config, env);
|
|
264
|
+
applyOptions(config, options);
|
|
265
|
+
validate(config);
|
|
266
|
+
|
|
267
|
+
return { config, help, version, errors, configFile, warnings: configWarnings(config) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
PACKAGE_ROOT,
|
|
272
|
+
DEFAULTS,
|
|
273
|
+
loadRelayConfig,
|
|
274
|
+
parseArgv,
|
|
275
|
+
validate,
|
|
276
|
+
configWarnings,
|
|
277
|
+
defaultStateDir,
|
|
278
|
+
};
|