@openparachute/hub 0.5.14-rc.16 → 0.5.14-rc.17
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 +45 -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 +29 -15
- 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 {
|
|
@@ -362,6 +362,51 @@ describe("parachute start", () => {
|
|
|
362
362
|
}
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
+
test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
|
|
366
|
+
// Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
|
|
367
|
+
// loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
|
|
368
|
+
// wrote loopback). expose-state.json carries the real public origin. A
|
|
369
|
+
// plain `parachute start vault` must rewrite vault/.env to the public
|
|
370
|
+
// origin so the daemon stops 401ing hub tokens — the self-heal half of the
|
|
371
|
+
// Cloudflare 401 fix.
|
|
372
|
+
const h = makeHarness();
|
|
373
|
+
try {
|
|
374
|
+
seedVault(h.manifestPath);
|
|
375
|
+
writeFileSync(
|
|
376
|
+
join(h.configDir, "expose-state.json"),
|
|
377
|
+
JSON.stringify({
|
|
378
|
+
version: 1,
|
|
379
|
+
layer: "public",
|
|
380
|
+
mode: "subdomain",
|
|
381
|
+
canonicalFqdn: "gitcoin-parachute.unforced.dev",
|
|
382
|
+
port: 1939,
|
|
383
|
+
funnel: false,
|
|
384
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
385
|
+
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
// Pre-seed vault/.env with a stale loopback value (the broken state).
|
|
389
|
+
mkdirSync(join(h.configDir, "vault"), { recursive: true });
|
|
390
|
+
writeFileSync(
|
|
391
|
+
join(h.configDir, "vault", ".env"),
|
|
392
|
+
"PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
|
|
393
|
+
);
|
|
394
|
+
const spawner = makeSpawner([4242]);
|
|
395
|
+
const code = await start("vault", {
|
|
396
|
+
configDir: h.configDir,
|
|
397
|
+
manifestPath: h.manifestPath,
|
|
398
|
+
spawner,
|
|
399
|
+
log: () => {},
|
|
400
|
+
});
|
|
401
|
+
expect(code).toBe(0);
|
|
402
|
+
expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
403
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
404
|
+
);
|
|
405
|
+
} finally {
|
|
406
|
+
h.cleanup();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
365
410
|
test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
|
|
366
411
|
const h = makeHarness();
|
|
367
412
|
try {
|
|
@@ -10,10 +10,14 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { readEnvFileValues } from "../env-file.ts";
|
|
13
|
+
import type { ExposeState } from "../expose-state.ts";
|
|
14
|
+
import { writeExposeState } from "../expose-state.ts";
|
|
13
15
|
import {
|
|
14
16
|
clearVaultHubOrigin,
|
|
15
17
|
isLoopbackOrigin,
|
|
16
18
|
persistVaultHubOrigin,
|
|
19
|
+
publicOriginFromExposeState,
|
|
20
|
+
selfHealVaultHubOrigin,
|
|
17
21
|
} from "../vault-hub-origin-env.ts";
|
|
18
22
|
|
|
19
23
|
let dir: string;
|
|
@@ -124,3 +128,136 @@ function mkVaultDir(): string {
|
|
|
124
128
|
mkdirSync(join(dir, "vault"), { recursive: true });
|
|
125
129
|
return vaultEnv();
|
|
126
130
|
}
|
|
131
|
+
|
|
132
|
+
function exposeStatePath(): string {
|
|
133
|
+
return join(dir, "expose-state.json");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Cloudflare-shaped expose state (subdomain mode, single hub-catchall entry). */
|
|
137
|
+
function cloudflareState(overrides: Partial<ExposeState> = {}): ExposeState {
|
|
138
|
+
return {
|
|
139
|
+
version: 1,
|
|
140
|
+
layer: "public",
|
|
141
|
+
mode: "subdomain",
|
|
142
|
+
canonicalFqdn: "gitcoin-parachute.unforced.dev",
|
|
143
|
+
port: 1939,
|
|
144
|
+
funnel: false,
|
|
145
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
146
|
+
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
147
|
+
...overrides,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Tailnet-shaped expose state (path mode). */
|
|
152
|
+
function tailnetState(overrides: Partial<ExposeState> = {}): ExposeState {
|
|
153
|
+
return {
|
|
154
|
+
version: 1,
|
|
155
|
+
layer: "tailnet",
|
|
156
|
+
mode: "path",
|
|
157
|
+
canonicalFqdn: "parachute-aaron.tailc75afc.ts.net",
|
|
158
|
+
port: 1939,
|
|
159
|
+
funnel: false,
|
|
160
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
161
|
+
hubOrigin: "https://parachute-aaron.tailc75afc.ts.net",
|
|
162
|
+
...overrides,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe("publicOriginFromExposeState", () => {
|
|
167
|
+
test("undefined when no expose-state file exists", () => {
|
|
168
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("returns the cloudflare hubOrigin", () => {
|
|
172
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
173
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBe(
|
|
174
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("returns the tailnet hubOrigin", () => {
|
|
179
|
+
writeExposeState(tailnetState(), exposeStatePath());
|
|
180
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBe(
|
|
181
|
+
"https://parachute-aaron.tailc75afc.ts.net",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("synthesizes https://<canonicalFqdn> when hubOrigin is absent (pre-Phase-0 state)", () => {
|
|
186
|
+
// hubOrigin is optional on older state files; canonicalFqdn is mandatory.
|
|
187
|
+
const { hubOrigin, ...rest } = cloudflareState();
|
|
188
|
+
void hubOrigin;
|
|
189
|
+
writeExposeState(rest as ExposeState, exposeStatePath());
|
|
190
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBe(
|
|
191
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("selfHealVaultHubOrigin (Cloudflare 401 self-heal)", () => {
|
|
197
|
+
test("writes the cloudflare public origin when vault/.env is UNSET", () => {
|
|
198
|
+
// The exact broken-deploy shape: expose-state carries a public cloudflare
|
|
199
|
+
// hubOrigin but vault/.env has no PARACHUTE_HUB_ORIGIN, so the daemon falls
|
|
200
|
+
// back to loopback and 401s every hub token. Restart self-corrects it.
|
|
201
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
202
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
203
|
+
expect(wrote).toBe(true);
|
|
204
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
205
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("overwrites a LOOPBACK value already persisted in vault/.env", () => {
|
|
210
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
211
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n");
|
|
212
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
213
|
+
expect(wrote).toBe(true);
|
|
214
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
215
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("tailnet shape still self-heals (no regression)", () => {
|
|
220
|
+
writeExposeState(tailnetState(), exposeStatePath());
|
|
221
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
222
|
+
expect(wrote).toBe(true);
|
|
223
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
224
|
+
"https://parachute-aaron.tailc75afc.ts.net",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("does NOT persist when there's no exposure (genuine loopback / local dev)", () => {
|
|
229
|
+
// No expose-state file → no public origin → vault keeps its loopback
|
|
230
|
+
// default. Persisting loopback would shadow a later exposure.
|
|
231
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
232
|
+
expect(wrote).toBe(false);
|
|
233
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("leaves a DIFFERENT non-loopback value alone (deliberate --hub-origin override)", () => {
|
|
237
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
238
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://custom.example.com\n");
|
|
239
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
240
|
+
expect(wrote).toBe(false);
|
|
241
|
+
// Untouched — self-heal only fixes unset/loopback, never clobbers a public
|
|
242
|
+
// value an operator may have set on purpose.
|
|
243
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe("https://custom.example.com");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("no-op (no double-write) when the persisted value already equals the public origin", () => {
|
|
247
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
248
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://gitcoin-parachute.unforced.dev\n");
|
|
249
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
250
|
+
expect(wrote).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("expose-state with a loopback hubOrigin is treated as no public exposure", () => {
|
|
254
|
+
// A loopback hubOrigin (local-dev hub) must never be persisted — it would
|
|
255
|
+
// recreate the iss mismatch on the daemon boot path.
|
|
256
|
+
writeExposeState(cloudflareState({ hubOrigin: "http://127.0.0.1:1939" }), exposeStatePath());
|
|
257
|
+
// canonicalFqdn is still public here, but hubOrigin wins — we honor the
|
|
258
|
+
// explicit value the writer chose.
|
|
259
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
260
|
+
expect(wrote).toBe(false);
|
|
261
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -41,12 +41,14 @@ import {
|
|
|
41
41
|
readHubPort,
|
|
42
42
|
} from "../hub-control.ts";
|
|
43
43
|
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
44
|
-
import { type AliveFn, defaultAlive } from "../process-state.ts";
|
|
44
|
+
import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
|
|
45
45
|
import { readManifest } from "../services-manifest.ts";
|
|
46
46
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
47
|
+
import { persistVaultHubOrigin } from "../vault-hub-origin-env.ts";
|
|
47
48
|
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
48
49
|
import { WELL_KNOWN_DIR } from "../well-known.ts";
|
|
49
50
|
import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
|
|
51
|
+
import { restart } from "./lifecycle.ts";
|
|
50
52
|
|
|
51
53
|
const AUTH_DOC_URL =
|
|
52
54
|
"https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
|
|
@@ -328,6 +330,14 @@ export interface ExposeCloudflareOpts {
|
|
|
328
330
|
* `<vaultHome>/config.yaml` from disk. (#186)
|
|
329
331
|
*/
|
|
330
332
|
vaultAuthStatus?: VaultAuthStatus;
|
|
333
|
+
/**
|
|
334
|
+
* Restart a hub-dependent service so it re-reads the new public hub origin.
|
|
335
|
+
* Mirrors the Tailscale path's `restartService` seam (`expose.ts`). Defaults
|
|
336
|
+
* to lifecycle `restart`; tests inject a fake to assert the call without
|
|
337
|
+
* spawning a real daemon. Only invoked for vault (the only `iss`-validating
|
|
338
|
+
* service) and only when it's already running.
|
|
339
|
+
*/
|
|
340
|
+
restartService?: (short: string) => Promise<number>;
|
|
331
341
|
}
|
|
332
342
|
|
|
333
343
|
interface Resolved {
|
|
@@ -353,6 +363,7 @@ interface Resolved {
|
|
|
353
363
|
now: () => Date;
|
|
354
364
|
vaultHome: string | undefined;
|
|
355
365
|
vaultAuthStatus: VaultAuthStatus | undefined;
|
|
366
|
+
restartService: (short: string) => Promise<number>;
|
|
356
367
|
}
|
|
357
368
|
|
|
358
369
|
function resolve(opts: ExposeCloudflareOpts): Resolved {
|
|
@@ -395,6 +406,14 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
|
|
|
395
406
|
now: opts.now ?? (() => new Date()),
|
|
396
407
|
vaultHome: opts.vaultHome,
|
|
397
408
|
vaultAuthStatus: opts.vaultAuthStatus,
|
|
409
|
+
restartService:
|
|
410
|
+
opts.restartService ??
|
|
411
|
+
((short: string) =>
|
|
412
|
+
restart(short, {
|
|
413
|
+
manifestPath: opts.manifestPath,
|
|
414
|
+
configDir,
|
|
415
|
+
log: opts.log ?? (() => {}),
|
|
416
|
+
})),
|
|
398
417
|
};
|
|
399
418
|
}
|
|
400
419
|
|
|
@@ -700,6 +719,35 @@ export async function exposeCloudflareUp(
|
|
|
700
719
|
};
|
|
701
720
|
writeExposeState(exposeState, r.exposeStatePath);
|
|
702
721
|
|
|
722
|
+
// Persist the public hub origin into vault's `.env` and restart vault — the
|
|
723
|
+
// durable half of the OAuth issuer-mismatch fix on Cloudflare deploys.
|
|
724
|
+
//
|
|
725
|
+
// The bug (vault 401s every hub token on a Cloudflare deploy): the Tailscale
|
|
726
|
+
// path gets this for free because it auto-restarts vault, and that restart
|
|
727
|
+
// flows the freshly-written expose-state `hubOrigin` into `vault/.env` via
|
|
728
|
+
// lifecycle's `persistVaultHubOrigin`. The Cloudflare path wrote expose-state
|
|
729
|
+
// but never touched vault's `.env` or restarted it, so the launchd / systemd
|
|
730
|
+
// daemon kept booting vault with NO `PARACHUTE_HUB_ORIGIN` → vault fell back
|
|
731
|
+
// to loopback as its expected issuer → every hub-minted token (whose `iss`
|
|
732
|
+
// is the public origin) failed the `iss` check → 401 → "You're not signed in
|
|
733
|
+
// to the hub." We mirror the Tailscale path here exactly.
|
|
734
|
+
//
|
|
735
|
+
// `persistVaultHubOrigin` writes the durable `.env` (skips loopback itself,
|
|
736
|
+
// so a `--hub-origin http://127.0.0.1` override never bakes a dead issuer in);
|
|
737
|
+
// the restart makes the running vault re-read it immediately rather than
|
|
738
|
+
// waiting for the next reboot.
|
|
739
|
+
persistVaultHubOrigin(r.configDir, hubOrigin, r.log);
|
|
740
|
+
if (processState("vault", r.configDir, r.alive).status === "running") {
|
|
741
|
+
r.log("");
|
|
742
|
+
r.log("Restarting vault to pick up new hub origin…");
|
|
743
|
+
const rcode = await r.restartService("vault");
|
|
744
|
+
if (rcode !== 0) {
|
|
745
|
+
r.log(
|
|
746
|
+
"⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
703
751
|
const baseUrl = `https://${hostname}`;
|
|
704
752
|
// A well-formed vault manifest always lists at least one mount path. If
|
|
705
753
|
// it's empty, something went sideways in `parachute install vault` — warn
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
shortNameForManifest,
|
|
35
35
|
} from "../service-spec.ts";
|
|
36
36
|
import { type ServiceEntry, readManifest } from "../services-manifest.ts";
|
|
37
|
-
import { persistVaultHubOrigin } from "../vault-hub-origin-env.ts";
|
|
37
|
+
import { persistVaultHubOrigin, selfHealVaultHubOrigin } from "../vault-hub-origin-env.ts";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Tiny seam over `Bun.spawn` for lifecycle tests. The real spawner opens the
|
|
@@ -657,30 +657,44 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
|
|
|
657
657
|
` It may still be coming up — check \`parachute status\` and \`parachute logs ${short}\`.`,
|
|
658
658
|
);
|
|
659
659
|
if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
|
|
660
|
-
if (short === "vault"
|
|
661
|
-
persistVaultHubOrigin(r.configDir, r.hubOrigin, r.log);
|
|
662
|
-
}
|
|
660
|
+
if (short === "vault") persistVaultHubOriginForStart(r);
|
|
663
661
|
continue;
|
|
664
662
|
}
|
|
665
663
|
}
|
|
666
664
|
|
|
667
665
|
r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
|
|
668
666
|
if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
|
|
669
|
-
|
|
670
|
-
// systemd daemon (which boots vault out-of-band on reboot / crash-restart
|
|
671
|
-
// and never sees this spawn env) validates hub-minted JWTs' `iss` against
|
|
672
|
-
// the public origin. The spawn-env injection above is ephemeral; this is
|
|
673
|
-
// the durable half of the OAuth issuer-mismatch fix. Vault is the only
|
|
674
|
-
// hub-origin-dependent service with an OS-supervised autostart daemon
|
|
675
|
-
// today — scribe/notes don't validate `iss`. See
|
|
676
|
-
// `vault-hub-origin-env.ts:persistVaultHubOrigin`.
|
|
677
|
-
if (short === "vault" && r.hubOrigin) {
|
|
678
|
-
persistVaultHubOrigin(r.configDir, r.hubOrigin, r.log);
|
|
679
|
-
}
|
|
667
|
+
if (short === "vault") persistVaultHubOriginForStart(r);
|
|
680
668
|
}
|
|
681
669
|
return failures === 0 ? 0 : 1;
|
|
682
670
|
}
|
|
683
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Durable-persist vault's `PARACHUTE_HUB_ORIGIN` on a vault `start`. Two cases,
|
|
674
|
+
* in order:
|
|
675
|
+
*
|
|
676
|
+
* 1. The resolved spawn origin (`r.hubOrigin`) is a real public origin — write
|
|
677
|
+
* it. This is the long-standing happy path: an exposure is live, the
|
|
678
|
+
* launchd / systemd daemon (which boots vault out-of-band and never sees
|
|
679
|
+
* this spawn env) needs it in `.env` to validate hub-minted JWTs' `iss`.
|
|
680
|
+
* `persistVaultHubOrigin` skips loopback / unchanged values itself.
|
|
681
|
+
*
|
|
682
|
+
* 2. Self-heal: even when `r.hubOrigin` resolved to loopback or undefined
|
|
683
|
+
* (e.g. the hub.port file outran the expose-state read, or this is a bare
|
|
684
|
+
* `restart vault` on a deploy whose `.env` was never written), consult
|
|
685
|
+
* `expose-state.json` directly. If it advertises a public origin and
|
|
686
|
+
* vault's persisted value is unset / loopback, write the public origin.
|
|
687
|
+
* This is what lets an EXISTING broken Cloudflare deploy self-correct on
|
|
688
|
+
* the next `parachute restart vault`, not only fresh exposes.
|
|
689
|
+
*
|
|
690
|
+
* Case 1 covers the override / freshly-resolved path; case 2 catches the gap
|
|
691
|
+
* the Cloudflare 401 P0 fell through. See `vault-hub-origin-env.ts`.
|
|
692
|
+
*/
|
|
693
|
+
function persistVaultHubOriginForStart(r: Resolved): void {
|
|
694
|
+
if (r.hubOrigin) persistVaultHubOrigin(r.configDir, r.hubOrigin, r.log);
|
|
695
|
+
selfHealVaultHubOrigin(r.configDir, r.log, join(r.configDir, "expose-state.json"));
|
|
696
|
+
}
|
|
697
|
+
|
|
684
698
|
export async function stop(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
|
|
685
699
|
const r = resolve(opts);
|
|
686
700
|
if (svc === HUB_SVC) return stopHubSvc(r);
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
|
|
31
|
+
import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
|
|
31
32
|
import { HUB_ORIGIN_ENV } from "./hub-origin.ts";
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -98,3 +99,65 @@ export function clearVaultHubOrigin(configDir: string, log: (line: string) => vo
|
|
|
98
99
|
log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
|
|
99
100
|
return true;
|
|
100
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The public origin a live exposure advertises, or undefined when no exposure
|
|
105
|
+
* is active. Both the Tailscale and Cloudflare expose paths populate
|
|
106
|
+
* `expose-state.json` with a `hubOrigin` (the URL stamped into OAuth tokens'
|
|
107
|
+
* `iss` claim); older state files predating Phase 0 may carry only
|
|
108
|
+
* `canonicalFqdn`, so we synthesize `https://<fqdn>` as a fallback. Loopback /
|
|
109
|
+
* empty values resolve to undefined — there's no public origin to persist.
|
|
110
|
+
*/
|
|
111
|
+
export function publicOriginFromExposeState(
|
|
112
|
+
exposeStatePath: string = EXPOSE_STATE_PATH,
|
|
113
|
+
): string | undefined {
|
|
114
|
+
let state: ReturnType<typeof readExposeState>;
|
|
115
|
+
try {
|
|
116
|
+
state = readExposeState(exposeStatePath);
|
|
117
|
+
} catch {
|
|
118
|
+
// A malformed expose-state must never block a vault start — treat it as
|
|
119
|
+
// "no exposure" and let the loopback default stand.
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (!state) return undefined;
|
|
123
|
+
const origin = state.hubOrigin ?? (state.canonicalFqdn ? `https://${state.canonicalFqdn}` : "");
|
|
124
|
+
if (!origin || isLoopbackOrigin(origin)) return undefined;
|
|
125
|
+
return origin.replace(/\/+$/, "");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Self-heal vault's persisted `PARACHUTE_HUB_ORIGIN` from `expose-state.json`.
|
|
130
|
+
*
|
|
131
|
+
* The bug this closes (the Cloudflare 401 P0): on a Cloudflare-tunnel deploy the
|
|
132
|
+
* expose path writes a public `hubOrigin` into `expose-state.json`, but — unlike
|
|
133
|
+
* the Tailscale path, which auto-restarts vault and so flows the public origin
|
|
134
|
+
* into `vault/.env` via `persistVaultHubOrigin` — it never wrote vault's `.env`.
|
|
135
|
+
* So the launchd / systemd daemon kept booting vault with NO `PARACHUTE_HUB_ORIGIN`,
|
|
136
|
+
* vault fell back to loopback as its expected issuer, and every hub-minted token
|
|
137
|
+
* (whose `iss` is the public origin) failed the `iss` check → 401 on every vault
|
|
138
|
+
* request → "You're not signed in to the hub."
|
|
139
|
+
*
|
|
140
|
+
* Called on `parachute start|restart vault`: when expose-state advertises a
|
|
141
|
+
* public origin AND vault's persisted value is unset or loopback, write the
|
|
142
|
+
* public origin. Existing broken deploys self-correct on the next restart, not
|
|
143
|
+
* just fresh ones. We deliberately do NOT overwrite a *different* non-loopback
|
|
144
|
+
* value already in `.env` — that could be a deliberate `--hub-origin` override;
|
|
145
|
+
* `persistVaultHubOrigin` (the explicit, resolved-origin path) owns that case.
|
|
146
|
+
*
|
|
147
|
+
* Returns true iff `.env` was written this call.
|
|
148
|
+
*/
|
|
149
|
+
export function selfHealVaultHubOrigin(
|
|
150
|
+
configDir: string,
|
|
151
|
+
log: (line: string) => void,
|
|
152
|
+
exposeStatePath: string = EXPOSE_STATE_PATH,
|
|
153
|
+
): boolean {
|
|
154
|
+
const publicOrigin = publicOriginFromExposeState(exposeStatePath);
|
|
155
|
+
if (!publicOrigin) return false;
|
|
156
|
+
const current = parseEnvFile(vaultEnvPath(configDir)).values[HUB_ORIGIN_ENV];
|
|
157
|
+
// Only heal the broken shapes: unset (daemon falls back to loopback) or an
|
|
158
|
+
// already-persisted loopback (a value that itself causes the iss mismatch).
|
|
159
|
+
// A current public value — including one equal to publicOrigin — is left to
|
|
160
|
+
// persistVaultHubOrigin's idempotent path so we don't double-log.
|
|
161
|
+
if (current !== undefined && !isLoopbackOrigin(current)) return false;
|
|
162
|
+
return persistVaultHubOrigin(configDir, publicOrigin, log);
|
|
163
|
+
}
|