@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.
Files changed (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { mkdirSync, openSync } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import { DEFAULT_TUNNEL_NAME, cloudflaredPathsFor, writeConfig } from "../cloudflare/config.ts";
@@ -26,12 +27,28 @@ import {
26
27
  findTunnelByName,
27
28
  routeDns,
28
29
  } from "../cloudflare/tunnel.ts";
29
- import { SERVICES_MANIFEST_PATH } from "../config.ts";
30
- import { type AliveFn, defaultAlive } from "../process-state.ts";
30
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
31
+ import {
32
+ EXPOSE_STATE_PATH,
33
+ type ExposeState,
34
+ clearExposeState,
35
+ writeExposeState,
36
+ } from "../expose-state.ts";
37
+ import {
38
+ type EnsureHubOpts,
39
+ HUB_DEFAULT_PORT,
40
+ ensureHubRunning,
41
+ readHubPort,
42
+ } from "../hub-control.ts";
43
+ import { deriveHubOrigin } from "../hub-origin.ts";
44
+ import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
31
45
  import { readManifest } from "../services-manifest.ts";
32
46
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
47
+ import { persistVaultHubOrigin } from "../vault-hub-origin-env.ts";
33
48
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
49
+ import { WELL_KNOWN_DIR } from "../well-known.ts";
34
50
  import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
51
+ import { restart } from "./lifecycle.ts";
35
52
 
36
53
  const AUTH_DOC_URL =
37
54
  "https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
@@ -86,14 +103,171 @@ const defaultKill: KillFn = (pid, signal) => {
86
103
  process.kill(pid, signal);
87
104
  };
88
105
 
106
+ /**
107
+ * Find the PIDs of every running `cloudflared` connector serving THIS tunnel.
108
+ * "This tunnel" is identified by either the tunnel UUID or the config.yml path
109
+ * appearing on the process command line — both are unique to Parachute's
110
+ * connector for this tunnel, so we never touch an unrelated cloudflared the
111
+ * operator may be running for a different tunnel.
112
+ *
113
+ * The motivating bug (hub#487): each `parachute expose public --cloudflare`
114
+ * "reused the tunnel" but spawned a fresh connector (new pid) without killing
115
+ * the prior ones, and the state file only tracked the most-recent pid. Orphan
116
+ * connectors accumulated — multiple `cloudflared tunnel run` processes all
117
+ * serving stale `config.yml` snapshots, so edge routing became nondeterministic
118
+ * ("silent fails"). Sweeping by UUID/config-path catches the orphans that the
119
+ * single-pid state record misses (prior runs that crashed mid-rewrite, or a
120
+ * connector the operator started by hand for this tunnel).
121
+ *
122
+ * Injectable so tests assert the sweep without a live `pgrep`.
123
+ */
124
+ export type ConnectorPidsFn = (tunnelUuid: string, configPath: string) => number[];
125
+
126
+ export const defaultConnectorPids: ConnectorPidsFn = (tunnelUuid, configPath) => {
127
+ try {
128
+ // `pgrep -fl cloudflared` lists "<pid> <full command line>" for every
129
+ // process whose command line matches "cloudflared". We then filter to the
130
+ // ones that name THIS tunnel (uuid or config path) so the kill is surgical.
131
+ // macOS + Linux ship pgrep; Windows is out of scope (mirrors hub#287's lsof
132
+ // assumption). Any failure → [] (caller falls back to state-tracked pid).
133
+ const result = spawnSync("pgrep", ["-fl", "cloudflared"], {
134
+ encoding: "utf8",
135
+ timeout: 2000,
136
+ });
137
+ if (result.status !== 0 || typeof result.stdout !== "string") return [];
138
+ const selfPid = process.pid;
139
+ const pids: number[] = [];
140
+ for (const line of result.stdout.split("\n")) {
141
+ const trimmed = line.trim();
142
+ if (trimmed.length === 0) continue;
143
+ const match = trimmed.match(/^(\d+)\s+(.*)$/);
144
+ if (!match) continue;
145
+ const pid = Number.parseInt(match[1]!, 10);
146
+ const cmdline = match[2]!;
147
+ if (!Number.isInteger(pid) || pid <= 0 || pid === selfPid) continue;
148
+ // Surgical match: only connectors that name this tunnel's UUID or its
149
+ // config path. A bare `cloudflared` (e.g. `--version`, `tunnel list`)
150
+ // or a connector for a *different* tunnel won't match either token.
151
+ if (cmdline.includes(tunnelUuid) || cmdline.includes(configPath)) {
152
+ pids.push(pid);
153
+ }
154
+ }
155
+ return pids;
156
+ } catch {
157
+ return [];
158
+ }
159
+ };
160
+
161
+ /**
162
+ * Resolve a hostname to its A/AAAA addresses. Returns [] when the name doesn't
163
+ * resolve (NXDOMAIN, SERVFAIL, no records yet) — the signal the DNS
164
+ * self-diagnosis keys on. Injectable so tests drive each case (unresolved /
165
+ * Cloudflare / non-Cloudflare) deterministically.
166
+ */
167
+ export type ResolveHostFn = (hostname: string) => Promise<string[]>;
168
+
169
+ export const defaultResolveHost: ResolveHostFn = async (hostname) => {
170
+ try {
171
+ // Bun.dns ships with the runtime; `node:dns/promises` is equally fine but
172
+ // Bun.dns.lookup returns both families in one call. `all: true` gives every
173
+ // record so a partially-propagated name still surfaces an address.
174
+ const records = await Bun.dns.lookup(hostname, { family: 0 });
175
+ return records.map((r) => r.address).filter((a) => typeof a === "string" && a.length > 0);
176
+ } catch {
177
+ return [];
178
+ }
179
+ };
180
+
181
+ /**
182
+ * Cloudflare's published anycast IPv4 ranges (the proxy edge). A proxied
183
+ * (orange-cloud) record — which is what `cloudflared tunnel route dns` creates
184
+ * — resolves to one of these. If the hostname resolves to something *outside*
185
+ * these ranges, it's almost certainly shadowed: a Pages project, an A record,
186
+ * or a grey-cloud CNAME pointing elsewhere. We keep the list to the v4 ranges
187
+ * (the common case) and treat any IPv6 in Cloudflare's 2606:4700::/32 block as
188
+ * Cloudflare too. Source: https://www.cloudflare.com/ips/ (stable for years).
189
+ */
190
+ const CLOUDFLARE_V4_RANGES: ReadonlyArray<readonly [string, number]> = [
191
+ ["173.245.48.0", 20],
192
+ ["103.21.244.0", 22],
193
+ ["103.22.200.0", 22],
194
+ ["103.31.4.0", 22],
195
+ ["141.101.64.0", 18],
196
+ ["108.162.192.0", 18],
197
+ ["190.93.240.0", 20],
198
+ ["188.114.96.0", 20],
199
+ ["197.234.240.0", 22],
200
+ ["198.41.128.0", 17],
201
+ ["162.158.0.0", 15],
202
+ ["104.16.0.0", 13],
203
+ ["104.24.0.0", 14],
204
+ ["172.64.0.0", 13],
205
+ ["131.0.72.0", 22],
206
+ ];
207
+
208
+ function ipv4ToInt(ip: string): number | undefined {
209
+ const parts = ip.split(".");
210
+ if (parts.length !== 4) return undefined;
211
+ let n = 0;
212
+ for (const part of parts) {
213
+ const octet = Number.parseInt(part, 10);
214
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) return undefined;
215
+ n = n * 256 + octet;
216
+ }
217
+ return n >>> 0;
218
+ }
219
+
220
+ /** True if any resolved address belongs to Cloudflare's edge. */
221
+ export function looksLikeCloudflare(addresses: readonly string[]): boolean {
222
+ for (const addr of addresses) {
223
+ // IPv6: Cloudflare's edge lives in 2606:4700::/32.
224
+ if (addr.includes(":")) {
225
+ if (addr.toLowerCase().startsWith("2606:4700")) return true;
226
+ continue;
227
+ }
228
+ const ipInt = ipv4ToInt(addr);
229
+ if (ipInt === undefined) continue;
230
+ for (const [base, bits] of CLOUDFLARE_V4_RANGES) {
231
+ const baseInt = ipv4ToInt(base);
232
+ if (baseInt === undefined) continue;
233
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
234
+ if ((ipInt & mask) === (baseInt & mask)) return true;
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+
89
240
  export interface ExposeCloudflareOpts {
90
241
  runner?: Runner;
91
242
  spawner?: CloudflaredSpawner;
92
243
  alive?: AliveFn;
93
244
  kill?: KillFn;
245
+ /**
246
+ * Find every running cloudflared connector PID serving this tunnel (by UUID
247
+ * or config-path match). Used to sweep orphan connectors before spawning a
248
+ * fresh one (hub#487). Tests inject a stub; production uses
249
+ * `defaultConnectorPids` (a filtered `pgrep -fl cloudflared`).
250
+ */
251
+ connectorPids?: ConnectorPidsFn;
252
+ /**
253
+ * Resolve a hostname to its addresses, for the post-route DNS self-diagnosis
254
+ * (hub#487). Returns the resolved IPs (empty when NXDOMAIN / not yet live).
255
+ * Best-effort and non-fatal — a failure to resolve never blocks the expose.
256
+ * Tests inject a stub; production uses `defaultResolveHost` (Bun DNS).
257
+ */
258
+ resolveHost?: ResolveHostFn;
94
259
  log?: (line: string) => void;
95
260
  manifestPath?: string;
96
261
  statePath?: string;
262
+ /**
263
+ * Path to `expose-state.json` — the shared cross-provider expose record the
264
+ * Tailscale path also writes (`expose.ts`). Distinct from `statePath`
265
+ * (cloudflared-state.json, the per-tunnel process record). The cloudflare
266
+ * up-path writes this so downstream consumers (`resolveAdminUrl` in init,
267
+ * `resolveHubOrigin` in lifecycle / auth) see the public URL instead of
268
+ * loopback; the off-path clears it. Defaults to `EXPOSE_STATE_PATH`.
269
+ */
270
+ exposeStatePath?: string;
97
271
  /**
98
272
  * Tunnel name targeted by this invocation. Defaults to `parachute` —
99
273
  * the canonical single-tunnel name. Override to run multiple tunnels on
@@ -112,6 +286,37 @@ export interface ExposeCloudflareOpts {
112
286
  logPath?: string;
113
287
  /** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
114
288
  cloudflaredHome?: string;
289
+ /**
290
+ * Config root for hub PID / port / log files. Defaults to `~/.parachute`.
291
+ * Threaded into `ensureHubRunning` so cloudflared's ingress target stays
292
+ * in sync with where the hub actually bound.
293
+ */
294
+ configDir?: string;
295
+ /**
296
+ * Override the public hub origin (the `iss` claim baked into the OAuth
297
+ * issuer). Mirrors the Tailscale path — when set, this URL is what the
298
+ * hub advertises rather than the cloudflared hostname.
299
+ */
300
+ hubOrigin?: string;
301
+ /**
302
+ * Overrides for hub lifecycle — primarily for tests. Tests pass
303
+ * `skipHubLifecycle: true` (above) plus a seeded `hub.port` file so the
304
+ * cloudflare path can resolve a port without actually spawning a hub.
305
+ */
306
+ hubEnsureOpts?: Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log">;
307
+ /**
308
+ * Directory holding hub.html (passed through to the hub server on first
309
+ * spawn). Defaults to the same `well-known/` resolution the Tailscale
310
+ * path uses.
311
+ */
312
+ wellKnownDir?: string;
313
+ /**
314
+ * Skip spawning the hub server. Tests flip this on and pre-seed
315
+ * `<configDir>/hub/run/hub.port` so `readHubPort` can resolve the
316
+ * cloudflared target without a live process. Production always leaves
317
+ * this off so the bringup self-heals a missing hub.
318
+ */
319
+ skipHub?: boolean;
115
320
  now?: () => Date;
116
321
  /**
117
322
  * Override `~/.parachute/vault` for the 2FA-enrollment probe. Tests
@@ -125,6 +330,14 @@ export interface ExposeCloudflareOpts {
125
330
  * `<vaultHome>/config.yaml` from disk. (#186)
126
331
  */
127
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>;
128
341
  }
129
342
 
130
343
  interface Resolved {
@@ -132,36 +345,75 @@ interface Resolved {
132
345
  spawner: CloudflaredSpawner;
133
346
  alive: AliveFn;
134
347
  kill: KillFn;
348
+ connectorPids: ConnectorPidsFn;
349
+ resolveHost: ResolveHostFn;
135
350
  log: (line: string) => void;
136
351
  manifestPath: string;
137
352
  statePath: string;
353
+ exposeStatePath: string;
138
354
  tunnelName: string;
139
355
  configPath: string;
140
356
  logPath: string;
141
357
  cloudflaredHome: string;
358
+ configDir: string;
359
+ hubOrigin: string | undefined;
360
+ hubEnsureOpts: Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log">;
361
+ wellKnownDir: string;
362
+ skipHub: boolean;
142
363
  now: () => Date;
143
364
  vaultHome: string | undefined;
144
365
  vaultAuthStatus: VaultAuthStatus | undefined;
366
+ restartService: (short: string) => Promise<number>;
145
367
  }
146
368
 
147
369
  function resolve(opts: ExposeCloudflareOpts): Resolved {
148
370
  const tunnelName = opts.tunnelName ?? DEFAULT_TUNNEL_NAME;
149
- const paths = cloudflaredPathsFor(tunnelName);
371
+ const configDir = opts.configDir ?? CONFIG_DIR;
372
+ // Derive per-tunnel config/log paths from the *resolved* configDir, not the
373
+ // real `CONFIG_DIR`. When a test threads a tmp `configDir` but omits explicit
374
+ // `configPath`/`logPath`, this keeps the derived files inside the tmp dir
375
+ // instead of writing fixtures into the operator's real ~/.parachute.
376
+ const paths = cloudflaredPathsFor(tunnelName, configDir);
150
377
  return {
151
378
  runner: opts.runner ?? defaultRunner,
152
379
  spawner: opts.spawner ?? defaultCloudflaredSpawner,
153
380
  alive: opts.alive ?? defaultAlive,
154
381
  kill: opts.kill ?? defaultKill,
382
+ // Defaulting policy mirrors lifecycle's startReadyMs (hub#487): the real
383
+ // implementations shell out (`pgrep`) / hit the network (DNS). When a test
384
+ // injects a fake `spawner` but no explicit seam, fall back to inert stubs
385
+ // (no orphans found; "resolves at Cloudflare" → no DNS warning) so suites
386
+ // stay deterministic and offline. Production (no spawner override) always
387
+ // gets the real `pgrep` sweep + DNS diagnosis.
388
+ connectorPids:
389
+ opts.connectorPids ?? (opts.spawner === undefined ? defaultConnectorPids : () => []),
390
+ resolveHost:
391
+ opts.resolveHost ??
392
+ (opts.spawner === undefined ? defaultResolveHost : async () => ["104.16.0.1"]),
155
393
  log: opts.log ?? ((line) => console.log(line)),
156
394
  manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
157
395
  statePath: opts.statePath ?? CLOUDFLARED_STATE_PATH,
396
+ exposeStatePath: opts.exposeStatePath ?? EXPOSE_STATE_PATH,
158
397
  tunnelName,
159
398
  configPath: opts.configPath ?? paths.configPath,
160
399
  logPath: opts.logPath ?? paths.logPath,
161
400
  cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
401
+ configDir,
402
+ hubOrigin: opts.hubOrigin,
403
+ hubEnsureOpts: opts.hubEnsureOpts ?? {},
404
+ wellKnownDir: opts.wellKnownDir ?? WELL_KNOWN_DIR,
405
+ skipHub: opts.skipHub ?? false,
162
406
  now: opts.now ?? (() => new Date()),
163
407
  vaultHome: opts.vaultHome,
164
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
+ })),
165
417
  };
166
418
  }
167
419
 
@@ -174,19 +426,65 @@ function printAuthGuidance(log: (line: string) => void, vaultUrl: string): void
174
426
  log("Pick the path that matches how you'll reach it:");
175
427
  log("");
176
428
  log(" Humans (claude.ai / ChatGPT connectors, browser):");
177
- log(" parachute auth set-password # set an owner password");
178
- log(" parachute auth 2fa enroll # (recommended) TOTP + backup codes");
429
+ log(" parachute auth set-password # set a STRONG owner password");
430
+ log(" parachute auth 2fa enroll # add a second factor (recommended)");
431
+ log(" # (or set 2FA up in the browser at /account/2fa for a scannable QR)");
179
432
  log(" then point your connector at:");
180
433
  log(` ${vaultUrl}`);
181
434
  log("");
182
- log(" Scripts / machines:");
183
- log(" parachute vault tokens create # creates a pvt_… bearer token");
184
- log(" Authorization: Bearer pvt_… # attach to every request");
435
+ log(" Scripts / machines (hub-issued JWT — set the owner password first):");
436
+ log(" parachute auth mint-token --scope vault:<name>:read # or :write");
437
+ log(" Authorization: Bearer <hub-jwt> # attach the printed token to every request");
438
+ log(" (or: Admin → Vaults → Connect mints one and shows the header for you)");
185
439
  log("");
186
- log("Neither is a prerequisite for the other. Full auth reference:");
440
+ log("The owner password gates both paths browser sign-in and minting tokens.");
441
+ log("Full auth reference:");
187
442
  log(` ${AUTH_DOC_URL}`);
188
443
  }
