@openparachute/hub 0.3.0-rc.1

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 (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,522 @@
1
+ import { existsSync, unlinkSync } from "node:fs";
2
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
3
+ import {
4
+ EXPOSE_STATE_PATH,
5
+ type ExposeLayer,
6
+ type ExposeState,
7
+ clearExposeState,
8
+ readExposeState,
9
+ writeExposeState,
10
+ } from "../expose-state.ts";
11
+ import {
12
+ type EnsureHubOpts,
13
+ type StopHubOpts,
14
+ defaultPortProbe,
15
+ ensureHubRunning,
16
+ readHubPort,
17
+ stopHub,
18
+ } from "../hub-control.ts";
19
+ import { deriveHubOrigin } from "../hub-origin.ts";
20
+ import { HUB_MOUNT, HUB_PATH, writeHubFile } from "../hub.ts";
21
+ import { type AliveFn, processState } from "../process-state.ts";
22
+ import { effectivePublicExposure, shortNameForManifest } from "../service-spec.ts";
23
+ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
24
+ import { type ServeEntry, bringupCommand, teardownCommand } from "../tailscale/commands.ts";
25
+ import { getFqdn, isTailscaleInstalled } from "../tailscale/detect.ts";
26
+ import { type Runner, defaultRunner } from "../tailscale/run.ts";
27
+ import {
28
+ WELL_KNOWN_DIR,
29
+ WELL_KNOWN_MOUNT,
30
+ WELL_KNOWN_PATH,
31
+ buildWellKnown,
32
+ isVaultEntry,
33
+ shortName,
34
+ vaultInstanceName,
35
+ writeWellKnownFile,
36
+ } from "../well-known.ts";
37
+ import { restart } from "./lifecycle.ts";
38
+
39
+ /**
40
+ * Two exposure layers share a single tailscale serve config on this node.
41
+ * Public layer adds `--funnel` to each handler; everything else is identical.
42
+ *
43
+ * Funnel constraint: Tailscale allows at most three public HTTPS ports per
44
+ * node (443, 8443, 10000). Path-routing packs every service onto a single
45
+ * port — that's why we default to one `--https=443` and mount services under
46
+ * `/vault`, `/notes`, etc. rather than giving each service its own port or
47
+ * subdomain. Subdomain-per-service requires the Tailscale Services feature
48
+ * (virtual-IP advertisement) and is deferred.
49
+ *
50
+ * Hub + well-known entries are HTTP proxies to an internal Bun.serve (see
51
+ * `hub-control.ts`). They used to be `--set-path=<mount> <file>` entries but
52
+ * macOS `tailscaled` runs sandboxed and can't read arbitrary files; proxy
53
+ * mode is the only reliable shape.
54
+ */
55
+
56
+ export interface ExposeOpts {
57
+ runner?: Runner;
58
+ manifestPath?: string;
59
+ statePath?: string;
60
+ wellKnownPath?: string;
61
+ hubPath?: string;
62
+ /** Directory holding hub.html + parachute.json (passed to the hub server). */
63
+ wellKnownDir?: string;
64
+ configDir?: string;
65
+ port?: number;
66
+ log?: (line: string) => void;
67
+ /** Override detected FQDN — primarily for tests. */
68
+ fqdnOverride?: string;
69
+ /** Overrides for the hub lifecycle — primarily for tests. */
70
+ hubEnsureOpts?: Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log">;
71
+ hubStopOpts?: Omit<StopHubOpts, "configDir" | "log">;
72
+ /** Skip spawning the hub server. Tests flip this off to verify it's called. */
73
+ skipHub?: boolean;
74
+ /**
75
+ * Probe a port to decide whether a service is responding. Returns true when
76
+ * something is listening (i.e., bind-probe fails). Primarily a test seam —
77
+ * the default walks every service port before bringup and warns on any
78
+ * that don't answer.
79
+ */
80
+ servicePortProbe?: (port: number) => Promise<boolean>;
81
+ /**
82
+ * Override the computed hub origin. Lets the user pin the OAuth issuer to
83
+ * something other than the detected tailnet FQDN — e.g., a custom domain
84
+ * fronting tailscale funnel, or a staging URL during a migration. Passed
85
+ * through to vault (and future services) via PARACHUTE_HUB_ORIGIN.
86
+ */
87
+ hubOrigin?: string;
88
+ /** Process-liveness check for auto-restart — test seam. */
89
+ alive?: AliveFn;
90
+ /**
91
+ * Restart a service by short name after exposure changes. Defaults to the
92
+ * lifecycle `restart`; tests inject a fake to assert the call without
93
+ * spawning real child processes.
94
+ */
95
+ restartService?: (short: string) => Promise<number>;
96
+ }
97
+
98
+ /**
99
+ * Short names whose running process caches the hub origin (today:
100
+ * PARACHUTE_HUB_ORIGIN → vault's OAuth issuer). `exposeUp` restarts these
101
+ * after writing new expose-state so in-memory state matches what clients see.
102
+ * Hard-coded while vault is the only dependent; a services.json field will
103
+ * generalize this once a second service needs it.
104
+ */
105
+ const HUB_DEPENDENT_SHORTS = ["vault"] as const;
106
+
107
+ /**
108
+ * OAuth paths the hub fronts on behalf of vault (Phase 0: vault implements
109
+ * OAuth, hub owns the public URL). The mount path is what clients see; the
110
+ * target tail is what vault expects. tailscale strips the mount before
111
+ * forwarding, so the target must include vault's `/vault/<name>` prefix to
112
+ * land at the right handler.
113
+ */
114
+ const OAUTH_PATHS = [
115
+ "/.well-known/oauth-authorization-server",
116
+ "/oauth/authorize",
117
+ "/oauth/token",
118
+ "/oauth/register",
119
+ ] as const;
120
+
121
+ /**
122
+ * Single-vault launch assumption: find the first `parachute-vault` entry.
123
+ * Multi-vault OAuth routing is Phase 2+ (design note open-question #4).
124
+ */
125
+ function primaryVault(services: readonly ServiceEntry[]): ServiceEntry | undefined {
126
+ return services.find((s) => isVaultEntry(s));
127
+ }
128
+
129
+ /**
130
+ * Remap legacy `paths: ["/"]` entries to `/<shortname>` so they don't collide
131
+ * with the hub page at `/`. Emits a warning per remapped service. This is the
132
+ * transitional path for services installed before the vault PR that writes
133
+ * `paths: ["/vault/<default>"]` — once `parachute install` is re-run those
134
+ * entries update themselves and this branch goes dormant.
135
+ */
136
+ function remapLegacyRoot(
137
+ services: readonly ServiceEntry[],
138
+ log: (line: string) => void,
139
+ ): ServiceEntry[] {
140
+ return services.map((s) => {
141
+ const first = s.paths[0];
142
+ if (first !== "/") return s;
143
+ const sn = shortName(s.name);
144
+ const remapped = `/${sn}`;
145
+ log(
146
+ `note: ${s.name} claims "/"; hub page lives there — exposing at "${remapped}" instead. Re-run \`parachute install ${sn}\` to update services.json.`,
147
+ );
148
+ return { ...s, paths: [remapped, ...s.paths.slice(1)] };
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Partition services into ones that will be mounted on the layer versus ones
154
+ * that stay loopback-only. "allowed" services go on the serve plan; every
155
+ * other effective exposure state (explicit loopback, explicit auth-required,
156
+ * spec-default auth-required) is withheld. Hidden services still appear in
157
+ * services.json so on-box callers reach them at http://127.0.0.1:<port>.
158
+ */
159
+ interface ExposurePartition {
160
+ exposed: ServiceEntry[];
161
+ hidden: Array<{ entry: ServiceEntry; reason: string }>;
162
+ }
163
+
164
+ function partitionByExposure(services: readonly ServiceEntry[]): ExposurePartition {
165
+ const exposed: ServiceEntry[] = [];
166
+ const hidden: Array<{ entry: ServiceEntry; reason: string }> = [];
167
+ for (const s of services) {
168
+ const eff = effectivePublicExposure(s);
169
+ if (eff === "allowed") {
170
+ exposed.push(s);
171
+ continue;
172
+ }
173
+ // Explicit declaration tells the user exactly what the service asked for;
174
+ // a spec-derived default points at the usual cause (no auth configured).
175
+ let reason: string;
176
+ if (s.publicExposure === "loopback") {
177
+ reason = "loopback-only by service declaration";
178
+ } else if (s.publicExposure === "auth-required") {
179
+ reason = "auth-required: service reports auth is not yet configured";
180
+ } else {
181
+ reason = "auth-required: service has no auth gate — set the service's auth token to expose";
182
+ }
183
+ hidden.push({ entry: s, reason });
184
+ }
185
+ return { exposed, hidden };
186
+ }
187
+
188
+ /**
189
+ * Compose the tailscale serve target URL for a service rooted at `mount`.
190
+ *
191
+ * `tailscale serve --set-path=<mount> <target>` strips `<mount>` from the
192
+ * incoming request path before forwarding. So if the backend expects
193
+ * requests to keep arriving at `<mount>/...` (every SPA with a configured
194
+ * base path, plus vault's `/vault/<name>/` API root) the target URL must
195
+ * include the same mount path — otherwise the backend sees requests at `/`,
196
+ * emits a redirect back to its real base, tailscale strips again, and the
197
+ * client loops on `ERR_TOO_MANY_REDIRECTS`.
198
+ *
199
+ * The rule of thumb is: mount and target path must match byte-for-byte
200
+ * (including trailing slash state), so tailscale's strip-then-forward is a
201
+ * no-op and the backend sees the full path it expects.
202
+ */
203
+ function serviceProxyTarget(port: number, mount: string): string {
204
+ return `http://127.0.0.1:${port}${mount}`;
205
+ }
206
+
207
+ function planEntries(services: readonly ServiceEntry[], hubPort: number): ServeEntry[] {
208
+ const entries: ServeEntry[] = [];
209
+ entries.push({
210
+ kind: "proxy",
211
+ mount: HUB_MOUNT,
212
+ target: serviceProxyTarget(hubPort, HUB_MOUNT),
213
+ service: "hub",
214
+ });
215
+ for (const s of services) {
216
+ const mount = s.paths[0] ?? `/${shortName(s.name)}`;
217
+ entries.push({
218
+ kind: "proxy",
219
+ mount,
220
+ target: serviceProxyTarget(s.port, mount),
221
+ service: s.name,
222
+ });
223
+ }
224
+ entries.push({
225
+ kind: "proxy",
226
+ mount: WELL_KNOWN_MOUNT,
227
+ target: serviceProxyTarget(hubPort, WELL_KNOWN_MOUNT),
228
+ service: "well-known",
229
+ });
230
+
231
+ // Phase 0 OAuth seam: hub origin owns the public OAuth URLs; vault owns
232
+ // the implementation. When vault is installed, mount the four endpoints
233
+ // at the hub origin and proxy them into vault's `/vault/<name>/oauth/*`.
234
+ const vault = primaryVault(services);
235
+ if (vault) {
236
+ const vaultMount = vault.paths[0] ?? `/vault/${vaultInstanceName(vault)}`;
237
+ const vaultBase = vaultMount.replace(/\/$/, "");
238
+ for (const oauthPath of OAUTH_PATHS) {
239
+ entries.push({
240
+ kind: "proxy",
241
+ mount: oauthPath,
242
+ target: `http://127.0.0.1:${vault.port}${vaultBase}${oauthPath}`,
243
+ service: `${vault.name}:oauth`,
244
+ });
245
+ }
246
+ }
247
+ return entries;
248
+ }
249
+
250
+ async function runEach(
251
+ runner: Runner,
252
+ commands: string[][],
253
+ log: (line: string) => void,
254
+ ): Promise<number> {
255
+ for (const cmd of commands) {
256
+ log(` $ ${cmd.join(" ")}`);
257
+ const { code, stderr } = await runner(cmd);
258
+ if (code !== 0) {
259
+ if (stderr.trim()) log(stderr.trim());
260
+ return code;
261
+ }
262
+ }
263
+ return 0;
264
+ }
265
+
266
+ /**
267
+ * Tailscale's `serve/funnel … off` exits non-zero with stderr like
268
+ * `error: failed to remove web serve: handler does not exist` when the entry
269
+ * is already absent from tailscale's state. This happens when the user ran
270
+ * `tailscale funnel reset` externally, tailscaled restarted and dropped
271
+ * ephemeral state, or a prior teardown partially succeeded. From the user's
272
+ * perspective `off` is idempotent — the goal is "this handler is gone" and
273
+ * it already is. Match the narrow `does not exist` phrase; real errors
274
+ * (auth, daemon down) don't include it and still abort.
275
+ */
276
+ function teardownAlreadyGone(stderr: string): boolean {
277
+ return stderr.toLowerCase().includes("does not exist");
278
+ }
279
+
280
+ /**
281
+ * Like `runEach` but tolerant of already-gone entries. Each command that
282
+ * fails with a "does not exist" stderr is logged and skipped; any other
283
+ * non-zero exit still aborts so real failures surface.
284
+ */
285
+ async function runTeardown(
286
+ runner: Runner,
287
+ commands: string[][],
288
+ log: (line: string) => void,
289
+ ): Promise<number> {
290
+ for (const cmd of commands) {
291
+ log(` $ ${cmd.join(" ")}`);
292
+ const { code, stderr } = await runner(cmd);
293
+ if (code === 0) continue;
294
+ if (teardownAlreadyGone(stderr)) {
295
+ const firstLine = stderr.trim().split("\n")[0] ?? "already gone";
296
+ log(` (already gone — ${firstLine})`);
297
+ continue;
298
+ }
299
+ if (stderr.trim()) log(stderr.trim());
300
+ return code;
301
+ }
302
+ return 0;
303
+ }
304
+
305
+ function layerLabel(layer: ExposeLayer): string {
306
+ return layer === "public" ? "Public (Funnel)" : "Tailnet";
307
+ }
308
+
309
+ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promise<number> {
310
+ const runner = opts.runner ?? defaultRunner;
311
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
312
+ const statePath = opts.statePath ?? EXPOSE_STATE_PATH;
313
+ const wellKnownFilePath = opts.wellKnownPath ?? WELL_KNOWN_PATH;
314
+ const hubFilePath = opts.hubPath ?? HUB_PATH;
315
+ const wellKnownDir = opts.wellKnownDir ?? WELL_KNOWN_DIR;
316
+ const configDir = opts.configDir ?? CONFIG_DIR;
317
+ const port = opts.port ?? 443;
318
+ const log = opts.log ?? ((line) => console.log(line));
319
+ const funnel = layer === "public";
320
+
321
+ if (!(await isTailscaleInstalled(runner))) {
322
+ log("tailscale is not installed or not on PATH.");
323
+ log("Install from https://tailscale.com/download and run `tailscale up`.");
324
+ return 1;
325
+ }
326
+
327
+ const manifest = readManifest(manifestPath);
328
+ if (manifest.services.length === 0) {
329
+ log("No services installed yet. Try: parachute install vault");
330
+ return 1;
331
+ }
332
+
333
+ const fqdn = opts.fqdnOverride ?? (await getFqdn(runner));
334
+ const canonicalOrigin = `https://${fqdn}`;
335
+
336
+ const prior = readExposeState(statePath);
337
+ if (prior && prior.entries.length > 0) {
338
+ const priorLabel = layerLabel(prior.layer);
339
+ log(`Found prior ${priorLabel} exposure; tearing down ${prior.entries.length} entries first…`);
340
+ const teardownCmds = prior.entries.map((e) =>
341
+ teardownCommand(e, { port: prior.port, funnel: prior.funnel }),
342
+ );
343
+ const code = await runTeardown(runner, teardownCmds, log);
344
+ if (code !== 0) {
345
+ log("Teardown of prior state failed; aborting.");
346
+ return code;
347
+ }
348
+ }
349
+
350
+ const allServices = remapLegacyRoot(manifest.services, log);
351
+ // Split out loopback/auth-required services before planning the serve routes.
352
+ // Hidden services keep their /127.0.0.1:<port> accessibility for on-box
353
+ // callers (e.g., vault's transcription-worker dialing scribe); they just
354
+ // don't land on tailnet/funnel.
355
+ const { exposed: services, hidden } = partitionByExposure(allServices);
356
+
357
+ /**
358
+ * Probe each service port before wiring tailscale up. A service that's
359
+ * quietly stopped would otherwise get proxied for silent 502s. Warn and
360
+ * continue — users sometimes expose paths ahead of starting a service,
361
+ * and we don't want probe flakes to block bringup.
362
+ */
363
+ const portProbe = opts.servicePortProbe ?? (async (p: number) => !(await defaultPortProbe(p)));
364
+ const probeResults = await Promise.all(
365
+ services.map(async (s) => ({ svc: s, up: await portProbe(s.port) })),
366
+ );
367
+ for (const { svc, up } of probeResults) {
368
+ if (up) continue;
369
+ const short = shortNameForManifest(svc.name) ?? svc.name;
370
+ log(
371
+ `⚠ ${svc.name} (port ${svc.port}) is not responding; its path will proxy to a dead port. Run \`parachute start ${short}\`.`,
372
+ );
373
+ }
374
+
375
+ const wellKnownDoc = buildWellKnown({ services, canonicalOrigin });
376
+ writeWellKnownFile(wellKnownDoc, wellKnownFilePath);
377
+ log(`Wrote ${wellKnownFilePath}`);
378
+ writeHubFile(hubFilePath);
379
+ log(`Wrote ${hubFilePath}`);
380
+
381
+ let hubPort: number;
382
+ if (opts.skipHub) {
383
+ const existing = readHubPort(configDir);
384
+ if (existing === undefined) {
385
+ throw new Error("skipHub set but no hub.port on disk — tests must seed one");
386
+ }
387
+ hubPort = existing;
388
+ } else {
389
+ const hub = await ensureHubRunning({
390
+ reservedPorts: services.map((s) => s.port),
391
+ ...(opts.hubEnsureOpts ?? {}),
392
+ configDir,
393
+ wellKnownDir,
394
+ log,
395
+ });
396
+ hubPort = hub.port;
397
+ if (hub.started) log(`✓ hub started (pid ${hub.pid}, port ${hub.port}).`);
398
+ else log(`✓ hub already running (pid ${hub.pid}, port ${hub.port}).`);
399
+ }
400
+
401
+ const entries = planEntries(services, hubPort);
402
+ log(`Exposing under ${canonicalOrigin} (${layerLabel(layer)}, path-routing, port ${port}):`);
403
+ for (const e of entries) {
404
+ const suffix = e.kind === "proxy" ? `→ ${e.target} (${e.service})` : `→ ${e.target}`;
405
+ log(` ${e.mount.padEnd(30, " ")} ${suffix}`);
406
+ }
407
+ for (const { entry: hiddenSvc, reason } of hidden) {
408
+ log(` (${hiddenSvc.name} is loopback-only — ${reason})`);
409
+ }
410
+
411
+ const cmds = entries.map((e) => bringupCommand(e, { port, funnel }));
412
+ const code = await runEach(runner, cmds, log);
413
+ if (code !== 0) {
414
+ log("Bringup failed; see error above. Prior tailscale state may be partially applied.");
415
+ return code;
416
+ }
417
+
418
+ const hubOrigin =
419
+ deriveHubOrigin({ override: opts.hubOrigin, exposeFqdn: fqdn }) ?? canonicalOrigin;
420
+ const state: ExposeState = {
421
+ version: 1,
422
+ layer,
423
+ mode: "path",
424
+ canonicalFqdn: fqdn,
425
+ port,
426
+ funnel,
427
+ entries,
428
+ hubOrigin,
429
+ };
430
+ writeExposeState(state, statePath);
431
+
432
+ log("");
433
+ if (layer === "public") {
434
+ log(`✓ Public exposure active (Funnel). Open: ${canonicalOrigin}/`);
435
+ log(" This node is reachable from the public internet.");
436
+ } else {
437
+ log(`✓ Tailnet exposure active. Open: ${canonicalOrigin}/`);
438
+ }
439
+ log(` Discovery: ${canonicalOrigin}${WELL_KNOWN_MOUNT}`);
440
+ if (primaryVault(services)) {
441
+ log(` OAuth issuer: ${hubOrigin}`);
442
+ }
443
+
444
+ // Auto-restart services that cache the hub origin. Aaron hit this on launch
445
+ // day: after `expose public` first-run, vault kept its stale (loopback)
446
+ // PARACHUTE_HUB_ORIGIN, the OAuth issuer didn't match what clients saw, and
447
+ // claude.ai MCP failed with a cryptic "Couldn't reach the MCP server". The
448
+ // old output told the user to restart manually; it got buried in the wall
449
+ // of expose output. Do the restart ourselves.
450
+ const doRestart =
451
+ opts.restartService ?? ((short: string) => restart(short, { manifestPath, configDir, log }));
452
+ for (const short of HUB_DEPENDENT_SHORTS) {
453
+ if (processState(short, configDir, opts.alive).status !== "running") continue;
454
+ log("");
455
+ log(`Restarting ${short} to pick up new hub origin…`);
456
+ const rcode = await doRestart(short);
457
+ if (rcode !== 0) {
458
+ log(
459
+ `⚠ ${short} restart failed. Run manually once the issue is resolved: parachute restart ${short}`,
460
+ );
461
+ }
462
+ }
463
+ return 0;
464
+ }
465
+
466
+ export async function exposeOff(layer: ExposeLayer, opts: ExposeOpts = {}): Promise<number> {
467
+ const runner = opts.runner ?? defaultRunner;
468
+ const statePath = opts.statePath ?? EXPOSE_STATE_PATH;
469
+ const wellKnownFilePath = opts.wellKnownPath ?? WELL_KNOWN_PATH;
470
+ const hubFilePath = opts.hubPath ?? HUB_PATH;
471
+ const configDir = opts.configDir ?? CONFIG_DIR;
472
+ const log = opts.log ?? ((line) => console.log(line));
473
+
474
+ const state = readExposeState(statePath);
475
+ if (!state || state.entries.length === 0) {
476
+ log(`No ${layerLabel(layer)} exposure recorded. Nothing to tear down.`);
477
+ return 0;
478
+ }
479
+ if (state.layer !== layer) {
480
+ log(`No ${layerLabel(layer)} exposure recorded.`);
481
+ log(`Current exposure is ${layerLabel(state.layer)}.`);
482
+ log(`Run: parachute expose ${state.layer} off`);
483
+ return 0;
484
+ }
485
+
486
+ log(`Tearing down ${state.entries.length} ${layerLabel(layer)} serve entries…`);
487
+ const cmds = state.entries.map((e) =>
488
+ teardownCommand(e, { port: state.port, funnel: state.funnel }),
489
+ );
490
+ const code = await runTeardown(runner, cmds, log);
491
+ if (code !== 0) {
492
+ log("Teardown failed. State file left in place so you can retry.");
493
+ return code;
494
+ }
495
+
496
+ clearExposeState(statePath);
497
+ if (existsSync(wellKnownFilePath)) {
498
+ unlinkSync(wellKnownFilePath);
499
+ }
500
+ if (existsSync(hubFilePath)) {
501
+ unlinkSync(hubFilePath);
502
+ }
503
+
504
+ // Hub lives only as long as some layer is exposed. State was just cleared,
505
+ // so no layer is active — stop the hub. (Layer switch doesn't go through
506
+ // here; that path reuses the running hub.)
507
+ if (!opts.skipHub) {
508
+ const stopped = await stopHub({ ...(opts.hubStopOpts ?? {}), configDir, log });
509
+ if (stopped) log("✓ hub stopped.");
510
+ }
511
+
512
+ log(`✓ ${layerLabel(layer)} exposure removed.`);
513
+ return 0;
514
+ }
515
+
516
+ export async function exposeTailnet(action: "up" | "off", opts: ExposeOpts = {}): Promise<number> {
517
+ return action === "off" ? exposeOff("tailnet", opts) : exposeUp("tailnet", opts);
518
+ }
519
+
520
+ export async function exposePublic(action: "up" | "off", opts: ExposeOpts = {}): Promise<number> {
521
+ return action === "off" ? exposeOff("public", opts) : exposeUp("public", opts);
522
+ }