@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.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, openSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
122
|
-
|
|
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,
|
|
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 "${
|
|
226
|
+
r.log(`Creating Cloudflare tunnel "${r.tunnelName}"…`);
|
|
203
227
|
try {
|
|
204
|
-
tunnel = await createTunnel(r.runner,
|
|
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 "${
|
|
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,
|
|
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
|
|
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;
|
|
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
|
|
261
|
-
version: 1,
|
|
284
|
+
const record: CloudflaredTunnelRecord = {
|
|
262
285
|
pid,
|
|
263
286
|
tunnelUuid: tunnel.id,
|
|
264
|
-
tunnelName:
|
|
287
|
+
tunnelName: r.tunnelName,
|
|
265
288
|
hostname,
|
|
266
289
|
startedAt: r.now().toISOString(),
|
|
267
290
|
configPath: r.configPath,
|
|
268
291
|
};
|
|
269
|
-
writeCloudflaredState(
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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(
|
|
336
|
+
if (r.alive(record.pid)) {
|
|
304
337
|
try {
|
|
305
|
-
r.kill(
|
|
306
|
-
r.log(`✓ Stopped cloudflared (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 ${
|
|
345
|
+
r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
|
|
313
346
|
}
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
`
|
|
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 {
|
|
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
|
-
|
|
134
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
353
|
-
const tsReady =
|
|
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 — ${
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}
|