@openparachute/hub 0.5.2 → 0.5.7
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/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +648 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +147 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
package/src/commands/expose.ts
CHANGED
|
@@ -17,39 +17,47 @@ import {
|
|
|
17
17
|
stopHub,
|
|
18
18
|
} from "../hub-control.ts";
|
|
19
19
|
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { HUB_PATH, writeHubFile } from "../hub.ts";
|
|
21
21
|
import { type AliveFn, processState } from "../process-state.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { shortNameForManifest } from "../service-spec.ts";
|
|
23
23
|
import { type ServiceEntry, readManifest } from "../services-manifest.ts";
|
|
24
24
|
import { type ServeEntry, bringupCommand, teardownCommand } from "../tailscale/commands.ts";
|
|
25
25
|
import { getFqdn, isTailscaleInstalled } from "../tailscale/detect.ts";
|
|
26
26
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
27
|
+
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
27
28
|
import {
|
|
28
29
|
WELL_KNOWN_DIR,
|
|
29
30
|
WELL_KNOWN_MOUNT,
|
|
30
31
|
WELL_KNOWN_PATH,
|
|
31
32
|
buildWellKnown,
|
|
32
|
-
isVaultEntry,
|
|
33
33
|
shortName,
|
|
34
34
|
writeWellKnownFile,
|
|
35
35
|
} from "../well-known.ts";
|
|
36
|
+
import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
|
|
36
37
|
import { restart } from "./lifecycle.ts";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Two exposure layers share a single tailscale serve config on this node.
|
|
40
41
|
* Public layer adds `--funnel` to each handler; everything else is identical.
|
|
41
42
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* (
|
|
43
|
+
* Single-rule shape: tailnet bringup emits exactly one `tailscale serve`
|
|
44
|
+
* mount — `/ → http://127.0.0.1:<hubPort>/`. The hub does all internal
|
|
45
|
+
* routing per request: hub UI, OAuth, well-known, vault SPA + per-vault
|
|
46
|
+
* proxy, and generic services.json-driven `/<svc>/*` dispatch. Layer
|
|
47
|
+
* detection (loopback / tailnet / public) and `publicExposure` enforcement
|
|
48
|
+
* also live in the hub (`layerOf` + `effectivePublicExposure`), so this
|
|
49
|
+
* plan layer no longer partitions services up-front. Cloudflare ingress
|
|
50
|
+
* shipped the same shape on 0.5.2 in #178; this closes the symmetry.
|
|
48
51
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
52
|
+
* Funnel constraint, mostly historical now: Tailscale allows at most three
|
|
53
|
+
* public HTTPS ports per node (443, 8443, 10000). With one rule there is
|
|
54
|
+
* one port — symbolic but the constraint is what motivated path-routing
|
|
55
|
+
* over subdomain-per-service in the first place.
|
|
56
|
+
*
|
|
57
|
+
* Hub mount is an HTTP proxy to the internal Bun.serve (see `hub-control.ts`).
|
|
58
|
+
* Used to be `--set-path=<mount> <file>` entries but macOS `tailscaled` runs
|
|
59
|
+
* sandboxed and can't read arbitrary files; proxy mode is the only reliable
|
|
60
|
+
* shape.
|
|
53
61
|
*/
|
|
54
62
|
|
|
55
63
|
export interface ExposeOpts {
|
|
@@ -92,6 +100,18 @@ export interface ExposeOpts {
|
|
|
92
100
|
* spawning real child processes.
|
|
93
101
|
*/
|
|
94
102
|
restartService?: (short: string) => Promise<number>;
|
|
103
|
+
/**
|
|
104
|
+
* Override `~/.parachute/vault` for the 2FA-enrollment probe on the public
|
|
105
|
+
* (Funnel) layer. Tests point at a tmp dir; production omits and the probe
|
|
106
|
+
* defaults to the resolved vault home. (#186)
|
|
107
|
+
*/
|
|
108
|
+
vaultHome?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Pre-computed vault auth status, primarily for tests. When set,
|
|
111
|
+
* `printPublic2FAWarning` consults this instead of reading
|
|
112
|
+
* `<vaultHome>/config.yaml` from disk. (#186)
|
|
113
|
+
*/
|
|
114
|
+
vaultAuthStatus?: VaultAuthStatus;
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
/**
|
|
@@ -104,166 +124,49 @@ export interface ExposeOpts {
|
|
|
104
124
|
const HUB_DEPENDENT_SHORTS = ["vault"] as const;
|
|
105
125
|
|
|
106
126
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* handlers; after PR (c) the hub IS the OAuth IdP and vault validates
|
|
114
|
-
* hub-issued JWTs (vault#169).
|
|
127
|
+
* Single tailscale serve mount: `/ → http://127.0.0.1:<hubPort>/`. The hub
|
|
128
|
+
* dispatches everything internally (hub page, /admin, /api, /hub SPA, /oauth,
|
|
129
|
+
* /.well-known, /vault SPA + proxy, /vaults POST, generic /<svc>/*), so the
|
|
130
|
+
* tailscale plan stays at this single rule regardless of how many services
|
|
131
|
+
* are installed. `publicExposure: "loopback"` enforcement happens inside the
|
|
132
|
+
* hub via `layerOf` — see `proxyToService` / `proxyToVault` in hub-server.ts.
|
|
115
133
|
*/
|
|
116
|
-
const
|
|
117
|
-
"/.well-known/oauth-authorization-server",
|
|
118
|
-
"/oauth/authorize",
|
|
119
|
-
"/oauth/token",
|
|
120
|
-
"/oauth/register",
|
|
121
|
-
] as const;
|
|
134
|
+
const HUB_CATCHALL_MOUNT = "/";
|
|
122
135
|
|
|
123
136
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* Trailing slash distinguishes the mount from `/vaults` (the create-vault
|
|
132
|
-
* POST endpoint, an exact-match on hub).
|
|
137
|
+
* Warn (but don't rewrite) for legacy `paths: ["/"]` entries. Pre-#144 these
|
|
138
|
+
* were remapped to `/<shortname>` so they didn't collide with the hub page
|
|
139
|
+
* at `/`. Now that the entire tailnet is one catchall to the hub, the hub
|
|
140
|
+
* dispatches by services.json `paths[]` per request — a `paths: ["/"]` entry
|
|
141
|
+
* still wouldn't route correctly, but the failure is hub-side rather than a
|
|
142
|
+
* tailscale plan collision. Emit the warning so operators know to re-install.
|
|
133
143
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
* Remap legacy `paths: ["/"]` entries to `/<shortname>` so they don't collide
|
|
138
|
-
* with the hub page at `/`. Emits a warning per remapped service. This is the
|
|
139
|
-
* transitional path for services installed before the vault PR that writes
|
|
140
|
-
* `paths: ["/vault/<default>"]` — once `parachute install` is re-run those
|
|
141
|
-
* entries update themselves and this branch goes dormant.
|
|
142
|
-
*/
|
|
143
|
-
function remapLegacyRoot(
|
|
144
|
-
services: readonly ServiceEntry[],
|
|
145
|
-
log: (line: string) => void,
|
|
146
|
-
): ServiceEntry[] {
|
|
147
|
-
return services.map((s) => {
|
|
148
|
-
const first = s.paths[0];
|
|
149
|
-
if (first !== "/") return s;
|
|
144
|
+
function warnLegacyRoot(services: readonly ServiceEntry[], log: (line: string) => void): void {
|
|
145
|
+
for (const s of services) {
|
|
146
|
+
if (s.paths[0] !== "/") continue;
|
|
150
147
|
const sn = shortName(s.name);
|
|
151
|
-
const remapped = `/${sn}`;
|
|
152
148
|
log(
|
|
153
|
-
`note: ${s.name} claims "/"; hub page lives there —
|
|
149
|
+
`note: ${s.name} claims "/"; hub page lives there — re-run \`parachute install ${sn}\` to update services.json.`,
|
|
154
150
|
);
|
|
155
|
-
return { ...s, paths: [remapped, ...s.paths.slice(1)] };
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Partition services into ones that will be mounted on the layer versus ones
|
|
161
|
-
* that stay loopback-only. "allowed" services go on the serve plan; every
|
|
162
|
-
* other effective exposure state (explicit loopback, explicit auth-required,
|
|
163
|
-
* spec-default auth-required) is withheld. Hidden services still appear in
|
|
164
|
-
* services.json so on-box callers reach them at http://127.0.0.1:<port>.
|
|
165
|
-
*/
|
|
166
|
-
interface ExposurePartition {
|
|
167
|
-
exposed: ServiceEntry[];
|
|
168
|
-
hidden: Array<{ entry: ServiceEntry; reason: string }>;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function partitionByExposure(services: readonly ServiceEntry[]): ExposurePartition {
|
|
172
|
-
const exposed: ServiceEntry[] = [];
|
|
173
|
-
const hidden: Array<{ entry: ServiceEntry; reason: string }> = [];
|
|
174
|
-
for (const s of services) {
|
|
175
|
-
const eff = effectivePublicExposure(s);
|
|
176
|
-
if (eff === "allowed") {
|
|
177
|
-
exposed.push(s);
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
// Explicit declaration tells the user exactly what the service asked for;
|
|
181
|
-
// a spec-derived default points at the usual cause (no auth configured).
|
|
182
|
-
let reason: string;
|
|
183
|
-
if (s.publicExposure === "loopback") {
|
|
184
|
-
reason = "loopback-only by service declaration";
|
|
185
|
-
} else if (s.publicExposure === "auth-required") {
|
|
186
|
-
reason = "auth-required: service reports auth is not yet configured";
|
|
187
|
-
} else {
|
|
188
|
-
reason = "auth-required: service has no auth gate — set the service's auth token to expose";
|
|
189
|
-
}
|
|
190
|
-
hidden.push({ entry: s, reason });
|
|
191
151
|
}
|
|
192
|
-
return { exposed, hidden };
|
|
193
152
|
}
|
|
194
153
|
|
|
195
154
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* base path, plus vault's `/vault/<name>/` API root) the target URL must
|
|
202
|
-
* include the same mount path — otherwise the backend sees requests at `/`,
|
|
203
|
-
* emits a redirect back to its real base, tailscale strips again, and the
|
|
204
|
-
* client loops on `ERR_TOO_MANY_REDIRECTS`.
|
|
205
|
-
*
|
|
206
|
-
* The rule of thumb is: mount and target path must match byte-for-byte
|
|
207
|
-
* (including trailing slash state), so tailscale's strip-then-forward is a
|
|
208
|
-
* no-op and the backend sees the full path it expects.
|
|
155
|
+
* Build the tailscale plan: one rule, `/ → http://127.0.0.1:<hubPort>/`.
|
|
156
|
+
* Hub does internal dispatch (UI, OAuth, well-known, vault SPA + per-vault
|
|
157
|
+
* proxy, generic /<svc>/* services.json dispatch) and per-request layer
|
|
158
|
+
* gating for `publicExposure: "loopback"` services. See `layerOf` in
|
|
159
|
+
* `hub-server.ts` for the access-control matrix.
|
|
209
160
|
*/
|
|
210
|
-
function
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
function planEntries(services: readonly ServiceEntry[], hubPort: number): ServeEntry[] {
|
|
215
|
-
const entries: ServeEntry[] = [];
|
|
216
|
-
entries.push({
|
|
217
|
-
kind: "proxy",
|
|
218
|
-
mount: HUB_MOUNT,
|
|
219
|
-
target: serviceProxyTarget(hubPort, HUB_MOUNT),
|
|
220
|
-
service: "hub",
|
|
221
|
-
});
|
|
222
|
-
let anyVault = false;
|
|
223
|
-
for (const s of services) {
|
|
224
|
-
if (isVaultEntry(s)) {
|
|
225
|
-
// Vault paths route through the single `/vault/` → hub mount below so
|
|
226
|
-
// `parachute vault create <name>` is reachable on the tailnet without
|
|
227
|
-
// a re-expose. Hub does the per-request services.json lookup (#144).
|
|
228
|
-
anyVault = true;
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
const mount = s.paths[0] ?? `/${shortName(s.name)}`;
|
|
232
|
-
entries.push({
|
|
161
|
+
function planEntries(hubPort: number): ServeEntry[] {
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
233
164
|
kind: "proxy",
|
|
234
|
-
mount,
|
|
235
|
-
target:
|
|
236
|
-
service:
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (anyVault) {
|
|
240
|
-
entries.push({
|
|
241
|
-
kind: "proxy",
|
|
242
|
-
mount: VAULT_MOUNT,
|
|
243
|
-
target: serviceProxyTarget(hubPort, VAULT_MOUNT),
|
|
244
|
-
service: "vault",
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
entries.push({
|
|
248
|
-
kind: "proxy",
|
|
249
|
-
mount: WELL_KNOWN_MOUNT,
|
|
250
|
-
target: serviceProxyTarget(hubPort, WELL_KNOWN_MOUNT),
|
|
251
|
-
service: "well-known",
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
// The hub is the OAuth IdP — mount the four endpoints at the canonical
|
|
255
|
-
// origin and proxy them to the hub's loopback. tailscale strips the mount
|
|
256
|
-
// before forwarding, so the target keeps the same path (matches the
|
|
257
|
-
// `serviceProxyTarget` rule of thumb in the doc above).
|
|
258
|
-
for (const oauthPath of OAUTH_PATHS) {
|
|
259
|
-
entries.push({
|
|
260
|
-
kind: "proxy",
|
|
261
|
-
mount: oauthPath,
|
|
262
|
-
target: serviceProxyTarget(hubPort, oauthPath),
|
|
263
|
-
service: "hub:oauth",
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
return entries;
|
|
165
|
+
mount: HUB_CATCHALL_MOUNT,
|
|
166
|
+
target: `http://127.0.0.1:${hubPort}/`,
|
|
167
|
+
service: "hub",
|
|
168
|
+
},
|
|
169
|
+
];
|
|
267
170
|
}
|
|
268
171
|
|
|
269
172
|
async function runEach(
|
|
@@ -366,12 +269,13 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
|
|
|
366
269
|
}
|
|
367
270
|
}
|
|
368
271
|
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
272
|
+
// Plan no longer partitions services — every service goes through the
|
|
273
|
+
// single hub catchall, and hub gates per request (`publicExposure` +
|
|
274
|
+
// `layerOf` in hub-server.ts). Just surface the legacy `paths: ["/"]`
|
|
275
|
+
// warning so operators know to re-install. `warnLegacyRoot` is
|
|
276
|
+
// side-effect-only (warning to `log`); use `manifest.services` directly
|
|
277
|
+
// downstream.
|
|
278
|
+
warnLegacyRoot(manifest.services, log);
|
|
375
279
|
|
|
376
280
|
/**
|
|
377
281
|
* Probe each service port before wiring tailscale up. A service that's
|
|
@@ -381,7 +285,7 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
|
|
|
381
285
|
*/
|
|
382
286
|
const portProbe = opts.servicePortProbe ?? (async (p: number) => !(await defaultPortProbe(p)));
|
|
383
287
|
const probeResults = await Promise.all(
|
|
384
|
-
services.map(async (s) => ({ svc: s, up: await portProbe(s.port) })),
|
|
288
|
+
manifest.services.map(async (s) => ({ svc: s, up: await portProbe(s.port) })),
|
|
385
289
|
);
|
|
386
290
|
for (const { svc, up } of probeResults) {
|
|
387
291
|
if (up) continue;
|
|
@@ -394,7 +298,7 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
|
|
|
394
298
|
// Kept for manual debugging / inspection only — the hub server now builds
|
|
395
299
|
// /.well-known/parachute.json dynamically from services.json at request time
|
|
396
300
|
// (#135), so this on-disk copy is no longer load-bearing for any consumer.
|
|
397
|
-
const wellKnownDoc = buildWellKnown({ services, canonicalOrigin });
|
|
301
|
+
const wellKnownDoc = buildWellKnown({ services: manifest.services, canonicalOrigin });
|
|
398
302
|
writeWellKnownFile(wellKnownDoc, wellKnownFilePath);
|
|
399
303
|
log(`Wrote ${wellKnownFilePath}`);
|
|
400
304
|
writeHubFile(hubFilePath);
|
|
@@ -416,7 +320,7 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
|
|
|
416
320
|
hubPort = existing;
|
|
417
321
|
} else {
|
|
418
322
|
const hub = await ensureHubRunning({
|
|
419
|
-
reservedPorts: services.map((s) => s.port),
|
|
323
|
+
reservedPorts: manifest.services.map((s) => s.port),
|
|
420
324
|
...(opts.hubEnsureOpts ?? {}),
|
|
421
325
|
configDir,
|
|
422
326
|
wellKnownDir,
|
|
@@ -428,15 +332,12 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
|
|
|
428
332
|
else log(`✓ hub already running (pid ${hub.pid}, port ${hub.port}).`);
|
|
429
333
|
}
|
|
430
334
|
|
|
431
|
-
const entries = planEntries(
|
|
335
|
+
const entries = planEntries(hubPort);
|
|
432
336
|
log(`Exposing under ${canonicalOrigin} (${layerLabel(layer)}, path-routing, port ${port}):`);
|
|
433
337
|
for (const e of entries) {
|
|
434
338
|
const suffix = e.kind === "proxy" ? `→ ${e.target} (${e.service})` : `→ ${e.target}`;
|
|
435
339
|
log(` ${e.mount.padEnd(30, " ")} ${suffix}`);
|
|
436
340
|
}
|
|
437
|
-
for (const { entry: hiddenSvc, reason } of hidden) {
|
|
438
|
-
log(` (${hiddenSvc.name} is loopback-only — ${reason})`);
|
|
439
|
-
}
|
|
440
341
|
|
|
441
342
|
const cmds = entries.map((e) => bringupCommand(e, { port, funnel }));
|
|
442
343
|
const code = await runEach(runner, cmds, log);
|
|
@@ -470,6 +371,20 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
|
|
|
470
371
|
log(` Discovery: ${canonicalOrigin}${WELL_KNOWN_MOUNT}`);
|
|
471
372
|
log(` OAuth issuer: ${hubOrigin}`);
|
|
472
373
|
|
|
374
|
+
// 2FA-enrollment warning, public-layer only. /admin/login became reachable
|
|
375
|
+
// from every layer when 0.5.3-rc.1 collapsed the access-control matrix into
|
|
376
|
+
// the hub; on Funnel that means the open internet, where 2FA is the
|
|
377
|
+
// defense beyond #188's rate-limit floor. Tailnet exposure stays
|
|
378
|
+
// tailscale-authed at the ingress so the warning is moot there. See #186.
|
|
379
|
+
if (layer === "public") {
|
|
380
|
+
printPublic2FAWarning({
|
|
381
|
+
log,
|
|
382
|
+
publicUrl: canonicalOrigin,
|
|
383
|
+
...(opts.vaultHome !== undefined ? { vaultHome: opts.vaultHome } : {}),
|
|
384
|
+
...(opts.vaultAuthStatus !== undefined ? { status: opts.vaultAuthStatus } : {}),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
473
388
|
// Auto-restart services that cache the hub origin. Aaron hit this on launch
|
|
474
389
|
// day: after `expose public` first-run, vault kept its stale (loopback)
|
|
475
390
|
// PARACHUTE_HUB_ORIGIN, the OAuth issuer didn't match what clients saw, and
|
package/src/commands/install.ts
CHANGED
|
@@ -545,29 +545,27 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
545
545
|
}
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
548
|
+
// Hub-as-port-authority (#53): pick the service's port now and reflect it
|
|
549
|
+
// in services.json. Pre-hub#206 the install path also wrote `PORT=<port>`
|
|
550
|
+
// into the service's `.env`; post-#206 (option A) services.json is the
|
|
551
|
+
// single source of truth — services follow the 4-tier resolvePort ladder
|
|
552
|
+
// (services.json → service config → bare PORT env → compiled-in default,
|
|
553
|
+
// per parachute-scribe#41 / parachute-agent#146 / parachute-agent#148 /
|
|
554
|
+
// parachute-patterns#45), so the duplicate `.env` PORT was at best dead
|
|
555
|
+
// weight and at worst a source of drift on re-install. Existing `.env`
|
|
556
|
+
// PORT lines on operator machines stay where they are — harmless — and
|
|
557
|
+
// future installs no longer touch them.
|
|
555
558
|
const preInitEntry = findService(entryName, manifestPath);
|
|
556
559
|
const probe = opts.portProbe ?? defaultPortProbe;
|
|
557
560
|
const occupied = await collectOccupiedPorts(manifestPath, entryName, preInitEntry?.port, probe);
|
|
558
|
-
const envPath = join(configDir, short, ".env");
|
|
559
561
|
const canonicalPort = spec.seedEntry?.().port ?? preInitEntry?.port;
|
|
560
562
|
const portResult = assignServicePort({
|
|
561
|
-
envPath,
|
|
562
563
|
canonical: canonicalPort,
|
|
563
564
|
occupied,
|
|
564
565
|
});
|
|
565
566
|
if (portResult.warning) {
|
|
566
567
|
log(`⚠ ${portResult.warning}`);
|
|
567
568
|
}
|
|
568
|
-
if (portResult.written) {
|
|
569
|
-
log(`Wrote PORT=${portResult.port} to ${envPath}.`);
|
|
570
|
-
}
|
|
571
569
|
|
|
572
570
|
// Find-or-seed the manifest entry. Re-read after the seed write so a silent
|
|
573
571
|
// upsert failure (filesystem permission, races against an external writer)
|
|
@@ -600,7 +598,7 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
600
598
|
} else if (entry && entry.port !== portResult.port) {
|
|
601
599
|
// init wrote an entry on the canonical port but the CLI assigned a
|
|
602
600
|
// different one (collision). Reflect the CLI's choice so the hub and
|
|
603
|
-
// status views stay consistent with the
|
|
601
|
+
// status views stay consistent with the canonical-port assignment.
|
|
604
602
|
upsertService({ ...entry, port: portResult.port }, manifestPath);
|
|
605
603
|
entry = findService(entryName, manifestPath);
|
|
606
604
|
log(
|
|
@@ -136,6 +136,24 @@ export interface LifecycleOpts {
|
|
|
136
136
|
killWaitMs?: number;
|
|
137
137
|
/** Poll interval while waiting for SIGTERM to land. */
|
|
138
138
|
pollIntervalMs?: number;
|
|
139
|
+
/**
|
|
140
|
+
* How long `start` sleeps before re-checking `alive(pid)` to catch the
|
|
141
|
+
* spawn-then-immediately-die failure shape (hub#194: notes-serve crashed
|
|
142
|
+
* 50ms in on Bun.resolveSync, but `start` reported success because the
|
|
143
|
+
* spawn returned a pid). 250ms is the default in production — long
|
|
144
|
+
* enough to catch real silent-crashes (resolve failures, port
|
|
145
|
+
* collisions, missing args) without making `parachute start` feel
|
|
146
|
+
* laggy.
|
|
147
|
+
*
|
|
148
|
+
* Defaulting policy: if `alive` is not overridden, the settle defaults
|
|
149
|
+
* to 0 (skipped). Stub spawners hand back fake pids that the real
|
|
150
|
+
* `defaultAlive` would mark as dead, which would make every existing
|
|
151
|
+
* stub-spawner test fail spuriously. Tests that want to exercise the
|
|
152
|
+
* settle path inject both `alive` and `startSettleMs` explicitly.
|
|
153
|
+
* Production paths use the real `defaultAlive` and get the real 250ms
|
|
154
|
+
* settle.
|
|
155
|
+
*/
|
|
156
|
+
startSettleMs?: number;
|
|
139
157
|
/**
|
|
140
158
|
* Override the hub origin passed to services as PARACHUTE_HUB_ORIGIN. If
|
|
141
159
|
* unset, `start` derives it from `expose-state.json` (when exposed) or
|
|
@@ -168,6 +186,7 @@ interface Resolved {
|
|
|
168
186
|
log: (line: string) => void;
|
|
169
187
|
killWaitMs: number;
|
|
170
188
|
pollIntervalMs: number;
|
|
189
|
+
startSettleMs: number;
|
|
171
190
|
hubOrigin: string | undefined;
|
|
172
191
|
ensureHub: (opts: EnsureHubOpts) => Promise<EnsureHubResult>;
|
|
173
192
|
stopHubFn: (opts: StopHubOpts) => Promise<boolean>;
|
|
@@ -186,6 +205,14 @@ function resolve(opts: LifecycleOpts): Resolved {
|
|
|
186
205
|
log: opts.log ?? ((line) => console.log(line)),
|
|
187
206
|
killWaitMs: opts.killWaitMs ?? 10_000,
|
|
188
207
|
pollIntervalMs: opts.pollIntervalMs ?? 200,
|
|
208
|
+
// See `LifecycleOpts.startSettleMs` doc. Production (no spawner
|
|
209
|
+
// override, no alive override) gets the 250ms settle. Tests that
|
|
210
|
+
// inject a stub spawner without a stub alive get 0 — `defaultAlive`
|
|
211
|
+
// against a fake pid would always report dead and break unrelated
|
|
212
|
+
// tests. Tests that want to exercise the settle path explicitly
|
|
213
|
+
// override `alive`, which re-enables the default 250ms.
|
|
214
|
+
startSettleMs:
|
|
215
|
+
opts.startSettleMs ?? (opts.spawner === undefined || opts.alive !== undefined ? 250 : 0),
|
|
189
216
|
hubOrigin: resolveHubOrigin(opts.hubOrigin, configDir),
|
|
190
217
|
ensureHub: opts.hub?.ensureRunning ?? ensureHubRunning,
|
|
191
218
|
stopHubFn: opts.hub?.stop ?? stopHub,
|
|
@@ -371,16 +398,38 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
|
|
|
371
398
|
if (entry.installDir) spawnerOpts.cwd = entry.installDir;
|
|
372
399
|
const passOpts =
|
|
373
400
|
spawnerOpts.env !== undefined || spawnerOpts.cwd !== undefined ? spawnerOpts : undefined;
|
|
401
|
+
let pid: number;
|
|
374
402
|
try {
|
|
375
|
-
|
|
376
|
-
writePid(short, pid, r.configDir);
|
|
377
|
-
r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
|
|
378
|
-
if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
|
|
403
|
+
pid = r.spawner.spawn(cmd, logFile, passOpts);
|
|
379
404
|
} catch (err) {
|
|
380
405
|
failures++;
|
|
381
406
|
const msg = err instanceof Error ? err.message : String(err);
|
|
382
407
|
r.log(`✗ ${short} failed to start: ${msg}`);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
writePid(short, pid, r.configDir);
|
|
411
|
+
|
|
412
|
+
// Settle-poll for spawn-then-immediately-die (hub#194). A spawn returning
|
|
413
|
+
// a pid only proves the kernel forked the process; the child may exit
|
|
414
|
+
// microseconds later if its main code path throws before listening
|
|
415
|
+
// (e.g. notes-serve's Bun.resolveSync failing for bun-linked installs).
|
|
416
|
+
// Without this poll, we'd report success and the operator would chase
|
|
417
|
+
// a phantom 502.
|
|
418
|
+
if (r.startSettleMs > 0) {
|
|
419
|
+
await r.sleep(r.startSettleMs);
|
|
420
|
+
if (!r.alive(pid)) {
|
|
421
|
+
clearPid(short, r.configDir);
|
|
422
|
+
failures++;
|
|
423
|
+
r.log(
|
|
424
|
+
`✗ ${short} failed to start: spawned pid ${pid} but the process exited within ${r.startSettleMs}ms.`,
|
|
425
|
+
);
|
|
426
|
+
r.log(` Tail the log for details: tail -50 ${logFile}`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
383
429
|
}
|
|
430
|
+
|
|
431
|
+
r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
|
|
432
|
+
if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
|
|
384
433
|
}
|
|
385
434
|
return failures === 0 ? 0 : 1;
|
|
386
435
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
2
2
|
import { HUB_SVC, readHubPort } from "../hub-control.ts";
|
|
3
3
|
import { type AliveFn, defaultAlive, formatUptime, processState } from "../process-state.ts";
|
|
4
|
-
import { getSpec, shortNameForManifest } from "../service-spec.ts";
|
|
4
|
+
import { canonicalPortForManifest, getSpec, shortNameForManifest } from "../service-spec.ts";
|
|
5
5
|
import { type ServiceEntry, readManifest } from "../services-manifest.ts";
|
|
6
6
|
|
|
7
7
|
export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
|
|
@@ -74,6 +74,14 @@ interface StatusRow {
|
|
|
74
74
|
url: string | undefined;
|
|
75
75
|
healthy: boolean;
|
|
76
76
|
skipped: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Canonical-port drift warning. Set when the entry has a known canonical
|
|
79
|
+
* port (first-party / known short) AND the actual port differs. Surfaced
|
|
80
|
+
* as a continuation line under the row so operators see a silent miswire
|
|
81
|
+
* (e.g. parachute-hub#195: scribe + agent both at 1944) without us
|
|
82
|
+
* hard-erroring on a deliberate operator port change.
|
|
83
|
+
*/
|
|
84
|
+
driftWarning?: string;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
/**
|
|
@@ -157,6 +165,19 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
157
165
|
|
|
158
166
|
const url = urlForEntry(entry, short);
|
|
159
167
|
|
|
168
|
+
// Canonical-port drift detection (hub#195). Only fires for known
|
|
169
|
+
// first-party services where we have a canonical assignment. Third-party
|
|
170
|
+
// rows have no canonical to compare against. Warning is informational —
|
|
171
|
+
// operators may have moved a service off canonical deliberately.
|
|
172
|
+
// Note: multi-vault instance rows (`parachute-vault-<instance>`) don't
|
|
173
|
+
// match a canonical manifest name, so drift warnings don't fire for
|
|
174
|
+
// them. Intentional — see `canonicalPortForManifest` for the rationale.
|
|
175
|
+
const canonical = canonicalPortForManifest(entry.name);
|
|
176
|
+
const driftWarning =
|
|
177
|
+
canonical !== undefined && canonical !== entry.port
|
|
178
|
+
? `canonical port is ${canonical}`
|
|
179
|
+
: undefined;
|
|
180
|
+
|
|
160
181
|
// Only skip probe when we know the process is dead (PID file was
|
|
161
182
|
// present but kill(pid, 0) failed). "unknown" status (no PID file)
|
|
162
183
|
// still probes — externally-managed services should report health.
|
|
@@ -173,6 +194,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
173
194
|
url,
|
|
174
195
|
healthy: false,
|
|
175
196
|
skipped: true,
|
|
197
|
+
driftWarning,
|
|
176
198
|
};
|
|
177
199
|
}
|
|
178
200
|
|
|
@@ -194,6 +216,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
194
216
|
url,
|
|
195
217
|
healthy: p.healthy,
|
|
196
218
|
skipped: false,
|
|
219
|
+
driftWarning,
|
|
197
220
|
};
|
|
198
221
|
}),
|
|
199
222
|
);
|
|
@@ -228,6 +251,10 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
228
251
|
if (!cells || !row) continue;
|
|
229
252
|
print(formatRow(cells, widths));
|
|
230
253
|
if (row.url) print(` → ${row.url}`);
|
|
254
|
+
// Drift warning rides as its own continuation line. Plain ASCII (no
|
|
255
|
+
// emoji / unicode glyphs) for terminal compatibility — the same
|
|
256
|
+
// surface that prints to scripts piping `parachute status`.
|
|
257
|
+
if (row.driftWarning) print(` ! ${row.driftWarning}`);
|
|
231
258
|
}
|
|
232
259
|
|
|
233
260
|
/**
|
package/src/help.ts
CHANGED
|
@@ -44,9 +44,9 @@ Services:
|
|
|
44
44
|
What it does:
|
|
45
45
|
1. bun add -g @openparachute/<service>[@<tag>]
|
|
46
46
|
2. run any service-specific init (e.g. \`parachute-vault init\`)
|
|
47
|
-
3. assign a canonical port (1939–1949) and
|
|
48
|
-
\`~/.parachute
|
|
49
|
-
|
|
47
|
+
3. assign a canonical port (1939–1949) and reflect it in
|
|
48
|
+
\`~/.parachute/services.json\` — the single source of truth at boot
|
|
49
|
+
(services follow a 4-tier resolvePort ladder; services.json wins).
|
|
50
50
|
4. verify the service registered itself in ~/.parachute/services.json
|
|
51
51
|
5. for scribe in a TTY: prompt for transcription provider + API key
|
|
52
52
|
(or take \`--scribe-provider\` / \`--scribe-key\`)
|