@openparachute/hub 0.6.1-rc.4 → 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.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +34 -0
- package/src/__tests__/cloudflare-config.test.ts +65 -1
- package/src/__tests__/cloudflare-connector-service.test.ts +441 -0
- package/src/__tests__/expose-cloudflare.test.ts +684 -16
- package/src/account-home-ui.ts +4 -1
- package/src/cli.ts +2 -1
- package/src/cloudflare/config.ts +70 -4
- package/src/cloudflare/connector-service.ts +478 -0
- package/src/cloudflare/state.ts +13 -1
- package/src/commands/expose-cloudflare.ts +308 -43
- package/src/help.ts +7 -2
package/src/account-home-ui.ts
CHANGED
|
@@ -310,8 +310,11 @@ function renderVaultCard(opts: VaultCardOpts): string {
|
|
|
310
310
|
<p class="vault-notes-cta">
|
|
311
311
|
<a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
|
|
312
312
|
target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
|
|
313
|
+
<a class="btn btn-secondary" href="https://notes.parachute.computer/import?url=${vaultUrlForAdd}"
|
|
314
|
+
target="_blank" rel="noopener" data-testid="import-notes-cta">Import notes ↗</a>
|
|
313
315
|
<span class="vault-notes-cta-sub">Prefer a browser UI? Open Notes to browse +
|
|
314
|
-
capture in this vault
|
|
316
|
+
capture in this vault — or jump straight to bulk-importing Markdown/Obsidian
|
|
317
|
+
notes into it.</span>
|
|
315
318
|
</p>
|
|
316
319
|
${tokenMintBlock}
|
|
317
320
|
</div>`;
|
package/src/cli.ts
CHANGED
|
@@ -159,7 +159,8 @@ function extractNamedFlag(
|
|
|
159
159
|
* fall through to today's Tailscale
|
|
160
160
|
* default (CI escape hatch, #29)
|
|
161
161
|
* --domain=<host> hostname for the Cloudflare path
|
|
162
|
-
* --tunnel-name=<name> named tunnel override (#32)
|
|
162
|
+
* --tunnel-name=<name> named tunnel override (#32); defaults to a
|
|
163
|
+
* per-hostname dedicated name (#491)
|
|
163
164
|
*
|
|
164
165
|
* Returns the stripped argv so the layer/action parser sees `[layer, action?]`
|
|
165
166
|
* regardless of flag placement. `--tailnet` + `--cloudflare` together is
|
package/src/cloudflare/config.ts
CHANGED
|
@@ -2,18 +2,84 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR } from "../config.ts";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The legacy shared tunnel name. Pre-#491, every machine defaulted its
|
|
7
|
+
* Cloudflare tunnel to this single constant — but Cloudflare tunnels are
|
|
8
|
+
* account-wide, so a second machine exposing a *different* hostname found and
|
|
9
|
+
* reused the SAME tunnel, both connectors registered on one UUID, and the edge
|
|
10
|
+
* load-balanced requests across them → a request for host B could land on host
|
|
11
|
+
* A's connector (whose config.yml only routes host A) → ~50% cross-host 404s.
|
|
12
|
+
*
|
|
13
|
+
* The default is now a per-hostname derived name (`deriveTunnelName`). This
|
|
14
|
+
* constant's role narrows to "the legacy shared name we migrate away from":
|
|
15
|
+
* - the up-path legacy-sweep kills a stale `"parachute"` connector on the box
|
|
16
|
+
* so running deploys self-heal on the next expose, and
|
|
17
|
+
* - the off-path reuse-hint compares against it (records no longer equal it,
|
|
18
|
+
* so the hint always includes `--tunnel-name`, which is now correct).
|
|
19
|
+
*/
|
|
5
20
|
export const DEFAULT_TUNNEL_NAME = "parachute";
|
|
6
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Derive a dedicated, per-hostname tunnel name from a hostname. Cloudflare
|
|
24
|
+
* tunnels are account-wide, so each machine/hostname needs its OWN tunnel —
|
|
25
|
+
* sharing one name across boxes collides their connectors (#491). The name is
|
|
26
|
+
* deterministic (same hostname → same name) so re-exposing the same hostname
|
|
27
|
+
* is idempotent: it finds and reuses the tunnel it created last time.
|
|
28
|
+
*
|
|
29
|
+
* Sanitization: lowercase, dots → hyphens, drop anything outside `[a-z0-9_-]`,
|
|
30
|
+
* then prefix `parachute-`. Examples:
|
|
31
|
+
* `our.parachute.computer` → `parachute-our-parachute-computer`
|
|
32
|
+
* `vault.example.com` → `parachute-vault-example-com`
|
|
33
|
+
*
|
|
34
|
+
* Length: tunnel names must satisfy `isValidTunnelName` (≤64 chars). When the
|
|
35
|
+
* derived name would exceed 64, truncate the sanitized body and append a short
|
|
36
|
+
* stable suffix (`-<8-hex>`) computed deterministically from the FULL hostname
|
|
37
|
+
* so two long hostnames sharing a 64-char prefix can't collide on the same
|
|
38
|
+
* tunnel. The hash is a non-crypto FNV-1a-style fold — deterministic, no
|
|
39
|
+
* Math.random / Date dependency (those would break idempotent re-expose).
|
|
40
|
+
*/
|
|
41
|
+
const TUNNEL_NAME_PREFIX = "parachute-";
|
|
42
|
+
const MAX_TUNNEL_NAME = 64;
|
|
43
|
+
|
|
44
|
+
function shortStableHash(input: string): string {
|
|
45
|
+
// FNV-1a 32-bit. Deterministic, dependency-free, good enough to disambiguate
|
|
46
|
+
// two hostnames that sanitize to the same truncated prefix. >>> 0 keeps it
|
|
47
|
+
// unsigned so the hex is stable across runtimes.
|
|
48
|
+
let h = 0x811c9dc5;
|
|
49
|
+
for (let i = 0; i < input.length; i++) {
|
|
50
|
+
h ^= input.charCodeAt(i);
|
|
51
|
+
h = Math.imul(h, 0x01000193);
|
|
52
|
+
}
|
|
53
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function deriveTunnelName(hostname: string): string {
|
|
57
|
+
const body = hostname
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/\./g, "-")
|
|
60
|
+
.replace(/[^a-z0-9_-]/g, "");
|
|
61
|
+
const full = `${TUNNEL_NAME_PREFIX}${body}`;
|
|
62
|
+
if (full.length <= MAX_TUNNEL_NAME) return full;
|
|
63
|
+
// Too long — truncate the body and append a stable 8-hex suffix derived from
|
|
64
|
+
// the full hostname. Reserve room for the prefix + "-" + 8 hex chars.
|
|
65
|
+
const suffix = `-${shortStableHash(hostname)}`;
|
|
66
|
+
const room = MAX_TUNNEL_NAME - TUNNEL_NAME_PREFIX.length - suffix.length;
|
|
67
|
+
// Strip any trailing hyphen the truncation left behind (e.g. a slice that
|
|
68
|
+
// lands on a dot-turned-hyphen) so the body doesn't abut the suffix as `--`.
|
|
69
|
+
const truncated = body.slice(0, room).replace(/-+$/, "");
|
|
70
|
+
return `${TUNNEL_NAME_PREFIX}${truncated}${suffix}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
7
73
|
/**
|
|
8
74
|
* Per-tunnel config + log file paths. Each tunnel gets its own subdirectory
|
|
9
75
|
* under `~/.parachute/cloudflared/<tunnelName>/` so multiple tunnels on one
|
|
10
76
|
* box don't trample each other's config.yml or interleave log lines.
|
|
11
77
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
78
|
+
* The per-hostname tunnel (`deriveTunnelName(host)`, e.g.
|
|
79
|
+
* `parachute-our-parachute-computer`) lives at
|
|
80
|
+
* `~/.parachute/cloudflared/<tunnelName>/{config.yml,cloudflared.log}`.
|
|
15
81
|
* Re-running `parachute expose public --cloudflare` regenerates the file
|
|
16
|
-
* at
|
|
82
|
+
* at that path; any legacy `parachute/` file is left in place but unused.
|
|
17
83
|
*
|
|
18
84
|
* `configDir` overrides the base (`~/.parachute` by default). Tests pass a
|
|
19
85
|
* tmp dir so per-tunnel-derived paths never resolve against the operator's
|
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|
package/src/cloudflare/state.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|