@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -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__/cloudflare-detect.test.ts +60 -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 +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- 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__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -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 +633 -53
- 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 +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -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-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- 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 +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -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/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -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 +738 -125
- 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 +200 -25
- 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,12 +14,20 @@ 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";
|
|
19
|
+
import { writeHubPort } from "../hub-control.ts";
|
|
17
20
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
18
21
|
|
|
22
|
+
// Default seeded hub port used by tests with `skipHub: true`. The cloudflared
|
|
23
|
+
// path reads `<configDir>/hub/run/hub.port` instead of spawning a real hub.
|
|
24
|
+
const TEST_HUB_PORT = 1939;
|
|
25
|
+
|
|
19
26
|
interface TestEnv {
|
|
20
27
|
configDir: string;
|
|
21
28
|
manifestPath: string;
|
|
22
29
|
statePath: string;
|
|
30
|
+
exposeStatePath: string;
|
|
23
31
|
configPath: string;
|
|
24
32
|
logPath: string;
|
|
25
33
|
cloudflaredHome: string;
|
|
@@ -35,12 +43,18 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
|
|
|
35
43
|
const cloudflaredHome = join(dir, "cloudflared");
|
|
36
44
|
const manifestPath = join(configDir, "services.json");
|
|
37
45
|
const statePath = join(configDir, "cloudflared-state.json");
|
|
46
|
+
const exposeStatePath = join(configDir, "expose-state.json");
|
|
38
47
|
const configPath = join(configDir, "cloudflared", "parachute", "config.yml");
|
|
39
48
|
const logPath = join(configDir, "cloudflared", "parachute", "cloudflared.log");
|
|
40
49
|
|
|
41
50
|
require("node:fs").mkdirSync(configDir, { recursive: true });
|
|
42
51
|
require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
|
|
43
52
|
|
|
53
|
+
// Seed the hub port so `skipHub: true` invocations can resolve a port
|
|
54
|
+
// without spawning the actual hub process. Matches the seam pattern used
|
|
55
|
+
// by expose.test.ts (which threads `hubEnsureOpts` for the same purpose).
|
|
56
|
+
writeHubPort(TEST_HUB_PORT, configDir);
|
|
57
|
+
|
|
44
58
|
if (loggedIn) {
|
|
45
59
|
writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
|
|
46
60
|
}
|
|
@@ -62,6 +76,7 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
|
|
|
62
76
|
configDir,
|
|
63
77
|
manifestPath,
|
|
64
78
|
statePath,
|
|
79
|
+
exposeStatePath,
|
|
65
80
|
configPath,
|
|
66
81
|
logPath,
|
|
67
82
|
cloudflaredHome,
|
|
@@ -125,9 +140,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
125
140
|
log: (l) => logs.push(l),
|
|
126
141
|
manifestPath: env.manifestPath,
|
|
127
142
|
statePath: env.statePath,
|
|
143
|
+
exposeStatePath: env.exposeStatePath,
|
|
128
144
|
configPath: env.configPath,
|
|
129
145
|
logPath: env.logPath,
|
|
130
146
|
cloudflaredHome: env.cloudflaredHome,
|
|
147
|
+
configDir: env.configDir,
|
|
148
|
+
skipHub: true,
|
|
131
149
|
now: () => new Date("2026-04-22T12:00:00Z"),
|
|
132
150
|
});
|
|
133
151
|
|
|
@@ -170,18 +188,207 @@ describe("exposeCloudflareUp", () => {
|
|
|
170
188
|
const yaml = readFileSync(env.configPath, "utf8");
|
|
171
189
|
expect(yaml).toContain(`tunnel: ${uuid}`);
|
|
172
190
|
expect(yaml).toContain("- hostname: vault.example.com");
|
|
173
|
-
|
|
191
|
+
// Routes through the hub (not directly at vault). The hub dispatches
|
|
192
|
+
// discovery / admin / OAuth / per-vault proxy / generic /<svc>/* —
|
|
193
|
+
// same shape Tailscale Funnel uses. Pre-2026-05-27 this was
|
|
194
|
+
// http://localhost:1940 (vault's port), which served vault's own 404
|
|
195
|
+
// page on every request that wasn't /vault/<name>/...
|
|
196
|
+
expect(yaml).toContain(`service: http://localhost:${TEST_HUB_PORT}`);
|
|
174
197
|
|
|
175
198
|
// Security copy surfaces both paths plus a pointer to the auth doc.
|
|
176
199
|
const joined = logs.join("\n");
|
|
177
200
|
expect(joined).toContain("parachute auth set-password");
|
|
178
|
-
|
|
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_");
|
|
179
207
|
expect(joined).toContain("auth-model.md");
|
|
180
208
|
} finally {
|
|
181
209
|
env.cleanup();
|
|
182
210
|
}
|
|
183
211
|
});
|
|
184
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
|
+
|
|
185
392
|
test("reuses existing tunnel when name already present", async () => {
|
|
186
393
|
const env = makeEnv();
|
|
187
394
|
try {
|
|
@@ -206,9 +413,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
206
413
|
log: (l) => logs.push(l),
|
|
207
414
|
manifestPath: env.manifestPath,
|
|
208
415
|
statePath: env.statePath,
|
|
416
|
+
exposeStatePath: env.exposeStatePath,
|
|
209
417
|
configPath: env.configPath,
|
|
210
418
|
logPath: env.logPath,
|
|
211
419
|
cloudflaredHome: env.cloudflaredHome,
|
|
420
|
+
configDir: env.configDir,
|
|
421
|
+
skipHub: true,
|
|
212
422
|
});
|
|
213
423
|
expect(code).toBe(0);
|
|
214
424
|
// No `tunnel create` — only list + route.
|
|
@@ -233,9 +443,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
233
443
|
log: (l) => logs.push(l),
|
|
234
444
|
manifestPath: env.manifestPath,
|
|
235
445
|
statePath: env.statePath,
|
|
446
|
+
exposeStatePath: env.exposeStatePath,
|
|
236
447
|
configPath: env.configPath,
|
|
237
448
|
logPath: env.logPath,
|
|
238
449
|
cloudflaredHome: env.cloudflaredHome,
|
|
450
|
+
configDir: env.configDir,
|
|
451
|
+
skipHub: true,
|
|
239
452
|
});
|
|
240
453
|
|
|
241
454
|
expect(code).toBe(1);
|
|
@@ -259,9 +472,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
259
472
|
log: (l) => logs.push(l),
|
|
260
473
|
manifestPath: env.manifestPath,
|
|
261
474
|
statePath: env.statePath,
|
|
475
|
+
exposeStatePath: env.exposeStatePath,
|
|
262
476
|
configPath: env.configPath,
|
|
263
477
|
logPath: env.logPath,
|
|
264
478
|
cloudflaredHome: env.cloudflaredHome,
|
|
479
|
+
configDir: env.configDir,
|
|
480
|
+
skipHub: true,
|
|
265
481
|
tunnelName: "bad name with spaces",
|
|
266
482
|
});
|
|
267
483
|
|
|
@@ -288,9 +504,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
288
504
|
log: (l) => logs.push(l),
|
|
289
505
|
manifestPath: env.manifestPath,
|
|
290
506
|
statePath: env.statePath,
|
|
507
|
+
exposeStatePath: env.exposeStatePath,
|
|
291
508
|
configPath: env.configPath,
|
|
292
509
|
logPath: env.logPath,
|
|
293
510
|
cloudflaredHome: env.cloudflaredHome,
|
|
511
|
+
configDir: env.configDir,
|
|
512
|
+
skipHub: true,
|
|
294
513
|
});
|
|
295
514
|
|
|
296
515
|
expect(code).toBe(1);
|
|
@@ -314,9 +533,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
314
533
|
log: (l) => logs.push(l),
|
|
315
534
|
manifestPath: env.manifestPath,
|
|
316
535
|
statePath: env.statePath,
|
|
536
|
+
exposeStatePath: env.exposeStatePath,
|
|
317
537
|
configPath: env.configPath,
|
|
318
538
|
logPath: env.logPath,
|
|
319
539
|
cloudflaredHome: env.cloudflaredHome,
|
|
540
|
+
configDir: env.configDir,
|
|
541
|
+
skipHub: true,
|
|
320
542
|
});
|
|
321
543
|
|
|
322
544
|
expect(code).toBe(1);
|
|
@@ -339,9 +561,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
339
561
|
log: (l) => logs.push(l),
|
|
340
562
|
manifestPath: env.manifestPath,
|
|
341
563
|
statePath: env.statePath,
|
|
564
|
+
exposeStatePath: env.exposeStatePath,
|
|
342
565
|
configPath: env.configPath,
|
|
343
566
|
logPath: env.logPath,
|
|
344
567
|
cloudflaredHome: env.cloudflaredHome,
|
|
568
|
+
configDir: env.configDir,
|
|
569
|
+
skipHub: true,
|
|
345
570
|
});
|
|
346
571
|
|
|
347
572
|
expect(code).toBe(1);
|
|
@@ -373,9 +598,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
373
598
|
log: (l) => logs.push(l),
|
|
374
599
|
manifestPath: env.manifestPath,
|
|
375
600
|
statePath: env.statePath,
|
|
601
|
+
exposeStatePath: env.exposeStatePath,
|
|
376
602
|
configPath: env.configPath,
|
|
377
603
|
logPath: env.logPath,
|
|
378
604
|
cloudflaredHome: env.cloudflaredHome,
|
|
605
|
+
configDir: env.configDir,
|
|
606
|
+
skipHub: true,
|
|
379
607
|
});
|
|
380
608
|
|
|
381
609
|
expect(code).toBe(1);
|
|
@@ -422,9 +650,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
422
650
|
log: () => {},
|
|
423
651
|
manifestPath: env.manifestPath,
|
|
424
652
|
statePath: env.statePath,
|
|
653
|
+
exposeStatePath: env.exposeStatePath,
|
|
425
654
|
configPath: env.configPath,
|
|
426
655
|
logPath: env.logPath,
|
|
427
656
|
cloudflaredHome: env.cloudflaredHome,
|
|
657
|
+
configDir: env.configDir,
|
|
658
|
+
skipHub: true,
|
|
428
659
|
});
|
|
429
660
|
|
|
430
661
|
expect(code).toBe(0);
|
|
@@ -436,6 +667,191 @@ describe("exposeCloudflareUp", () => {
|
|
|
436
667
|
}
|
|
437
668
|
});
|
|
438
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
|
+
|
|
439
855
|
test("two tunnels with different --tunnel-name coexist in state", async () => {
|
|
440
856
|
const env = makeEnv();
|
|
441
857
|
try {
|
|
@@ -461,8 +877,14 @@ describe("exposeCloudflareUp", () => {
|
|
|
461
877
|
log: () => {},
|
|
462
878
|
manifestPath: env.manifestPath,
|
|
463
879
|
statePath: env.statePath,
|
|
880
|
+
exposeStatePath: env.exposeStatePath,
|
|
464
881
|
cloudflaredHome: env.cloudflaredHome,
|
|
465
|
-
|
|
882
|
+
configDir: env.configDir,
|
|
883
|
+
skipHub: true,
|
|
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/.
|
|
466
888
|
});
|
|
467
889
|
expect(code1).toBe(0);
|
|
468
890
|
|
|
@@ -486,7 +908,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
486
908
|
log: () => {},
|
|
487
909
|
manifestPath: env.manifestPath,
|
|
488
910
|
statePath: env.statePath,
|
|
911
|
+
exposeStatePath: env.exposeStatePath,
|
|
489
912
|
cloudflaredHome: env.cloudflaredHome,
|
|
913
|
+
configDir: env.configDir,
|
|
914
|
+
skipHub: true,
|
|
490
915
|
tunnelName: "second",
|
|
491
916
|
});
|
|
492
917
|
expect(code2).toBe(0);
|
|
@@ -541,9 +966,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
541
966
|
log: (l) => logs.push(l),
|
|
542
967
|
manifestPath: env.manifestPath,
|
|
543
968
|
statePath: env.statePath,
|
|
969
|
+
exposeStatePath: env.exposeStatePath,
|
|
544
970
|
configPath: env.configPath,
|
|
545
971
|
logPath: env.logPath,
|
|
546
972
|
cloudflaredHome: env.cloudflaredHome,
|
|
973
|
+
configDir: env.configDir,
|
|
974
|
+
skipHub: true,
|
|
547
975
|
// No password, no 2FA — fully wide open. The warning should still
|
|
548
976
|
// fire; password-recovery copy already lives in `printAuthGuidance`.
|
|
549
977
|
vaultAuthStatus: {
|
|
@@ -556,7 +984,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
556
984
|
|
|
557
985
|
expect(code).toBe(0);
|
|
558
986
|
const joined = logs.join("\n");
|
|
559
|
-
|
|
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");
|
|
560
990
|
expect(joined).toContain("https://vault.example.com/login");
|
|
561
991
|
expect(joined).toContain("parachute auth 2fa enroll");
|
|
562
992
|
} finally {
|
|
@@ -564,7 +994,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
564
994
|
}
|
|
565
995
|
});
|
|
566
996
|
|
|
567
|
-
test("enrolled → warning suppressed (no
|
|
997
|
+
test("enrolled → warning suppressed (no public-/login warning line)", async () => {
|
|
568
998
|
const env = makeEnv();
|
|
569
999
|
try {
|
|
570
1000
|
const uuid = "dddddddd-0000-0000-0000-000000000004";
|
|
@@ -589,9 +1019,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
589
1019
|
log: (l) => logs.push(l),
|
|
590
1020
|
manifestPath: env.manifestPath,
|
|
591
1021
|
statePath: env.statePath,
|
|
1022
|
+
exposeStatePath: env.exposeStatePath,
|
|
592
1023
|
configPath: env.configPath,
|
|
593
1024
|
logPath: env.logPath,
|
|
594
1025
|
cloudflaredHome: env.cloudflaredHome,
|
|
1026
|
+
configDir: env.configDir,
|
|
1027
|
+
skipHub: true,
|
|
595
1028
|
vaultAuthStatus: {
|
|
596
1029
|
hasOwnerPassword: true,
|
|
597
1030
|
hasTotp: true,
|
|
@@ -602,11 +1035,76 @@ describe("exposeCloudflareUp", () => {
|
|
|
602
1035
|
|
|
603
1036
|
expect(code).toBe(0);
|
|
604
1037
|
const joined = logs.join("\n");
|
|
605
|
-
expect(joined).not.toContain("
|
|
606
|
-
// The
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
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");
|
|
1045
|
+
} finally {
|
|
1046
|
+
env.cleanup();
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
describe("routes through hub, not vault", () => {
|
|
1052
|
+
test("config.yml targets the hub port; success log mentions Admin + OAuth URLs", async () => {
|
|
1053
|
+
// Regression guard for the 2026-05-27 cut. Aaron ran `parachute expose
|
|
1054
|
+
// public` on a fresh EC2 box, configured Cloudflare with a custom
|
|
1055
|
+
// domain, and hit it — and got vault's 404 page rather than the hub's
|
|
1056
|
+
// discovery / admin. The pre-fix cloudflared config routed straight at
|
|
1057
|
+
// vault's port; the fix routes at the hub, mirroring the Tailscale
|
|
1058
|
+
// Funnel shape (single mount → hub catchall; hub dispatches per-request).
|
|
1059
|
+
const env = makeEnv();
|
|
1060
|
+
try {
|
|
1061
|
+
// Re-seed hub port to a non-default value so the assertion is
|
|
1062
|
+
// unambiguous about *which* port got into the yaml.
|
|
1063
|
+
writeHubPort(1949, env.configDir);
|
|
1064
|
+
|
|
1065
|
+
const uuid = "ffff0000-0000-0000-0000-00000000beef";
|
|
1066
|
+
const { runner } = queueRunner([
|
|
1067
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1068
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
1069
|
+
{
|
|
1070
|
+
code: 0,
|
|
1071
|
+
stdout: `Created tunnel parachute with id ${uuid}\n`,
|
|
1072
|
+
stderr: "",
|
|
1073
|
+
},
|
|
1074
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
1075
|
+
]);
|
|
1076
|
+
const { spawner } = fakeSpawner(60001);
|
|
1077
|
+
const logs: string[] = [];
|
|
1078
|
+
|
|
1079
|
+
const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
|
|
1080
|
+
runner,
|
|
1081
|
+
spawner,
|
|
1082
|
+
alive: () => false,
|
|
1083
|
+
kill: () => {},
|
|
1084
|
+
log: (l) => logs.push(l),
|
|
1085
|
+
manifestPath: env.manifestPath,
|
|
1086
|
+
statePath: env.statePath,
|
|
1087
|
+
exposeStatePath: env.exposeStatePath,
|
|
1088
|
+
configPath: env.configPath,
|
|
1089
|
+
logPath: env.logPath,
|
|
1090
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1091
|
+
configDir: env.configDir,
|
|
1092
|
+
skipHub: true,
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
expect(code).toBe(0);
|
|
1096
|
+
const yaml = readFileSync(env.configPath, "utf8");
|
|
1097
|
+
// Routes through the hub on its loopback port.
|
|
1098
|
+
expect(yaml).toContain("service: http://localhost:1949");
|
|
1099
|
+
// Does NOT route directly at vault's port (1940 per makeEnv default).
|
|
1100
|
+
expect(yaml).not.toContain("service: http://localhost:1940");
|
|
1101
|
+
|
|
1102
|
+
const joined = logs.join("\n");
|
|
1103
|
+
// Discoverable surfaces: open / admin / vault / OAuth all surfaced.
|
|
1104
|
+
expect(joined).toContain("https://gitcoin.parachute.computer/");
|
|
1105
|
+
expect(joined).toContain("Admin: https://gitcoin.parachute.computer/admin/");
|
|
1106
|
+
expect(joined).toContain("Vault: https://gitcoin.parachute.computer/vault/default");
|
|
1107
|
+
expect(joined).toContain("OAuth: https://gitcoin.parachute.computer");
|
|
610
1108
|
} finally {
|
|
611
1109
|
env.cleanup();
|
|
612
1110
|
}
|
|
@@ -621,6 +1119,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
621
1119
|
const logs: string[] = [];
|
|
622
1120
|
const code = await exposeCloudflareOff({
|
|
623
1121
|
statePath: env.statePath,
|
|
1122
|
+
exposeStatePath: env.exposeStatePath,
|
|
624
1123
|
log: (l) => logs.push(l),
|
|
625
1124
|
});
|
|
626
1125
|
expect(code).toBe(0);
|
|
@@ -630,7 +1129,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
630
1129
|
}
|
|
631
1130
|
});
|
|
632
1131
|
|
|
633
|
-
test("SIGTERMs the process and clears state", async () => {
|
|
1132
|
+
test("SIGTERMs the process and clears state (incl. expose-state.json — Fix 1)", async () => {
|
|
634
1133
|
const env = makeEnv();
|
|
635
1134
|
try {
|
|
636
1135
|
writeCloudflaredState(
|
|
@@ -649,10 +1148,36 @@ describe("exposeCloudflareOff", () => {
|
|
|
649
1148
|
},
|
|
650
1149
|
env.statePath,
|
|
651
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
|
+
|
|
652
1176
|
const killed: number[] = [];
|
|
653
1177
|
const logs: string[] = [];
|
|
654
1178
|
const code = await exposeCloudflareOff({
|
|
655
1179
|
statePath: env.statePath,
|
|
1180
|
+
exposeStatePath: env.exposeStatePath,
|
|
656
1181
|
alive: () => true,
|
|
657
1182
|
kill: (pid) => killed.push(pid),
|
|
658
1183
|
log: (l) => logs.push(l),
|
|
@@ -660,6 +1185,9 @@ describe("exposeCloudflareOff", () => {
|
|
|
660
1185
|
expect(code).toBe(0);
|
|
661
1186
|
expect(killed).toEqual([55555]);
|
|
662
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();
|
|
663
1191
|
// Reassures the user that the tunnel definition isn't lost.
|
|
664
1192
|
expect(logs.join("\n")).toContain("remains defined in Cloudflare");
|
|
665
1193
|
} finally {
|
|
@@ -689,6 +1217,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
689
1217
|
const killed: number[] = [];
|
|
690
1218
|
const code = await exposeCloudflareOff({
|
|
691
1219
|
statePath: env.statePath,
|
|
1220
|
+
exposeStatePath: env.exposeStatePath,
|
|
692
1221
|
alive: () => false,
|
|
693
1222
|
kill: (pid) => killed.push(pid),
|
|
694
1223
|
log: () => {},
|
|
@@ -701,6 +1230,46 @@ describe("exposeCloudflareOff", () => {
|
|
|
701
1230
|
}
|
|
702
1231
|
});
|
|
703
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
|
+
|
|
704
1273
|
test("targets the named tunnel and leaves siblings intact", async () => {
|
|
705
1274
|
const env = makeEnv();
|
|
706
1275
|
try {
|
|
@@ -728,6 +1297,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
728
1297
|
const killed: number[] = [];
|
|
729
1298
|
const code = await exposeCloudflareOff({
|
|
730
1299
|
statePath: env.statePath,
|
|
1300
|
+
exposeStatePath: env.exposeStatePath,
|
|
731
1301
|
alive: () => true,
|
|
732
1302
|
kill: (pid) => killed.push(pid),
|
|
733
1303
|
log: () => {},
|
|
@@ -762,6 +1332,7 @@ describe("exposeCloudflareOff", () => {
|
|
|
762
1332
|
const logs: string[] = [];
|
|
763
1333
|
const code = await exposeCloudflareOff({
|
|
764
1334
|
statePath: env.statePath,
|
|
1335
|
+
exposeStatePath: env.exposeStatePath,
|
|
765
1336
|
alive: () => true,
|
|
766
1337
|
kill: () => {},
|
|
767
1338
|
log: (l) => logs.push(l),
|