@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,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
|
+
}
|