@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.
- package/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- package/src/well-known.ts +127 -0
|
@@ -0,0 +1,329 @@
|
|
|
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";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_CLOUDFLARED_HOME,
|
|
11
|
+
cloudflaredInstallHint,
|
|
12
|
+
isCloudflaredInstalled,
|
|
13
|
+
isCloudflaredLoggedIn,
|
|
14
|
+
} from "../cloudflare/detect.ts";
|
|
15
|
+
import {
|
|
16
|
+
CLOUDFLARED_STATE_PATH,
|
|
17
|
+
type CloudflaredState,
|
|
18
|
+
clearCloudflaredState,
|
|
19
|
+
readCloudflaredState,
|
|
20
|
+
writeCloudflaredState,
|
|
21
|
+
} from "../cloudflare/state.ts";
|
|
22
|
+
import {
|
|
23
|
+
CloudflaredError,
|
|
24
|
+
type Tunnel,
|
|
25
|
+
createTunnel,
|
|
26
|
+
credentialsPath,
|
|
27
|
+
findTunnelByName,
|
|
28
|
+
routeDns,
|
|
29
|
+
} from "../cloudflare/tunnel.ts";
|
|
30
|
+
import { SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
31
|
+
import { type AliveFn, defaultAlive } from "../process-state.ts";
|
|
32
|
+
import { readManifest } from "../services-manifest.ts";
|
|
33
|
+
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
34
|
+
|
|
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
|
+
const AUTH_DOC_URL =
|
|
46
|
+
"https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hostname validation — permissive by design. We reject the obviously broken
|
|
50
|
+
* shapes (empty, missing dot, label containing `/` or whitespace) and let
|
|
51
|
+
* Cloudflare's own validation catch the rest. Pre-checking against every
|
|
52
|
+
* RFC 1123 corner would be overkill for a CLI flag that the user just typed.
|
|
53
|
+
*/
|
|
54
|
+
export function isValidHostname(h: string): boolean {
|
|
55
|
+
if (h.length === 0 || h.length > 253) return false;
|
|
56
|
+
if (!h.includes(".")) return false;
|
|
57
|
+
const labelRe = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
58
|
+
return h.split(".").every((label) => labelRe.test(label));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CloudflaredSpawner {
|
|
62
|
+
spawn(cmd: readonly string[], logFile: string): number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const defaultCloudflaredSpawner: CloudflaredSpawner = {
|
|
66
|
+
spawn(cmd, logFile) {
|
|
67
|
+
mkdirSync(dirname(logFile), { recursive: true });
|
|
68
|
+
const fd = openSync(logFile, "a");
|
|
69
|
+
const proc = Bun.spawn([...cmd], { stdio: ["ignore", fd, fd] });
|
|
70
|
+
proc.unref();
|
|
71
|
+
return proc.pid;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
|
|
76
|
+
|
|
77
|
+
const defaultKill: KillFn = (pid, signal) => {
|
|
78
|
+
process.kill(pid, signal);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface ExposeCloudflareOpts {
|
|
82
|
+
runner?: Runner;
|
|
83
|
+
spawner?: CloudflaredSpawner;
|
|
84
|
+
alive?: AliveFn;
|
|
85
|
+
kill?: KillFn;
|
|
86
|
+
log?: (line: string) => void;
|
|
87
|
+
manifestPath?: string;
|
|
88
|
+
statePath?: string;
|
|
89
|
+
/** Path to the cloudflared config.yml this invocation writes. */
|
|
90
|
+
configPath?: string;
|
|
91
|
+
/** Path to the log file the spawned cloudflared appends to. */
|
|
92
|
+
logPath?: string;
|
|
93
|
+
/** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
|
|
94
|
+
cloudflaredHome?: string;
|
|
95
|
+
now?: () => Date;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Resolved {
|
|
99
|
+
runner: Runner;
|
|
100
|
+
spawner: CloudflaredSpawner;
|
|
101
|
+
alive: AliveFn;
|
|
102
|
+
kill: KillFn;
|
|
103
|
+
log: (line: string) => void;
|
|
104
|
+
manifestPath: string;
|
|
105
|
+
statePath: string;
|
|
106
|
+
configPath: string;
|
|
107
|
+
logPath: string;
|
|
108
|
+
cloudflaredHome: string;
|
|
109
|
+
now: () => Date;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolve(opts: ExposeCloudflareOpts): Resolved {
|
|
113
|
+
return {
|
|
114
|
+
runner: opts.runner ?? defaultRunner,
|
|
115
|
+
spawner: opts.spawner ?? defaultCloudflaredSpawner,
|
|
116
|
+
alive: opts.alive ?? defaultAlive,
|
|
117
|
+
kill: opts.kill ?? defaultKill,
|
|
118
|
+
log: opts.log ?? ((line) => console.log(line)),
|
|
119
|
+
manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
120
|
+
statePath: opts.statePath ?? CLOUDFLARED_STATE_PATH,
|
|
121
|
+
configPath: opts.configPath ?? CLOUDFLARED_CONFIG_PATH,
|
|
122
|
+
logPath: opts.logPath ?? CLOUDFLARED_LOG_PATH,
|
|
123
|
+
cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
|
|
124
|
+
now: opts.now ?? (() => new Date()),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function printAuthGuidance(log: (line: string) => void, vaultUrl: string): void {
|
|
129
|
+
log("");
|
|
130
|
+
log("Security: your vault is auth-gated by default, and this exposure does not");
|
|
131
|
+
log("change that. Anyone who hits the URL has to clear the auth gate before");
|
|
132
|
+
log("they can read or write.");
|
|
133
|
+
log("");
|
|
134
|
+
log("Pick the path that matches how you'll reach it:");
|
|
135
|
+
log("");
|
|
136
|
+
log(" Humans (claude.ai / ChatGPT connectors, browser):");
|
|
137
|
+
log(" parachute auth set-password # set an owner password");
|
|
138
|
+
log(" parachute auth 2fa enroll # (recommended) TOTP + backup codes");
|
|
139
|
+
log(" then point your connector at:");
|
|
140
|
+
log(` ${vaultUrl}`);
|
|
141
|
+
log("");
|
|
142
|
+
log(" Scripts / machines:");
|
|
143
|
+
log(" parachute vault tokens create # creates a pvt_… bearer token");
|
|
144
|
+
log(" Authorization: Bearer pvt_… # attach to every request");
|
|
145
|
+
log("");
|
|
146
|
+
log("Neither is a prerequisite for the other. Full auth reference:");
|
|
147
|
+
log(` ${AUTH_DOC_URL}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function exposeCloudflareUp(
|
|
151
|
+
hostname: string,
|
|
152
|
+
opts: ExposeCloudflareOpts = {},
|
|
153
|
+
): Promise<number> {
|
|
154
|
+
const r = resolve(opts);
|
|
155
|
+
|
|
156
|
+
if (!isValidHostname(hostname)) {
|
|
157
|
+
r.log(
|
|
158
|
+
`parachute expose public --cloudflare: --domain must be a valid hostname (got "${hostname}").`,
|
|
159
|
+
);
|
|
160
|
+
r.log("Example: --domain vault.example.com");
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!(await isCloudflaredInstalled(r.runner))) {
|
|
165
|
+
r.log("cloudflared is not installed or not on PATH.");
|
|
166
|
+
r.log("");
|
|
167
|
+
r.log(cloudflaredInstallHint());
|
|
168
|
+
r.log("");
|
|
169
|
+
r.log("After install, run `cloudflared tunnel login` to authenticate,");
|
|
170
|
+
r.log(`then re-run: parachute expose public --cloudflare --domain ${hostname}`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!isCloudflaredLoggedIn(r.cloudflaredHome)) {
|
|
175
|
+
r.log("cloudflared is not logged in.");
|
|
176
|
+
r.log("");
|
|
177
|
+
r.log("Run: cloudflared tunnel login");
|
|
178
|
+
r.log("");
|
|
179
|
+
r.log("That opens a browser where you pick the domain you've added to Cloudflare.");
|
|
180
|
+
r.log("If the domain isn't there yet, add it at https://dash.cloudflare.com → Add site");
|
|
181
|
+
r.log("(Namecheap / Porkbun / any registrar is fine — Cloudflare just manages DNS).");
|
|
182
|
+
r.log("");
|
|
183
|
+
r.log(`After login, re-run: parachute expose public --cloudflare --domain ${hostname}`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const manifest = readManifest(r.manifestPath);
|
|
188
|
+
const vaultEntry = manifest.services.find((s) => s.name === "parachute-vault");
|
|
189
|
+
if (!vaultEntry) {
|
|
190
|
+
r.log("parachute-vault is not installed; nothing to route.");
|
|
191
|
+
r.log("Run: parachute install vault");
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let tunnel: Tunnel | undefined;
|
|
196
|
+
try {
|
|
197
|
+
tunnel = await findTunnelByName(r.runner, TUNNEL_NAME);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return reportCloudflaredError(err, r.log);
|
|
200
|
+
}
|
|
201
|
+
if (!tunnel) {
|
|
202
|
+
r.log(`Creating Cloudflare tunnel "${TUNNEL_NAME}"…`);
|
|
203
|
+
try {
|
|
204
|
+
tunnel = await createTunnel(r.runner, TUNNEL_NAME);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return reportCloudflaredError(err, r.log);
|
|
207
|
+
}
|
|
208
|
+
r.log(`✓ Created tunnel ${tunnel.id}`);
|
|
209
|
+
} else {
|
|
210
|
+
r.log(`✓ Reusing existing tunnel "${TUNNEL_NAME}" (${tunnel.id})`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
r.log(`Routing DNS: ${hostname} → tunnel ${tunnel.id}…`);
|
|
214
|
+
try {
|
|
215
|
+
await routeDns(r.runner, TUNNEL_NAME, hostname);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (err instanceof CloudflaredError) {
|
|
218
|
+
r.log("");
|
|
219
|
+
r.log(`✗ DNS route failed: ${err.message}`);
|
|
220
|
+
r.log("");
|
|
221
|
+
r.log("Common causes:");
|
|
222
|
+
r.log(` 1. The apex of ${hostname} isn't a Cloudflare zone on this account.`);
|
|
223
|
+
r.log(" Add the domain at https://dash.cloudflare.com → Add site, then re-run.");
|
|
224
|
+
r.log(` 2. ${hostname} already has a conflicting DNS record.`);
|
|
225
|
+
r.log(" Remove it at https://dash.cloudflare.com → DNS for that zone, then re-run.");
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
r.log("✓ DNS routed.");
|
|
231
|
+
|
|
232
|
+
const credsFile = credentialsPath(tunnel.id, r.cloudflaredHome);
|
|
233
|
+
writeConfig(
|
|
234
|
+
{
|
|
235
|
+
tunnelUuid: tunnel.id,
|
|
236
|
+
credentialsFile: credsFile,
|
|
237
|
+
hostname,
|
|
238
|
+
servicePort: vaultEntry.port,
|
|
239
|
+
},
|
|
240
|
+
r.configPath,
|
|
241
|
+
);
|
|
242
|
+
r.log(`✓ Wrote ${r.configPath}`);
|
|
243
|
+
|
|
244
|
+
const prior = readCloudflaredState(r.statePath);
|
|
245
|
+
if (prior && r.alive(prior.pid)) {
|
|
246
|
+
try {
|
|
247
|
+
r.kill(prior.pid, "SIGTERM");
|
|
248
|
+
r.log(`Stopped prior cloudflared (pid ${prior.pid}).`);
|
|
249
|
+
} catch {
|
|
250
|
+
// Process is already gone — safe to ignore; clearCloudflaredState drops the state file below.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (prior) clearCloudflaredState(r.statePath);
|
|
254
|
+
|
|
255
|
+
const pid = r.spawner.spawn(
|
|
256
|
+
["cloudflared", "tunnel", "--config", r.configPath, "run"],
|
|
257
|
+
r.logPath,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const state: CloudflaredState = {
|
|
261
|
+
version: 1,
|
|
262
|
+
pid,
|
|
263
|
+
tunnelUuid: tunnel.id,
|
|
264
|
+
tunnelName: TUNNEL_NAME,
|
|
265
|
+
hostname,
|
|
266
|
+
startedAt: r.now().toISOString(),
|
|
267
|
+
configPath: r.configPath,
|
|
268
|
+
};
|
|
269
|
+
writeCloudflaredState(state, r.statePath);
|
|
270
|
+
|
|
271
|
+
const baseUrl = `https://${hostname}`;
|
|
272
|
+
// A well-formed vault manifest always lists at least one mount path. If
|
|
273
|
+
// it's empty, something went sideways in `parachute install vault` — warn
|
|
274
|
+
// so the user can fix services.json rather than chasing a phantom 404 on
|
|
275
|
+
// /vault/default that may or may not exist.
|
|
276
|
+
if (!vaultEntry.paths[0]) {
|
|
277
|
+
r.log(
|
|
278
|
+
`⚠ vault entry in services.json has no paths[]; defaulting to "/vault/default". Check the manifest.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const vaultMount = vaultEntry.paths[0] ?? "/vault/default";
|
|
282
|
+
const vaultUrl = `${baseUrl}${vaultMount}`;
|
|
283
|
+
|
|
284
|
+
r.log("");
|
|
285
|
+
r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
|
|
286
|
+
r.log(` URL: ${baseUrl}`);
|
|
287
|
+
r.log(` Vault: ${vaultUrl}`);
|
|
288
|
+
r.log(` Logs: ${r.logPath}`);
|
|
289
|
+
r.log("");
|
|
290
|
+
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
291
|
+
r.log(` ${vaultUrl}`);
|
|
292
|
+
printAuthGuidance(r.log, vaultUrl);
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
|
|
297
|
+
const r = resolve(opts);
|
|
298
|
+
const state = readCloudflaredState(r.statePath);
|
|
299
|
+
if (!state) {
|
|
300
|
+
r.log("No Cloudflare exposure recorded. Nothing to tear down.");
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
if (r.alive(state.pid)) {
|
|
304
|
+
try {
|
|
305
|
+
r.kill(state.pid, "SIGTERM");
|
|
306
|
+
r.log(`✓ Stopped cloudflared (pid ${state.pid}).`);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
r.log(`✗ Failed to stop cloudflared: ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
r.log(`cloudflared (pid ${state.pid}) wasn't running; clearing stale state.`);
|
|
313
|
+
}
|
|
314
|
+
clearCloudflaredState(r.statePath);
|
|
315
|
+
r.log(` ${state.hostname} is no longer reachable through this machine.`);
|
|
316
|
+
r.log(
|
|
317
|
+
` Tunnel "${state.tunnelName}" (${state.tunnelUuid}) remains defined in Cloudflare; re-running`,
|
|
318
|
+
);
|
|
319
|
+
r.log(` \`parachute expose public --cloudflare --domain ${state.hostname}\` reuses it.`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function reportCloudflaredError(err: unknown, log: (line: string) => void): number {
|
|
324
|
+
if (err instanceof CloudflaredError) {
|
|
325
|
+
log(`✗ ${err.message}`);
|
|
326
|
+
return 1;
|
|
327
|
+
}
|
|
328
|
+
throw err;
|
|
329
|
+
}
|