herdr-remote 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,76 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+
5
+ // Interface names that are almost always virtual bridges rather than something
6
+ // a phone can reach. They are still listed, just ranked last.
7
+ const VIRTUAL_NAME_PATTERN = /^(docker|br-|virbr|veth|vmnet|vboxnet|lxcbr|cni|flannel|kube)/i;
8
+ const TAILSCALE_NAME_PATTERN = /^(tailscale|ts)\d*$/i;
9
+
10
+ /**
11
+ * Is this address inside Tailscale's 100.64.0.0/10 CGNAT range? Interface names
12
+ * differ per platform (tailscale0 on Linux, utunN on macOS), so the address
13
+ * range is the portable signal.
14
+ */
15
+ function isTailscaleAddress(address) {
16
+ const octets = String(address).split('.');
17
+ if (octets.length !== 4) return false;
18
+ const first = Number(octets[0]);
19
+ const second = Number(octets[1]);
20
+ if (!Number.isInteger(first) || !Number.isInteger(second)) return false;
21
+ return first === 100 && second >= 64 && second <= 127;
22
+ }
23
+
24
+ function classify(name, info) {
25
+ if (info.internal) return 'loopback';
26
+ if (TAILSCALE_NAME_PATTERN.test(name) || isTailscaleAddress(info.address)) return 'tailscale';
27
+ if (VIRTUAL_NAME_PATTERN.test(name)) return 'virtual';
28
+ return 'lan';
29
+ }
30
+
31
+ const KIND_ORDER = { tailscale: 0, lan: 1, virtual: 2, loopback: 3 };
32
+
33
+ /**
34
+ * Addresses this machine can be reached at, best candidate first.
35
+ *
36
+ * Tailscale addresses come first because a device on the tailnet reaches them
37
+ * from anywhere, which is exactly the "works away from home without running a
38
+ * relay" case; plain LAN addresses follow, then virtual bridges, then loopback.
39
+ */
40
+ function listReachableAddresses({ includeLoopback = true, includeIpv6 = false } = {}) {
41
+ const interfaces = os.networkInterfaces();
42
+ const results = [];
43
+ for (const [name, entries] of Object.entries(interfaces)) {
44
+ for (const info of entries || []) {
45
+ const family = typeof info.family === 'string' ? info.family : `IPv${info.family}`;
46
+ if (family !== 'IPv4' && !(includeIpv6 && family === 'IPv6')) continue;
47
+ const kind = classify(name, info);
48
+ if (kind === 'loopback' && !includeLoopback) continue;
49
+ results.push({
50
+ name,
51
+ address: info.address,
52
+ family,
53
+ kind,
54
+ internal: Boolean(info.internal),
55
+ });
56
+ }
57
+ }
58
+ results.sort((a, b) => {
59
+ const byKind = KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
60
+ if (byKind !== 0) return byKind;
61
+ return a.address.localeCompare(b.address);
62
+ });
63
+ return results;
64
+ }
65
+
66
+ /** Best guess for the address to advertise when binding to every interface. */
67
+ function preferredLanAddress() {
68
+ const candidates = listReachableAddresses({ includeLoopback: false });
69
+ return candidates.length > 0 ? candidates[0].address : null;
70
+ }
71
+
72
+ module.exports = {
73
+ isTailscaleAddress,
74
+ listReachableAddresses,
75
+ preferredLanAddress,
76
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+ const pty = require('node-pty');
5
+
6
+ class PtySession {
7
+ static DEFAULT_COLS = 100;
8
+ static DEFAULT_ROWS = 30;
9
+ static MIN_DIMENSION = 2;
10
+ static MAX_DIMENSION = 500;
11
+
12
+ constructor({ command, args = [], cwd = os.homedir(), socketPath } = {}) {
13
+ if (typeof command !== 'string' || command.length === 0) throw new TypeError('command must be a non-empty string');
14
+ if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) throw new TypeError('args must be strings');
15
+ this.command = command;
16
+ this.args = args;
17
+ this.cwd = cwd;
18
+ this.socketPath = socketPath;
19
+ this.terminal = null;
20
+ this.startedAt = null;
21
+ }
22
+
23
+ static childEnv(socketPath) {
24
+ const env = { ...process.env };
25
+ for (const key of Object.keys(env)) {
26
+ if (key.startsWith('HERDR_')) delete env[key];
27
+ }
28
+ if (socketPath) env.HERDR_SOCKET_PATH = socketPath;
29
+ env.TERM = 'xterm-256color';
30
+ env.COLORTERM = 'truecolor';
31
+ return env;
32
+ }
33
+
34
+ static clampDimension(value, fallback) {
35
+ const numeric = Number(value);
36
+ if (!Number.isInteger(numeric)) return fallback;
37
+ return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
38
+ }
39
+
40
+ start({ cols, rows, onData, onExit }) {
41
+ if (this.terminal) throw new Error('PTY session already started');
42
+ this.terminal = pty.spawn(this.command, this.args, {
43
+ name: 'xterm-256color',
44
+ cols: PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
45
+ rows: PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
46
+ cwd: this.cwd,
47
+ env: PtySession.childEnv(this.socketPath),
48
+ });
49
+ this.startedAt = new Date().toISOString();
50
+ if (onData) this.terminal.onData(onData);
51
+ if (onExit) {
52
+ this.terminal.onExit((event) => {
53
+ this.terminal = null;
54
+ onExit(event);
55
+ });
56
+ }
57
+ return this;
58
+ }
59
+
60
+ write(data) {
61
+ if (!this.terminal) return;
62
+ this.terminal.write(Buffer.isBuffer(data) ? data.toString('utf8') : String(data));
63
+ }
64
+
65
+ resize(cols, rows) {
66
+ if (!this.terminal) return;
67
+ this.terminal.resize(
68
+ PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
69
+ PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
70
+ );
71
+ }
72
+
73
+ info() {
74
+ return {
75
+ pid: this.terminal?.pid || null,
76
+ command: this.command,
77
+ cols: this.terminal?.cols || null,
78
+ rows: this.terminal?.rows || null,
79
+ cwd: this.cwd,
80
+ createdAt: this.startedAt,
81
+ };
82
+ }
83
+
84
+ kill() {
85
+ try {
86
+ this.terminal?.kill();
87
+ } catch {}
88
+ this.terminal = null;
89
+ }
90
+ }
91
+
92
+ module.exports = { PtySession };
package/src/service.js ADDED
@@ -0,0 +1,492 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const http = require('node:http');
5
+ const https = require('node:https');
6
+ const path = require('node:path');
7
+ const { spawn } = require('node:child_process');
8
+ const {
9
+ PACKAGE_ROOT,
10
+ bindAddress,
11
+ configDir,
12
+ loadConfig,
13
+ resolveAdminOrigin,
14
+ resolveHostRelayUrl,
15
+ resolvePublicUrl,
16
+ runsLocalRelay,
17
+ runtimeStatePath,
18
+ stateDir,
19
+ } = require('./config');
20
+ const { ensureDir, randomToken, readJson, writeJsonAtomic } = require('./state');
21
+ const {
22
+ probeTerminalPalette,
23
+ paletteFromEnvironment,
24
+ rememberTerminalPalette,
25
+ rememberedTerminalPalette,
26
+ } = require('./terminal-palette');
27
+ const { resolveSocketPath } = require('./socket-discovery');
28
+ const { preferredLanAddress } = require('./net-interfaces');
29
+ const { resolveHerdrCommand } = require('./herdr-command');
30
+
31
+ const RUNTIME_VERSION = 2;
32
+
33
+ function pidAlive(pid) {
34
+ if (!Number.isInteger(pid) || pid <= 0) return false;
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch (error) {
39
+ return error.code === 'EPERM';
40
+ }
41
+ }
42
+
43
+ function readRuntime() {
44
+ const state = readJson(runtimeStatePath(), {});
45
+ return state && typeof state === 'object' ? state : {};
46
+ }
47
+
48
+ /**
49
+ * Load (creating if needed) the long-lived identifiers and secrets this
50
+ * workstation uses. These live in the state directory rather than config.json
51
+ * because they are credentials: writeJsonAtomic stores them mode 0600.
52
+ */
53
+ function ensureRuntime() {
54
+ ensureDir(configDir());
55
+ ensureDir(stateDir());
56
+ const state = readRuntime();
57
+ if (!state.version) state.version = RUNTIME_VERSION;
58
+ if (!state.hostId) state.hostId = `host-${randomToken(9)}`;
59
+ if (!state.hostToken) state.hostToken = randomToken(32);
60
+ writeJsonAtomic(runtimeStatePath(), state);
61
+ return state;
62
+ }
63
+
64
+ /**
65
+ * Set the password used to join a relay. An empty value means "no password",
66
+ * which is what a public relay expects.
67
+ *
68
+ * It lives in the state file rather than config.json because it is a secret:
69
+ * writeJsonAtomic stores that file mode 0600.
70
+ */
71
+ function setRelayPassword(password) {
72
+ const state = ensureRuntime();
73
+ state.relayPassword = typeof password === 'string' ? password.trim() : '';
74
+ writeJsonAtomic(runtimeStatePath(), state);
75
+ return state;
76
+ }
77
+
78
+ /**
79
+ * Issue this workstation a new identity on the relay.
80
+ *
81
+ * The host token is what proves ownership of this workstation, so replacing it
82
+ * means the relay no longer recognises the old one — useful if it leaked, but
83
+ * it also orphans the previous record on a relay that already enrolled it.
84
+ */
85
+ function regenerateHostIdentity() {
86
+ const state = ensureRuntime();
87
+ state.hostId = `host-${randomToken(9)}`;
88
+ state.hostToken = randomToken(32);
89
+ writeJsonAtomic(runtimeStatePath(), state);
90
+ return state;
91
+ }
92
+
93
+ /**
94
+ * The colors of the terminal this workstation is looked at through.
95
+ *
96
+ * Asked once per process, from whichever start path has a real terminal, and
97
+ * remembered in the state file: a later start from a service manager has no
98
+ * terminal to ask, and the workstation's appearance has not changed just
99
+ * because systemd, and not a person, launched it this time.
100
+ */
101
+ let cachedTerminalPalette;
102
+ function hostTerminalPalette({ refresh = false } = {}) {
103
+ if (!refresh && cachedTerminalPalette !== undefined) return cachedTerminalPalette;
104
+
105
+ const inherited = paletteFromEnvironment();
106
+ if (inherited) {
107
+ cachedTerminalPalette = inherited;
108
+ return cachedTerminalPalette;
109
+ }
110
+
111
+ const probed = probeTerminalPalette();
112
+ if (probed) {
113
+ cachedTerminalPalette = rememberTerminalPalette(probed);
114
+ return cachedTerminalPalette;
115
+ }
116
+
117
+ // Nothing to ask: fall back to what a start with a terminal wrote down,
118
+ // re-validated, because a state file is not a trusted wire either.
119
+ cachedTerminalPalette = rememberedTerminalPalette();
120
+ return cachedTerminalPalette;
121
+ }
122
+
123
+ function logPath(name) {
124
+ return path.join(stateDir(), `${name}.log`);
125
+ }
126
+
127
+ function relayBinPath() {
128
+ // Resolved rather than hard-coded: the relay is a separate npm package, so
129
+ // its location depends on how npm hoisted it.
130
+ const manifest = require.resolve('herdr-remote-relay/package.json');
131
+ return path.join(path.dirname(manifest), 'bin', 'herdr-remote-relay.js');
132
+ }
133
+
134
+ function relayAuthStatePath() {
135
+ return path.join(stateDir(), 'relay-auth.json');
136
+ }
137
+
138
+ /**
139
+ * The processes that make up a running herdr-remote, as declarative specs.
140
+ *
141
+ * Both the detached starter and the foreground supervisor build their children
142
+ * from this one list so the two paths cannot drift in how they configure the
143
+ * relay or the host connector.
144
+ */
145
+ function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
146
+ const specs = [];
147
+ const publicUrl = resolvePublicUrl(config, preferredLanAddress());
148
+
149
+ if (runsLocalRelay(config)) {
150
+ specs.push({
151
+ name: 'relay',
152
+ command: process.execPath,
153
+ args: [relayBinPath()],
154
+ env: {
155
+ // Let the shared web UI tell a private workstation relay apart from
156
+ // the operator-facing self-hosted relay. Standalone relay installs
157
+ // default to remote mode.
158
+ RELAY_DEPLOYMENT_MODE: 'local',
159
+ RELAY_BIND: bindAddress(config),
160
+ RELAY_PORT: String(config.relay.port),
161
+ RELAY_PUBLIC_URL: publicUrl,
162
+ // A relay we start ourselves is closed to everything but this
163
+ // workstation: reusing the host token as its password costs nothing and
164
+ // stops another machine on the LAN from enrolling into it.
165
+ RELAY_PASSWORD: state.hostToken,
166
+ RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
167
+ RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(','),
168
+ RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
169
+ },
170
+ });
171
+ }
172
+
173
+ const terminalPalette = hostTerminalPalette();
174
+
175
+ specs.push({
176
+ name: 'host',
177
+ command: process.execPath,
178
+ args: [path.join(PACKAGE_ROOT, 'src', 'host-connector.js')],
179
+ env: {
180
+ // Captured here, where a terminal may still be attached, because the
181
+ // connector itself usually runs detached with no terminal to ask.
182
+ ...(terminalPalette ? { HERDR_TERM_PALETTE_JSON: JSON.stringify(terminalPalette) } : {}),
183
+ RELAY_URL: resolveHostRelayUrl(config),
184
+ RELAY_HOST_ID: state.hostId,
185
+ RELAY_HOST_TOKEN: state.hostToken,
186
+ RELAY_PASSWORD: runsLocalRelay(config) ? state.hostToken : (state.relayPassword || ''),
187
+ HERDR_SOCKET_PATH: resolveSocketPath(config.herdr.socketPath),
188
+ HERDR_ARGS_JSON: JSON.stringify(config.herdr.args),
189
+ HERDR_CWD: config.herdr.cwd,
190
+ HERDR_BIN_PATH: resolveHerdrCommand(),
191
+ },
192
+ });
193
+
194
+ return specs;
195
+ }
196
+
197
+ function baseEnvironment() {
198
+ return { ...process.env, HERDR_REMOTE_SERVICE: '1' };
199
+ }
200
+
201
+ function spawnDetached(spec) {
202
+ ensureDir(stateDir());
203
+ const logFd = fs.openSync(logPath(spec.name), 'a');
204
+ try {
205
+ const child = spawn(spec.command, spec.args, {
206
+ cwd: PACKAGE_ROOT,
207
+ env: { ...baseEnvironment(), ...spec.env },
208
+ detached: true,
209
+ stdio: ['ignore', logFd, logFd],
210
+ });
211
+ child.unref();
212
+ return child.pid;
213
+ } finally {
214
+ fs.closeSync(logFd);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Remember a process we started, in a list that survives being overwritten.
220
+ *
221
+ * `relayPid`/`hostPid` alone were not enough: the supervisor rewrote them with
222
+ * its own children, which orphaned anything an earlier detached start had
223
+ * spawned. Those orphans kept the port and the host session, so the relay could
224
+ * never bind and "stop" had nothing left to kill. The ledger is append-only
225
+ * (minus dead entries) precisely so no writer can lose another writer's pids.
226
+ */
227
+ function recordManagedPid(state, name, pid) {
228
+ const existing = Array.isArray(state.managedPids) ? state.managedPids : [];
229
+ const kept = existing.filter((entry) => entry && entry.pid !== pid && pidAlive(entry.pid));
230
+ // Only track something that is actually running: a pid that already exited
231
+ // would sit in the ledger until its number is recycled by an unrelated
232
+ // process, which we would then happily signal.
233
+ if (Number.isInteger(pid) && pidAlive(pid)) {
234
+ kept.push({ name, pid, startedAt: new Date().toISOString() });
235
+ }
236
+ state.managedPids = kept;
237
+ return state;
238
+ }
239
+
240
+ /** Every process we believe we started, alive right now. */
241
+ function managedPids(state = readRuntime()) {
242
+ const pids = new Map();
243
+ // Supervisor first and never overwritten: `stopServices` relies on the order
244
+ // to signal it before its children, and one pid can appear under several
245
+ // fields.
246
+ for (const [name, pid] of [['supervisor', state.supervisorPid], ['host', state.hostPid], ['relay', state.relayPid]]) {
247
+ if (pidAlive(pid) && !pids.has(pid)) pids.set(pid, name);
248
+ }
249
+ for (const entry of state.managedPids || []) {
250
+ if (entry && pidAlive(entry.pid) && !pids.has(entry.pid)) pids.set(entry.pid, entry.name);
251
+ }
252
+ return [...pids].map(([pid, name]) => ({ pid, name }));
253
+ }
254
+
255
+ function startServices() {
256
+ const config = loadConfig();
257
+ const state = ensureRuntime();
258
+ const next = { ...state };
259
+ const specs = serviceSpecs(config, state);
260
+
261
+ for (const spec of specs) {
262
+ const pidKey = `${spec.name}Pid`;
263
+ if (pidAlive(next[pidKey])) continue;
264
+ next[pidKey] = spawnDetached(spec);
265
+ recordManagedPid(next, spec.name, next[pidKey]);
266
+ }
267
+ // A relay that is no longer part of the plan (switched to remote mode) must
268
+ // not be left running on the old port.
269
+ if (!specs.some((spec) => spec.name === 'relay') && pidAlive(next.relayPid)) {
270
+ try { process.kill(next.relayPid, 'SIGTERM'); } catch {}
271
+ next.relayPid = null;
272
+ }
273
+
274
+ next.startedAt = next.startedAt || new Date().toISOString();
275
+ next.mode = config.relay.mode;
276
+ writeJsonAtomic(runtimeStatePath(), next);
277
+ return {
278
+ ok: true,
279
+ mode: config.relay.mode,
280
+ relay: {
281
+ local: runsLocalRelay(config),
282
+ pid: next.relayPid || null,
283
+ alive: pidAlive(next.relayPid),
284
+ bind: runsLocalRelay(config) ? bindAddress(config) : null,
285
+ port: config.relay.port,
286
+ remoteUrl: config.relay.remoteUrl || null,
287
+ },
288
+ host: {
289
+ pid: next.hostPid || null,
290
+ alive: pidAlive(next.hostPid),
291
+ socketPath: resolveSocketPath(config.herdr.socketPath),
292
+ },
293
+ publicUrl: resolvePublicUrl(config, preferredLanAddress()),
294
+ };
295
+ }
296
+
297
+ function stopServices() {
298
+ const state = readRuntime();
299
+ const stopped = [];
300
+
301
+ // Supervisor first: it would otherwise see its children die and restart them
302
+ // faster than we can kill them.
303
+ const targets = managedPids(state).sort((a, b) => (
304
+ (a.name === 'supervisor' ? 0 : 1) - (b.name === 'supervisor' ? 0 : 1)
305
+ ));
306
+
307
+ for (const { pid, name } of targets) {
308
+ try {
309
+ process.kill(pid, 'SIGTERM');
310
+ stopped.push({ name, pid });
311
+ } catch (error) {
312
+ process.stderr.write(`herdr-remote: could not stop ${name} (pid ${pid}): ${error.message}\n`);
313
+ }
314
+ }
315
+
316
+ state.hostPid = null;
317
+ state.relayPid = null;
318
+ state.supervisorPid = null;
319
+ state.managedPids = [];
320
+ state.startedAt = null;
321
+ ensureDir(stateDir());
322
+ writeJsonAtomic(runtimeStatePath(), state);
323
+ return { ok: true, stopped };
324
+ }
325
+
326
+ function restartServices() {
327
+ stopServices();
328
+ return startServices();
329
+ }
330
+
331
+ /**
332
+ * Minimal JSON client. Picks http or https from the URL: a self-hosted relay is
333
+ * reached over https, while the local relay is plain http on loopback.
334
+ */
335
+ function requestJson(urlString, options = {}) {
336
+ return new Promise((resolve, reject) => {
337
+ let url;
338
+ try { url = new URL(urlString); } catch (error) { return reject(error); }
339
+ const transport = url.protocol === 'https:' ? https : http;
340
+ const request = transport.request(url, {
341
+ method: options.method || 'GET',
342
+ headers: options.headers || {},
343
+ timeout: options.timeout || 1500,
344
+ }, (response) => {
345
+ const chunks = [];
346
+ response.on('data', (chunk) => chunks.push(chunk));
347
+ response.on('end', () => {
348
+ const text = Buffer.concat(chunks).toString('utf8');
349
+ let body;
350
+ try { body = JSON.parse(text); } catch { body = { raw: text }; }
351
+ if (response.statusCode >= 400) {
352
+ const error = new Error(body.message || `HTTP ${response.statusCode}`);
353
+ error.statusCode = response.statusCode;
354
+ error.body = body;
355
+ reject(error);
356
+ } else resolve(body);
357
+ });
358
+ });
359
+ request.on('timeout', () => request.destroy(new Error('request timed out')));
360
+ request.on('error', reject);
361
+ if (options.body) request.write(options.body);
362
+ request.end();
363
+ });
364
+ }
365
+
366
+ function healthUrl(config) {
367
+ return `${resolveAdminOrigin(config)}/healthz`;
368
+ }
369
+
370
+ async function waitForRelay(config, { attempts = 20, delayMs = 100 } = {}) {
371
+ let lastError;
372
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
373
+ try { return await requestJson(healthUrl(config), { timeout: 800 }); } catch (error) { lastError = error; }
374
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
375
+ }
376
+ throw lastError || new Error('relay did not become ready');
377
+ }
378
+
379
+ async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
380
+ let lastHealth = null;
381
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
382
+ try {
383
+ lastHealth = await requestJson(healthUrl(config), { timeout: 800 });
384
+ if (lastHealth.hosts > 0) return lastHealth;
385
+ } catch (error) {
386
+ lastHealth = { ok: false, message: error.message };
387
+ }
388
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
389
+ }
390
+ const error = new Error(lastHealth?.message || 'the Herdr host connector did not register with the relay');
391
+ error.health = lastHealth;
392
+ throw error;
393
+ }
394
+
395
+ async function statusServices() {
396
+ const config = loadConfig();
397
+ const state = readRuntime();
398
+ const lanAddress = preferredLanAddress();
399
+ let health = null;
400
+ try {
401
+ health = await requestJson(healthUrl(config), { timeout: 1200 });
402
+ } catch (error) {
403
+ health = { ok: false, message: error.message };
404
+ }
405
+ const socketPath = resolveSocketPath(config.herdr.socketPath);
406
+ return {
407
+ ok: true,
408
+ mode: config.relay.mode,
409
+ relay: {
410
+ local: runsLocalRelay(config),
411
+ pid: state.relayPid || null,
412
+ alive: runsLocalRelay(config) ? pidAlive(state.relayPid) : null,
413
+ bind: runsLocalRelay(config) ? bindAddress(config) : null,
414
+ port: config.relay.port,
415
+ remoteUrl: config.relay.remoteUrl || null,
416
+ health,
417
+ },
418
+ host: {
419
+ pid: state.hostPid || null,
420
+ alive: pidAlive(state.hostPid),
421
+ hostId: state.hostId || null,
422
+ socketPath,
423
+ socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
424
+ },
425
+ publicUrl: resolvePublicUrl(config, lanAddress),
426
+ startedAt: state.startedAt || null,
427
+ };
428
+ }
429
+
430
+ async function pair() {
431
+ const config = loadConfig();
432
+ const state = ensureRuntime();
433
+ if (runsLocalRelay(config)) startServices();
434
+ await waitForRelay(config);
435
+ await waitForHost(config);
436
+ // Authenticated with this workstation's own host token: on a shared relay
437
+ // that is what proves the pairing code is being minted for our terminal and
438
+ // nobody else's.
439
+ const pairing = await requestJson(`${resolveAdminOrigin(config)}/api/pair/start`, {
440
+ method: 'POST',
441
+ headers: {
442
+ 'X-Herdr-Host-Id': state.hostId,
443
+ 'X-Herdr-Host-Token': state.hostToken,
444
+ },
445
+ timeout: 5000,
446
+ });
447
+ return pairing;
448
+ }
449
+
450
+ function extractPairingCode(response) {
451
+ const code = response?.code || response?.pairCode;
452
+ if (typeof code !== 'string' || code.length === 0) {
453
+ throw new Error('the relay response did not contain a valid pairing code');
454
+ }
455
+ return code;
456
+ }
457
+
458
+ function readLogTail(name, lines = 40) {
459
+ try {
460
+ const content = fs.readFileSync(logPath(name), 'utf8');
461
+ return content.split('\n').filter(Boolean).slice(-lines);
462
+ } catch {
463
+ return [];
464
+ }
465
+ }
466
+
467
+ module.exports = {
468
+ RUNTIME_VERSION,
469
+ pidAlive,
470
+ readRuntime,
471
+ ensureRuntime,
472
+ recordManagedPid,
473
+ managedPids,
474
+ setRelayPassword,
475
+ regenerateHostIdentity,
476
+ serviceSpecs,
477
+ baseEnvironment,
478
+ hostTerminalPalette,
479
+ startServices,
480
+ stopServices,
481
+ restartServices,
482
+ statusServices,
483
+ requestJson,
484
+ waitForRelay,
485
+ waitForHost,
486
+ pair,
487
+ extractPairingCode,
488
+ relayBinPath,
489
+ relayAuthStatePath,
490
+ logPath,
491
+ readLogTail,
492
+ };