@openparachute/hub 0.3.0-rc.1 → 0.5.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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -1,11 +1,6 @@
1
1
  import { mkdirSync, openSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- import {
5
- CLOUDFLARED_CONFIG_PATH,
6
- CLOUDFLARED_LOG_PATH,
7
- writeConfig,
8
- } from "../cloudflare/config.ts";
2
+ import { dirname } from "node:path";
3
+ import { DEFAULT_TUNNEL_NAME, cloudflaredPathsFor, writeConfig } from "../cloudflare/config.ts";
9
4
  import {
10
5
  DEFAULT_CLOUDFLARED_HOME,
11
6
  cloudflaredInstallHint,
@@ -14,9 +9,13 @@ import {
14
9
  } from "../cloudflare/detect.ts";
15
10
  import {
16
11
  CLOUDFLARED_STATE_PATH,
17
- type CloudflaredState,
12
+ type CloudflaredTunnelRecord,
18
13
  clearCloudflaredState,
14
+ findTunnelRecord,
15
+ listTunnelRecords,
19
16
  readCloudflaredState,
17
+ withTunnelRecord,
18
+ withoutTunnelRecord,
20
19
  writeCloudflaredState,
21
20
  } from "../cloudflare/state.ts";
22
21
  import {
@@ -32,19 +31,21 @@ import { type AliveFn, defaultAlive } from "../process-state.ts";
32
31
  import { readManifest } from "../services-manifest.ts";
33
32
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
34
33
 
35
- /**
36
- * Single canonical tunnel name reused across runs. Creating fresh tunnels
37
- * per invocation would leave orphaned tunnels in the user's Cloudflare
38
- * account every time they rotated hostnames; re-use keeps that clean.
39
- *
40
- * If someone needs multiple tunnels on one box (dev + prod, two domains),
41
- * we'll add `--tunnel-name` later. Single-tunnel covers the launch use case.
42
- */
43
- const TUNNEL_NAME = "parachute";
44
-
45
34
  const AUTH_DOC_URL =
46
35
  "https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
47
36
 
37
+ /**
38
+ * Tunnel-name validation. We mirror the conservative shape Cloudflare itself
39
+ * uses for tunnel identifiers — alphanumerics, hyphens, underscores — and
40
+ * keep it short enough to fit in a path segment without surprising the
41
+ * filesystem (e.g. macOS encoded NFC quirks). Anything more permissive would
42
+ * just push validation work onto the cloudflared binary.
43
+ */
44
+ export function isValidTunnelName(name: string): boolean {
45
+ if (name.length === 0 || name.length > 64) return false;
46
+ return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name);
47
+ }
48
+
48
49
  /**
49
50
  * Hostname validation — permissive by design. We reject the obviously broken
50
51
  * shapes (empty, missing dot, label containing `/` or whitespace) and let
@@ -86,9 +87,21 @@ export interface ExposeCloudflareOpts {
86
87
  log?: (line: string) => void;
87
88
  manifestPath?: string;
88
89
  statePath?: string;
89
- /** Path to the cloudflared config.yml this invocation writes. */
90
+ /**
91
+ * Tunnel name targeted by this invocation. Defaults to `parachute` —
92
+ * the canonical single-tunnel name. Override to run multiple tunnels on
93
+ * one box (#32).
94
+ */
95
+ tunnelName?: string;
96
+ /**
97
+ * Path to the cloudflared config.yml this invocation writes. Defaults to
98
+ * the per-tunnel layout `~/.parachute/cloudflared/<tunnelName>/config.yml`.
99
+ */
90
100
  configPath?: string;
91
- /** Path to the log file the spawned cloudflared appends to. */
101
+ /**
102
+ * Path to the log file the spawned cloudflared appends to. Defaults to
103
+ * the per-tunnel layout `~/.parachute/cloudflared/<tunnelName>/cloudflared.log`.
104
+ */
92
105
  logPath?: string;
93
106
  /** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
94
107
  cloudflaredHome?: string;
@@ -103,6 +116,7 @@ interface Resolved {
103
116
  log: (line: string) => void;
104
117
  manifestPath: string;
105
118
  statePath: string;
119
+ tunnelName: string;
106
120
  configPath: string;
107
121
  logPath: string;
108
122
  cloudflaredHome: string;
@@ -110,6 +124,8 @@ interface Resolved {
110
124
  }
111
125
 
112
126
  function resolve(opts: ExposeCloudflareOpts): Resolved {
127
+ const tunnelName = opts.tunnelName ?? DEFAULT_TUNNEL_NAME;
128
+ const paths = cloudflaredPathsFor(tunnelName);
113
129
  return {
114
130
  runner: opts.runner ?? defaultRunner,
115
131
  spawner: opts.spawner ?? defaultCloudflaredSpawner,
@@ -118,8 +134,9 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
118
134
  log: opts.log ?? ((line) => console.log(line)),
119
135
  manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
120
136
  statePath: opts.statePath ?? CLOUDFLARED_STATE_PATH,
121
- configPath: opts.configPath ?? CLOUDFLARED_CONFIG_PATH,
122
- logPath: opts.logPath ?? CLOUDFLARED_LOG_PATH,
137
+ tunnelName,
138
+ configPath: opts.configPath ?? paths.configPath,
139
+ logPath: opts.logPath ?? paths.logPath,
123
140
  cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
124
141
  now: opts.now ?? (() => new Date()),
125
142
  };
@@ -153,6 +170,13 @@ export async function exposeCloudflareUp(
153
170
  ): Promise<number> {
154
171
  const r = resolve(opts);
155
172
 
173
+ if (!isValidTunnelName(r.tunnelName)) {
174
+ r.log(
175
+ `parachute expose public --cloudflare: --tunnel-name must be alphanumeric with -/_ (got "${r.tunnelName}").`,
176
+ );
177
+ return 1;
178
+ }
179
+
156
180
  if (!isValidHostname(hostname)) {
157
181
  r.log(
158
182
  `parachute expose public --cloudflare: --domain must be a valid hostname (got "${hostname}").`,
@@ -194,25 +218,25 @@ export async function exposeCloudflareUp(
194
218
 
195
219
  let tunnel: Tunnel | undefined;
196
220
  try {
197
- tunnel = await findTunnelByName(r.runner, TUNNEL_NAME);
221
+ tunnel = await findTunnelByName(r.runner, r.tunnelName);
198
222
  } catch (err) {
199
223
  return reportCloudflaredError(err, r.log);
200
224
  }
201
225
  if (!tunnel) {
202
- r.log(`Creating Cloudflare tunnel "${TUNNEL_NAME}"…`);
226
+ r.log(`Creating Cloudflare tunnel "${r.tunnelName}"…`);
203
227
  try {
204
- tunnel = await createTunnel(r.runner, TUNNEL_NAME);
228
+ tunnel = await createTunnel(r.runner, r.tunnelName);
205
229
  } catch (err) {
206
230
  return reportCloudflaredError(err, r.log);
207
231
  }
208
232
  r.log(`✓ Created tunnel ${tunnel.id}`);
209
233
  } else {
210
- r.log(`✓ Reusing existing tunnel "${TUNNEL_NAME}" (${tunnel.id})`);
234
+ r.log(`✓ Reusing existing tunnel "${r.tunnelName}" (${tunnel.id})`);
211
235
  }
212
236
 
213
237
  r.log(`Routing DNS: ${hostname} → tunnel ${tunnel.id}…`);
214
238
  try {
215
- await routeDns(r.runner, TUNNEL_NAME, hostname);
239
+ await routeDns(r.runner, r.tunnelName, hostname);
216
240
  } catch (err) {
217
241
  if (err instanceof CloudflaredError) {
218
242
  r.log("");
@@ -241,32 +265,31 @@ export async function exposeCloudflareUp(
241
265
  );
242
266
  r.log(`✓ Wrote ${r.configPath}`);
243
267
 
244
- const prior = readCloudflaredState(r.statePath);
268
+ const stateBefore = readCloudflaredState(r.statePath);
269
+ const prior = findTunnelRecord(stateBefore, r.tunnelName);
245
270
  if (prior && r.alive(prior.pid)) {
246
271
  try {
247
272
  r.kill(prior.pid, "SIGTERM");
248
273
  r.log(`Stopped prior cloudflared (pid ${prior.pid}).`);
249
274
  } catch {
250
- // Process is already gone — safe to ignore; clearCloudflaredState drops the state file below.
275
+ // Process is already gone — safe to ignore; we replace the record below.
251
276
  }
252
277
  }
253
- if (prior) clearCloudflaredState(r.statePath);
254
278
 
255
279
  const pid = r.spawner.spawn(
256
280
  ["cloudflared", "tunnel", "--config", r.configPath, "run"],
257
281
  r.logPath,
258
282
  );
259
283
 
260
- const state: CloudflaredState = {
261
- version: 1,
284
+ const record: CloudflaredTunnelRecord = {
262
285
  pid,
263
286
  tunnelUuid: tunnel.id,
264
- tunnelName: TUNNEL_NAME,
287
+ tunnelName: r.tunnelName,
265
288
  hostname,
266
289
  startedAt: r.now().toISOString(),
267
290
  configPath: r.configPath,
268
291
  };
269
- writeCloudflaredState(state, r.statePath);
292
+ writeCloudflaredState(withTunnelRecord(stateBefore, record), r.statePath);
270
293
 
271
294
  const baseUrl = `https://${hostname}`;
272
295
  // A well-formed vault manifest always lists at least one mount path. If
@@ -295,28 +318,45 @@ export async function exposeCloudflareUp(
295
318
 
296
319
  export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
297
320
  const r = resolve(opts);
298
- const state = readCloudflaredState(r.statePath);
299
- if (!state) {
300
- r.log("No Cloudflare exposure recorded. Nothing to tear down.");
321
+ const stateBefore = readCloudflaredState(r.statePath);
322
+ const record = findTunnelRecord(stateBefore, r.tunnelName);
323
+ if (!record) {
324
+ if (stateBefore && Object.keys(stateBefore.tunnels).length > 0) {
325
+ const others = listTunnelRecords(stateBefore)
326
+ .map((t) => t.tunnelName)
327
+ .join(", ");
328
+ r.log(
329
+ `No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
330
+ );
331
+ } else {
332
+ r.log("No Cloudflare exposure recorded. Nothing to tear down.");
333
+ }
301
334
  return 0;
302
335
  }
303
- if (r.alive(state.pid)) {
336
+ if (r.alive(record.pid)) {
304
337
  try {
305
- r.kill(state.pid, "SIGTERM");
306
- r.log(`✓ Stopped cloudflared (pid ${state.pid}).`);
338
+ r.kill(record.pid, "SIGTERM");
339
+ r.log(`✓ Stopped cloudflared (pid ${record.pid}).`);
307
340
  } catch (err) {
308
341
  r.log(`✗ Failed to stop cloudflared: ${err instanceof Error ? err.message : String(err)}`);
309
342
  return 1;
310
343
  }
311
344
  } else {
312
- r.log(`cloudflared (pid ${state.pid}) wasn't running; clearing stale state.`);
345
+ r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
313
346
  }
314
- clearCloudflaredState(r.statePath);
315
- r.log(` ${state.hostname} is no longer reachable through this machine.`);
347
+ const stateAfter = withoutTunnelRecord(stateBefore, r.tunnelName);
348
+ if (stateAfter) {
349
+ writeCloudflaredState(stateAfter, r.statePath);
350
+ } else {
351
+ clearCloudflaredState(r.statePath);
352
+ }
353
+ r.log(` ${record.hostname} is no longer reachable through this machine.`);
354
+ r.log(
355
+ ` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
356
+ );
316
357
  r.log(
317
- ` Tunnel "${state.tunnelName}" (${state.tunnelUuid}) remains defined in Cloudflare; re-running`,
358
+ ` \`parachute expose public --cloudflare --domain ${record.hostname}${record.tunnelName === DEFAULT_TUNNEL_NAME ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
318
359
  );
319
- r.log(` \`parachute expose public --cloudflare --domain ${state.hostname}\` reuses it.`);
320
360
  return 0;
321
361
  }
322
362
 
@@ -22,7 +22,12 @@ import {
22
22
  readLastProvider,
23
23
  writeLastProvider,
24
24
  } from "../expose-last-provider.ts";
25
- import { getTailscaleStatus, isTailscaleInstalled } from "../tailscale/detect.ts";
25
+ import {
26
+ type ProviderAvailability,
27
+ detectProviders,
28
+ isCloudflareReady,
29
+ isTailnetReady,
30
+ } from "../providers/detect.ts";
26
31
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
27
32
  import { type AuthPreflightOpts, runAuthPreflight } from "./expose-auth-preflight.ts";
28
33
  import {
@@ -130,40 +135,8 @@ function resolve(opts: ExposeInteractiveOpts): Resolved {
130
135
  };
131
136
  }
132
137
 
133
- interface Readiness {
134
- tailscaleInstalled: boolean;
135
- tailscaleLoggedIn: boolean;
136
- tailscaleFunnelCap: boolean;
137
- cloudflareInstalled: boolean;
138
- cloudflareLoggedIn: boolean;
139
- }
140
-
141
- async function detectReadiness(r: Resolved): Promise<Readiness> {
142
- const tailscaleInstalled = await isTailscaleInstalled(r.runner);
143
- // One `tailscale status --json` covers both login state and Funnel cap.
144
- // Skipped when the binary's missing — the call would just fail.
145
- const { loggedIn: tailscaleLoggedIn, funnelCapable: tailscaleFunnelCap } = tailscaleInstalled
146
- ? await getTailscaleStatus(r.runner)
147
- : { loggedIn: false, funnelCapable: false };
148
-
149
- const cloudflareInstalled = await isCloudflaredInstalled(r.runner);
150
- const cloudflareLoggedIn = cloudflareInstalled ? isCloudflaredLoggedIn(r.cloudflaredHome) : false;
151
-
152
- return {
153
- tailscaleInstalled,
154
- tailscaleLoggedIn,
155
- tailscaleFunnelCap,
156
- cloudflareInstalled,
157
- cloudflareLoggedIn,
158
- };
159
- }
160
-
161
- function isTailscaleReady(r: Readiness): boolean {
162
- return r.tailscaleInstalled && r.tailscaleLoggedIn && r.tailscaleFunnelCap;
163
- }
164
-
165
- function isCloudflareReady(r: Readiness): boolean {
166
- return r.cloudflareInstalled && r.cloudflareLoggedIn;
138
+ async function probeReadiness(r: Resolved): Promise<ProviderAvailability> {
139
+ return detectProviders({ runner: r.runner, cloudflaredHome: r.cloudflaredHome });
167
140
  }
168
141
 
169
142
  type PickResult = ExposeProvider | "quit";
@@ -221,11 +194,11 @@ async function promptHostname(r: Resolved): Promise<string | undefined> {
221
194
  * an admin-console change scoped to the tailnet — the CLI impersonating
222
195
  * either would be presumptuous. User re-runs after fixing.
223
196
  */
224
- function printTailscaleSetupGuidance(r: Resolved, readiness: Readiness): void {
197
+ function printTailscaleSetupGuidance(r: Resolved, readiness: ProviderAvailability): void {
225
198
  r.log("");
226
199
  r.log("Tailscale Funnel needs three things:");
227
200
  r.log("");
228
- if (!readiness.tailscaleInstalled) {
201
+ if (!readiness.tailnet.available) {
229
202
  r.log(" 1. Install Tailscale:");
230
203
  if (r.platform === "darwin") {
231
204
  r.log(" brew install tailscale");
@@ -235,13 +208,13 @@ function printTailscaleSetupGuidance(r: Resolved, readiness: Readiness): void {
235
208
  } else {
236
209
  r.log(" 1. ✓ Tailscale is installed.");
237
210
  }
238
- if (!readiness.tailscaleLoggedIn) {
211
+ if (!readiness.tailnet.loggedIn) {
239
212
  r.log(" 2. Log this machine into your tailnet:");
240
213
  r.log(" tailscale up");
241
214
  } else {
242
215
  r.log(" 2. ✓ This machine is logged in.");
243
216
  }
244
- if (!readiness.tailscaleFunnelCap) {
217
+ if (!readiness.tailnet.funnelEnabled) {
245
218
  r.log(" 3. Enable Funnel for this node in your tailnet ACLs:");
246
219
  r.log(" https://login.tailscale.com/admin/acls");
247
220
  r.log(" Add (or merge) this block under the ACL's top-level object:");
@@ -264,9 +237,12 @@ function printTailscaleSetupGuidance(r: Resolved, readiness: Readiness): void {
264
237
  * pointers and bail so the user can pick apt/dnf/tarball. Returns true only
265
238
  * when cloudflared is both present and logged in afterwards.
266
239
  */
267
- async function guideCloudflareSetup(r: Resolved, readiness: Readiness): Promise<boolean> {
268
- let installed = readiness.cloudflareInstalled;
269
- let loggedIn = readiness.cloudflareLoggedIn;
240
+ async function guideCloudflareSetup(
241
+ r: Resolved,
242
+ readiness: ProviderAvailability,
243
+ ): Promise<boolean> {
244
+ let installed = readiness.cloudflare.available;
245
+ let loggedIn = readiness.cloudflare.loggedIn;
270
246
 
271
247
  if (!installed) {
272
248
  if (r.platform === "darwin") {
@@ -349,8 +325,8 @@ function defaultProviderFrom(lastPath: string): ExposeProvider {
349
325
 
350
326
  export async function exposePublicInteractive(opts: ExposeInteractiveOpts = {}): Promise<number> {
351
327
  const r = resolve(opts);
352
- const readiness = await detectReadiness(r);
353
- const tsReady = isTailscaleReady(readiness);
328
+ const readiness = await probeReadiness(r);
329
+ const tsReady = isTailnetReady(readiness);
354
330
  const cfReady = isCloudflareReady(readiness);
355
331
 
356
332
  let provider: ExposeProvider;
@@ -15,6 +15,10 @@
15
15
  * - Both live → prompt (TTY) for which to tear down, or `both`;
16
16
  * non-TTY tears down both (off means off).
17
17
  *
18
+ * Since #32 the cloudflared-state.json holds a map of tunnels keyed by
19
+ * name, so "cloudflare is live" means any tunnel record exists; tearing
20
+ * down "cloudflare" iterates every recorded tunnel.
21
+ *
18
22
  * `--cloudflare` still works as an explicit override and skips this module
19
23
  * entirely (see cli.ts). Shape mirrors the other Layer-N modules — every
20
24
  * side-effectful edge is an injectable seam so the full decision tree is
@@ -25,6 +29,7 @@ import { createInterface } from "node:readline/promises";
25
29
  import {
26
30
  CLOUDFLARED_STATE_PATH,
27
31
  type CloudflaredState,
32
+ listTunnelRecords,
28
33
  readCloudflaredState,
29
34
  } from "../cloudflare/state.ts";
30
35
  import { EXPOSE_STATE_PATH, type ExposeState, readExposeState } from "../expose-state.ts";
@@ -50,7 +55,9 @@ export interface ExposePublicOffAutoOpts {
50
55
  */
51
56
  tailscaleOffOpts?: ExposeOpts;
52
57
  /**
53
- * Forwarded to the cloudflare teardown. Tests use it the same way.
58
+ * Forwarded to the cloudflare teardown. Tests use it the same way. The
59
+ * wrapper sets `tunnelName` per record when iterating; callers don't need
60
+ * to provide it.
54
61
  */
55
62
  cloudflareOffOpts?: ExposeCloudflareOpts;
56
63
 
@@ -95,17 +102,13 @@ function tailscalePublicIsLive(state: ExposeState | undefined): state is ExposeS
95
102
  }
96
103
 
97
104
  function cloudflareIsLive(state: CloudflaredState | undefined): state is CloudflaredState {
98
- return !!state;
105
+ return !!state && Object.keys(state.tunnels).length > 0;
99
106
  }
100
107
 
101
108
  function tailscaleUrl(state: ExposeState): string {
102
109
  return `https://${state.canonicalFqdn}`;
103
110
  }
104
111
 
105
- function cloudflareUrl(state: CloudflaredState): string {
106
- return `https://${state.hostname}`;
107
- }
108
-
109
112
  type BothChoice = "tailscale" | "cloudflare" | "both" | "cancel";
110
113
 
111
114
  async function promptBothLive(
@@ -113,9 +116,14 @@ async function promptBothLive(
113
116
  tsState: ExposeState,
114
117
  cfState: CloudflaredState,
115
118
  ): Promise<BothChoice> {
119
+ const records = listTunnelRecords(cfState);
120
+ const cfSummary =
121
+ records.length === 1
122
+ ? `https://${records[0]?.hostname}`
123
+ : records.map((t) => `https://${t.hostname}`).join(", ");
116
124
  r.log("Two public exposures are currently live:");
117
125
  r.log(` [1] Tailscale Funnel — ${tailscaleUrl(tsState)}`);
118
- r.log(` [2] Cloudflare Tunnel — ${cloudflareUrl(cfState)}`);
126
+ r.log(` [2] Cloudflare Tunnel — ${cfSummary}`);
119
127
  r.log(" [3] both");
120
128
  r.log(" [4] cancel");
121
129
  while (true) {
@@ -136,10 +144,18 @@ async function tearDownTailscale(r: Resolved, state: ExposeState): Promise<numbe
136
144
  }
137
145
 
138
146
  async function tearDownCloudflare(r: Resolved, state: CloudflaredState): Promise<number> {
139
- const url = cloudflareUrl(state);
140
- const code = await r.exposeCloudflareOffImpl(r.cloudflareOffOpts);
141
- if (code === 0) r.log(`✓ Tore down Cloudflare Tunnel (was: ${url})`);
142
- return code;
147
+ const records = listTunnelRecords(state);
148
+ let firstFailure = 0;
149
+ for (const record of records) {
150
+ const url = `https://${record.hostname}`;
151
+ const code = await r.exposeCloudflareOffImpl({
152
+ ...r.cloudflareOffOpts,
153
+ tunnelName: record.tunnelName,
154
+ });
155
+ if (code === 0) r.log(`✓ Tore down Cloudflare Tunnel (was: ${url})`);
156
+ if (code !== 0 && firstFailure === 0) firstFailure = code;
157
+ }
158
+ return firstFailure;
143
159
  }
144
160
 
145
161
  export async function runExposePublicOffAutoDetect(
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `parachute expose public` (no provider flag, non-TTY) — auto-pick logic.
3
+ *
4
+ * The interactive picker (`expose-interactive.ts`) covers the TTY case end to
5
+ * end. Scripts and CI hit a different shape: they can't answer prompts, but
6
+ * they still benefit from "use what's set up rather than always defaulting to
7
+ * Tailscale and failing if Tailscale isn't logged in."
8
+ *
9
+ * Decision tree, deterministic:
10
+ *
11
+ * - Both providers ready → ambiguous, can't prompt; fail with a hint at
12
+ * `--tailnet` / `--cloudflare`. Better to refuse than silently bias.
13
+ * - Exactly one ready → use it.
14
+ * - Tailnet: proceed to `exposePublic("up", …)`.
15
+ * - Cloudflare: needs `--domain`; if missing, fail with the same
16
+ * usage hint the explicit `--cloudflare` path emits.
17
+ * - Neither ready → fail with install pointers for both. The user almost
18
+ * certainly meant "spin up the public layer" and we can't, so this is the
19
+ * loud surface — not a silent default.
20
+ *
21
+ * The `--skip-provider-check` escape hatch bypasses everything and runs
22
+ * today's Tailscale Funnel path. CI that's already prepared its environment
23
+ * (or doesn't care about Cloudflare) can pin to the legacy behavior with a
24
+ * single flag.
25
+ *
26
+ * Shape mirrors the other expose modules — every side-effectful edge is an
27
+ * injectable seam so the decision tree is testable without spawning real
28
+ * tailscale/cloudflared.
29
+ */
30
+
31
+ import { DEFAULT_CLOUDFLARED_HOME } from "../cloudflare/detect.ts";
32
+ import {
33
+ type ProviderAvailability,
34
+ type DetectProvidersOpts,
35
+ detectProviders,
36
+ isCloudflareReady,
37
+ isTailnetReady,
38
+ } from "../providers/detect.ts";
39
+ import {
40
+ type ExposeCloudflareOpts,
41
+ exposeCloudflareUp as defaultExposeCloudflareUp,
42
+ } from "./expose-cloudflare.ts";
43
+ import { type ExposeOpts, exposePublic as defaultExposePublic } from "./expose.ts";
44
+
45
+ export interface ExposePublicAutoOpts {
46
+ /** Hostname for the Cloudflare path. Required when Cloudflare ends up picked. */
47
+ domain?: string;
48
+ /** Tunnel name override for the Cloudflare path (#32). */
49
+ tunnelName?: string;
50
+ /** Forwarded to the Tailscale Funnel handoff. */
51
+ tailscaleOpts?: ExposeOpts;
52
+ /** Forwarded to the Cloudflare handoff. */
53
+ cloudflareOpts?: ExposeCloudflareOpts;
54
+ /** Override `~/.cloudflared` (parity with the interactive picker's seam). */
55
+ cloudflaredHome?: string;
56
+ log?: (line: string) => void;
57
+
58
+ /** Test seams. */
59
+ detectProvidersImpl?: (opts: DetectProvidersOpts) => Promise<ProviderAvailability>;
60
+ exposePublicImpl?: (action: "up", opts: ExposeOpts) => Promise<number>;
61
+ exposeCloudflareUpImpl?: (hostname: string, opts: ExposeCloudflareOpts) => Promise<number>;
62
+ }
63
+
64
+ interface Resolved {
65
+ domain: string | undefined;
66
+ tunnelName: string | undefined;
67
+ tailscaleOpts: ExposeOpts;
68
+ cloudflareOpts: ExposeCloudflareOpts;
69
+ cloudflaredHome: string;
70
+ log: (line: string) => void;
71
+ detectProvidersImpl: (opts: DetectProvidersOpts) => Promise<ProviderAvailability>;
72
+ exposePublicImpl: (action: "up", opts: ExposeOpts) => Promise<number>;
73
+ exposeCloudflareUpImpl: (hostname: string, opts: ExposeCloudflareOpts) => Promise<number>;
74
+ }
75
+
76
+ function resolve(opts: ExposePublicAutoOpts): Resolved {
77
+ return {
78
+ domain: opts.domain,
79
+ tunnelName: opts.tunnelName,
80
+ tailscaleOpts: opts.tailscaleOpts ?? {},
81
+ cloudflareOpts: opts.cloudflareOpts ?? {},
82
+ cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
83
+ log: opts.log ?? ((line) => console.log(line)),
84
+ detectProvidersImpl: opts.detectProvidersImpl ?? detectProviders,
85
+ exposePublicImpl: opts.exposePublicImpl ?? ((a, o) => defaultExposePublic(a, o)),
86
+ exposeCloudflareUpImpl: opts.exposeCloudflareUpImpl ?? defaultExposeCloudflareUp,
87
+ };
88
+ }
89
+
90
+ function reportNeitherReady(r: Resolved, p: ProviderAvailability): number {
91
+ r.log("parachute expose public: no exposure provider is set up on this machine.");
92
+ r.log("");
93
+ r.log("Pick one and finish setting it up, then re-run.");
94
+ r.log("");
95
+ r.log(" Option A — Tailscale Funnel (free, *.ts.net URL, no domain needed):");
96
+ if (!p.tailnet.available) {
97
+ r.log(" 1. Install: https://tailscale.com/download");
98
+ r.log(" 2. Log in: tailscale up");
99
+ r.log(" 3. Enable Funnel: https://login.tailscale.com/admin/acls");
100
+ } else if (!p.tailnet.loggedIn) {
101
+ r.log(" 1. ✓ tailscale installed");
102
+ r.log(" 2. Log in: tailscale up");
103
+ r.log(" 3. Enable Funnel: https://login.tailscale.com/admin/acls");
104
+ } else {
105
+ r.log(" 1. ✓ tailscale installed");
106
+ r.log(" 2. ✓ logged in");
107
+ r.log(" 3. Enable Funnel: https://login.tailscale.com/admin/acls");
108
+ }
109
+ r.log("");
110
+ r.log(" Option B — Cloudflare Tunnel (your own domain, Cloudflare DNS):");
111
+ if (!p.cloudflare.available) {
112
+ r.log(
113
+ " 1. Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
114
+ );
115
+ r.log(" 2. Log in: cloudflared tunnel login");
116
+ r.log(" 3. Re-run with --domain: parachute expose public --cloudflare --domain <hostname>");
117
+ } else {
118
+ r.log(" 1. ✓ cloudflared installed");
119
+ r.log(" 2. Log in: cloudflared tunnel login");
120
+ r.log(" 3. Re-run with --domain: parachute expose public --cloudflare --domain <hostname>");
121
+ }
122
+ r.log("");
123
+ r.log("To bypass this check (e.g., CI scripts pinning to today's Tailscale default):");
124
+ r.log(" parachute expose public --skip-provider-check");
125
+ return 1;
126
+ }
127
+
128
+ function reportBothReadyAmbiguous(r: Resolved): number {
129
+ r.log("parachute expose public: both Tailscale Funnel and Cloudflare Tunnel are configured.");
130
+ r.log("");
131
+ r.log("Without a TTY there's no way to ask which one — pick explicitly:");
132
+ r.log(" parachute expose public --tailnet");
133
+ r.log(" parachute expose public --cloudflare --domain <hostname>");
134
+ r.log("");
135
+ r.log("Or pin to today's Tailscale-Funnel default with --skip-provider-check.");
136
+ return 1;
137
+ }
138
+
139
+ function reportCloudflareNeedsDomain(r: Resolved): number {
140
+ r.log("parachute expose public: Cloudflare Tunnel is the only configured provider,");
141
+ r.log("but `--domain <hostname>` is required to route DNS through it.");
142
+ r.log("");
143
+ r.log("Re-run with the hostname:");
144
+ r.log(" parachute expose public --cloudflare --domain vault.example.com");
145
+ r.log("");
146
+ r.log(
147
+ "The hostname's apex domain must already be a zone on your Cloudflare account.",
148
+ );
149
+ return 1;
150
+ }
151
+
152
+ /**
153
+ * Auto-pick entry point — call from `cli.ts` only when neither provider flag
154
+ * (`--tailnet` / `--cloudflare`) was supplied AND we're not in a TTY (the TTY
155
+ * path runs `expose-interactive.ts` instead).
156
+ */
157
+ export async function exposePublicAutoPick(
158
+ opts: ExposePublicAutoOpts = {},
159
+ ): Promise<number> {
160
+ const r = resolve(opts);
161
+ const availability = await r.detectProvidersImpl({ cloudflaredHome: r.cloudflaredHome });
162
+ const tsReady = isTailnetReady(availability);
163
+ const cfReady = isCloudflareReady(availability);
164
+
165
+ if (tsReady && cfReady) return reportBothReadyAmbiguous(r);
166
+ if (!tsReady && !cfReady) return reportNeitherReady(r, availability);
167
+
168
+ if (tsReady) {
169
+ r.log("Auto-detected Tailscale Funnel as the only configured provider.");
170
+ return r.exposePublicImpl("up", r.tailscaleOpts);
171
+ }
172
+
173
+ // cfReady
174
+ if (!r.domain) return reportCloudflareNeedsDomain(r);
175
+ r.log("Auto-detected Cloudflare Tunnel as the only configured provider.");
176
+ const cfOpts: ExposeCloudflareOpts = { ...r.cloudflareOpts };
177
+ if (r.tunnelName !== undefined) cfOpts.tunnelName = r.tunnelName;
178
+ return r.exposeCloudflareUpImpl(r.domain, cfOpts);
179
+ }