@openparachute/hub 0.5.14-rc.16 → 0.5.14-rc.18
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 -1
- package/src/__tests__/expose-cloudflare.test.ts +116 -0
- package/src/__tests__/lifecycle.test.ts +196 -0
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +137 -0
- package/src/commands/expose-cloudflare.ts +49 -1
- package/src/commands/lifecycle.ts +109 -15
- package/src/operator-token.ts +151 -0
- package/src/vault-hub-origin-env.ts +63 -0
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
exposeCloudflareOff,
|
|
15
15
|
exposeCloudflareUp,
|
|
16
16
|
} from "../commands/expose-cloudflare.ts";
|
|
17
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
17
18
|
import { readExposeState } from "../expose-state.ts";
|
|
18
19
|
import { writeHubPort } from "../hub-control.ts";
|
|
19
20
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
@@ -273,6 +274,121 @@ describe("exposeCloudflareUp", () => {
|
|
|
273
274
|
}
|
|
274
275
|
});
|
|
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
|
+
|
|
276
392
|
test("reuses existing tunnel when name already present", async () => {
|
|
277
393
|
const env = makeEnv();
|
|
278
394
|
try {
|
|
@@ -13,8 +13,16 @@ import {
|
|
|
13
13
|
} from "../commands/lifecycle.ts";
|
|
14
14
|
import { readEnvFileValues } from "../env-file.ts";
|
|
15
15
|
import { writeHubPort } from "../hub-control.ts";
|
|
16
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
18
|
+
import {
|
|
19
|
+
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
20
|
+
issueOperatorToken,
|
|
21
|
+
readOperatorTokenFile,
|
|
22
|
+
} from "../operator-token.ts";
|
|
16
23
|
import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
|
|
17
24
|
import { upsertService } from "../services-manifest.ts";
|
|
25
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
18
26
|
|
|
19
27
|
interface Harness {
|
|
20
28
|
configDir: string;
|
|
@@ -362,6 +370,51 @@ describe("parachute start", () => {
|
|
|
362
370
|
}
|
|
363
371
|
});
|
|
364
372
|
|
|
373
|
+
test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
|
|
374
|
+
// Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
|
|
375
|
+
// loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
|
|
376
|
+
// wrote loopback). expose-state.json carries the real public origin. A
|
|
377
|
+
// plain `parachute start vault` must rewrite vault/.env to the public
|
|
378
|
+
// origin so the daemon stops 401ing hub tokens — the self-heal half of the
|
|
379
|
+
// Cloudflare 401 fix.
|
|
380
|
+
const h = makeHarness();
|
|
381
|
+
try {
|
|
382
|
+
seedVault(h.manifestPath);
|
|
383
|
+
writeFileSync(
|
|
384
|
+
join(h.configDir, "expose-state.json"),
|
|
385
|
+
JSON.stringify({
|
|
386
|
+
version: 1,
|
|
387
|
+
layer: "public",
|
|
388
|
+
mode: "subdomain",
|
|
389
|
+
canonicalFqdn: "gitcoin-parachute.unforced.dev",
|
|
390
|
+
port: 1939,
|
|
391
|
+
funnel: false,
|
|
392
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
393
|
+
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
// Pre-seed vault/.env with a stale loopback value (the broken state).
|
|
397
|
+
mkdirSync(join(h.configDir, "vault"), { recursive: true });
|
|
398
|
+
writeFileSync(
|
|
399
|
+
join(h.configDir, "vault", ".env"),
|
|
400
|
+
"PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
|
|
401
|
+
);
|
|
402
|
+
const spawner = makeSpawner([4242]);
|
|
403
|
+
const code = await start("vault", {
|
|
404
|
+
configDir: h.configDir,
|
|
405
|
+
manifestPath: h.manifestPath,
|
|
406
|
+
spawner,
|
|
407
|
+
log: () => {},
|
|
408
|
+
});
|
|
409
|
+
expect(code).toBe(0);
|
|
410
|
+
expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
411
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
412
|
+
);
|
|
413
|
+
} finally {
|
|
414
|
+
h.cleanup();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
365
418
|
test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
|
|
366
419
|
const h = makeHarness();
|
|
367
420
|
try {
|
|
@@ -1480,6 +1533,149 @@ describe("parachute start|stop|restart hub", () => {
|
|
|
1480
1533
|
}
|
|
1481
1534
|
});
|
|
1482
1535
|
|
|
1536
|
+
// hub#481 — `start hub` self-heals a stale operator-token issuer. Tests use
|
|
1537
|
+
// the injectable `hub.selfHealOperatorToken` seam to assert the call happens
|
|
1538
|
+
// (and to make it throw without failing start); a separate test drives the
|
|
1539
|
+
// REAL self-heal against an on-disk operator token + hub.db.
|
|
1540
|
+
test("start hub: invokes operator-token self-heal with the resolved issuer + configDir", async () => {
|
|
1541
|
+
const h = makeHarness();
|
|
1542
|
+
try {
|
|
1543
|
+
const log: string[] = [];
|
|
1544
|
+
const calls: Array<{ issuer: string; configDir: string }> = [];
|
|
1545
|
+
const code = await start("hub", {
|
|
1546
|
+
configDir: h.configDir,
|
|
1547
|
+
manifestPath: h.manifestPath,
|
|
1548
|
+
hubOrigin: "https://hub.example.com",
|
|
1549
|
+
hub: {
|
|
1550
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1551
|
+
selfHealOperatorToken: async (args) => {
|
|
1552
|
+
calls.push({ issuer: args.issuer, configDir: args.configDir });
|
|
1553
|
+
return {
|
|
1554
|
+
kind: "rotated",
|
|
1555
|
+
path: "/x/operator.token",
|
|
1556
|
+
scopeSet: "admin",
|
|
1557
|
+
expiresAt: "z",
|
|
1558
|
+
};
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
log: (l) => log.push(l),
|
|
1562
|
+
});
|
|
1563
|
+
expect(code).toBe(0);
|
|
1564
|
+
expect(calls).toEqual([{ issuer: "https://hub.example.com", configDir: h.configDir }]);
|
|
1565
|
+
// Rotation emits an operator-facing line.
|
|
1566
|
+
expect(log.join("\n")).toMatch(
|
|
1567
|
+
/refreshed operator\.token issuer → https:\/\/hub\.example\.com/,
|
|
1568
|
+
);
|
|
1569
|
+
} finally {
|
|
1570
|
+
h.cleanup();
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
test("start hub: skips operator-token self-heal when no hub origin is resolvable", async () => {
|
|
1575
|
+
const h = makeHarness();
|
|
1576
|
+
try {
|
|
1577
|
+
let called = false;
|
|
1578
|
+
// No hubOrigin override, no expose-state, no hub.port file → resolveHubOrigin
|
|
1579
|
+
// yields undefined, so the self-heal seam must NOT be called.
|
|
1580
|
+
const code = await start("hub", {
|
|
1581
|
+
configDir: h.configDir,
|
|
1582
|
+
manifestPath: h.manifestPath,
|
|
1583
|
+
hub: {
|
|
1584
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1585
|
+
selfHealOperatorToken: async () => {
|
|
1586
|
+
called = true;
|
|
1587
|
+
return { kind: "absent" };
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
1590
|
+
log: () => {},
|
|
1591
|
+
});
|
|
1592
|
+
expect(code).toBe(0);
|
|
1593
|
+
expect(called).toBe(false);
|
|
1594
|
+
} finally {
|
|
1595
|
+
h.cleanup();
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
test("start hub: a thrown error inside operator-token self-heal does NOT fail start", async () => {
|
|
1600
|
+
const h = makeHarness();
|
|
1601
|
+
try {
|
|
1602
|
+
const log: string[] = [];
|
|
1603
|
+
const code = await start("hub", {
|
|
1604
|
+
configDir: h.configDir,
|
|
1605
|
+
manifestPath: h.manifestPath,
|
|
1606
|
+
hubOrigin: "https://hub.example.com",
|
|
1607
|
+
hub: {
|
|
1608
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1609
|
+
selfHealOperatorToken: async () => {
|
|
1610
|
+
throw new Error("hub.db is locked");
|
|
1611
|
+
},
|
|
1612
|
+
},
|
|
1613
|
+
log: (l) => log.push(l),
|
|
1614
|
+
});
|
|
1615
|
+
expect(code).toBe(0);
|
|
1616
|
+
// Degrades to a brief note, not a hard failure.
|
|
1617
|
+
expect(log.join("\n")).toMatch(
|
|
1618
|
+
/operator\.token issuer self-heal skipped \(hub\.db is locked\)/,
|
|
1619
|
+
);
|
|
1620
|
+
} finally {
|
|
1621
|
+
h.cleanup();
|
|
1622
|
+
}
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
test("start hub: real self-heal re-mints a stale-iss operator token on disk", async () => {
|
|
1626
|
+
const h = makeHarness();
|
|
1627
|
+
try {
|
|
1628
|
+
// Seed signing keys + a stale-iss operator token in the harness configDir's
|
|
1629
|
+
// hub.db / operator.token, then drive the production self-heal seam.
|
|
1630
|
+
const db = openHubDb(hubDbPath(h.configDir));
|
|
1631
|
+
try {
|
|
1632
|
+
rotateSigningKey(db);
|
|
1633
|
+
await issueOperatorToken(db, "user-abc", {
|
|
1634
|
+
dir: h.configDir,
|
|
1635
|
+
issuer: "http://127.0.0.1:1939",
|
|
1636
|
+
scopeSet: "start",
|
|
1637
|
+
});
|
|
1638
|
+
} finally {
|
|
1639
|
+
db.close();
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const log: string[] = [];
|
|
1643
|
+
const code = await start("hub", {
|
|
1644
|
+
configDir: h.configDir,
|
|
1645
|
+
manifestPath: h.manifestPath,
|
|
1646
|
+
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
1647
|
+
// No selfHealOperatorToken override → exercises defaultSelfHealOperatorToken
|
|
1648
|
+
// (opens hub.db at <configDir>/hub.db).
|
|
1649
|
+
hub: {
|
|
1650
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1651
|
+
},
|
|
1652
|
+
log: (l) => log.push(l),
|
|
1653
|
+
});
|
|
1654
|
+
expect(code).toBe(0);
|
|
1655
|
+
expect(log.join("\n")).toMatch(
|
|
1656
|
+
/refreshed operator\.token issuer → https:\/\/gitcoin-parachute\.unforced\.dev/,
|
|
1657
|
+
);
|
|
1658
|
+
|
|
1659
|
+
// The on-disk token now validates under the new issuer, scope-set preserved.
|
|
1660
|
+
const verifyDb = openHubDb(hubDbPath(h.configDir));
|
|
1661
|
+
try {
|
|
1662
|
+
const onDisk = await readOperatorTokenFile(h.configDir);
|
|
1663
|
+
expect(onDisk).not.toBeNull();
|
|
1664
|
+
const validated = await validateAccessToken(
|
|
1665
|
+
verifyDb,
|
|
1666
|
+
onDisk as string,
|
|
1667
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
1668
|
+
);
|
|
1669
|
+
expect(validated.payload.iss).toBe("https://gitcoin-parachute.unforced.dev");
|
|
1670
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
1671
|
+
} finally {
|
|
1672
|
+
verifyDb.close();
|
|
1673
|
+
}
|
|
1674
|
+
} finally {
|
|
1675
|
+
h.cleanup();
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1483
1679
|
test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
|
|
1484
1680
|
const h = makeHarness();
|
|
1485
1681
|
try {
|