@openparachute/hub 0.5.14-rc.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
exposeCloudflareOff,
|
|
15
15
|
exposeCloudflareUp,
|
|
16
16
|
} from "../commands/expose-cloudflare.ts";
|
|
17
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
18
|
+
import { readExposeState } from "../expose-state.ts";
|
|
17
19
|
import { writeHubPort } from "../hub-control.ts";
|
|
18
20
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
19
21
|
|
|
@@ -25,6 +27,7 @@ interface TestEnv {
|
|
|
25
27
|
configDir: string;
|
|
26
28
|
manifestPath: string;
|
|
27
29
|
statePath: string;
|
|
30
|
+
exposeStatePath: string;
|
|
28
31
|
configPath: string;
|
|
29
32
|
logPath: string;
|
|
30
33
|
cloudflaredHome: string;
|
|
@@ -40,6 +43,7 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
|
|
|
40
43
|
const cloudflaredHome = join(dir, "cloudflared");
|
|
41
44
|
const manifestPath = join(configDir, "services.json");
|
|
42
45
|
const statePath = join(configDir, "cloudflared-state.json");
|
|
46
|
+
const exposeStatePath = join(configDir, "expose-state.json");
|
|
43
47
|
const configPath = join(configDir, "cloudflared", "parachute", "config.yml");
|
|
44
48
|
const logPath = join(configDir, "cloudflared", "parachute", "cloudflared.log");
|
|
45
49
|
|
|
@@ -72,6 +76,7 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
|
|
|
72
76
|
configDir,
|
|
73
77
|
manifestPath,
|
|
74
78
|
statePath,
|
|
79
|
+
exposeStatePath,
|
|
75
80
|
configPath,
|
|
76
81
|
logPath,
|
|
77
82
|
cloudflaredHome,
|
|
@@ -135,6 +140,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
135
140
|
log: (l) => logs.push(l),
|
|
136
141
|
manifestPath: env.manifestPath,
|
|
137
142
|
statePath: env.statePath,
|
|
143
|
+
exposeStatePath: env.exposeStatePath,
|
|
138
144
|
configPath: env.configPath,
|
|
139
145
|
logPath: env.logPath,
|
|
140
146
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -192,13 +198,197 @@ describe("exposeCloudflareUp", () => {
|
|
|
192
198
|
// Security copy surfaces both paths plus a pointer to the auth doc.
|
|
193
199
|
const joined = logs.join("\n");
|
|
194
200
|
expect(joined).toContain("parachute auth set-password");
|
|
195
|
-
|
|
201
|
+
// Scripts/machines path points at the hub-JWT mint (vault#412 / hub#466
|
|
202
|
+
// DROPped `vault tokens create`), not the removed pvt_* command.
|
|
203
|
+
expect(joined).toContain("parachute auth mint-token --scope vault:");
|
|
204
|
+
expect(joined).toContain("Bearer <hub-jwt>");
|
|
205
|
+
expect(joined).not.toContain("vault tokens create");
|
|
206
|
+
expect(joined).not.toContain("pvt_");
|
|
196
207
|
expect(joined).toContain("auth-model.md");
|
|
197
208
|
} finally {
|
|
198
209
|
env.cleanup();
|
|
199
210
|
}
|
|
200
211
|
});
|
|
201
212
|
|
|
213
|
+
test("persists expose-state.json with the canonicalFqdn + public hubOrigin (Fix 1)", async () => {
|
|
214
|
+
// The OAuth-iss bug: pre-fix the cloudflare path never wrote
|
|
215
|
+
// expose-state.json, so `readExposeState()` returned undefined and
|
|
216
|
+
// downstream consumers (init's resolveAdminUrl, lifecycle's
|
|
217
|
+
// resolveHubOrigin, the vault .env PARACHUTE_HUB_ORIGIN persistence)
|
|
218
|
+
// fell back to loopback — wrong OAuth `iss` on Cloudflare deploys.
|
|
219
|
+
const env = makeEnv();
|
|
220
|
+
try {
|
|
221
|
+
const uuid = "eeeeeeee-0000-0000-0000-000000000005";
|
|
222
|
+
const { runner } = queueRunner([
|
|
223
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
224
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
225
|
+
{
|
|
226
|
+
code: 0,
|
|
227
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
228
|
+
stderr: "",
|
|
229
|
+
},
|
|
230
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
231
|
+
]);
|
|
232
|
+
const { spawner } = fakeSpawner(42200);
|
|
233
|
+
|
|
234
|
+
// Pre-condition: no expose-state.json yet.
|
|
235
|
+
expect(readExposeState(env.exposeStatePath)).toBeUndefined();
|
|
236
|
+
|
|
237
|
+
const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
|
|
238
|
+
runner,
|
|
239
|
+
spawner,
|
|
240
|
+
alive: () => false,
|
|
241
|
+
kill: () => {},
|
|
242
|
+
log: () => {},
|
|
243
|
+
manifestPath: env.manifestPath,
|
|
244
|
+
statePath: env.statePath,
|
|
245
|
+
exposeStatePath: env.exposeStatePath,
|
|
246
|
+
configPath: env.configPath,
|
|
247
|
+
logPath: env.logPath,
|
|
248
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
249
|
+
configDir: env.configDir,
|
|
250
|
+
skipHub: true,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(code).toBe(0);
|
|
254
|
+
const exposeState = readExposeState(env.exposeStatePath);
|
|
255
|
+
expect(exposeState).toBeDefined();
|
|
256
|
+
expect(exposeState?.layer).toBe("public");
|
|
257
|
+
expect(exposeState?.mode).toBe("subdomain");
|
|
258
|
+
expect(exposeState?.canonicalFqdn).toBe("gitcoin.parachute.computer");
|
|
259
|
+
expect(exposeState?.funnel).toBe(false);
|
|
260
|
+
expect(exposeState?.port).toBe(TEST_HUB_PORT);
|
|
261
|
+
// The public origin OAuth clients will see — the load-bearing field.
|
|
262
|
+
expect(exposeState?.hubOrigin).toBe("https://gitcoin.parachute.computer");
|
|
263
|
+
// Single hub-catchall proxy entry (matches the Tailscale path's shape).
|
|
264
|
+
expect(exposeState?.entries).toEqual([
|
|
265
|
+
{
|
|
266
|
+
kind: "proxy",
|
|
267
|
+
mount: "/",
|
|
268
|
+
target: `http://localhost:${TEST_HUB_PORT}`,
|
|
269
|
+
service: "hub",
|
|
270
|
+
},
|
|
271
|
+
]);
|
|
272
|
+
} finally {
|
|
273
|
+
env.cleanup();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("persists the public hub origin to vault/.env + restarts vault (Cloudflare 401 fix)", async () => {
|
|
278
|
+
// The Cloudflare 401 P0: the cloudflare path wrote expose-state.json but —
|
|
279
|
+
// unlike the Tailscale path, which auto-restarts vault and so flows the
|
|
280
|
+
// public origin into vault/.env via lifecycle's persistVaultHubOrigin —
|
|
281
|
+
// never touched vault's .env or restarted it. The launchd/systemd daemon
|
|
282
|
+
// kept booting vault with NO PARACHUTE_HUB_ORIGIN → vault fell back to
|
|
283
|
+
// loopback as its expected issuer → every hub-minted token (iss=public)
|
|
284
|
+
// failed the iss check → 401. This asserts the durable .env write + the
|
|
285
|
+
// running-vault restart that mirrors the Tailscale path.
|
|
286
|
+
const env = makeEnv();
|
|
287
|
+
try {
|
|
288
|
+
// Seed vault as "running" so the restart branch fires. PID lives at
|
|
289
|
+
// <configDir>/vault/run/vault.pid (see process-state.ts:pidPath).
|
|
290
|
+
const vaultRun = join(env.configDir, "vault", "run");
|
|
291
|
+
require("node:fs").mkdirSync(vaultRun, { recursive: true });
|
|
292
|
+
writeFileSync(join(vaultRun, "vault.pid"), "99001");
|
|
293
|
+
|
|
294
|
+
const uuid = "ffffffff-0000-0000-0000-000000000006";
|
|
295
|
+
const { runner } = queueRunner([
|
|
296
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
297
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
298
|
+
{
|
|
299
|
+
code: 0,
|
|
300
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
301
|
+
stderr: "",
|
|
302
|
+
},
|
|
303
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
304
|
+
]);
|
|
305
|
+
const { spawner } = fakeSpawner(42300);
|
|
306
|
+
const restarted: string[] = [];
|
|
307
|
+
|
|
308
|
+
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
309
|
+
runner,
|
|
310
|
+
spawner,
|
|
311
|
+
// `alive` reports the seeded vault pid as running so processState() ===
|
|
312
|
+
// "running" and the restart branch executes.
|
|
313
|
+
alive: (pid) => pid === 99001,
|
|
314
|
+
kill: () => {},
|
|
315
|
+
log: () => {},
|
|
316
|
+
manifestPath: env.manifestPath,
|
|
317
|
+
statePath: env.statePath,
|
|
318
|
+
exposeStatePath: env.exposeStatePath,
|
|
319
|
+
configPath: env.configPath,
|
|
320
|
+
logPath: env.logPath,
|
|
321
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
322
|
+
configDir: env.configDir,
|
|
323
|
+
skipHub: true,
|
|
324
|
+
restartService: async (short) => {
|
|
325
|
+
restarted.push(short);
|
|
326
|
+
return 0;
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(code).toBe(0);
|
|
331
|
+
// Durable half: the public origin is written to vault/.env (NOT loopback,
|
|
332
|
+
// NOT unset) so the daemon boot path validates iss against it.
|
|
333
|
+
expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
334
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
335
|
+
);
|
|
336
|
+
// Live half: the running vault is restarted to re-read the new origin.
|
|
337
|
+
expect(restarted).toEqual(["vault"]);
|
|
338
|
+
} finally {
|
|
339
|
+
env.cleanup();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("persists vault/.env but does NOT restart when vault isn't running", async () => {
|
|
344
|
+
// No vault pidfile → processState() !== "running" → no restart, but the
|
|
345
|
+
// durable .env write still happens so the next daemon boot is correct.
|
|
346
|
+
const env = makeEnv();
|
|
347
|
+
try {
|
|
348
|
+
const uuid = "ffffffff-0000-0000-0000-000000000007";
|
|
349
|
+
const { runner } = queueRunner([
|
|
350
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
351
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
352
|
+
{
|
|
353
|
+
code: 0,
|
|
354
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
355
|
+
stderr: "",
|
|
356
|
+
},
|
|
357
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
358
|
+
]);
|
|
359
|
+
const { spawner } = fakeSpawner(42301);
|
|
360
|
+
const restarted: string[] = [];
|
|
361
|
+
|
|
362
|
+
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
363
|
+
runner,
|
|
364
|
+
spawner,
|
|
365
|
+
alive: () => false,
|
|
366
|
+
kill: () => {},
|
|
367
|
+
log: () => {},
|
|
368
|
+
manifestPath: env.manifestPath,
|
|
369
|
+
statePath: env.statePath,
|
|
370
|
+
exposeStatePath: env.exposeStatePath,
|
|
371
|
+
configPath: env.configPath,
|
|
372
|
+
logPath: env.logPath,
|
|
373
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
374
|
+
configDir: env.configDir,
|
|
375
|
+
skipHub: true,
|
|
376
|
+
restartService: async (short) => {
|
|
377
|
+
restarted.push(short);
|
|
378
|
+
return 0;
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(code).toBe(0);
|
|
383
|
+
expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
384
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
385
|
+
);
|
|
386
|
+
expect(restarted).toEqual([]);
|
|
387
|
+
} finally {
|
|
388
|
+
env.cleanup();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
202
392
|
test("reuses existing tunnel when name already present", async () => {
|
|
203
393
|
const env = makeEnv();
|
|
204
394
|
try {
|
|
@@ -223,6 +413,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
223
413
|
log: (l) => logs.push(l),
|
|
224
414
|
manifestPath: env.manifestPath,
|
|
225
415
|
statePath: env.statePath,
|
|
416
|
+
exposeStatePath: env.exposeStatePath,
|
|
226
417
|
configPath: env.configPath,
|
|
227
418
|
logPath: env.logPath,
|
|
228
419
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -252,6 +443,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
252
443
|
log: (l) => logs.push(l),
|
|
253
444
|
manifestPath: env.manifestPath,
|
|
254
445
|
statePath: env.statePath,
|
|
446
|
+
exposeStatePath: env.exposeStatePath,
|
|
255
447
|
configPath: env.configPath,
|
|
256
448
|
logPath: env.logPath,
|
|
257
449
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -280,6 +472,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
280
472
|
log: (l) => logs.push(l),
|
|
281
473
|
manifestPath: env.manifestPath,
|
|
282
474
|
statePath: env.statePath,
|
|
475
|
+
exposeStatePath: env.exposeStatePath,
|
|
283
476
|
configPath: env.configPath,
|
|
284
477
|
logPath: env.logPath,
|
|
285
478
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -311,6 +504,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
311
504
|
log: (l) => logs.push(l),
|
|
312
505
|
manifestPath: env.manifestPath,
|
|
313
506
|
statePath: env.statePath,
|
|
507
|
+
exposeStatePath: env.exposeStatePath,
|
|
314
508
|
configPath: env.configPath,
|
|
315
509
|
logPath: env.logPath,
|
|
316
510
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -339,6 +533,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
339
533
|
log: (l) => logs.push(l),
|
|
340
534
|
manifestPath: env.manifestPath,
|
|
341
535
|
statePath: env.statePath,
|
|
536
|
+
exposeStatePath: env.exposeStatePath,
|
|
342
537
|
configPath: env.configPath,
|
|
343
538
|
logPath: env.logPath,
|
|
344
539
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -366,6 +561,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
366
561
|
log: (l) => logs.push(l),
|
|
367
562
|
manifestPath: env.manifestPath,
|
|
368
563
|
statePath: env.statePath,
|
|
564
|
+
exposeStatePath: env.exposeStatePath,
|
|
369
565
|
configPath: env.configPath,
|
|
370
566
|
logPath: env.logPath,
|
|
371
567
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -402,6 +598,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
402
598
|
log: (l) => logs.push(l),
|
|
403
599
|
manifestPath: env.manifestPath,
|
|
404
600
|
statePath: env.statePath,
|
|
601
|
+
exposeStatePath: env.exposeStatePath,
|
|
405
602
|
configPath: env.configPath,
|
|
406
603
|
logPath: env.logPath,
|
|
407
604
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -453,6 +650,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
453
650
|
log: () => {},
|
|
454
651
|
manifestPath: env.manifestPath,
|
|
455
652
|
statePath: env.statePath,
|
|
653
|
+
exposeStatePath: env.exposeStatePath,
|
|
456
654
|
configPath: env.configPath,
|
|
457
655
|
logPath: env.logPath,
|
|
458
656
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -469,6 +667,191 @@ describe("exposeCloudflareUp", () => {
|
|
|
469
667
|
}
|
|
470
668
|
});
|
|
471
669
|
|
|
670
|
+
test("hub#487: kills orphan connectors found by pgrep before spawning, not just the state pid", async () => {
|
|
671
|
+
// The orphan-accumulation bug: each re-expose spawned a fresh connector
|
|
672
|
+
// without killing prior ones, and state only tracked the most-recent pid.
|
|
673
|
+
// Orphans the state file lost track of (crashed mid-rewrite, started by
|
|
674
|
+
// hand) must still be swept — `connectorPids` finds them by UUID/config
|
|
675
|
+
// path. Here state knows pid 99999, but pgrep also surfaces 88888 + 77777
|
|
676
|
+
// serving the same tunnel; all three get SIGTERM before the new spawn.
|
|
677
|
+
const env = makeEnv();
|
|
678
|
+
try {
|
|
679
|
+
const uuid = "cccccccc-0000-0000-0000-000000000003";
|
|
680
|
+
const priorRecord: CloudflaredTunnelRecord = {
|
|
681
|
+
pid: 99999,
|
|
682
|
+
tunnelUuid: uuid,
|
|
683
|
+
tunnelName: "parachute",
|
|
684
|
+
hostname: "vault.example.com",
|
|
685
|
+
startedAt: "2026-04-21T00:00:00.000Z",
|
|
686
|
+
configPath: env.configPath,
|
|
687
|
+
};
|
|
688
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: priorRecord } }, env.statePath);
|
|
689
|
+
|
|
690
|
+
const { runner } = queueRunner([
|
|
691
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
692
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
693
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
694
|
+
]);
|
|
695
|
+
const { spawner, seen } = fakeSpawner(42010);
|
|
696
|
+
const killed: number[] = [];
|
|
697
|
+
|
|
698
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
699
|
+
runner,
|
|
700
|
+
spawner,
|
|
701
|
+
alive: () => true, // all candidate pids report alive
|
|
702
|
+
kill: (pid) => killed.push(pid),
|
|
703
|
+
// pgrep surfaces two orphans the state record didn't track.
|
|
704
|
+
connectorPids: () => [88888, 77777],
|
|
705
|
+
resolveHost: async () => ["104.16.0.1"], // Cloudflare — no DNS warning
|
|
706
|
+
log: () => {},
|
|
707
|
+
manifestPath: env.manifestPath,
|
|
708
|
+
statePath: env.statePath,
|
|
709
|
+
exposeStatePath: env.exposeStatePath,
|
|
710
|
+
configPath: env.configPath,
|
|
711
|
+
logPath: env.logPath,
|
|
712
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
713
|
+
configDir: env.configDir,
|
|
714
|
+
skipHub: true,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
expect(code).toBe(0);
|
|
718
|
+
// Every prior connector (state pid + both pgrep orphans) is stopped
|
|
719
|
+
// before the new one spawns.
|
|
720
|
+
expect(killed.sort()).toEqual([77777, 88888, 99999]);
|
|
721
|
+
// Exactly one fresh connector spawned, and it's the one recorded.
|
|
722
|
+
expect(seen).toHaveLength(1);
|
|
723
|
+
expect(findTunnelRecord(readCloudflaredState(env.statePath), "parachute")?.pid).toBe(42010);
|
|
724
|
+
} finally {
|
|
725
|
+
env.cleanup();
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("hub#487: warns when DNS doesn't resolve yet (pending zone)", async () => {
|
|
730
|
+
// route dns succeeded but the hostname doesn't resolve — the "pending"
|
|
731
|
+
// zone shape (NS not switched at the registrar). Non-fatal: still exit 0,
|
|
732
|
+
// still print the URLs, but add the nameserver-switch nudge.
|
|
733
|
+
const env = makeEnv();
|
|
734
|
+
try {
|
|
735
|
+
const uuid = "dddddddd-0000-0000-0000-000000000004";
|
|
736
|
+
const { runner } = queueRunner([
|
|
737
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
738
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
739
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
740
|
+
]);
|
|
741
|
+
const { spawner } = fakeSpawner(42020);
|
|
742
|
+
const logs: string[] = [];
|
|
743
|
+
|
|
744
|
+
const code = await exposeCloudflareUp("vault.newzone.com", {
|
|
745
|
+
runner,
|
|
746
|
+
spawner,
|
|
747
|
+
alive: () => false,
|
|
748
|
+
kill: () => {},
|
|
749
|
+
connectorPids: () => [],
|
|
750
|
+
resolveHost: async () => [], // NXDOMAIN / not live yet
|
|
751
|
+
log: (l) => logs.push(l),
|
|
752
|
+
manifestPath: env.manifestPath,
|
|
753
|
+
statePath: env.statePath,
|
|
754
|
+
exposeStatePath: env.exposeStatePath,
|
|
755
|
+
configPath: env.configPath,
|
|
756
|
+
logPath: env.logPath,
|
|
757
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
758
|
+
configDir: env.configDir,
|
|
759
|
+
skipHub: true,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
expect(code).toBe(0); // non-fatal — the expose still completes
|
|
763
|
+
const joined = logs.join("\n");
|
|
764
|
+
expect(joined).toContain("DNS isn't live yet for vault.newzone.com");
|
|
765
|
+
expect(joined).toContain("dig +short newzone.com NS");
|
|
766
|
+
expect(joined).toContain("ns.cloudflare.com");
|
|
767
|
+
// The success URLs still print.
|
|
768
|
+
expect(joined).toContain("https://vault.newzone.com/admin/");
|
|
769
|
+
} finally {
|
|
770
|
+
env.cleanup();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("hub#487: warns when hostname resolves but not to Cloudflare (shadowed)", async () => {
|
|
775
|
+
// route dns succeeded but the hostname resolves to a non-Cloudflare IP —
|
|
776
|
+
// a Pages project / grey-cloud A record shadowing the tunnel → edge 404.
|
|
777
|
+
const env = makeEnv();
|
|
778
|
+
try {
|
|
779
|
+
const uuid = "eeeeeeee-0000-0000-0000-000000000006";
|
|
780
|
+
const { runner } = queueRunner([
|
|
781
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
782
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
783
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
784
|
+
]);
|
|
785
|
+
const { spawner } = fakeSpawner(42021);
|
|
786
|
+
const logs: string[] = [];
|
|
787
|
+
|
|
788
|
+
const code = await exposeCloudflareUp("docs.parachute.computer", {
|
|
789
|
+
runner,
|
|
790
|
+
spawner,
|
|
791
|
+
alive: () => false,
|
|
792
|
+
kill: () => {},
|
|
793
|
+
connectorPids: () => [],
|
|
794
|
+
resolveHost: async () => ["203.0.113.10"], // not a Cloudflare range
|
|
795
|
+
log: (l) => logs.push(l),
|
|
796
|
+
manifestPath: env.manifestPath,
|
|
797
|
+
statePath: env.statePath,
|
|
798
|
+
exposeStatePath: env.exposeStatePath,
|
|
799
|
+
configPath: env.configPath,
|
|
800
|
+
logPath: env.logPath,
|
|
801
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
802
|
+
configDir: env.configDir,
|
|
803
|
+
skipHub: true,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
expect(code).toBe(0);
|
|
807
|
+
const joined = logs.join("\n");
|
|
808
|
+
expect(joined).toContain("not to Cloudflare's edge");
|
|
809
|
+
expect(joined).toContain("shadowed");
|
|
810
|
+
expect(joined).toContain("Pages project");
|
|
811
|
+
} finally {
|
|
812
|
+
env.cleanup();
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("hub#487: no DNS warning when hostname resolves at Cloudflare's edge", async () => {
|
|
817
|
+
const env = makeEnv();
|
|
818
|
+
try {
|
|
819
|
+
const uuid = "ffffffff-0000-0000-0000-000000000007";
|
|
820
|
+
const { runner } = queueRunner([
|
|
821
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
822
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
823
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
824
|
+
]);
|
|
825
|
+
const { spawner } = fakeSpawner(42022);
|
|
826
|
+
const logs: string[] = [];
|
|
827
|
+
|
|
828
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
829
|
+
runner,
|
|
830
|
+
spawner,
|
|
831
|
+
alive: () => false,
|
|
832
|
+
kill: () => {},
|
|
833
|
+
connectorPids: () => [],
|
|
834
|
+
resolveHost: async () => ["104.18.32.7"], // 104.16.0.0/13 — Cloudflare
|
|
835
|
+
log: (l) => logs.push(l),
|
|
836
|
+
manifestPath: env.manifestPath,
|
|
837
|
+
statePath: env.statePath,
|
|
838
|
+
exposeStatePath: env.exposeStatePath,
|
|
839
|
+
configPath: env.configPath,
|
|
840
|
+
logPath: env.logPath,
|
|
841
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
842
|
+
configDir: env.configDir,
|
|
843
|
+
skipHub: true,
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
expect(code).toBe(0);
|
|
847
|
+
const joined = logs.join("\n");
|
|
848
|
+
expect(joined).not.toContain("DNS isn't live yet");
|
|
849
|
+
expect(joined).not.toContain("not to Cloudflare's edge");
|
|
850
|
+
} finally {
|
|
851
|
+
env.cleanup();
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
472
855
|
test("two tunnels with different --tunnel-name coexist in state", async () => {
|
|
473
856
|
const env = makeEnv();
|
|
474
857
|
try {
|
|
@@ -494,10 +877,14 @@ describe("exposeCloudflareUp", () => {
|
|
|
494
877
|
log: () => {},
|
|
495
878
|
manifestPath: env.manifestPath,
|
|
496
879
|
statePath: env.statePath,
|
|
880
|
+
exposeStatePath: env.exposeStatePath,
|
|
497
881
|
cloudflaredHome: env.cloudflaredHome,
|
|
498
882
|
configDir: env.configDir,
|
|
499
883
|
skipHub: true,
|
|
500
|
-
//
|
|
884
|
+
// Omit configPath/logPath so they're per-tunnel-derived — but the
|
|
885
|
+
// derivation now resolves against the tmp `configDir` above, so the
|
|
886
|
+
// generated config.yml lands under tmp, not the operator's real
|
|
887
|
+
// ~/.parachute/cloudflared/parachute/.
|
|
501
888
|
});
|
|
502
889
|
expect(code1).toBe(0);
|
|
503
890
|
|
|
@@ -521,6 +908,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
521
908
|
log: () => {},
|
|
522
909
|
manifestPath: env.manifestPath,
|
|
523
910
|
statePath: env.statePath,
|
|
911
|
+
exposeStatePath: env.exposeStatePath,
|
|
524
912
|
cloudflaredHome: env.cloudflaredHome,
|
|
525
913
|
configDir: env.configDir,
|
|
526
914
|
skipHub: true,
|
|
@@ -578,11 +966,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
578
966
|
log: (l) => logs.push(l),
|
|
579
967
|
manifestPath: env.manifestPath,
|
|
580
968
|
statePath: env.statePath,
|
|
969
|
+
exposeStatePath: env.exposeStatePath,
|
|
581
970
|
configPath: env.configPath,
|
|
582
971
|
logPath: env.logPath,
|
|
583
972
|
cloudflaredHome: env.cloudflaredHome,
|
|
584
|
-
|
|
585
|
-
|
|
973
|
+
configDir: env.configDir,
|
|
974
|
+
skipHub: true,
|
|
586
975
|
// No password, no 2FA — fully wide open. The warning should still
|
|
587
976
|
// fire; password-recovery copy already lives in `printAuthGuidance`.
|
|
588
977
|
vaultAuthStatus: {
|
|
@@ -595,7 +984,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
595
984
|
|
|
596
985
|
expect(code).toBe(0);
|
|
597
986
|
const joined = logs.join("\n");
|
|
598
|
-
|
|
987
|
+
// hub#473: real hub-login 2FA — the warning now recommends the real
|
|
988
|
+
// `parachute auth 2fa enroll` path.
|
|
989
|
+
expect(joined).toContain("/login is now reachable on the public internet");
|
|
599
990
|
expect(joined).toContain("https://vault.example.com/login");
|
|
600
991
|
expect(joined).toContain("parachute auth 2fa enroll");
|
|
601
992
|
} finally {
|
|
@@ -603,7 +994,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
603
994
|
}
|
|
604
995
|
});
|
|
605
996
|
|
|
606
|
-
test("enrolled → warning suppressed (no
|
|
997
|
+
test("enrolled → warning suppressed (no public-/login warning line)", async () => {
|
|
607
998
|
const env = makeEnv();
|
|
608
999
|
try {
|
|
609
1000
|
const uuid = "dddddddd-0000-0000-0000-000000000004";
|
|
@@ -628,11 +1019,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
628
1019
|
log: (l) => logs.push(l),
|
|
629
1020
|
manifestPath: env.manifestPath,
|
|
630
1021
|
statePath: env.statePath,
|
|
1022
|
+
exposeStatePath: env.exposeStatePath,
|
|
631
1023
|
configPath: env.configPath,
|
|
632
1024
|
logPath: env.logPath,
|
|
633
1025
|
cloudflaredHome: env.cloudflaredHome,
|
|
634
|
-
|
|
635
|
-
|
|
1026
|
+
configDir: env.configDir,
|
|
1027
|
+
skipHub: true,
|
|
636
1028
|
vaultAuthStatus: {
|
|
637
1029
|
hasOwnerPassword: true,
|
|
638
1030
|
hasTotp: true,
|
|
@@ -643,11 +1035,13 @@ describe("exposeCloudflareUp", () => {
|
|
|
643
1035
|
|
|
644
1036
|
expect(code).toBe(0);
|
|
645
1037
|
const joined = logs.join("\n");
|
|
646
|
-
expect(joined).not.toContain("
|
|
647
|
-
// The
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
|
|
1038
|
+
expect(joined).not.toContain("/login is now reachable on the public internet");
|
|
1039
|
+
// The contextual 2FA warning is suppressed (2FA already enrolled); the
|
|
1040
|
+
// always-shown owner-password guidance from `printAuthGuidance` still
|
|
1041
|
+
// appears, and it now (hub#473) also surfaces the real `2fa enroll`
|
|
1042
|
+
// path in the humans section.
|
|
1043
|
+
expect(joined).toContain("parachute auth set-password");
|
|
1044
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
651
1045
|
} finally {
|
|
652
1046
|
env.cleanup();
|
|
653
1047
|
}
|
|
@@ -690,6 +1084,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
690
1084
|
log: (l) => logs.push(l),
|
|
691
1085
|
manifestPath: env.manifestPath,
|
|
692
1086
|
statePath: env.statePath,
|
|
1087
|
+
exposeStatePath: env.exposeStatePath,
|
|
693
1088
|
configPath: env.configPath,
|
|
694
1089
|
logPath: env.logPath,
|
|
695
1090
|
cloudflaredHome: env.cloudflaredHome,
|
|
@@ -724,6 +1119,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
724
1119
|
const logs: string[] = [];
|
|
725
1120
|
const code = await exposeCloudflareOff({
|
|
726
1121
|
statePath: env.statePath,
|
|
1122
|
+
exposeStatePath: env.exposeStatePath,
|
|
727
1123
|
log: (l) => logs.push(l),
|
|
728
1124
|
});
|
|
729
1125
|
expect(code).toBe(0);
|
|
@@ -733,7 +1129,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
733
1129
|
}
|
|
734
1130
|
});
|
|
735
1131
|
|
|
736
|
-
test("SIGTERMs the process and clears state", async () => {
|
|
1132
|
+
test("SIGTERMs the process and clears state (incl. expose-state.json — Fix 1)", async () => {
|
|
737
1133
|
const env = makeEnv();
|
|
738
1134
|
try {
|
|
739
1135
|
writeCloudflaredState(
|
|
@@ -752,10 +1148,36 @@ describe("exposeCloudflareOff", () => {
|
|
|
752
1148
|
},
|
|
753
1149
|
env.statePath,
|
|
754
1150
|
);
|
|
1151
|
+
// Seed the shared expose-state.json the up-path would have written, so we
|
|
1152
|
+
// can assert teardown clears it (downstream consumers stop resolving the
|
|
1153
|
+
// now-dead public URL).
|
|
1154
|
+
writeFileSync(
|
|
1155
|
+
env.exposeStatePath,
|
|
1156
|
+
`${JSON.stringify({
|
|
1157
|
+
version: 1,
|
|
1158
|
+
layer: "public",
|
|
1159
|
+
mode: "subdomain",
|
|
1160
|
+
canonicalFqdn: "vault.example.com",
|
|
1161
|
+
port: TEST_HUB_PORT,
|
|
1162
|
+
funnel: false,
|
|
1163
|
+
entries: [
|
|
1164
|
+
{
|
|
1165
|
+
kind: "proxy",
|
|
1166
|
+
mount: "/",
|
|
1167
|
+
target: `http://localhost:${TEST_HUB_PORT}`,
|
|
1168
|
+
service: "hub",
|
|
1169
|
+
},
|
|
1170
|
+
],
|
|
1171
|
+
hubOrigin: "https://vault.example.com",
|
|
1172
|
+
})}\n`,
|
|
1173
|
+
);
|
|
1174
|
+
expect(readExposeState(env.exposeStatePath)).toBeDefined();
|
|
1175
|
+
|
|
755
1176
|
const killed: number[] = [];
|
|
756
1177
|
const logs: string[] = [];
|
|
757
1178
|
const code = await exposeCloudflareOff({
|
|
758
1179
|
statePath: env.statePath,
|
|
1180
|
+
exposeStatePath: env.exposeStatePath,
|
|
759
1181
|
alive: () => true,
|
|
760
1182
|
kill: (pid) => killed.push(pid),
|
|
761
1183
|
log: (l) => logs.push(l),
|
|
@@ -763,6 +1185,9 @@ describe("exposeCloudflareOff", () => {
|
|
|
763
1185
|
expect(code).toBe(0);
|
|
764
1186
|
expect(killed).toEqual([55555]);
|
|
765
1187
|
expect(existsSync(env.statePath)).toBe(false);
|
|
1188
|
+
// Fix 1: the shared expose-state.json is cleared on the last tunnel down.
|
|
1189
|
+
expect(existsSync(env.exposeStatePath)).toBe(false);
|
|
1190
|
+
expect(readExposeState(env.exposeStatePath)).toBeUndefined();
|
|
766
1191
|
// Reassures the user that the tunnel definition isn't lost.
|
|
767
1192
|
expect(logs.join("\n")).toContain("remains defined in Cloudflare");
|
|
768
1193
|
} finally {
|
|
@@ -792,6 +1217,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
792
1217
|
const killed: number[] = [];
|
|
793
1218
|
const code = await exposeCloudflareOff({
|
|
794
1219
|
statePath: env.statePath,
|
|
1220
|
+
exposeStatePath: env.exposeStatePath,
|
|
795
1221
|
alive: () => false,
|
|
796
1222
|
kill: (pid) => killed.push(pid),
|
|
797
1223
|
log: () => {},
|
|
@@ -804,6 +1230,46 @@ describe("exposeCloudflareOff", () => {
|
|
|
804
1230
|
}
|
|
805
1231
|
});
|
|
806
1232
|
|
|
1233
|
+
test("hub#487: off sweeps orphan connectors the state record didn't track", async () => {
|
|
1234
|
+
const env = makeEnv();
|
|
1235
|
+
try {
|
|
1236
|
+
const uuid = "abababab-0000-0000-0000-000000000009";
|
|
1237
|
+
writeCloudflaredState(
|
|
1238
|
+
{
|
|
1239
|
+
version: 2,
|
|
1240
|
+
tunnels: {
|
|
1241
|
+
parachute: {
|
|
1242
|
+
pid: 55555,
|
|
1243
|
+
tunnelUuid: uuid,
|
|
1244
|
+
tunnelName: "parachute",
|
|
1245
|
+
hostname: "vault.example.com",
|
|
1246
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1247
|
+
configPath: env.configPath,
|
|
1248
|
+
},
|
|
1249
|
+
},
|
|
1250
|
+
},
|
|
1251
|
+
env.statePath,
|
|
1252
|
+
);
|
|
1253
|
+
const killed: number[] = [];
|
|
1254
|
+
const code = await exposeCloudflareOff({
|
|
1255
|
+
statePath: env.statePath,
|
|
1256
|
+
exposeStatePath: env.exposeStatePath,
|
|
1257
|
+
alive: () => true,
|
|
1258
|
+
kill: (pid) => killed.push(pid),
|
|
1259
|
+
// pgrep finds the tracked pid (skipped — already signalled) plus an
|
|
1260
|
+
// untracked orphan 66666 serving the same tunnel.
|
|
1261
|
+
connectorPids: () => [55555, 66666],
|
|
1262
|
+
log: () => {},
|
|
1263
|
+
});
|
|
1264
|
+
expect(code).toBe(0);
|
|
1265
|
+
// Tracked pid stopped once, orphan also stopped — no double-kill of 55555.
|
|
1266
|
+
expect(killed.sort()).toEqual([55555, 66666]);
|
|
1267
|
+
expect(existsSync(env.statePath)).toBe(false);
|
|
1268
|
+
} finally {
|
|
1269
|
+
env.cleanup();
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
|
|
807
1273
|
test("targets the named tunnel and leaves siblings intact", async () => {
|
|
808
1274
|
const env = makeEnv();
|
|
809
1275
|
try {
|
|
@@ -831,6 +1297,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
831
1297
|
const killed: number[] = [];
|
|
832
1298
|
const code = await exposeCloudflareOff({
|
|
833
1299
|
statePath: env.statePath,
|
|
1300
|
+
exposeStatePath: env.exposeStatePath,
|
|
834
1301
|
alive: () => true,
|
|
835
1302
|
kill: (pid) => killed.push(pid),
|
|
836
1303
|
log: () => {},
|
|
@@ -865,6 +1332,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
865
1332
|
const logs: string[] = [];
|
|
866
1333
|
const code = await exposeCloudflareOff({
|
|
867
1334
|
statePath: env.statePath,
|
|
1335
|
+
exposeStatePath: env.exposeStatePath,
|
|
868
1336
|
alive: () => true,
|
|
869
1337
|
kill: () => {},
|
|
870
1338
|
log: (l) => logs.push(l),
|