@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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "parachute
|
|
3
|
+
"version": "0.6.2",
|
|
4
|
+
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -74,6 +74,40 @@ describe("renderAccountHome", () => {
|
|
|
74
74
|
expect(html).toContain('data-testid="connect-any-client-hint"');
|
|
75
75
|
// Notes CTA still present, now framed as the browser-UI option.
|
|
76
76
|
expect(html).toContain('data-testid="open-notes-cta"');
|
|
77
|
+
// Import-notes CTA deep-links to the Notes-UI /import route for the same
|
|
78
|
+
// vault, mirroring the Open-Notes target-resolution (same hosted origin,
|
|
79
|
+
// same `?url=${hubOrigin}/vault/<name>` vault-targeting param).
|
|
80
|
+
expect(html).toContain(`https://notes.parachute.computer/import?url=${encodedVaultUrl}`);
|
|
81
|
+
expect(html).toContain('data-testid="import-notes-cta"');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("assigned-vault branch — import-notes CTA gated alongside open-notes (no dead link)", () => {
|
|
85
|
+
// Both CTAs render together for an assigned vault and are absent together
|
|
86
|
+
// in the no-vault branches — so we never surface an Import link that points
|
|
87
|
+
// at a vault the user can't reach.
|
|
88
|
+
const html = renderAccountHome({
|
|
89
|
+
username: "alice",
|
|
90
|
+
assignedVaults: ["alice"],
|
|
91
|
+
passwordChanged: true,
|
|
92
|
+
hubOrigin: HUB_ORIGIN,
|
|
93
|
+
isFirstAdmin: false,
|
|
94
|
+
csrfToken: CSRF,
|
|
95
|
+
twoFactorEnabled: false,
|
|
96
|
+
});
|
|
97
|
+
expect(html).toContain('data-testid="open-notes-cta"');
|
|
98
|
+
expect(html).toContain('data-testid="import-notes-cta"');
|
|
99
|
+
|
|
100
|
+
const adminHtml = renderAccountHome({
|
|
101
|
+
username: "admin",
|
|
102
|
+
assignedVaults: [],
|
|
103
|
+
passwordChanged: true,
|
|
104
|
+
hubOrigin: HUB_ORIGIN,
|
|
105
|
+
isFirstAdmin: true,
|
|
106
|
+
csrfToken: CSRF,
|
|
107
|
+
twoFactorEnabled: false,
|
|
108
|
+
});
|
|
109
|
+
expect(adminHtml).not.toContain('data-testid="import-notes-cta"');
|
|
110
|
+
expect(adminHtml).not.toContain("notes.parachute.computer/import");
|
|
77
111
|
});
|
|
78
112
|
|
|
79
113
|
test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
|
|
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { renderConfig, writeConfig } from "../cloudflare/config.ts";
|
|
5
|
+
import { deriveTunnelName, renderConfig, writeConfig } from "../cloudflare/config.ts";
|
|
6
|
+
import { isValidTunnelName } from "../commands/expose-cloudflare.ts";
|
|
6
7
|
|
|
7
8
|
describe("cloudflare config", () => {
|
|
8
9
|
test("renderConfig produces a valid cloudflared YAML with one-hostname ingress + catch-all 404", () => {
|
|
@@ -52,3 +53,66 @@ describe("cloudflare config", () => {
|
|
|
52
53
|
}
|
|
53
54
|
});
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("deriveTunnelName (#491 — per-hostname dedicated tunnels)", () => {
|
|
58
|
+
test("prefixes parachute- and turns dots into hyphens", () => {
|
|
59
|
+
expect(deriveTunnelName("our.parachute.computer")).toBe("parachute-our-parachute-computer");
|
|
60
|
+
expect(deriveTunnelName("vault.example.com")).toBe("parachute-vault-example-com");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("lowercases and strips characters outside [a-z0-9_-]", () => {
|
|
64
|
+
// Uppercase → lowercase; a stray char that an over-permissive hostname
|
|
65
|
+
// validator might let through is dropped so the result stays a valid
|
|
66
|
+
// tunnel name. (Dots are already mapped to hyphens before stripping.)
|
|
67
|
+
expect(deriveTunnelName("Vault.Example.COM")).toBe("parachute-vault-example-com");
|
|
68
|
+
expect(deriveTunnelName("a_b-c.example.com")).toBe("parachute-a_b-c-example-com");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("every derived name satisfies isValidTunnelName", () => {
|
|
72
|
+
for (const host of [
|
|
73
|
+
"our.parachute.computer",
|
|
74
|
+
"vault.example.com",
|
|
75
|
+
"Vault.Example.COM",
|
|
76
|
+
"a_b-c.example.com",
|
|
77
|
+
`${"x".repeat(200)}.example.com`,
|
|
78
|
+
]) {
|
|
79
|
+
const name = deriveTunnelName(host);
|
|
80
|
+
expect(isValidTunnelName(name)).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("truncates + appends a stable 8-hex suffix when the name would exceed 64 chars", () => {
|
|
85
|
+
const longHost = `${"sub.".repeat(20)}example.com`; // way over 64 once prefixed
|
|
86
|
+
const name = deriveTunnelName(longHost);
|
|
87
|
+
expect(name.length).toBeLessThanOrEqual(64);
|
|
88
|
+
expect(name.startsWith("parachute-")).toBe(true);
|
|
89
|
+
// 8-hex stable suffix on the end.
|
|
90
|
+
expect(name).toMatch(/-[0-9a-f]{8}$/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("is deterministic — same hostname always derives the same name (idempotent re-expose)", () => {
|
|
94
|
+
const longHost = `${"sub.".repeat(20)}example.com`;
|
|
95
|
+
expect(deriveTunnelName(longHost)).toBe(deriveTunnelName(longHost));
|
|
96
|
+
expect(deriveTunnelName("our.parachute.computer")).toBe(
|
|
97
|
+
deriveTunnelName("our.parachute.computer"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("two distinct long hostnames whose truncated bodies are identical don't collide", () => {
|
|
102
|
+
// Identical leading labels long enough that the body truncation
|
|
103
|
+
// (parachute- + body-slice + -<8hex>, capped at 64) cuts BEFORE the
|
|
104
|
+
// differing tail — so the truncated bodies are byte-identical and only the
|
|
105
|
+
// full-hostname hash distinguishes them. Verifies the suffix disambiguates.
|
|
106
|
+
const sharedPrefix = "x".repeat(80); // single long label, well past the truncation point
|
|
107
|
+
const a = `${sharedPrefix}.alpha.example.com`;
|
|
108
|
+
const b = `${sharedPrefix}.beta.example.com`;
|
|
109
|
+
const nameA = deriveTunnelName(a);
|
|
110
|
+
const nameB = deriveTunnelName(b);
|
|
111
|
+
expect(nameA.length).toBeLessThanOrEqual(64);
|
|
112
|
+
expect(nameB.length).toBeLessThanOrEqual(64);
|
|
113
|
+
// Bodies before the suffix are identical (truncation cut inside the shared
|
|
114
|
+
// prefix), so the names can only differ in the trailing 8-hex hash.
|
|
115
|
+
expect(nameA.slice(0, -8)).toBe(nameB.slice(0, -8));
|
|
116
|
+
expect(nameA).not.toBe(nameB);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ConnectorServiceDeps,
|
|
4
|
+
type ServiceCommandResult,
|
|
5
|
+
installConnectorService,
|
|
6
|
+
launchdLabel,
|
|
7
|
+
launchdPlistPath,
|
|
8
|
+
removeConnectorService,
|
|
9
|
+
renderLaunchdPlist,
|
|
10
|
+
renderSystemdUnit,
|
|
11
|
+
systemdUnitName,
|
|
12
|
+
systemdUnitPath,
|
|
13
|
+
} from "../cloudflare/connector-service.ts";
|
|
14
|
+
|
|
15
|
+
const TUNNEL = "parachute-vault-example-com";
|
|
16
|
+
const CONFIG = "/home/op/.parachute/cloudflared/parachute-vault-example-com/config.yml";
|
|
17
|
+
const LOG = "/home/op/.parachute/cloudflared/parachute-vault-example-com/cloudflared.log";
|
|
18
|
+
const CF_BIN = "/usr/local/bin/cloudflared";
|
|
19
|
+
|
|
20
|
+
interface FakeDepsState {
|
|
21
|
+
deps: ConnectorServiceDeps;
|
|
22
|
+
calls: string[][];
|
|
23
|
+
files: Map<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a fully-injected dep set. Defaults to a happy macOS/Linux path where
|
|
28
|
+
* `which` resolves both cloudflared and the init tool and every command exits
|
|
29
|
+
* 0. Override per-test via `over`.
|
|
30
|
+
*/
|
|
31
|
+
function fakeDeps(
|
|
32
|
+
over: Partial<ConnectorServiceDeps> & { runResults?: ServiceCommandResult[] } = {},
|
|
33
|
+
): FakeDepsState {
|
|
34
|
+
const calls: string[][] = [];
|
|
35
|
+
const files = new Map<string, string>();
|
|
36
|
+
let runIdx = 0;
|
|
37
|
+
const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
|
|
38
|
+
const deps: ConnectorServiceDeps = {
|
|
39
|
+
platform: over.platform ?? "darwin",
|
|
40
|
+
getuid: over.getuid ?? (() => 501),
|
|
41
|
+
homeDir: over.homeDir ?? (() => "/home/op"),
|
|
42
|
+
userName: over.userName ?? (() => "op"),
|
|
43
|
+
which:
|
|
44
|
+
over.which ??
|
|
45
|
+
((b) => {
|
|
46
|
+
if (b === "cloudflared") return CF_BIN;
|
|
47
|
+
if (b === "launchctl" || b === "systemctl" || b === "loginctl") return `/usr/bin/${b}`;
|
|
48
|
+
return null;
|
|
49
|
+
}),
|
|
50
|
+
run:
|
|
51
|
+
over.run ??
|
|
52
|
+
((cmd) => {
|
|
53
|
+
calls.push([...cmd]);
|
|
54
|
+
const r = over.runResults?.[runIdx++];
|
|
55
|
+
return r ?? ok;
|
|
56
|
+
}),
|
|
57
|
+
writeFile: over.writeFile ?? ((p, c) => void files.set(p, c)),
|
|
58
|
+
removeFile: over.removeFile ?? ((p) => void files.delete(p)),
|
|
59
|
+
readFile: over.readFile ?? ((p) => files.get(p)),
|
|
60
|
+
exists: over.exists ?? ((p) => files.has(p)),
|
|
61
|
+
};
|
|
62
|
+
return { deps, calls, files };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("renderLaunchdPlist", () => {
|
|
66
|
+
test("produces a valid plist with RunAtLoad + KeepAlive and the absolute argv", () => {
|
|
67
|
+
const plist = renderLaunchdPlist({
|
|
68
|
+
tunnelName: TUNNEL,
|
|
69
|
+
cloudflaredPath: CF_BIN,
|
|
70
|
+
configPath: CONFIG,
|
|
71
|
+
logPath: LOG,
|
|
72
|
+
});
|
|
73
|
+
expect(plist).toContain('<?xml version="1.0"');
|
|
74
|
+
expect(plist).toContain(`<string>${launchdLabel(TUNNEL)}</string>`);
|
|
75
|
+
// argv: absolute binary + the same `tunnel --config <path> run` shape the
|
|
76
|
+
// transient spawn used.
|
|
77
|
+
expect(plist).toContain(`<string>${CF_BIN}</string>`);
|
|
78
|
+
expect(plist).toContain("<string>tunnel</string>");
|
|
79
|
+
expect(plist).toContain("<string>--config</string>");
|
|
80
|
+
expect(plist).toContain(`<string>${CONFIG}</string>`);
|
|
81
|
+
expect(plist).toContain("<string>run</string>");
|
|
82
|
+
expect(plist).toContain("<key>RunAtLoad</key>\n <true/>");
|
|
83
|
+
expect(plist).toContain("<key>KeepAlive</key>\n <true/>");
|
|
84
|
+
expect(plist).toContain(`<string>${LOG}</string>`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("label + plist path use the reverse-DNS scheme under LaunchAgents", () => {
|
|
88
|
+
expect(launchdLabel(TUNNEL)).toBe(`computer.parachute.cloudflared.${TUNNEL}`);
|
|
89
|
+
expect(launchdPlistPath(TUNNEL, "/home/op")).toBe(
|
|
90
|
+
`/home/op/Library/LaunchAgents/computer.parachute.cloudflared.${TUNNEL}.plist`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("renderSystemdUnit", () => {
|
|
96
|
+
test("user unit: WantedBy=default.target, no User=, absolute ExecStart", () => {
|
|
97
|
+
const unit = renderSystemdUnit({
|
|
98
|
+
tunnelName: TUNNEL,
|
|
99
|
+
cloudflaredPath: CF_BIN,
|
|
100
|
+
configPath: CONFIG,
|
|
101
|
+
logPath: LOG,
|
|
102
|
+
root: false,
|
|
103
|
+
userName: "op",
|
|
104
|
+
});
|
|
105
|
+
expect(unit).toContain(`ExecStart=${CF_BIN} tunnel --config ${CONFIG} run`);
|
|
106
|
+
expect(unit).toContain("Restart=always");
|
|
107
|
+
expect(unit).toContain("WantedBy=default.target");
|
|
108
|
+
expect(unit).not.toContain("User=");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("system unit (root): WantedBy=multi-user.target, pins User=", () => {
|
|
112
|
+
const unit = renderSystemdUnit({
|
|
113
|
+
tunnelName: TUNNEL,
|
|
114
|
+
cloudflaredPath: CF_BIN,
|
|
115
|
+
configPath: CONFIG,
|
|
116
|
+
logPath: LOG,
|
|
117
|
+
root: true,
|
|
118
|
+
userName: "op",
|
|
119
|
+
});
|
|
120
|
+
expect(unit).toContain("WantedBy=multi-user.target");
|
|
121
|
+
expect(unit).toContain("User=op");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("unit name + path differ by scope", () => {
|
|
125
|
+
expect(systemdUnitName(TUNNEL)).toBe(`parachute-cloudflared-${TUNNEL}.service`);
|
|
126
|
+
expect(systemdUnitPath(TUNNEL, "/home/op", false)).toBe(
|
|
127
|
+
`/home/op/.config/systemd/user/parachute-cloudflared-${TUNNEL}.service`,
|
|
128
|
+
);
|
|
129
|
+
expect(systemdUnitPath(TUNNEL, "/home/op", true)).toBe(
|
|
130
|
+
`/etc/systemd/system/parachute-cloudflared-${TUNNEL}.service`,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("installConnectorService — macOS launchd", () => {
|
|
136
|
+
test("writes the plist + bootstraps it into the per-user GUI domain", () => {
|
|
137
|
+
const f = fakeDeps({ platform: "darwin", getuid: () => 501 });
|
|
138
|
+
const result = installConnectorService({
|
|
139
|
+
tunnelName: TUNNEL,
|
|
140
|
+
configPath: CONFIG,
|
|
141
|
+
logPath: LOG,
|
|
142
|
+
deps: f.deps,
|
|
143
|
+
});
|
|
144
|
+
expect(result.outcome).toBe("installed");
|
|
145
|
+
expect(result.kind).toBe("launchd");
|
|
146
|
+
const plistPath = launchdPlistPath(TUNNEL, "/home/op");
|
|
147
|
+
expect(result.servicePath).toBe(plistPath);
|
|
148
|
+
expect(f.files.get(plistPath)).toContain("<key>RunAtLoad</key>");
|
|
149
|
+
// bootout (idempotent unload) then bootstrap gui/501 <plist>.
|
|
150
|
+
expect(f.calls).toContainEqual(["launchctl", "bootout", `gui/501/${launchdLabel(TUNNEL)}`]);
|
|
151
|
+
expect(f.calls).toContainEqual(["launchctl", "bootstrap", "gui/501", plistPath]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("re-install is idempotent: bootout-then-bootstrap each time", () => {
|
|
155
|
+
const f = fakeDeps({ platform: "darwin" });
|
|
156
|
+
installConnectorService({ tunnelName: TUNNEL, configPath: CONFIG, logPath: LOG, deps: f.deps });
|
|
157
|
+
const firstCount = f.calls.length;
|
|
158
|
+
installConnectorService({ tunnelName: TUNNEL, configPath: CONFIG, logPath: LOG, deps: f.deps });
|
|
159
|
+
// Second install re-runs the same bootout+bootstrap sequence (no crash, no
|
|
160
|
+
// dependence on prior state).
|
|
161
|
+
expect(f.calls.length).toBe(firstCount * 2);
|
|
162
|
+
const bootstraps = f.calls.filter((c) => c[1] === "bootstrap");
|
|
163
|
+
expect(bootstraps.length).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("graceful fallback when launchctl is absent", () => {
|
|
167
|
+
const f = fakeDeps({
|
|
168
|
+
platform: "darwin",
|
|
169
|
+
which: (b) => (b === "cloudflared" ? CF_BIN : null),
|
|
170
|
+
});
|
|
171
|
+
const result = installConnectorService({
|
|
172
|
+
tunnelName: TUNNEL,
|
|
173
|
+
configPath: CONFIG,
|
|
174
|
+
logPath: LOG,
|
|
175
|
+
deps: f.deps,
|
|
176
|
+
});
|
|
177
|
+
expect(result.outcome).toBe("fallback");
|
|
178
|
+
expect(result.messages.join(" ")).toContain("launchctl not found");
|
|
179
|
+
// No plist written when the tool is unavailable.
|
|
180
|
+
expect(f.files.size).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("graceful fallback (no plist left behind) when bootstrap + legacy load both fail", () => {
|
|
184
|
+
const f = fakeDeps({
|
|
185
|
+
platform: "darwin",
|
|
186
|
+
// bootout(ignored) → bootstrap FAIL → load -w FAIL.
|
|
187
|
+
runResults: [
|
|
188
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
189
|
+
{ code: 1, stdout: "", stderr: "Bootstrap failed: 5: Input/output error" },
|
|
190
|
+
{ code: 1, stdout: "", stderr: "nothing found to load" },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
const result = installConnectorService({
|
|
194
|
+
tunnelName: TUNNEL,
|
|
195
|
+
configPath: CONFIG,
|
|
196
|
+
logPath: LOG,
|
|
197
|
+
deps: f.deps,
|
|
198
|
+
});
|
|
199
|
+
expect(result.outcome).toBe("fallback");
|
|
200
|
+
// The half-written plist is cleaned up so a stale, never-loaded file
|
|
201
|
+
// doesn't linger.
|
|
202
|
+
expect(f.files.size).toBe(0);
|
|
203
|
+
expect(result.messages.join(" ")).toContain("won't survive a reboot");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("installConnectorService — Linux systemd", () => {
|
|
208
|
+
test("non-root: writes a USER unit, enables linger, enable --now --user", () => {
|
|
209
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 1000, userName: () => "op" });
|
|
210
|
+
const result = installConnectorService({
|
|
211
|
+
tunnelName: TUNNEL,
|
|
212
|
+
configPath: CONFIG,
|
|
213
|
+
logPath: LOG,
|
|
214
|
+
deps: f.deps,
|
|
215
|
+
});
|
|
216
|
+
expect(result.outcome).toBe("installed");
|
|
217
|
+
expect(result.kind).toBe("systemd-user");
|
|
218
|
+
const unitPath = systemdUnitPath(TUNNEL, "/home/op", false);
|
|
219
|
+
expect(result.servicePath).toBe(unitPath);
|
|
220
|
+
expect(f.files.get(unitPath)).toContain("WantedBy=default.target");
|
|
221
|
+
expect(f.files.get(unitPath)).not.toContain("User=");
|
|
222
|
+
// linger + the --user-scoped daemon-reload + enable.
|
|
223
|
+
expect(f.calls).toContainEqual(["loginctl", "enable-linger", "op"]);
|
|
224
|
+
expect(f.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]);
|
|
225
|
+
expect(f.calls).toContainEqual([
|
|
226
|
+
"systemctl",
|
|
227
|
+
"--user",
|
|
228
|
+
"enable",
|
|
229
|
+
"--now",
|
|
230
|
+
systemdUnitName(TUNNEL),
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("root: writes a SYSTEM unit, no --user scope, no linger", () => {
|
|
235
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "root" });
|
|
236
|
+
const result = installConnectorService({
|
|
237
|
+
tunnelName: TUNNEL,
|
|
238
|
+
configPath: CONFIG,
|
|
239
|
+
logPath: LOG,
|
|
240
|
+
deps: f.deps,
|
|
241
|
+
});
|
|
242
|
+
expect(result.outcome).toBe("installed");
|
|
243
|
+
expect(result.kind).toBe("systemd-system");
|
|
244
|
+
const unitPath = systemdUnitPath(TUNNEL, "/home/op", true);
|
|
245
|
+
expect(result.servicePath).toBe(unitPath);
|
|
246
|
+
expect(unitPath.startsWith("/etc/systemd/system/")).toBe(true);
|
|
247
|
+
expect(f.files.get(unitPath)).toContain("WantedBy=multi-user.target");
|
|
248
|
+
expect(f.files.get(unitPath)).toContain("User=root");
|
|
249
|
+
// System scope: no `--user` on any systemctl call, and no linger.
|
|
250
|
+
expect(f.calls.some((c) => c.includes("--user"))).toBe(false);
|
|
251
|
+
expect(f.calls.some((c) => c[0] === "loginctl")).toBe(false);
|
|
252
|
+
expect(f.calls).toContainEqual(["systemctl", "daemon-reload"]);
|
|
253
|
+
expect(f.calls).toContainEqual(["systemctl", "enable", "--now", systemdUnitName(TUNNEL)]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("non-root: linger failure is a soft warning, still installs", () => {
|
|
257
|
+
const f = fakeDeps({
|
|
258
|
+
platform: "linux",
|
|
259
|
+
getuid: () => 1000,
|
|
260
|
+
userName: () => "op",
|
|
261
|
+
// enable-linger FAIL, then daemon-reload OK, enable --now OK.
|
|
262
|
+
runResults: [
|
|
263
|
+
{ code: 1, stdout: "", stderr: "Failed to enable linger" },
|
|
264
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
265
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
const result = installConnectorService({
|
|
269
|
+
tunnelName: TUNNEL,
|
|
270
|
+
configPath: CONFIG,
|
|
271
|
+
logPath: LOG,
|
|
272
|
+
deps: f.deps,
|
|
273
|
+
});
|
|
274
|
+
expect(result.outcome).toBe("installed");
|
|
275
|
+
expect(result.messages.join(" ")).toContain("could not enable lingering");
|
|
276
|
+
// The warning is actionable — points at the root/system-unit path (EC2 /
|
|
277
|
+
// headless case).
|
|
278
|
+
expect(result.messages.join(" ")).toContain("re-run this command as root");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("non-root: systemctl present but loginctl ABSENT → installs, warns, never throws", () => {
|
|
282
|
+
// The real robustness gap: production `Bun.spawnSync(["loginctl",…])`
|
|
283
|
+
// THROWS on ENOENT. With systemctl present but loginctl missing (a
|
|
284
|
+
// container with systemd but no logind), the unguarded linger call would
|
|
285
|
+
// propagate that throw out and hard-fail the whole expose. The `which`
|
|
286
|
+
// probe must skip the call and degrade to a soft warning.
|
|
287
|
+
let lingerRan = false;
|
|
288
|
+
const f = fakeDeps({
|
|
289
|
+
platform: "linux",
|
|
290
|
+
getuid: () => 1000,
|
|
291
|
+
userName: () => "op",
|
|
292
|
+
which: (b) => {
|
|
293
|
+
if (b === "cloudflared") return CF_BIN;
|
|
294
|
+
if (b === "systemctl") return "/usr/bin/systemctl";
|
|
295
|
+
if (b === "loginctl") return null; // absent
|
|
296
|
+
return null;
|
|
297
|
+
},
|
|
298
|
+
run: (cmd) => {
|
|
299
|
+
if (cmd[0] === "loginctl") lingerRan = true;
|
|
300
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const result = installConnectorService({
|
|
304
|
+
tunnelName: TUNNEL,
|
|
305
|
+
configPath: CONFIG,
|
|
306
|
+
logPath: LOG,
|
|
307
|
+
deps: f.deps,
|
|
308
|
+
});
|
|
309
|
+
expect(result.outcome).toBe("installed");
|
|
310
|
+
expect(result.kind).toBe("systemd-user");
|
|
311
|
+
// loginctl was NOT invoked (the probe short-circuited it).
|
|
312
|
+
expect(lingerRan).toBe(false);
|
|
313
|
+
expect(result.messages.join(" ")).toContain("could not enable lingering");
|
|
314
|
+
expect(result.messages.join(" ")).toContain("re-run this command as root");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("non-root: loginctl present but the run THROWS → caught, installs, warns", () => {
|
|
318
|
+
// Belt-and-suspenders for the race where loginctl passes the `which` probe
|
|
319
|
+
// but the spawn itself throws (binary removed between probe and run, or an
|
|
320
|
+
// EACCES). The try/catch keeps it non-fatal.
|
|
321
|
+
const f = fakeDeps({
|
|
322
|
+
platform: "linux",
|
|
323
|
+
getuid: () => 1000,
|
|
324
|
+
userName: () => "op",
|
|
325
|
+
run: (cmd) => {
|
|
326
|
+
if (cmd[0] === "loginctl") throw new Error("spawn loginctl ENOENT");
|
|
327
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
const result = installConnectorService({
|
|
331
|
+
tunnelName: TUNNEL,
|
|
332
|
+
configPath: CONFIG,
|
|
333
|
+
logPath: LOG,
|
|
334
|
+
deps: f.deps,
|
|
335
|
+
});
|
|
336
|
+
expect(result.outcome).toBe("installed");
|
|
337
|
+
expect(result.messages.join(" ")).toContain("could not enable lingering");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("graceful fallback when systemctl is absent", () => {
|
|
341
|
+
const f = fakeDeps({
|
|
342
|
+
platform: "linux",
|
|
343
|
+
which: (b) => (b === "cloudflared" ? CF_BIN : null),
|
|
344
|
+
});
|
|
345
|
+
const result = installConnectorService({
|
|
346
|
+
tunnelName: TUNNEL,
|
|
347
|
+
configPath: CONFIG,
|
|
348
|
+
logPath: LOG,
|
|
349
|
+
deps: f.deps,
|
|
350
|
+
});
|
|
351
|
+
expect(result.outcome).toBe("fallback");
|
|
352
|
+
expect(result.messages.join(" ")).toContain("systemctl not found");
|
|
353
|
+
expect(f.files.size).toBe(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("graceful fallback (unit removed) when enable --now fails", () => {
|
|
357
|
+
const f = fakeDeps({
|
|
358
|
+
platform: "linux",
|
|
359
|
+
getuid: () => 0,
|
|
360
|
+
userName: () => "root",
|
|
361
|
+
// daemon-reload OK, enable --now FAIL.
|
|
362
|
+
runResults: [
|
|
363
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
364
|
+
{ code: 1, stdout: "", stderr: "Failed to enable unit: ..." },
|
|
365
|
+
],
|
|
366
|
+
});
|
|
367
|
+
const result = installConnectorService({
|
|
368
|
+
tunnelName: TUNNEL,
|
|
369
|
+
configPath: CONFIG,
|
|
370
|
+
logPath: LOG,
|
|
371
|
+
deps: f.deps,
|
|
372
|
+
});
|
|
373
|
+
expect(result.outcome).toBe("fallback");
|
|
374
|
+
// The unit file is removed on failure so a half-installed (written but not
|
|
375
|
+
// enabled) unit doesn't linger.
|
|
376
|
+
expect(f.files.size).toBe(0);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("installConnectorService — unsupported / missing cloudflared", () => {
|
|
381
|
+
test("fallback when cloudflared can't be resolved", () => {
|
|
382
|
+
const f = fakeDeps({ which: () => null });
|
|
383
|
+
const result = installConnectorService({
|
|
384
|
+
tunnelName: TUNNEL,
|
|
385
|
+
configPath: CONFIG,
|
|
386
|
+
logPath: LOG,
|
|
387
|
+
deps: f.deps,
|
|
388
|
+
});
|
|
389
|
+
expect(result.outcome).toBe("fallback");
|
|
390
|
+
expect(result.messages.join(" ")).toContain("cloudflared binary path");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("fallback on an unsupported platform (win32)", () => {
|
|
394
|
+
const f = fakeDeps({ platform: "win32" });
|
|
395
|
+
const result = installConnectorService({
|
|
396
|
+
tunnelName: TUNNEL,
|
|
397
|
+
configPath: CONFIG,
|
|
398
|
+
logPath: LOG,
|
|
399
|
+
deps: f.deps,
|
|
400
|
+
});
|
|
401
|
+
expect(result.outcome).toBe("fallback");
|
|
402
|
+
expect(result.messages.join(" ")).toContain("win32");
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("removeConnectorService", () => {
|
|
407
|
+
test("macOS: boots out + removes the plist", () => {
|
|
408
|
+
const f = fakeDeps({ platform: "darwin", getuid: () => 501 });
|
|
409
|
+
const plistPath = launchdPlistPath(TUNNEL, "/home/op");
|
|
410
|
+
f.files.set(plistPath, "<plist/>");
|
|
411
|
+
const result = removeConnectorService({ tunnelName: TUNNEL, deps: f.deps });
|
|
412
|
+
expect(result.removed).toBe(true);
|
|
413
|
+
expect(f.files.has(plistPath)).toBe(false);
|
|
414
|
+
expect(f.calls).toContainEqual(["launchctl", "bootout", `gui/501/${launchdLabel(TUNNEL)}`]);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("Linux non-root: disable --now --user + removes the user unit + daemon-reload", () => {
|
|
418
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 1000 });
|
|
419
|
+
const unitPath = systemdUnitPath(TUNNEL, "/home/op", false);
|
|
420
|
+
f.files.set(unitPath, "[Unit]");
|
|
421
|
+
const result = removeConnectorService({ tunnelName: TUNNEL, deps: f.deps });
|
|
422
|
+
expect(result.removed).toBe(true);
|
|
423
|
+
expect(f.files.has(unitPath)).toBe(false);
|
|
424
|
+
expect(f.calls).toContainEqual([
|
|
425
|
+
"systemctl",
|
|
426
|
+
"--user",
|
|
427
|
+
"disable",
|
|
428
|
+
"--now",
|
|
429
|
+
systemdUnitName(TUNNEL),
|
|
430
|
+
]);
|
|
431
|
+
expect(f.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("no-op (no throw) when no service file exists", () => {
|
|
435
|
+
const f = fakeDeps({ platform: "darwin" });
|
|
436
|
+
const result = removeConnectorService({ tunnelName: TUNNEL, deps: f.deps });
|
|
437
|
+
expect(result.removed).toBe(false);
|
|
438
|
+
// Nothing run — pure no-op so the off-path always succeeds at clearing state.
|
|
439
|
+
expect(f.calls.length).toBe(0);
|
|
440
|
+
});
|
|
441
|
+
});
|