@openparachute/hub 0.6.4-rc.5 → 0.6.4-rc.7
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 +1 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-cloudflare.test.ts +27 -5
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/init.test.ts +42 -3
- package/src/__tests__/install.test.ts +358 -2
- package/src/__tests__/supervisor.test.ts +197 -0
- package/src/cli.ts +6 -2
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-cloudflare.ts +75 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/init.ts +42 -2
- package/src/commands/install.ts +280 -3
- package/src/help.ts +16 -3
- package/src/supervisor.ts +148 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.6.4-rc.
|
|
3
|
+
"version": "0.6.4-rc.7",
|
|
4
4
|
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"format": "biome format --write .",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
35
|
-
"postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
|
|
36
35
|
"prepack": "bun run build:spa"
|
|
37
36
|
},
|
|
38
37
|
"devDependencies": {
|
|
@@ -7,12 +7,15 @@ import {
|
|
|
7
7
|
CloudflaredStateError,
|
|
8
8
|
type CloudflaredTunnelRecord,
|
|
9
9
|
clearCloudflaredState,
|
|
10
|
+
clearPendingHostname,
|
|
10
11
|
findTunnelRecord,
|
|
11
12
|
listTunnelRecords,
|
|
12
13
|
readCloudflaredState,
|
|
14
|
+
readPendingHostname,
|
|
13
15
|
withTunnelRecord,
|
|
14
16
|
withoutTunnelRecord,
|
|
15
17
|
writeCloudflaredState,
|
|
18
|
+
writePendingHostname,
|
|
16
19
|
} from "../cloudflare/state.ts";
|
|
17
20
|
|
|
18
21
|
function makeTempPath(): { path: string; cleanup: () => void } {
|
|
@@ -250,3 +253,104 @@ describe("cloudflared state — record helpers", () => {
|
|
|
250
253
|
expect(listTunnelRecords(undefined)).toEqual([]);
|
|
251
254
|
});
|
|
252
255
|
});
|
|
256
|
+
|
|
257
|
+
describe("hub#567 pending hostname", () => {
|
|
258
|
+
test("read returns undefined when no state file / no pending hostname", () => {
|
|
259
|
+
const { path, cleanup } = makeTempPath();
|
|
260
|
+
try {
|
|
261
|
+
expect(readPendingHostname(path)).toBeUndefined();
|
|
262
|
+
writeCloudflaredState(sample, path);
|
|
263
|
+
expect(readPendingHostname(path)).toBeUndefined();
|
|
264
|
+
} finally {
|
|
265
|
+
cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("write then read round-trips the pending hostname (seeds empty state)", () => {
|
|
270
|
+
const { path, cleanup } = makeTempPath();
|
|
271
|
+
try {
|
|
272
|
+
writePendingHostname("techne.parachute.computer", path);
|
|
273
|
+
expect(readPendingHostname(path)).toBe("techne.parachute.computer");
|
|
274
|
+
const state = readCloudflaredState(path);
|
|
275
|
+
expect(state?.pendingHostname).toBe("techne.parachute.computer");
|
|
276
|
+
expect(state?.tunnels).toEqual({});
|
|
277
|
+
} finally {
|
|
278
|
+
cleanup();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("write preserves existing tunnel records", () => {
|
|
283
|
+
const { path, cleanup } = makeTempPath();
|
|
284
|
+
try {
|
|
285
|
+
writeCloudflaredState(sample, path);
|
|
286
|
+
writePendingHostname("techne.parachute.computer", path);
|
|
287
|
+
const state = readCloudflaredState(path);
|
|
288
|
+
expect(state?.pendingHostname).toBe("techne.parachute.computer");
|
|
289
|
+
expect(state?.tunnels.parachute).toEqual(sampleRecord);
|
|
290
|
+
} finally {
|
|
291
|
+
cleanup();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("clear drops the pending hostname but keeps tunnel records", () => {
|
|
296
|
+
const { path, cleanup } = makeTempPath();
|
|
297
|
+
try {
|
|
298
|
+
writeCloudflaredState({ ...sample, pendingHostname: "techne.parachute.computer" }, path);
|
|
299
|
+
clearPendingHostname(path);
|
|
300
|
+
const state = readCloudflaredState(path);
|
|
301
|
+
expect(state?.pendingHostname).toBeUndefined();
|
|
302
|
+
expect(state?.tunnels.parachute).toEqual(sampleRecord);
|
|
303
|
+
} finally {
|
|
304
|
+
cleanup();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("clear removes the state file entirely when no tunnels remain", () => {
|
|
309
|
+
const { path, cleanup } = makeTempPath();
|
|
310
|
+
try {
|
|
311
|
+
writePendingHostname("techne.parachute.computer", path);
|
|
312
|
+
expect(existsSync(path)).toBe(true);
|
|
313
|
+
clearPendingHostname(path);
|
|
314
|
+
expect(existsSync(path)).toBe(false);
|
|
315
|
+
} finally {
|
|
316
|
+
cleanup();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("validate preserves a pending hostname round-tripped through the bytes", () => {
|
|
321
|
+
const { path, cleanup } = makeTempPath();
|
|
322
|
+
try {
|
|
323
|
+
const withPending: CloudflaredState = { ...sample, pendingHostname: "a.example.com" };
|
|
324
|
+
writeCloudflaredState(withPending, path);
|
|
325
|
+
expect(readCloudflaredState(path)).toEqual(withPending);
|
|
326
|
+
} finally {
|
|
327
|
+
cleanup();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("withTunnelRecord preserves an existing pending hostname", () => {
|
|
332
|
+
const seed: CloudflaredState = { version: 2, tunnels: {}, pendingHostname: "a.example.com" };
|
|
333
|
+
const next = withTunnelRecord(seed, sampleRecord);
|
|
334
|
+
expect(next.pendingHostname).toBe("a.example.com");
|
|
335
|
+
expect(next.tunnels.parachute).toEqual(sampleRecord);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("withoutTunnelRecord carries the pending hostname when it's the only thing left", () => {
|
|
339
|
+
const seed: CloudflaredState = {
|
|
340
|
+
version: 2,
|
|
341
|
+
tunnels: { parachute: sampleRecord },
|
|
342
|
+
pendingHostname: "a.example.com",
|
|
343
|
+
};
|
|
344
|
+
// Removing the last tunnel must NOT discard a typed-but-not-routed hostname.
|
|
345
|
+
expect(withoutTunnelRecord(seed, "parachute")).toEqual({
|
|
346
|
+
version: 2,
|
|
347
|
+
tunnels: {},
|
|
348
|
+
pendingHostname: "a.example.com",
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("withoutTunnelRecord returns undefined when no tunnels AND no pending hostname remain", () => {
|
|
353
|
+
const seed: CloudflaredState = { version: 2, tunnels: { parachute: sampleRecord } };
|
|
354
|
+
expect(withoutTunnelRecord(seed, "parachute")).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -509,16 +509,31 @@ describe("exposeCloudflareUp", () => {
|
|
|
509
509
|
}
|
|
510
510
|
});
|
|
511
511
|
|
|
512
|
-
test("
|
|
512
|
+
test("hub#564: continues (no vault gate) when vault isn't installed — routes the hub anyway", async () => {
|
|
513
513
|
const env = makeEnv({ includeVault: false });
|
|
514
514
|
try {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
515
|
+
const uuid = "3d2b8d8f-2345-6789-abcd-ef0123456789";
|
|
516
|
+
const derived = "parachute-vault-example-com";
|
|
517
|
+
// The full chain must run now that the vault gate is gone: version,
|
|
518
|
+
// tunnel list, tunnel create, route dns.
|
|
519
|
+
const { runner } = queueRunner([
|
|
520
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
|
|
521
|
+
{ code: 0, stdout: "[]", stderr: "" }, // tunnel list
|
|
522
|
+
{
|
|
523
|
+
code: 0,
|
|
524
|
+
stdout: `Created tunnel ${derived} with id ${uuid}\n`,
|
|
525
|
+
stderr: "",
|
|
526
|
+
}, // create
|
|
527
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
528
|
+
]);
|
|
529
|
+
const { spawner } = fakeSpawner(42000);
|
|
517
530
|
const logs: string[] = [];
|
|
518
531
|
|
|
519
532
|
const code = await exposeCloudflareUp("vault.example.com", {
|
|
520
533
|
runner,
|
|
521
534
|
spawner,
|
|
535
|
+
alive: () => false,
|
|
536
|
+
kill: () => {},
|
|
522
537
|
log: (l) => logs.push(l),
|
|
523
538
|
manifestPath: env.manifestPath,
|
|
524
539
|
statePath: env.statePath,
|
|
@@ -528,10 +543,17 @@ describe("exposeCloudflareUp", () => {
|
|
|
528
543
|
cloudflaredHome: env.cloudflaredHome,
|
|
529
544
|
configDir: env.configDir,
|
|
530
545
|
skipHub: true,
|
|
546
|
+
now: () => new Date("2026-04-22T12:00:00Z"),
|
|
531
547
|
});
|
|
532
548
|
|
|
533
|
-
|
|
534
|
-
|
|
549
|
+
// The expose succeeds (the hub is what gets routed), with a courtesy
|
|
550
|
+
// note instead of a dead-end. No "install vault" gate, no Vault-URL
|
|
551
|
+
// footer (vault isn't there to point at).
|
|
552
|
+
expect(code).toBe(0);
|
|
553
|
+
const joined = logs.join("\n");
|
|
554
|
+
expect(joined).toContain("vault not installed yet");
|
|
555
|
+
expect(joined).not.toContain("nothing to route");
|
|
556
|
+
expect(joined).not.toMatch(/^\s*Vault:/m);
|
|
535
557
|
} finally {
|
|
536
558
|
env.cleanup();
|
|
537
559
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { readPendingHostname, writePendingHostname } from "../cloudflare/state.ts";
|
|
5
6
|
import { exposePublicInteractive } from "../commands/expose-interactive.ts";
|
|
6
7
|
import { readLastProvider, writeLastProvider } from "../expose-last-provider.ts";
|
|
7
8
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
@@ -15,6 +16,7 @@ const noopPreflight = async () => {};
|
|
|
15
16
|
interface TestEnv {
|
|
16
17
|
cloudflaredHome: string;
|
|
17
18
|
lastProviderPath: string;
|
|
19
|
+
statePath: string;
|
|
18
20
|
cleanup: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -28,6 +30,7 @@ function makeEnv(opts: { cloudflaredLoggedIn?: boolean } = {}): TestEnv {
|
|
|
28
30
|
return {
|
|
29
31
|
cloudflaredHome,
|
|
30
32
|
lastProviderPath: join(dir, "expose-last-provider.json"),
|
|
33
|
+
statePath: join(dir, "cloudflared-state.json"),
|
|
31
34
|
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
32
35
|
};
|
|
33
36
|
}
|
|
@@ -194,6 +197,75 @@ describe("exposePublicInteractive — both ready", () => {
|
|
|
194
197
|
}
|
|
195
198
|
});
|
|
196
199
|
|
|
200
|
+
test("hub#567: persists the typed hostname as soon as it validates", async () => {
|
|
201
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
202
|
+
try {
|
|
203
|
+
const { runner } = fixedRunner({
|
|
204
|
+
tailscaleInstalled: true,
|
|
205
|
+
tailscaleLoggedIn: true,
|
|
206
|
+
tailscaleFunnelCap: true,
|
|
207
|
+
cloudflaredInstalled: true,
|
|
208
|
+
});
|
|
209
|
+
const { prompt } = queuePrompt(["2", "vault.example.com"]);
|
|
210
|
+
// exposeCloudflareUpImpl FAILS — so the hostname must survive for a retry.
|
|
211
|
+
const code = await exposePublicInteractive({
|
|
212
|
+
runner,
|
|
213
|
+
prompt,
|
|
214
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
215
|
+
lastProviderPath: env.lastProviderPath,
|
|
216
|
+
statePath: env.statePath,
|
|
217
|
+
log: () => {},
|
|
218
|
+
exposePublicImpl: async () => 0,
|
|
219
|
+
exposeCloudflareUpImpl: async () => 1,
|
|
220
|
+
runAuthPreflightImpl: noopPreflight,
|
|
221
|
+
});
|
|
222
|
+
expect(code).toBe(1);
|
|
223
|
+
// Stashed despite the downstream failure.
|
|
224
|
+
expect(readPendingHostname(env.statePath)).toBe("vault.example.com");
|
|
225
|
+
} finally {
|
|
226
|
+
env.cleanup();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("hub#567: pre-fills the hostname prompt from a stashed value; Enter accepts it", async () => {
|
|
231
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
232
|
+
try {
|
|
233
|
+
writePendingHostname("techne.parachute.computer", env.statePath);
|
|
234
|
+
const { runner } = fixedRunner({
|
|
235
|
+
tailscaleInstalled: true,
|
|
236
|
+
tailscaleLoggedIn: true,
|
|
237
|
+
tailscaleFunnelCap: true,
|
|
238
|
+
cloudflaredInstalled: true,
|
|
239
|
+
});
|
|
240
|
+
// Pick cloudflare, then press Enter (blank) at the hostname prompt.
|
|
241
|
+
const { prompt, asked } = queuePrompt(["2", ""]);
|
|
242
|
+
let cloudflareHostname: string | undefined;
|
|
243
|
+
const code = await exposePublicInteractive({
|
|
244
|
+
runner,
|
|
245
|
+
prompt,
|
|
246
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
247
|
+
lastProviderPath: env.lastProviderPath,
|
|
248
|
+
statePath: env.statePath,
|
|
249
|
+
log: () => {},
|
|
250
|
+
exposePublicImpl: async () => 0,
|
|
251
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
252
|
+
cloudflareHostname = h;
|
|
253
|
+
return 0;
|
|
254
|
+
},
|
|
255
|
+
runAuthPreflightImpl: noopPreflight,
|
|
256
|
+
});
|
|
257
|
+
expect(code).toBe(0);
|
|
258
|
+
// Enter accepted the stashed hostname.
|
|
259
|
+
expect(cloudflareHostname).toBe("techne.parachute.computer");
|
|
260
|
+
// The prompt surfaced the default in brackets.
|
|
261
|
+
expect(asked.some((q) => q.includes("[techne.parachute.computer]"))).toBe(true);
|
|
262
|
+
// Cleared once routing succeeded.
|
|
263
|
+
expect(readPendingHostname(env.statePath)).toBeUndefined();
|
|
264
|
+
} finally {
|
|
265
|
+
env.cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
197
269
|
test("'q' aborts cleanly with exit 0 and no downstream calls", async () => {
|
|
198
270
|
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
199
271
|
try {
|
|
@@ -440,11 +512,12 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
440
512
|
}
|
|
441
513
|
});
|
|
442
514
|
|
|
443
|
-
test("
|
|
515
|
+
test("hub#566: cloudflare on linux, user DECLINES auto-install: prints manual + --cloudflare hint, exits 1", async () => {
|
|
444
516
|
const env = makeEnv();
|
|
445
517
|
try {
|
|
446
518
|
const { runner } = fixedRunner({});
|
|
447
|
-
|
|
519
|
+
// "2" → cloudflare; "n" → decline the auto-install offer.
|
|
520
|
+
const { prompt } = queuePrompt(["2", "n"]);
|
|
448
521
|
const logs: string[] = [];
|
|
449
522
|
let interactiveCalled = false;
|
|
450
523
|
let cloudflareCalled = false;
|
|
@@ -456,8 +529,10 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
456
529
|
},
|
|
457
530
|
prompt,
|
|
458
531
|
platform: "linux",
|
|
532
|
+
arch: "x64",
|
|
459
533
|
cloudflaredHome: env.cloudflaredHome,
|
|
460
534
|
lastProviderPath: env.lastProviderPath,
|
|
535
|
+
statePath: env.statePath,
|
|
461
536
|
log: (l) => logs.push(l),
|
|
462
537
|
exposePublicImpl: async () => 0,
|
|
463
538
|
exposeCloudflareUpImpl: async () => {
|
|
@@ -466,16 +541,16 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
466
541
|
},
|
|
467
542
|
});
|
|
468
543
|
expect(code).toBe(1);
|
|
544
|
+
// Declining means no curl/chmod ran and we never reached the expose.
|
|
469
545
|
expect(interactiveCalled).toBe(false);
|
|
470
546
|
expect(cloudflareCalled).toBe(false);
|
|
471
547
|
const joined = logs.join("\n");
|
|
472
|
-
|
|
473
|
-
// off apt-get / dnf / developers.cloudflare.com (all unreliable —
|
|
474
|
-
// Aaron hit `No match for argument: cloudflared` on AL2023 and
|
|
475
|
-
// 404s from the docs URL on the same box) onto the static binary
|
|
476
|
-
// from GitHub releases.
|
|
548
|
+
expect(joined).toContain("Skipped auto-install");
|
|
477
549
|
expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
|
|
478
550
|
expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
551
|
+
// hub#566: re-run hint carries the --cloudflare flag (bare `expose
|
|
552
|
+
// public` defaults to Tailscale).
|
|
553
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
479
554
|
expect(joined).not.toContain("developers.cloudflare.com");
|
|
480
555
|
expect(joined).not.toContain("pkg.cloudflare.com");
|
|
481
556
|
expect(joined).not.toContain("sudo dnf install cloudflared");
|
|
@@ -484,6 +559,158 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
484
559
|
}
|
|
485
560
|
});
|
|
486
561
|
|
|
562
|
+
test("hub#566: cloudflare on linux as ROOT, accepts auto-install: runs bare curl+chmod (no sudo), then exposes", async () => {
|
|
563
|
+
const env = makeEnv();
|
|
564
|
+
try {
|
|
565
|
+
// cloudflared starts absent (so the install offer fires), then present
|
|
566
|
+
// after the install runs (so the verify probe + flow continue).
|
|
567
|
+
let cloudflaredPresent = false;
|
|
568
|
+
const runner: Runner = async (cmd) => {
|
|
569
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
570
|
+
return cloudflaredPresent
|
|
571
|
+
? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
|
|
572
|
+
: { code: 127, stdout: "", stderr: "not found" };
|
|
573
|
+
}
|
|
574
|
+
if (cmd[0] === "tailscale") {
|
|
575
|
+
// Detection: tailscale absent (forces the cloudflare-only path).
|
|
576
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
577
|
+
}
|
|
578
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
579
|
+
};
|
|
580
|
+
// "2" cloudflare → "Y" install → "Y" login → hostname. The login prompt
|
|
581
|
+
// fires because detection reported cloudflared absent (so loggedIn=false)
|
|
582
|
+
// even though cert.pem appears once login "runs".
|
|
583
|
+
const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
|
|
584
|
+
const interactiveCmds: string[][] = [];
|
|
585
|
+
const logs: string[] = [];
|
|
586
|
+
let cloudflareHostname = "";
|
|
587
|
+
const code = await exposePublicInteractive({
|
|
588
|
+
runner,
|
|
589
|
+
interactiveRunner: async (cmd) => {
|
|
590
|
+
interactiveCmds.push([...cmd]);
|
|
591
|
+
// Install "succeeds": flip cloudflared to present. Login "succeeds":
|
|
592
|
+
// drop the cert so `isCloudflaredLoggedIn` reads true afterward.
|
|
593
|
+
if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
|
|
594
|
+
else cloudflaredPresent = true;
|
|
595
|
+
return 0;
|
|
596
|
+
},
|
|
597
|
+
prompt,
|
|
598
|
+
platform: "linux",
|
|
599
|
+
arch: "x64",
|
|
600
|
+
getuid: () => 0, // root
|
|
601
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
602
|
+
lastProviderPath: env.lastProviderPath,
|
|
603
|
+
statePath: env.statePath,
|
|
604
|
+
log: (l) => logs.push(l),
|
|
605
|
+
exposePublicImpl: async () => 0,
|
|
606
|
+
exposeCloudflareUpImpl: async (hostname) => {
|
|
607
|
+
cloudflareHostname = hostname;
|
|
608
|
+
return 0;
|
|
609
|
+
},
|
|
610
|
+
runAuthPreflightImpl: noopPreflight,
|
|
611
|
+
});
|
|
612
|
+
expect(code).toBe(0);
|
|
613
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
614
|
+
// Root runs curl + chmod WITHOUT a sudo prefix.
|
|
615
|
+
expect(interactiveCmds[0]?.[0]).toBe("curl");
|
|
616
|
+
expect(interactiveCmds[0]).toContain("/usr/local/bin/cloudflared");
|
|
617
|
+
expect(interactiveCmds[1]?.[0]).toBe("chmod");
|
|
618
|
+
expect(interactiveCmds.some((c) => c[0] === "sudo")).toBe(false);
|
|
619
|
+
expect(logs.join("\n")).toContain("✓ cloudflared installed.");
|
|
620
|
+
} finally {
|
|
621
|
+
env.cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("hub#566: cloudflare on linux NON-root, accepts auto-install: wraps curl+chmod in sudo", async () => {
|
|
626
|
+
const env = makeEnv();
|
|
627
|
+
try {
|
|
628
|
+
let cloudflaredPresent = false;
|
|
629
|
+
const runner: Runner = async (cmd) => {
|
|
630
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
631
|
+
return cloudflaredPresent
|
|
632
|
+
? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
|
|
633
|
+
: { code: 127, stdout: "", stderr: "not found" };
|
|
634
|
+
}
|
|
635
|
+
if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
|
|
636
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
637
|
+
};
|
|
638
|
+
const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
|
|
639
|
+
const interactiveCmds: string[][] = [];
|
|
640
|
+
const code = await exposePublicInteractive({
|
|
641
|
+
runner,
|
|
642
|
+
interactiveRunner: async (cmd) => {
|
|
643
|
+
interactiveCmds.push([...cmd]);
|
|
644
|
+
if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
|
|
645
|
+
else cloudflaredPresent = true;
|
|
646
|
+
return 0;
|
|
647
|
+
},
|
|
648
|
+
prompt,
|
|
649
|
+
platform: "linux",
|
|
650
|
+
arch: "arm64",
|
|
651
|
+
getuid: () => 1000, // non-root
|
|
652
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
653
|
+
lastProviderPath: env.lastProviderPath,
|
|
654
|
+
statePath: env.statePath,
|
|
655
|
+
log: () => {},
|
|
656
|
+
exposePublicImpl: async () => 0,
|
|
657
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
658
|
+
runAuthPreflightImpl: noopPreflight,
|
|
659
|
+
});
|
|
660
|
+
expect(code).toBe(0);
|
|
661
|
+
// Non-root prefixes both privileged steps with non-interactive `sudo -n`
|
|
662
|
+
// (fails fast instead of hanging on a password prompt under a detached
|
|
663
|
+
// init).
|
|
664
|
+
expect(interactiveCmds[0]?.slice(0, 2)).toEqual(["sudo", "-n"]);
|
|
665
|
+
expect(interactiveCmds[0]).toContain("curl");
|
|
666
|
+
expect(interactiveCmds[1]?.slice(0, 2)).toEqual(["sudo", "-n"]);
|
|
667
|
+
expect(interactiveCmds[1]).toContain("chmod");
|
|
668
|
+
} finally {
|
|
669
|
+
env.cleanup();
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("hub#566: cloudflare on linux, sudo curl FAILS: prints manual + --cloudflare hint, exits 1", async () => {
|
|
674
|
+
const env = makeEnv();
|
|
675
|
+
try {
|
|
676
|
+
const runner: Runner = async (cmd) => {
|
|
677
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
678
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
679
|
+
}
|
|
680
|
+
if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
|
|
681
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
682
|
+
};
|
|
683
|
+
const { prompt } = queuePrompt(["2", "y"]);
|
|
684
|
+
const logs: string[] = [];
|
|
685
|
+
let cloudflareCalled = false;
|
|
686
|
+
const code = await exposePublicInteractive({
|
|
687
|
+
runner,
|
|
688
|
+
// Simulate sudo failing (no cached creds, no tty).
|
|
689
|
+
interactiveRunner: async () => 1,
|
|
690
|
+
prompt,
|
|
691
|
+
platform: "linux",
|
|
692
|
+
arch: "x64",
|
|
693
|
+
getuid: () => 1000,
|
|
694
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
695
|
+
lastProviderPath: env.lastProviderPath,
|
|
696
|
+
statePath: env.statePath,
|
|
697
|
+
log: (l) => logs.push(l),
|
|
698
|
+
exposePublicImpl: async () => 0,
|
|
699
|
+
exposeCloudflareUpImpl: async () => {
|
|
700
|
+
cloudflareCalled = true;
|
|
701
|
+
return 0;
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
expect(code).toBe(1);
|
|
705
|
+
expect(cloudflareCalled).toBe(false);
|
|
706
|
+
const joined = logs.join("\n");
|
|
707
|
+
expect(joined).toContain("Download failed");
|
|
708
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
709
|
+
} finally {
|
|
710
|
+
env.cleanup();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
487
714
|
test("user picks cloudflare on macos but declines brew: exits 1, no install attempted", async () => {
|
|
488
715
|
const env = makeEnv();
|
|
489
716
|
try {
|
|
@@ -898,14 +898,15 @@ describe("init exposure chain", () => {
|
|
|
898
898
|
}
|
|
899
899
|
});
|
|
900
900
|
|
|
901
|
-
test("exposure chain
|
|
901
|
+
test("hub#565: a failed exposure chain does NOT abort init — warns, continues, exits 0", async () => {
|
|
902
902
|
const h = makeHarness();
|
|
903
903
|
try {
|
|
904
904
|
writeHubPort(1939, h.configDir);
|
|
905
|
+
const logs: string[] = [];
|
|
905
906
|
const code = await init({
|
|
906
907
|
configDir: h.configDir,
|
|
907
908
|
manifestPath: h.manifestPath,
|
|
908
|
-
log: () =>
|
|
909
|
+
log: (l) => logs.push(l),
|
|
909
910
|
alive: () => false,
|
|
910
911
|
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
911
912
|
readExposeStateFn: () => undefined,
|
|
@@ -915,8 +916,46 @@ describe("init exposure chain", () => {
|
|
|
915
916
|
exposeTailnetImpl: async () => 0,
|
|
916
917
|
exposeCloudflareImpl: async () => 2,
|
|
917
918
|
exposeChoice: "cloudflare",
|
|
919
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
918
920
|
});
|
|
919
|
-
|
|
921
|
+
// Init reaches the admin-URL/wizard handoff regardless of the expose
|
|
922
|
+
// failure (exposure is an enhancement, not a prerequisite).
|
|
923
|
+
expect(code).toBe(0);
|
|
924
|
+
const joined = logs.join("\n");
|
|
925
|
+
// Warned about the failure + printed the exact retry command (the
|
|
926
|
+
// `--cloudflare` flag matters — bare `expose public` defaults to
|
|
927
|
+
// Tailscale, hub#566).
|
|
928
|
+
expect(joined).toContain("Couldn't finish setting up public access");
|
|
929
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
930
|
+
// Fell through to the loopback admin URL.
|
|
931
|
+
expect(joined).toContain("http://127.0.0.1:1939/admin/");
|
|
932
|
+
} finally {
|
|
933
|
+
h.cleanup();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test("hub#565: tailnet expose failure prints the --tailnet retry command", async () => {
|
|
938
|
+
const h = makeHarness();
|
|
939
|
+
try {
|
|
940
|
+
writeHubPort(1939, h.configDir);
|
|
941
|
+
const logs: string[] = [];
|
|
942
|
+
const code = await init({
|
|
943
|
+
configDir: h.configDir,
|
|
944
|
+
manifestPath: h.manifestPath,
|
|
945
|
+
log: (l) => logs.push(l),
|
|
946
|
+
alive: () => false,
|
|
947
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
948
|
+
readExposeStateFn: () => undefined,
|
|
949
|
+
isTty: false,
|
|
950
|
+
platform: "linux",
|
|
951
|
+
env: {},
|
|
952
|
+
exposeTailnetImpl: async () => 3,
|
|
953
|
+
exposeCloudflareImpl: async () => 0,
|
|
954
|
+
exposeChoice: "tailnet",
|
|
955
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
956
|
+
});
|
|
957
|
+
expect(code).toBe(0);
|
|
958
|
+
expect(logs.join("\n")).toContain("parachute expose public --tailnet");
|
|
920
959
|
} finally {
|
|
921
960
|
h.cleanup();
|
|
922
961
|
}
|