@openparachute/hub 0.6.1 → 0.6.2

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,478 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /**
6
+ * Reboot-persistent cloudflared connector.
7
+ *
8
+ * Pre-0.6.2 `parachute expose public --cloudflare` spawned the connector as a
9
+ * bare detached background process (`Bun.spawn(...).unref()`), which dies on
10
+ * reboot — the operator had to re-run the expose command every time the box
11
+ * restarted. This module installs a per-tunnel OS service that runs the same
12
+ * `cloudflared tunnel --config <path> run` command on boot, so the connector
13
+ * survives reboots.
14
+ *
15
+ * Platform shapes:
16
+ * - macOS → a launchd LaunchAgent plist at
17
+ * `~/Library/LaunchAgents/computer.parachute.cloudflared.<tunnelName>.plist`
18
+ * (RunAtLoad + KeepAlive), bootstrapped into the per-user GUI domain. No
19
+ * sudo: a LaunchAgent runs as the logged-in user.
20
+ * - Linux (non-root) → a systemd *user* unit at
21
+ * `~/.config/systemd/user/parachute-cloudflared-<tunnelName>.service`,
22
+ * `systemctl --user enable --now`, plus a best-effort
23
+ * `loginctl enable-linger $USER` so the unit runs without an active login.
24
+ * - Linux (root) → a systemd *system* unit at
25
+ * `/etc/systemd/system/parachute-cloudflared-<tunnelName>.service`,
26
+ * `systemctl enable --now`. No linger needed — system units run on boot.
27
+ *
28
+ * Everything is behind an injectable `ConnectorServiceDeps` seam (mirrors the
29
+ * `Runner`/`CloudflaredSpawner`/`KillFn` injection in expose-cloudflare.ts) so
30
+ * tests drive the install/remove without touching real launchctl/systemctl or
31
+ * the operator's home directory.
32
+ *
33
+ * Service name keyed by the same per-host tunnel name the 0.6.1 work derives
34
+ * (`deriveTunnelName`), so install / remove always target the connector for
35
+ * exactly one tunnel and the expose off / legacy-sweep paths can tear it down.
36
+ */
37
+
38
+ /** Synchronous command result from the injected service runner. */
39
+ export interface ServiceCommandResult {
40
+ code: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ }
44
+
45
+ /**
46
+ * Injectable side-effect seam for the connector-service module. Production
47
+ * wires the real fs / os / child-process implementations (`defaultServiceDeps`);
48
+ * tests inject fakes to assert generated file content + the install/remove
49
+ * command sequence without a live launchctl/systemctl.
50
+ */
51
+ export interface ConnectorServiceDeps {
52
+ /** `process.platform`. */
53
+ platform: NodeJS.Platform;
54
+ /**
55
+ * Effective uid. Linux uses `0 === root` to pick a system vs user systemd
56
+ * unit. `undefined` (Windows / platforms without getuid) → treated as
57
+ * non-root. macOS ignores this (LaunchAgents are always per-user).
58
+ */
59
+ getuid: () => number | undefined;
60
+ /** `$HOME`. */
61
+ homeDir: () => string;
62
+ /** Username for the linger call + systemd unit `User=` (system unit). */
63
+ userName: () => string;
64
+ /** Resolve a binary to an absolute path (launchd/systemd don't inherit PATH). */
65
+ which: (binary: string) => string | null;
66
+ /** Run launchctl/systemctl/loginctl synchronously. */
67
+ run: (cmd: readonly string[]) => ServiceCommandResult;
68
+ /** Write a service file (creates parent dirs). */
69
+ writeFile: (path: string, content: string) => void;
70
+ /** Remove a service file if present (no-op when absent). */
71
+ removeFile: (path: string) => void;
72
+ /** Read a service file, or undefined when absent. */
73
+ readFile: (path: string) => string | undefined;
74
+ /** True when the path exists. */
75
+ exists: (path: string) => boolean;
76
+ }
77
+
78
+ export const defaultServiceDeps: ConnectorServiceDeps = {
79
+ platform: process.platform,
80
+ getuid: () => (typeof process.getuid === "function" ? process.getuid() : undefined),
81
+ homeDir: () => homedir(),
82
+ userName: () => process.env.USER ?? process.env.LOGNAME ?? process.env.USERNAME ?? "",
83
+ which: (binary) => Bun.which(binary),
84
+ run: (cmd) => {
85
+ const proc = Bun.spawnSync([...cmd], { env: process.env });
86
+ return {
87
+ code: proc.exitCode ?? 1,
88
+ stdout: proc.stdout?.toString() ?? "",
89
+ stderr: proc.stderr?.toString() ?? "",
90
+ };
91
+ },
92
+ writeFile: (path, content) => {
93
+ mkdirSync(dirname(path), { recursive: true });
94
+ writeFileSync(path, content);
95
+ },
96
+ removeFile: (path) => {
97
+ if (existsSync(path)) rmSync(path, { force: true });
98
+ },
99
+ readFile: (path) => (existsSync(path) ? readFileSync(path, "utf8") : undefined),
100
+ exists: (path) => existsSync(path),
101
+ };
102
+
103
+ /** Reverse-DNS prefix for the launchd label + plist filename. */
104
+ const LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
105
+ /** systemd unit name prefix. */
106
+ const SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
107
+
108
+ /** launchd label for a tunnel (also the plist basename, minus `.plist`). */
109
+ export function launchdLabel(tunnelName: string): string {
110
+ return `${LAUNCHD_LABEL_PREFIX}.${tunnelName}`;
111
+ }
112
+
113
+ /** launchd plist path under the user's LaunchAgents dir. */
114
+ export function launchdPlistPath(tunnelName: string, home: string): string {
115
+ return join(home, "Library", "LaunchAgents", `${launchdLabel(tunnelName)}.plist`);
116
+ }
117
+
118
+ /** systemd unit name (with `.service` suffix). */
119
+ export function systemdUnitName(tunnelName: string): string {
120
+ return `${SYSTEMD_UNIT_PREFIX}${tunnelName}.service`;
121
+ }
122
+
123
+ /** systemd unit path — user-level under $HOME, system-level under /etc. */
124
+ export function systemdUnitPath(tunnelName: string, home: string, root: boolean): string {
125
+ return root
126
+ ? join("/etc/systemd/system", systemdUnitName(tunnelName))
127
+ : join(home, ".config", "systemd", "user", systemdUnitName(tunnelName));
128
+ }
129
+
130
+ /** XML-escape a string for safe inclusion in a plist `<string>` element. */
131
+ function plistEscape(s: string): string {
132
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
133
+ }
134
+
135
+ /**
136
+ * Render the launchd LaunchAgent plist. `RunAtLoad` starts the connector on
137
+ * load (and on login/boot once bootstrapped); `KeepAlive` restarts it if it
138
+ * exits. We pass `cloudflaredPath` (the resolved absolute binary) as argv[0]
139
+ * because launchd does not search `$PATH`. Logs go to the same per-tunnel log
140
+ * file the transient spawn used, so `parachute status`/the operator find them
141
+ * in one place.
142
+ */
143
+ export function renderLaunchdPlist(opts: {
144
+ tunnelName: string;
145
+ cloudflaredPath: string;
146
+ configPath: string;
147
+ logPath: string;
148
+ }): string {
149
+ const { tunnelName, cloudflaredPath, configPath, logPath } = opts;
150
+ const args = [cloudflaredPath, "tunnel", "--config", configPath, "run"];
151
+ const argXml = args.map((a) => ` <string>${plistEscape(a)}</string>`).join("\n");
152
+ return `<?xml version="1.0" encoding="UTF-8"?>
153
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
154
+ <!-- Generated by parachute expose public --cloudflare — do not edit by hand. -->
155
+ <plist version="1.0">
156
+ <dict>
157
+ <key>Label</key>
158
+ <string>${plistEscape(launchdLabel(tunnelName))}</string>
159
+ <key>ProgramArguments</key>
160
+ <array>
161
+ ${argXml}
162
+ </array>
163
+ <key>RunAtLoad</key>
164
+ <true/>
165
+ <key>KeepAlive</key>
166
+ <true/>
167
+ <key>StandardOutPath</key>
168
+ <string>${plistEscape(logPath)}</string>
169
+ <key>StandardErrorPath</key>
170
+ <string>${plistEscape(logPath)}</string>
171
+ </dict>
172
+ </plist>
173
+ `;
174
+ }
175
+
176
+ /**
177
+ * Render a systemd unit. `Restart=always` mirrors launchd's KeepAlive;
178
+ * `WantedBy` differs by scope (system → multi-user.target; user →
179
+ * default.target). The system unit pins `User=` so the connector doesn't run
180
+ * as root unnecessarily when we know the invoking user. `ExecStart` uses the
181
+ * resolved absolute `cloudflaredPath` (systemd doesn't search a login `$PATH`).
182
+ */
183
+ export function renderSystemdUnit(opts: {
184
+ tunnelName: string;
185
+ cloudflaredPath: string;
186
+ configPath: string;
187
+ logPath: string;
188
+ root: boolean;
189
+ userName: string;
190
+ }): string {
191
+ const { tunnelName, cloudflaredPath, configPath, root, userName } = opts;
192
+ const execStart = `${cloudflaredPath} tunnel --config ${configPath} run`;
193
+ const userLine = root && userName ? `User=${userName}\n` : "";
194
+ const wantedBy = root ? "multi-user.target" : "default.target";
195
+ return `# Generated by parachute expose public --cloudflare — do not edit by hand.
196
+ [Unit]
197
+ Description=Parachute Cloudflare connector (${tunnelName})
198
+ After=network-online.target
199
+ Wants=network-online.target
200
+
201
+ [Service]
202
+ Type=simple
203
+ ${userLine}ExecStart=${execStart}
204
+ Restart=always
205
+ RestartSec=5
206
+
207
+ [Install]
208
+ WantedBy=${wantedBy}
209
+ `;
210
+ }
211
+
212
+ export interface InstallResult {
213
+ /**
214
+ * "installed" → an OS service now owns the connector (survives reboot).
215
+ * "fallback" → the service tool was unavailable / failed; the caller should
216
+ * fall back to the transient `proc.unref()` spawn (does NOT survive reboot).
217
+ */
218
+ outcome: "installed" | "fallback";
219
+ /** Which init system installed the service (when outcome === "installed"). */
220
+ kind?: "launchd" | "systemd-user" | "systemd-system" | "unsupported";
221
+ /** Path of the written service file (when outcome === "installed"). */
222
+ servicePath?: string;
223
+ /** Human-readable lines for the CLI to print (warnings, hints). */
224
+ messages: string[];
225
+ }
226
+
227
+ export interface ConnectorServiceOpts {
228
+ tunnelName: string;
229
+ configPath: string;
230
+ logPath: string;
231
+ deps?: ConnectorServiceDeps;
232
+ }
233
+
234
+ /**
235
+ * Install (or refresh) the reboot-persistent connector service for one tunnel
236
+ * and start it. Idempotent: re-installing overwrites the service file and
237
+ * re-loads it, so re-`expose` of the same hostname converges on exactly one
238
+ * managed connector.
239
+ *
240
+ * Graceful fallback: if the platform's service tool is missing or any step
241
+ * fails, returns `{ outcome: "fallback", messages }` WITHOUT throwing — the
242
+ * caller then spawns the transient connector and warns it won't survive a
243
+ * reboot. We never hard-fail the expose because the service install didn't take.
244
+ */
245
+ export function installConnectorService(opts: ConnectorServiceOpts): InstallResult {
246
+ const deps = opts.deps ?? defaultServiceDeps;
247
+ const cloudflaredPath = deps.which("cloudflared");
248
+ if (!cloudflaredPath) {
249
+ return {
250
+ outcome: "fallback",
251
+ messages: ["Could not resolve the cloudflared binary path; skipping boot-service install."],
252
+ };
253
+ }
254
+
255
+ if (deps.platform === "darwin") {
256
+ return installLaunchd({ ...opts, deps, cloudflaredPath });
257
+ }
258
+ if (deps.platform === "linux") {
259
+ return installSystemd({ ...opts, deps, cloudflaredPath });
260
+ }
261
+ return {
262
+ outcome: "fallback",
263
+ messages: [
264
+ `Boot-persistent connector isn't supported on ${deps.platform}; using a transient connector.`,
265
+ ],
266
+ };
267
+ }
268
+
269
+ function installLaunchd(
270
+ opts: ConnectorServiceOpts & { deps: ConnectorServiceDeps; cloudflaredPath: string },
271
+ ): InstallResult {
272
+ const { deps, tunnelName, configPath, logPath, cloudflaredPath } = opts;
273
+ if (deps.which("launchctl") === null) {
274
+ return {
275
+ outcome: "fallback",
276
+ messages: ["launchctl not found; using a transient connector (won't survive a reboot)."],
277
+ };
278
+ }
279
+ const home = deps.homeDir();
280
+ const plistPath = launchdPlistPath(tunnelName, home);
281
+ const label = launchdLabel(tunnelName);
282
+ const uid = deps.getuid() ?? 0;
283
+ const domain = `gui/${uid}`;
284
+
285
+ try {
286
+ deps.writeFile(
287
+ plistPath,
288
+ renderLaunchdPlist({ tunnelName, cloudflaredPath, configPath, logPath }),
289
+ );
290
+ } catch (err) {
291
+ return {
292
+ outcome: "fallback",
293
+ messages: [
294
+ `Failed to write LaunchAgent (${err instanceof Error ? err.message : String(err)}); using a transient connector (won't survive a reboot).`,
295
+ ],
296
+ };
297
+ }
298
+
299
+ // Re-install must be idempotent: bootout any prior load (ignore failure when
300
+ // nothing's loaded), then bootstrap the freshly-written plist. `bootstrap`
301
+ // both loads AND starts (RunAtLoad), so no separate `kickstart` is needed on
302
+ // the happy path; we add a `kickstart -k` to force a restart when the label
303
+ // was already bootstrapped (bootstrap is a no-op then).
304
+ deps.run(["launchctl", "bootout", `${domain}/${label}`]);
305
+ const boot = deps.run(["launchctl", "bootstrap", domain, plistPath]);
306
+ if (boot.code !== 0) {
307
+ // Older macOS (or a sandboxed context) may not accept `bootstrap`; fall back
308
+ // to the legacy `load -w`, then to a transient connector.
309
+ const legacy = deps.run(["launchctl", "load", "-w", plistPath]);
310
+ if (legacy.code !== 0) {
311
+ deps.removeFile(plistPath);
312
+ return {
313
+ outcome: "fallback",
314
+ messages: [
315
+ `launchctl could not load the connector service (${boot.stderr.trim() || legacy.stderr.trim() || "unknown error"}); using a transient connector (won't survive a reboot).`,
316
+ ],
317
+ };
318
+ }
319
+ } else {
320
+ deps.run(["launchctl", "kickstart", "-k", `${domain}/${label}`]);
321
+ }
322
+
323
+ return {
324
+ outcome: "installed",
325
+ kind: "launchd",
326
+ servicePath: plistPath,
327
+ messages: [`Installed launchd LaunchAgent ${label} — the connector now starts on login/boot.`],
328
+ };
329
+ }
330
+
331
+ function installSystemd(
332
+ opts: ConnectorServiceOpts & { deps: ConnectorServiceDeps; cloudflaredPath: string },
333
+ ): InstallResult {
334
+ const { deps, tunnelName, configPath, logPath, cloudflaredPath } = opts;
335
+ if (deps.which("systemctl") === null) {
336
+ return {
337
+ outcome: "fallback",
338
+ messages: ["systemctl not found; using a transient connector (won't survive a reboot)."],
339
+ };
340
+ }
341
+ const root = (deps.getuid() ?? 1000) === 0;
342
+ const home = deps.homeDir();
343
+ const unitName = systemdUnitName(tunnelName);
344
+ const unitPath = systemdUnitPath(tunnelName, home, root);
345
+ const userName = deps.userName();
346
+
347
+ try {
348
+ deps.writeFile(
349
+ unitPath,
350
+ renderSystemdUnit({ tunnelName, cloudflaredPath, configPath, logPath, root, userName }),
351
+ );
352
+ } catch (err) {
353
+ return {
354
+ outcome: "fallback",
355
+ messages: [
356
+ `Failed to write systemd unit (${err instanceof Error ? err.message : String(err)}); using a transient connector (won't survive a reboot).`,
357
+ ],
358
+ };
359
+ }
360
+
361
+ const scope = root ? [] : ["--user"];
362
+ const messages: string[] = [];
363
+
364
+ // Non-root: enable linger so the user unit runs without an active login
365
+ // (i.e. after a reboot before the operator logs back in). Strictly
366
+ // best-effort — linger may be unavailable: `loginctl` absent entirely (a
367
+ // container with systemd but no logind), or present-but-failing. Either way
368
+ // we keep the install (a user unit is still better than a transient spawn)
369
+ // and warn. The probe + try/catch matter because production `Bun.spawnSync`
370
+ // THROWS on ENOENT — without the guard a box that has systemctl but not
371
+ // loginctl would propagate the spawn error out and hard-fail the expose.
372
+ if (!root && userName) {
373
+ const lingerWarning =
374
+ "Note: could not enable lingering (loginctl enable-linger) — the connector will run while you're logged in but may not start on a cold boot before login. To run on cold boot without an active login, re-run this command as root (installs a system unit that needs no linger).";
375
+ if (deps.which("loginctl") === null) {
376
+ messages.push(lingerWarning);
377
+ } else {
378
+ try {
379
+ const linger = deps.run(["loginctl", "enable-linger", userName]);
380
+ if (linger.code !== 0) messages.push(lingerWarning);
381
+ } catch {
382
+ // loginctl vanished between probe and run, or threw (ENOENT/EACCES) —
383
+ // never fatal; linger is a best-effort nicety.
384
+ messages.push(lingerWarning);
385
+ }
386
+ }
387
+ }
388
+
389
+ const reload = deps.run(["systemctl", ...scope, "daemon-reload"]);
390
+ if (reload.code !== 0) {
391
+ deps.removeFile(unitPath);
392
+ return {
393
+ outcome: "fallback",
394
+ messages: [
395
+ `systemctl daemon-reload failed (${reload.stderr.trim() || "unknown error"}); using a transient connector (won't survive a reboot).`,
396
+ ],
397
+ };
398
+ }
399
+ const enable = deps.run(["systemctl", ...scope, "enable", "--now", unitName]);
400
+ if (enable.code !== 0) {
401
+ deps.removeFile(unitPath);
402
+ deps.run(["systemctl", ...scope, "daemon-reload"]);
403
+ return {
404
+ outcome: "fallback",
405
+ messages: [
406
+ `systemctl enable --now failed (${enable.stderr.trim() || "unknown error"}); using a transient connector (won't survive a reboot).`,
407
+ ],
408
+ };
409
+ }
410
+
411
+ messages.unshift(
412
+ `Installed systemd ${root ? "system" : "user"} unit ${unitName} — the connector now starts on boot.`,
413
+ );
414
+ return {
415
+ outcome: "installed",
416
+ kind: root ? "systemd-system" : "systemd-user",
417
+ servicePath: unitPath,
418
+ messages,
419
+ };
420
+ }
421
+
422
+ export interface RemoveResult {
423
+ /** True when a service file was found + removed (best-effort tool teardown ran). */
424
+ removed: boolean;
425
+ messages: string[];
426
+ }
427
+
428
+ /**
429
+ * Stop + remove the reboot-persistent connector service for one tunnel.
430
+ * Idempotent + best-effort: a missing service file is a no-op; tool failures
431
+ * never throw (the expose-off path must always succeed at clearing state even
432
+ * if the OS service tool hiccups). Mirrors `installConnectorService`'s seam.
433
+ *
434
+ * Called by `exposeCloudflareOff` (and the legacy-tunnel sweep) so tearing down
435
+ * a tunnel also tears down its boot service — otherwise the service would
436
+ * resurrect a dead connector on the next reboot.
437
+ */
438
+ export function removeConnectorService(opts: {
439
+ tunnelName: string;
440
+ deps?: ConnectorServiceDeps;
441
+ }): RemoveResult {
442
+ const deps = opts.deps ?? defaultServiceDeps;
443
+ const { tunnelName } = opts;
444
+
445
+ if (deps.platform === "darwin") {
446
+ const home = deps.homeDir();
447
+ const plistPath = launchdPlistPath(tunnelName, home);
448
+ if (!deps.exists(plistPath)) return { removed: false, messages: [] };
449
+ const uid = deps.getuid() ?? 0;
450
+ const label = launchdLabel(tunnelName);
451
+ // bootout unloads + stops; ignore its exit (nothing-loaded is fine).
452
+ deps.run(["launchctl", "bootout", `gui/${uid}/${label}`]);
453
+ deps.removeFile(plistPath);
454
+ return {
455
+ removed: true,
456
+ messages: [`Removed launchd LaunchAgent ${label}.`],
457
+ };
458
+ }
459
+
460
+ if (deps.platform === "linux") {
461
+ const root = (deps.getuid() ?? 1000) === 0;
462
+ const home = deps.homeDir();
463
+ const unitName = systemdUnitName(tunnelName);
464
+ const unitPath = systemdUnitPath(tunnelName, home, root);
465
+ if (!deps.exists(unitPath)) return { removed: false, messages: [] };
466
+ const scope = root ? [] : ["--user"];
467
+ // disable --now stops + removes the enable symlink; ignore exit (best-effort).
468
+ deps.run(["systemctl", ...scope, "disable", "--now", unitName]);
469
+ deps.removeFile(unitPath);
470
+ deps.run(["systemctl", ...scope, "daemon-reload"]);
471
+ return {
472
+ removed: true,
473
+ messages: [`Removed systemd unit ${unitName}.`],
474
+ };
475
+ }
476
+
477
+ return { removed: false, messages: [] };
478
+ }
@@ -25,6 +25,14 @@ export interface CloudflaredTunnelRecord {
25
25
  startedAt: string;
26
26
  /** Absolute path to the cloudflared config.yml driving this tunnel. */
27
27
  configPath: string;
28
+ /**
29
+ * True when a reboot-persistent OS service (launchd/systemd) owns this
30
+ * connector (0.6.2). Drives the off-path to remove the service (not just
31
+ * SIGTERM the pid — a still-enabled service would otherwise restart the
32
+ * connector it just killed). Optional + defaults false so pre-0.6.2 state
33
+ * files (and the transient-fallback path) validate + read as unmanaged.
34
+ */
35
+ serviceManaged?: boolean;
28
36
  }
29
37
 
30
38
  /**
@@ -64,7 +72,7 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
64
72
  throw new CloudflaredStateError(`${path}: tunnel record must be an object`);
65
73
  }
66
74
  const r = raw as Record<string, unknown>;
67
- return {
75
+ const record: CloudflaredTunnelRecord = {
68
76
  pid: requirePositiveInt(r, "pid", path),
69
77
  tunnelUuid: requireString(r, "tunnelUuid", path),
70
78
  tunnelName: requireString(r, "tunnelName", path),
@@ -72,6 +80,10 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
72
80
  startedAt: requireString(r, "startedAt", path),
73
81
  configPath: requireString(r, "configPath", path),
74
82
  };
83
+ // Optional — present from 0.6.2 onward. A non-boolean (or absent) value
84
+ // reads as unmanaged so legacy state files keep validating.
85
+ if (r.serviceManaged === true) record.serviceManaged = true;
86
+ return record;
75
87
  }
76
88
 
77
89
  function validate(raw: unknown, path: string): CloudflaredState {