189
444
 
445
+ /**
446
+ * Best-effort registrable-zone guess: the last two labels of the hostname
447
+ * (`vault.example.com` → `example.com`, `gitcoin.parachute.computer` →
448
+ * `parachute.computer`). This is a heuristic — multi-label public suffixes
449
+ * (`foo.co.uk`) would guess `co.uk` — but it's only used to phrase the
450
+ * `dig +short <zone> NS` remedy, where being off by a label is a harmless
451
+ * nudge. We don't ship a full public-suffix list for one warning string.
452
+ */
453
+ function guessZone(hostname: string): string {
454
+ const labels = hostname.split(".").filter((l) => l.length > 0);
455
+ if (labels.length <= 2) return hostname;
456
+ return labels.slice(-2).join(".");
457
+ }
458
+
459
+ /**
460
+ * Non-fatal post-route DNS diagnosis. Resolves `hostname` and warns when the
461
+ * result looks wrong — see the call site for the two symptoms this addresses.
462
+ * Never throws (resolveHost swallows its own errors) and never changes the
463
+ * exit code; the worst case is no output.
464
+ */
465
+ async function diagnoseDns(hostname: string, r: Resolved): Promise<void> {
466
+ const zone = guessZone(hostname);
467
+ const addresses = await r.resolveHost(hostname);
468
+ if (addresses.length === 0) {
469
+ r.log("");
470
+ r.log(`⚠ DNS isn't live yet for ${hostname}.`);
471
+ r.log(` If ${zone} is a new Cloudflare zone, its nameservers may not be switched at your`);
472
+ r.log(" registrar yet. Check with:");
473
+ r.log(` dig +short ${zone} NS # should list *.ns.cloudflare.com`);
474
+ r.log(" Propagation can take minutes to hours. The tunnel itself is up — the URLs below");
475
+ r.log(" will start working once DNS resolves.");
476
+ return;
477
+ }
478
+ if (!looksLikeCloudflare(addresses)) {
479
+ r.log("");
480
+ r.log(`⚠ ${hostname} resolves (${addresses.join(", ")}) but not to Cloudflare's edge.`);
481
+ r.log(` It may be shadowed by another DNS record or a Cloudflare Pages project on ${zone}.`);
482
+ r.log(" Ensure it's a proxied (orange-cloud) CNAME to the tunnel — check");
483
+ r.log(` https://dash.cloudflare.com → DNS for ${zone}. A grey-cloud / A record / Pages`);
484
+ r.log(" binding on this hostname will 404 the tunnel at the edge.");
485
+ }
486
+ }
487
+
190
488
  export async function exposeCloudflareUp(
191
489
  hostname: string,
192
490
  opts: ExposeCloudflareOpts = {},
@@ -239,6 +537,46 @@ export async function exposeCloudflareUp(
239
537
  return 1;
240
538
  }
241
539
 
540
+ // Resolve the public hub origin before spawning the hub server — it gets
541
+ // baked into the OAuth `iss` claim via the `--issuer` flag. For Cloudflare
542
+ // ingress the canonical origin is the user-supplied hostname (mirrors the
543
+ // Tailscale Funnel path which uses the tailnet FQDN). Falling back to the
544
+ // request origin would put `http://127.0.0.1:<port>` in tokens, which any
545
+ // client following RFC 8414 would reject.
546
+ const canonicalOrigin = `https://${hostname}`;
547
+ const hubOrigin =
548
+ deriveHubOrigin({ override: r.hubOrigin, exposeFqdn: hostname }) ?? canonicalOrigin;
549
+
550
+ // Ensure the hub is running and figure out the loopback port cloudflared
551
+ // should target. The hub does all internal routing (discovery, admin,
552
+ // OAuth, well-known, per-vault proxy, generic /<svc>/* dispatch) — same
553
+ // shape the Tailscale Funnel path uses (see `planEntries` in expose.ts).
554
+ // Pre-2026-05-27 the cloudflared config routed straight at vault's port,
555
+ // so a public URL like https://gitcoin.parachute.computer/ returned 404
556
+ // from vault itself instead of the hub's discovery page; admin / OAuth
557
+ // were unreachable. Aaron hit this on a fresh EC2 install.
558
+ let hubPort: number;
559
+ if (r.skipHub) {
560
+ const existing = readHubPort(r.configDir);
561
+ if (existing === undefined) {
562
+ throw new Error("skipHub set but no hub.port on disk — tests must seed one");
563
+ }
564
+ hubPort = existing;
565
+ } else {
566
+ const hub = await ensureHubRunning({
567
+ reservedPorts: manifest.services.map((s) => s.port),
568
+ ...r.hubEnsureOpts,
569
+ configDir: r.configDir,
570
+ wellKnownDir: r.wellKnownDir,
571
+ issuer: hubOrigin,
572
+ log: r.log,
573
+ });
574
+ hubPort = hub.port;
575
+ if (hub.started) r.log(`✓ hub started (pid ${hub.pid}, port ${hub.port}).`);
576
+ else r.log(`✓ hub already running (pid ${hub.pid}, port ${hub.port}).`);
577
+ }
578
+ if (hubPort === 0) hubPort = HUB_DEFAULT_PORT;
579
+
242
580
  let tunnel: Tunnel | undefined;
243
581
  try {
244
582
  tunnel = await findTunnelByName(r.runner, r.tunnelName);
@@ -276,24 +614,59 @@ export async function exposeCloudflareUp(
276
614
  }
277
615
  r.log("✓ DNS routed.");
278
616
 
617
+ // Post-route DNS self-diagnosis (hub#487). `cloudflared tunnel route dns`
618
+ // can succeed (the CNAME is written in Cloudflare's API) while the hostname
619
+ // is still NOT actually serving the tunnel — two shapes Aaron hit:
620
+ // (a) a "pending" zone whose nameservers aren't switched at the registrar
621
+ // yet, so the record exists in Cloudflare but nothing resolves; and
622
+ // (b) a subdomain shadowed by a Cloudflare Pages project on the same zone,
623
+ // so the edge 404s the tunnel.
624
+ // Both previously printed "✓ DNS routed" + the URLs as if fine. This check
625
+ // is best-effort and strictly NON-FATAL — it only adds a warning; it never
626
+ // changes the exit code or blocks the expose. Fast: one DNS lookup with a
627
+ // built-in timeout in `resolveHost`.
628
+ await diagnoseDns(hostname, r);
629
+
279
630
  const credsFile = credentialsPath(tunnel.id, r.cloudflaredHome);
280
631
  writeConfig(
281
632
  {
282
633
  tunnelUuid: tunnel.id,
283
634
  credentialsFile: credsFile,
284
635
  hostname,
285
- servicePort: vaultEntry.port,
636
+ // Route into the hub, not vault directly. The hub dispatches
637
+ // discovery / admin / OAuth / per-vault proxy / generic /<svc>/*
638
+ // — same shape Tailscale Funnel uses (single mount → hub catchall).
639
+ // Pre-fix this was `vaultEntry.port`, which served vault's own 404
640
+ // page on every request that wasn't /vault/<name>/… — admin SPA and
641
+ // OAuth surfaces were unreachable from the public URL.
642
+ servicePort: hubPort,
286
643
  },
287
644
  r.configPath,
288
645
  );
289
646
  r.log(`✓ Wrote ${r.configPath}`);
290
647
 
648
+ // Orphan-connector sweep (hub#487). Before spawning a fresh connector, kill
649
+ // EVERY cloudflared connector currently serving this tunnel so exactly one
650
+ // process serves the config.yml we just wrote. Pre-fix, each re-expose
651
+ // spawned a new connector without killing the prior ones (state tracked only
652
+ // the most-recent pid), so orphans accumulated and edge routing became
653
+ // nondeterministic. We union two sources:
654
+ // - the pid recorded in cloudflared-state.json (the prior `parachute`-
655
+ // spawned connector for this tunnel name), and
656
+ // - any pid found by scanning running processes for this tunnel's UUID or
657
+ // config path (catches orphans the state file lost track of — crashed
658
+ // mid-rewrite, or started by hand for this tunnel).
291
659
  const stateBefore = readCloudflaredState(r.statePath);
292
660
  const prior = findTunnelRecord(stateBefore, r.tunnelName);
293
- if (prior && r.alive(prior.pid)) {
661
+ const toKill = new Set<number>();
662
+ if (prior && r.alive(prior.pid)) toKill.add(prior.pid);
663
+ for (const pid of r.connectorPids(tunnel.id, r.configPath)) {
664
+ if (r.alive(pid)) toKill.add(pid);
665
+ }
666
+ for (const deadPid of toKill) {
294
667
  try {
295
- r.kill(prior.pid, "SIGTERM");
296
- r.log(`Stopped prior cloudflared (pid ${prior.pid}).`);
668
+ r.kill(deadPid, "SIGTERM");
669
+ r.log(`Stopped prior cloudflared connector (pid ${deadPid}).`);
297
670
  } catch {
298
671
  // Process is already gone — safe to ignore; we replace the record below.
299
672
  }
@@ -314,6 +687,67 @@ export async function exposeCloudflareUp(
314
687
  };
315
688
  writeCloudflaredState(withTunnelRecord(stateBefore, record), r.statePath);
316
689
 
690
+ // Persist the shared cross-provider expose record. Without this, the
691
+ // Tailscale path was the only one writing expose-state.json — so after a
692
+ // Cloudflare bring-up `readExposeState()` returned undefined and downstream
693
+ // consumers fell back to loopback:
694
+ // - init's `resolveAdminUrl` printed http://127.0.0.1:1939/admin/ instead
695
+ // of the public URL.
696
+ // - lifecycle's `resolveHubOrigin` (and the hub#460 vault `.env`
697
+ // PARACHUTE_HUB_ORIGIN persistence) kept the loopback origin, so vault's
698
+ // OAuth `iss` claim didn't match the public host — the "rejected on
699
+ // reconnect" P0 on Cloudflare deploys.
700
+ // Mode is "subdomain": cloudflared routes the whole FQDN at the hub catchall
701
+ // (one ingress → hub), unlike the Tailscale path's "path" routing. The single
702
+ // proxy entry mirrors the hub-catchall shape the Tailscale Funnel path plans.
703
+ const exposeState: ExposeState = {
704
+ version: 1,
705
+ layer: "public",
706
+ mode: "subdomain",
707
+ canonicalFqdn: hostname,
708
+ port: hubPort,
709
+ funnel: false,
710
+ entries: [
711
+ {
712
+ kind: "proxy",
713
+ mount: "/",
714
+ target: `http://localhost:${hubPort}`,
715
+ service: "hub",
716
+ },
717
+ ],
718
+ hubOrigin,
719
+ };
720
+ writeExposeState(exposeState, r.exposeStatePath);
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
+
317
751
  const baseUrl = `https://${hostname}`;
318
752
  // A well-formed vault manifest always lists at least one mount path. If
319
753
  // it's empty, something went sideways in `parachute install vault` — warn
@@ -329,9 +763,11 @@ export async function exposeCloudflareUp(
329
763
 
330
764
  r.log("");
331
765
  r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
332
- r.log(` URL: ${baseUrl}`);
333
- r.log(` Vault: ${vaultUrl}`);
334
- r.log(` Logs: ${r.logPath}`);
766
+ r.log(` Open: ${baseUrl}/`);
767
+ r.log(` Admin: ${baseUrl}/admin/`);
768
+ r.log(` Vault: ${vaultUrl}`);
769
+ r.log(` OAuth: ${hubOrigin}`);
770
+ r.log(` Logs: ${r.logPath}`);
335
771
  r.log("");
336
772
  r.log("Point a claude.ai / ChatGPT connector at:");
337
773
  r.log(` ${vaultUrl}`);
@@ -376,12 +812,31 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
376
812
  } else {
377
813
  r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
378
814
  }
815
+ // Sweep any orphan connectors for this tunnel that the state record didn't
816
+ // track (hub#487) so `off` leaves exactly zero connectors serving it. Match
817
+ // by UUID/config-path; skip the record pid we already signalled above.
818
+ for (const orphanPid of r.connectorPids(record.tunnelUuid, record.configPath)) {
819
+ if (orphanPid === record.pid || !r.alive(orphanPid)) continue;
820
+ try {
821
+ r.kill(orphanPid, "SIGTERM");
822
+ r.log(`✓ Stopped orphan cloudflared connector (pid ${orphanPid}).`);
823
+ } catch {
824
+ // Already gone between probe and kill — fine.
825
+ }
826
+ }
379
827
  const stateAfter = withoutTunnelRecord(stateBefore, r.tunnelName);
380
828
  if (stateAfter) {
381
829
  writeCloudflaredState(stateAfter, r.statePath);
382
830
  } else {
383
831
  clearCloudflaredState(r.statePath);
384
832
  }
833
+ // Clear the shared expose-state.json when no Cloudflare tunnels remain, so
834
+ // downstream consumers stop resolving the now-dead public URL (mirrors the
835
+ // up-path write above + the Tailscale off-path's expose-state teardown). When
836
+ // other tunnels survive we leave it — a later off for the last one clears it.
837
+ if (!stateAfter) {
838
+ clearExposeState(r.exposeStatePath);
839
+ }
385
840
  r.log(` ${record.hostname} is no longer reachable through this machine.`);
386
841
  r.log(
387
842
  ` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
@@ -13,6 +13,7 @@
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import {
15
15
  DEFAULT_CLOUDFLARED_HOME,
16
+ cloudflaredInstallHint,
16
17
  isCloudflaredInstalled,
17
18
  isCloudflaredLoggedIn,
18
19
  } from "../cloudflare/detect.ts";
@@ -273,19 +274,17 @@ async function guideCloudflareSetup(
273
274
  return false;
274
275
  }
275
276
  } else {
277
+ // 2026-05-27 refresh: distro-package paths (`apt-get`, `dnf`) are
278
+ // unreliable across versions — Aaron hit `No match for argument:
279
+ // cloudflared` on Amazon Linux 2023 — and the
280
+ // pkg.cloudflare.com / developers.cloudflare.com paths the old hint
281
+ // pointed at now serve HTML/404. Defer to `cloudflaredInstallHint`,
282
+ // which writes the canonical GitHub-release static-binary path
283
+ // matching the host's architecture.
276
284
  r.log("");
277
285
  r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
278
- r.log("Install one way:");
279
- r.log(" Debian / Ubuntu:");
280
- r.log(
281
- " curl -L https://pkg.cloudflare.com/install.sh | sudo bash && sudo apt-get install -y cloudflared",
282
- );
283
- r.log(" RHEL / Fedora:");
284
- r.log(" sudo dnf install cloudflared");
285
- r.log(" Tarball / other:");
286
- r.log(
287
- " https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
288
- );
286
+ r.log("");
287
+ for (const line of cloudflaredInstallHint(r.platform).split("\n")) r.log(line);
289
288
  r.log("");
290
289
  r.log("After install, re-run: parachute expose public");
291
290
  return false;