@openparachute/hub 0.7.6-rc.1 → 0.7.6-rc.4
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 +1 -1
- package/package.json +1 -1
- package/src/__tests__/doctor.test.ts +131 -0
- package/src/__tests__/hub-instance.test.ts +297 -0
- package/src/__tests__/hub-server.test.ts +170 -1
- package/src/__tests__/scope-explanations.test.ts +1 -1
- package/src/__tests__/status-supervisor.test.ts +112 -0
- package/src/admin-clients.ts +1 -1
- package/src/admin-module-token.ts +1 -1
- package/src/brand.ts +1 -1
- package/src/chrome-strip.ts +2 -2
- package/src/commands/doctor.ts +167 -4
- package/src/commands/install.ts +2 -2
- package/src/commands/serve.ts +52 -0
- package/src/commands/status.ts +44 -3
- package/src/commands/upgrade.ts +4 -2
- package/src/help.ts +1 -1
- package/src/hub-instance.ts +365 -0
- package/src/hub-server.ts +90 -2
- package/src/hub.ts +3 -3
- package/src/module-manifest.ts +3 -3
- package/src/resource-binding.ts +1 -1
- package/src/scope-explanations.ts +1 -1
- package/src/scope-registry.ts +1 -1
- package/src/services-manifest.ts +3 -3
- package/src/well-known.ts +1 -1
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub instance identity + loopback-hijack detection (hub#737).
|
|
3
|
+
*
|
|
4
|
+
* ## The incident this defends against (2026-07-02 P0)
|
|
5
|
+
*
|
|
6
|
+
* The hub binds `*:1939` (INADDR_ANY / `0.0.0.0`). An OrbStack Linux machine
|
|
7
|
+
* auto-forwarded ITS port 1939 onto the host as a SPECIFIC bind on
|
|
8
|
+
* `127.0.0.1:1939` — and a specific loopback bind WINS over a wildcard bind for
|
|
9
|
+
* all loopback traffic. Every module's JWKS/API call to `127.0.0.1:1939`
|
|
10
|
+
* silently reached the WRONG hub (a fresh container DB → empty JWKS, no admin),
|
|
11
|
+
* so every hub-JWT validation failed `no applicable key found in the JWKS` and
|
|
12
|
+
* the ecosystem 401-looped for hours. `lsof -nP -i :1939` showed two LISTENs;
|
|
13
|
+
* the tell was `/health` reporting the container's version, not the checkout's.
|
|
14
|
+
*
|
|
15
|
+
* ## The primitive: a per-process instance nonce
|
|
16
|
+
*
|
|
17
|
+
* Each `parachute serve` process generates a random nonce at boot, (a) exposes
|
|
18
|
+
* it as `instance` in `/health`, and (b) writes it to
|
|
19
|
+
* `~/.parachute/hub-instance.json` (0644). That file is the linchpin: an
|
|
20
|
+
* EXTERNAL process (`parachute status`, `parachute doctor`) can learn THIS
|
|
21
|
+
* hub's true identity from disk WITHOUT traversing the (possibly hijacked)
|
|
22
|
+
* loopback — then compare it to what a loopback `GET /health` actually returns.
|
|
23
|
+
* A mismatch means another process owns `127.0.0.1:<port>`.
|
|
24
|
+
*
|
|
25
|
+
* The in-process self-probe (armed by `serve`) compares its own in-memory nonce
|
|
26
|
+
* to the loopback `/health` it fetches, logs loudly on mismatch, and records the
|
|
27
|
+
* verdict back into the same file's `selfProbe` field so external tools surface
|
|
28
|
+
* the serve process's own authoritative reading without re-probing.
|
|
29
|
+
*
|
|
30
|
+
* Every side effect (fs, network probe) is behind an injectable seam so the
|
|
31
|
+
* whole module runs deterministically in tests with no real network / disk.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { randomUUID } from "node:crypto";
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
import { CONFIG_DIR } from "./config.ts";
|
|
38
|
+
|
|
39
|
+
/** The public incident reference operators grep for. */
|
|
40
|
+
export const HIJACK_INCIDENT_REF = "hub#737 / team-vault Log/2026-07-02-port-exhaustion-incident";
|
|
41
|
+
|
|
42
|
+
/** Self-probe verdicts. `ok` = loopback reaches us; `hijacked` = someone else owns loopback. */
|
|
43
|
+
export type SelfProbeStatus = "ok" | "hijacked" | "unreachable";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The serve process's own most-recent loopback self-probe reading, persisted
|
|
47
|
+
* into the instance file so external readers (`status`) see the authoritative
|
|
48
|
+
* verdict without re-probing the (possibly hijacked) loopback themselves.
|
|
49
|
+
*/
|
|
50
|
+
export interface SelfProbeState {
|
|
51
|
+
status: SelfProbeStatus;
|
|
52
|
+
/** ISO timestamp of the reading. */
|
|
53
|
+
checkedAt: string;
|
|
54
|
+
/** The `instance` the loopback `/health` actually returned (present on a `hijacked` reading). */
|
|
55
|
+
observedInstance?: string;
|
|
56
|
+
/** One-line human detail (loud message on a hijack; the probe error class on unreachable). */
|
|
57
|
+
detail?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The `~/.parachute/hub-instance.json` record. */
|
|
61
|
+
export interface HubInstanceRecord {
|
|
62
|
+
/** Per-process random nonce (`crypto.randomUUID`) minted at serve boot. */
|
|
63
|
+
instance: string;
|
|
64
|
+
/** The serve process PID (informational — helps an operator map the file to a process). */
|
|
65
|
+
pid: number;
|
|
66
|
+
/** The port this serve bound. */
|
|
67
|
+
port: number;
|
|
68
|
+
/** ISO timestamp of serve boot. */
|
|
69
|
+
startedAt: string;
|
|
70
|
+
/** Last self-probe reading, patched in by the running serve process. */
|
|
71
|
+
selfProbe?: SelfProbeState;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Mint a fresh per-process nonce. */
|
|
75
|
+
export function generateInstanceNonce(): string {
|
|
76
|
+
return randomUUID();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Path to the instance file under a config dir (default `~/.parachute`). */
|
|
80
|
+
export function hubInstancePath(configDir: string = CONFIG_DIR): string {
|
|
81
|
+
return join(configDir, "hub-instance.json");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Atomically write the instance record (tmp + rename, 0644). Best-effort: a
|
|
86
|
+
* write failure must NEVER take the hub down — the file is a diagnostic aid, not
|
|
87
|
+
* a load-bearing runtime dependency. Returns true on success.
|
|
88
|
+
*/
|
|
89
|
+
export function writeHubInstanceFile(
|
|
90
|
+
record: HubInstanceRecord,
|
|
91
|
+
opts: { configDir?: string; log?: (line: string) => void } = {},
|
|
92
|
+
): boolean {
|
|
93
|
+
const path = hubInstancePath(opts.configDir);
|
|
94
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
95
|
+
try {
|
|
96
|
+
const dir = dirname(path);
|
|
97
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
98
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o644 });
|
|
99
|
+
renameSync(tmp, path);
|
|
100
|
+
return true;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Don't leave a half-written tmp behind if the rename (or write) failed.
|
|
103
|
+
try {
|
|
104
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
105
|
+
} catch {
|
|
106
|
+
// Best-effort cleanup — nothing more we can do.
|
|
107
|
+
}
|
|
108
|
+
opts.log?.(
|
|
109
|
+
`parachute serve: could not write ${path} (${err instanceof Error ? err.message : String(err)}); loopback-hijack detection for external tools is degraded, hub start continues.`,
|
|
110
|
+
);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Remove the instance file (best-effort). Called on graceful shutdown so a
|
|
117
|
+
* cleanly-stopped hub doesn't leave a stale identity/self-probe verdict on disk
|
|
118
|
+
* for `status` / `doctor` to read as a phantom. A hard kill (SIGKILL) can't run
|
|
119
|
+
* this — the readers additionally gate on live hub liveness, so a leftover file
|
|
120
|
+
* from a hard kill never surfaces as a false hijack.
|
|
121
|
+
*/
|
|
122
|
+
export function clearHubInstanceFile(configDir: string = CONFIG_DIR): void {
|
|
123
|
+
try {
|
|
124
|
+
rmSync(hubInstancePath(configDir), { force: true });
|
|
125
|
+
} catch {
|
|
126
|
+
// Best-effort — a missing / unremovable file is not worth surfacing.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read + validate the instance file. Returns null on absence / unreadable /
|
|
132
|
+
* malformed — a missing file is the benign "no nonce-aware serve wrote one yet"
|
|
133
|
+
* state, never an error.
|
|
134
|
+
*/
|
|
135
|
+
export function readHubInstanceFile(configDir: string = CONFIG_DIR): HubInstanceRecord | null {
|
|
136
|
+
const path = hubInstancePath(configDir);
|
|
137
|
+
let raw: unknown;
|
|
138
|
+
try {
|
|
139
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (!raw || typeof raw !== "object") return null;
|
|
144
|
+
const r = raw as Record<string, unknown>;
|
|
145
|
+
if (typeof r.instance !== "string" || r.instance.length === 0) return null;
|
|
146
|
+
if (typeof r.port !== "number") return null;
|
|
147
|
+
const rec: HubInstanceRecord = {
|
|
148
|
+
instance: r.instance,
|
|
149
|
+
pid: typeof r.pid === "number" ? r.pid : -1,
|
|
150
|
+
port: r.port,
|
|
151
|
+
startedAt: typeof r.startedAt === "string" ? r.startedAt : "",
|
|
152
|
+
};
|
|
153
|
+
const sp = r.selfProbe;
|
|
154
|
+
if (sp && typeof sp === "object") {
|
|
155
|
+
const s = sp as Record<string, unknown>;
|
|
156
|
+
if (s.status === "ok" || s.status === "hijacked" || s.status === "unreachable") {
|
|
157
|
+
const state: SelfProbeState = {
|
|
158
|
+
status: s.status,
|
|
159
|
+
checkedAt: typeof s.checkedAt === "string" ? s.checkedAt : "",
|
|
160
|
+
};
|
|
161
|
+
if (typeof s.observedInstance === "string") state.observedInstance = s.observedInstance;
|
|
162
|
+
if (typeof s.detail === "string") state.detail = s.detail;
|
|
163
|
+
rec.selfProbe = state;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return rec;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The result of probing a loopback `/health`. */
|
|
170
|
+
export interface LoopbackProbe {
|
|
171
|
+
/** The socket answered at all (any HTTP status). */
|
|
172
|
+
reachable: boolean;
|
|
173
|
+
/** HTTP status, when reachable. */
|
|
174
|
+
status?: number;
|
|
175
|
+
/** The `instance` field of the JSON body, when present + parseable. */
|
|
176
|
+
instance?: string;
|
|
177
|
+
/** True when the body self-identifies as a parachute hub (`service: "parachute-hub"`). */
|
|
178
|
+
isHub?: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Probe `http://127.0.0.1:<port>/health` and extract the instance identity.
|
|
183
|
+
* Bounded (default 1.5s); never throws — a network error is `{ reachable: false }`.
|
|
184
|
+
*/
|
|
185
|
+
export async function probeLoopbackInstance(
|
|
186
|
+
port: number,
|
|
187
|
+
opts: { timeoutMs?: number; fetchFn?: typeof fetch } = {},
|
|
188
|
+
): Promise<LoopbackProbe> {
|
|
189
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetchFn(`http://127.0.0.1:${port}/health`, {
|
|
192
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 1500),
|
|
193
|
+
});
|
|
194
|
+
const out: LoopbackProbe = { reachable: true, status: res.status };
|
|
195
|
+
try {
|
|
196
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
197
|
+
if (typeof body.instance === "string") out.instance = body.instance;
|
|
198
|
+
if (body.service === "parachute-hub") out.isHub = true;
|
|
199
|
+
} catch {
|
|
200
|
+
// A non-JSON / unparseable body still counts as "reachable" — a foreign
|
|
201
|
+
// process answering the port with junk is exactly the hijack shape.
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
} catch {
|
|
205
|
+
return { reachable: false };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Classify a loopback probe against our TRUE nonce.
|
|
211
|
+
* - not reachable → `unreachable` (we bound, but loopback refused/timed out — suspicious but soft).
|
|
212
|
+
* - reachable, instance === ours → `ok`.
|
|
213
|
+
* - reachable, instance !== ours → `hijacked` (a DIFFERENT process owns loopback: another hub, or a foreign
|
|
214
|
+
* server answering `/health` with no/other instance — the OrbStack-shadow class).
|
|
215
|
+
*/
|
|
216
|
+
export function classifyLoopback(ourNonce: string, probe: LoopbackProbe): SelfProbeStatus {
|
|
217
|
+
if (!probe.reachable) return "unreachable";
|
|
218
|
+
if (probe.instance === ourNonce) return "ok";
|
|
219
|
+
return "hijacked";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The LOUD, structured hijack alert. Names the class + the exact diagnosis
|
|
224
|
+
* commands + the incident reference so an operator scanning logs can act
|
|
225
|
+
* immediately. Repeated verbatim on every probe while mismatched (by design —
|
|
226
|
+
* a single line scrolls away; a hijack is a standing emergency).
|
|
227
|
+
*/
|
|
228
|
+
export function hijackAlertMessage(port: number, observedInstance?: string): string {
|
|
229
|
+
const observed = observedInstance
|
|
230
|
+
? `a DIFFERENT hub (instance=${observedInstance})`
|
|
231
|
+
: "a foreign process (no hub instance nonce in its /health)";
|
|
232
|
+
return [
|
|
233
|
+
`parachute serve: LOOPBACK HIJACK on 127.0.0.1:${port} — this hub bound the port but loopback /health is answered by ${observed}.`,
|
|
234
|
+
" Loopback traffic (module JWKS/API calls, CLI probes) is NOT reaching this hub — every hub-JWT validation downstream will fail.",
|
|
235
|
+
` A specific 127.0.0.1:${port} bind (commonly an OrbStack/container port-forward) wins over this hub's wildcard bind.`,
|
|
236
|
+
` Diagnose: lsof -nP -iTCP:${port} -sTCP:LISTEN (expect ONE listener — this hub)`,
|
|
237
|
+
` orb list (stop/delete any VM auto-forwarding ${port}, e.g. a leftover smoke-test machine)`,
|
|
238
|
+
` Incident: ${HIJACK_INCIDENT_REF}`,
|
|
239
|
+
].join("\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The softer "we're listening but loopback didn't answer" note (logged once per state change). */
|
|
243
|
+
export function unreachableNote(port: number): string {
|
|
244
|
+
return `parachute serve: loopback /health on 127.0.0.1:${port} did not answer, yet this hub is bound — transient, or another process is interfering with loopback. Watching (will re-probe).`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Self-probe timer (armed by `serve` after the listener is up)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
export interface HubSelfProbe {
|
|
252
|
+
/** Stop the interval. */
|
|
253
|
+
stop(): void;
|
|
254
|
+
/** Run exactly one probe now (used for the immediate startup check + tests). */
|
|
255
|
+
probeOnce(): Promise<SelfProbeStatus>;
|
|
256
|
+
/** The most recent in-memory verdict (tests). */
|
|
257
|
+
getState(): SelfProbeState | undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface HubSelfProbeDeps<H = unknown> {
|
|
261
|
+
/** Poll cadence in ms. Default 300_000 (5 min) — a safety net, not a hot path. */
|
|
262
|
+
intervalMs?: number;
|
|
263
|
+
/** Loopback probe (default {@link probeLoopbackInstance}). */
|
|
264
|
+
probe?: (port: number) => Promise<LoopbackProbe>;
|
|
265
|
+
/** Persist the verdict (default: patch the instance file's `selfProbe`). */
|
|
266
|
+
writeState?: (state: SelfProbeState) => void;
|
|
267
|
+
/** Loud log sink (default `console.error`). */
|
|
268
|
+
log?: (line: string) => void;
|
|
269
|
+
/** Clock seam (default `() => new Date()`). */
|
|
270
|
+
now?: () => Date;
|
|
271
|
+
/** Injectable scheduler (default `setInterval`). Tests drive ticks manually. */
|
|
272
|
+
setIntervalFn?: (cb: () => void, ms: number) => H;
|
|
273
|
+
/** Injectable clear (default `clearInterval`). */
|
|
274
|
+
clearIntervalFn?: (handle: H) => void;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Arm the loopback self-probe. On each tick (and on the immediate startup
|
|
279
|
+
* `probeOnce`) it fetches loopback `/health`, compares the returned instance to
|
|
280
|
+
* OUR nonce, logs per the incident-severity rules, and persists the verdict:
|
|
281
|
+
*
|
|
282
|
+
* - `hijacked` → LOUD structured alert EVERY tick (standing emergency), verdict persisted.
|
|
283
|
+
* - `unreachable`→ softer note, logged ONLY on a state change (avoid a spinning log on a flaky loopback).
|
|
284
|
+
* - `ok` → recovery line logged once when clearing a prior non-ok verdict.
|
|
285
|
+
*
|
|
286
|
+
* The verdict is written to the instance file's `selfProbe` field so external
|
|
287
|
+
* tools (`status`) read the authoritative reading without re-probing the
|
|
288
|
+
* hijacked loopback. Overlapping ticks are guarded (a slow probe never stacks).
|
|
289
|
+
* The interval is `unref`'d so it never keeps the event loop alive on its own.
|
|
290
|
+
*/
|
|
291
|
+
export function armHubSelfProbe<H = ReturnType<typeof setInterval>>(
|
|
292
|
+
args: { port: number; nonce: string; record: HubInstanceRecord; configDir?: string },
|
|
293
|
+
deps: HubSelfProbeDeps<H> = {},
|
|
294
|
+
): HubSelfProbe {
|
|
295
|
+
const { port, nonce, record } = args;
|
|
296
|
+
const intervalMs = deps.intervalMs ?? 300_000;
|
|
297
|
+
const probe = deps.probe ?? probeLoopbackInstance;
|
|
298
|
+
const log = deps.log ?? ((line: string) => console.error(line));
|
|
299
|
+
const now = deps.now ?? (() => new Date());
|
|
300
|
+
const writeState =
|
|
301
|
+
deps.writeState ??
|
|
302
|
+
((state: SelfProbeState) =>
|
|
303
|
+
writeHubInstanceFile(
|
|
304
|
+
{ ...record, selfProbe: state },
|
|
305
|
+
{ ...(args.configDir !== undefined ? { configDir: args.configDir } : {}), log },
|
|
306
|
+
));
|
|
307
|
+
const setIntervalFn =
|
|
308
|
+
deps.setIntervalFn ?? ((cb: () => void, ms: number) => setInterval(cb, ms) as unknown as H);
|
|
309
|
+
const clearIntervalFn =
|
|
310
|
+
deps.clearIntervalFn ??
|
|
311
|
+
((h: H) => clearInterval(h as unknown as ReturnType<typeof setInterval>));
|
|
312
|
+
|
|
313
|
+
let last: SelfProbeState | undefined;
|
|
314
|
+
let inFlight = false;
|
|
315
|
+
|
|
316
|
+
async function probeOnce(): Promise<SelfProbeStatus> {
|
|
317
|
+
if (inFlight) return last?.status ?? "ok";
|
|
318
|
+
inFlight = true;
|
|
319
|
+
try {
|
|
320
|
+
const result = await probe(port);
|
|
321
|
+
const status = classifyLoopback(nonce, result);
|
|
322
|
+
const state: SelfProbeState = { status, checkedAt: now().toISOString() };
|
|
323
|
+
if (status === "hijacked") {
|
|
324
|
+
if (result.instance !== undefined) state.observedInstance = result.instance;
|
|
325
|
+
state.detail = hijackAlertMessage(port, result.instance);
|
|
326
|
+
// LOUD every tick — a hijack is a standing emergency, not a one-shot notice.
|
|
327
|
+
log(state.detail);
|
|
328
|
+
} else if (status === "unreachable") {
|
|
329
|
+
state.detail = unreachableNote(port);
|
|
330
|
+
if (last?.status !== "unreachable") log(state.detail);
|
|
331
|
+
} else {
|
|
332
|
+
// ok — announce recovery once when clearing a prior non-ok verdict.
|
|
333
|
+
if (last && last.status !== "ok") {
|
|
334
|
+
log(
|
|
335
|
+
`parachute serve: loopback /health on 127.0.0.1:${port} is back to this hub (instance=${nonce}). Hijack cleared.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
last = state;
|
|
340
|
+
try {
|
|
341
|
+
writeState(state);
|
|
342
|
+
} catch {
|
|
343
|
+
// Persisting the verdict is best-effort; the loud log already fired.
|
|
344
|
+
}
|
|
345
|
+
return status;
|
|
346
|
+
} finally {
|
|
347
|
+
inFlight = false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const handle = setIntervalFn(() => {
|
|
352
|
+
void probeOnce();
|
|
353
|
+
}, intervalMs);
|
|
354
|
+
(handle as { unref?: () => void }).unref?.();
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
stop() {
|
|
358
|
+
clearIntervalFn(handle);
|
|
359
|
+
},
|
|
360
|
+
probeOnce,
|
|
361
|
+
getState() {
|
|
362
|
+
return last;
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
package/src/hub-server.ts
CHANGED
|
@@ -782,6 +782,52 @@ export function wsCapBucketKey(req: Request, peerAddr: string | null): string {
|
|
|
782
782
|
return peer ?? WS_CAP_SHARED_BUCKET;
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
+
/**
|
|
786
|
+
* Hop-by-hop headers (RFC 9110 §7.6.1) — connection-scoped, meaningful only
|
|
787
|
+
* on a single transport hop. An intermediary MUST NOT forward them, and the
|
|
788
|
+
* hub is exactly such an intermediary between the client and each loopback
|
|
789
|
+
* module.
|
|
790
|
+
*
|
|
791
|
+
* Forwarding a client's `Connection: close` verbatim was the P0 amplifier in
|
|
792
|
+
* the 2026-07-02 port exhaustion (hub#738): Bun's fetch honors the forwarded
|
|
793
|
+
* `Connection: close` and opens a FRESH ephemeral socket per proxied request
|
|
794
|
+
* instead of reusing its per-origin keep-alive pool. A hot client loop then
|
|
795
|
+
* converts request volume 1:1 into 30s TIME_WAIT entries (macOS: ~16k
|
|
796
|
+
* ephemeral ports), exhausting the range and taking out all host outbound.
|
|
797
|
+
* With these stripped, Bun reuses a handful of pooled sockets to each 127.0.0.1
|
|
798
|
+
* upstream regardless of what the client sends.
|
|
799
|
+
*/
|
|
800
|
+
const HOP_BY_HOP_HEADERS = [
|
|
801
|
+
"connection",
|
|
802
|
+
"keep-alive",
|
|
803
|
+
"proxy-authenticate",
|
|
804
|
+
"proxy-authorization",
|
|
805
|
+
"te",
|
|
806
|
+
"trailer",
|
|
807
|
+
"transfer-encoding",
|
|
808
|
+
"upgrade",
|
|
809
|
+
] as const;
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Delete hop-by-hop headers from an outgoing proxy header bag (mutates in
|
|
813
|
+
* place). The `Connection` field-value can NAME further headers to drop
|
|
814
|
+
* (RFC 9110 §7.6.1 — e.g. `Connection: close, X-Custom`), so those tokens are
|
|
815
|
+
* collected and deleted before the standard set. WebSocket upgrades never
|
|
816
|
+
* reach the fetch-based proxy (the Bun-native bridge handles them before
|
|
817
|
+
* dispatch — see `proxyRequest`'s docstring), so dropping `Upgrade`/`Connection`
|
|
818
|
+
* here only ever touches non-declaring mounts, which see a plain request.
|
|
819
|
+
*/
|
|
820
|
+
export function stripHopByHopHeaders(headers: Headers): void {
|
|
821
|
+
const connectionValue = headers.get("connection");
|
|
822
|
+
if (connectionValue) {
|
|
823
|
+
for (const token of connectionValue.split(",")) {
|
|
824
|
+
const named = token.trim().toLowerCase();
|
|
825
|
+
if (named) headers.delete(named);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
for (const name of HOP_BY_HOP_HEADERS) headers.delete(name);
|
|
829
|
+
}
|
|
830
|
+
|
|
785
831
|
/**
|
|
786
832
|
* Forward a request to a loopback service on `127.0.0.1:<port>`. By default
|
|
787
833
|
* the incoming pathname + query are preserved verbatim; pass `targetPath` to
|
|
@@ -847,6 +893,12 @@ async function proxyRequest(
|
|
|
847
893
|
// Host comes from the requester (tailnet FQDN); the loopback target wants
|
|
848
894
|
// its own. Bun's fetch fills it in when omitted.
|
|
849
895
|
headers.delete("host");
|
|
896
|
+
// Strip hop-by-hop headers before forwarding (RFC 9110 §7.6.1). Critically
|
|
897
|
+
// this drops a client-supplied `Connection: close`, which Bun's fetch would
|
|
898
|
+
// otherwise honor by disabling keep-alive and burning a fresh ephemeral
|
|
899
|
+
// socket per request — the P0 amplifier in hub#738. Bun refills the
|
|
900
|
+
// connection framing for the upstream hop itself.
|
|
901
|
+
stripHopByHopHeaders(headers);
|
|
850
902
|
// Force upstreams to reply with uncompressed bodies. The chrome-strip
|
|
851
903
|
// injector (workstream G) buffers + TextDecoders the HTML response to
|
|
852
904
|
// inject the persistent chrome; without this, a gzip- or br-compressed
|
|
@@ -887,6 +939,13 @@ async function proxyRequest(
|
|
|
887
939
|
method: req.method,
|
|
888
940
|
headers,
|
|
889
941
|
redirect: "manual",
|
|
942
|
+
// Forward the incoming request's abort signal to the upstream hop. When a
|
|
943
|
+
// client hangs up mid-response, this aborts the loopback fetch so the
|
|
944
|
+
// upstream stops streaming to a gone client and its socket is released
|
|
945
|
+
// back to the pool (or closed) instead of running the full body out and
|
|
946
|
+
// holding the connection — a secondary socket-retention leak alongside the
|
|
947
|
+
// TIME_WAIT churn (hub#738). `req.signal` is present on Bun.serve requests.
|
|
948
|
+
signal: req.signal,
|
|
890
949
|
};
|
|
891
950
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
892
951
|
init.body = req.body;
|
|
@@ -895,6 +954,14 @@ async function proxyRequest(
|
|
|
895
954
|
try {
|
|
896
955
|
return await fetch(upstream, init);
|
|
897
956
|
} catch (err) {
|
|
957
|
+
// Client hung up mid-flight: the upstream fetch was aborted via req.signal
|
|
958
|
+
// (forwarded above), not an upstream failure. The client is gone, so the
|
|
959
|
+
// response is discarded — don't run the boot-readiness classifier or
|
|
960
|
+
// render a "module unreachable" page that would misclassify a normal
|
|
961
|
+
// disconnect. 499 = client closed request (nginx convention).
|
|
962
|
+
if (req.signal?.aborted) {
|
|
963
|
+
return new Response(null, { status: 499 });
|
|
964
|
+
}
|
|
898
965
|
const msg = err instanceof Error ? err.message : String(err);
|
|
899
966
|
// Classify the failure (transient boot-window vs persistent crash) and
|
|
900
967
|
// render either an HTML page or a JSON error per the request's Accept.
|
|
@@ -1261,6 +1328,17 @@ export interface HubFetchDeps {
|
|
|
1261
1328
|
* rejected" because Origin ≠ tailnet issuer).
|
|
1262
1329
|
*/
|
|
1263
1330
|
loopbackPort?: number;
|
|
1331
|
+
/**
|
|
1332
|
+
* This serve process's per-boot instance nonce (hub#737). When present it's
|
|
1333
|
+
* echoed as `instance` in `/health` so an external reader can tell whether a
|
|
1334
|
+
* loopback `/health` actually reached THIS hub or a foreign process that has
|
|
1335
|
+
* shadowed the port (the OrbStack loopback-hijack class). `serve` mints it,
|
|
1336
|
+
* writes it to `~/.parachute/hub-instance.json`, and threads it here; absent
|
|
1337
|
+
* on the DB-less / test / `bun src/hub-server.ts` paths, where `/health`
|
|
1338
|
+
* simply omits the field (additive — no consumer parses `/health` for
|
|
1339
|
+
* `instance` strictly).
|
|
1340
|
+
*/
|
|
1341
|
+
instanceNonce?: string;
|
|
1264
1342
|
/**
|
|
1265
1343
|
* Test seam for reading `expose-state.json`'s `hubOrigin`. Production reads
|
|
1266
1344
|
* the operator's `~/.parachute/expose-state.json` via `readExposeState`;
|
|
@@ -1341,7 +1419,7 @@ async function loadManagementUrls(
|
|
|
1341
1419
|
* earlier "vaults browse via Notes — no tile" rule retired with PR 1 of
|
|
1342
1420
|
* workstream C; operators administer per-vault tokens / config / MCP via
|
|
1343
1421
|
* the vault admin SPA, which is a different audience from Notes' content
|
|
1344
|
-
* browse. See [`module-ui-declaration.md` §"Use vs admin"](https://github.com/ParachuteComputer/parachute-
|
|
1422
|
+
* browse. See [`module-ui-declaration.md` §"Use vs admin"](https://github.com/ParachuteComputer/parachute-hub/blob/main/docs/contracts/module-ui-declaration.md#use-vs-admin--both-can-be-true).
|
|
1345
1423
|
*
|
|
1346
1424
|
* `loadManagementUrls` continues to handle vault's `managementUrl` for
|
|
1347
1425
|
* the hub admin SPA's vault-list "Manage" link — a different surface
|
|
@@ -2314,7 +2392,17 @@ export function hubFetch(
|
|
|
2314
2392
|
}
|
|
2315
2393
|
}
|
|
2316
2394
|
return new Response(
|
|
2317
|
-
JSON.stringify({
|
|
2395
|
+
JSON.stringify({
|
|
2396
|
+
status: "ok",
|
|
2397
|
+
service: "parachute-hub",
|
|
2398
|
+
version: pkg.version,
|
|
2399
|
+
db,
|
|
2400
|
+
// Per-boot instance nonce (hub#737): lets an external reader detect a
|
|
2401
|
+
// loopback hijack (foreign process shadowing 127.0.0.1:<port>) by
|
|
2402
|
+
// comparing this to the nonce serve wrote to hub-instance.json.
|
|
2403
|
+
// Omitted when unset (DB-less / test / dev-entrypoint paths).
|
|
2404
|
+
...(deps?.instanceNonce ? { instance: deps.instanceNonce } : {}),
|
|
2405
|
+
}),
|
|
2318
2406
|
{
|
|
2319
2407
|
headers: {
|
|
2320
2408
|
"content-type": "application/json",
|
package/src/hub.ts
CHANGED
|
@@ -484,7 +484,7 @@ const DISCOVERY_SCRIPT = `<script>
|
|
|
484
484
|
// The previous SERVICE_LABELS / SERVICE_ORDER / isVaultName hardcoding
|
|
485
485
|
// is retired: vault has no uiUrl, so the "skip vault" rule emerges
|
|
486
486
|
// from data rather than a name check; ordering is alphabetical-by-
|
|
487
|
-
// displayName per
|
|
487
|
+
// displayName per docs/contracts/module-json-extensibility.md.
|
|
488
488
|
|
|
489
489
|
// Admin entries: always visible. Even a fresh hub with zero vaults wants
|
|
490
490
|
// the operator to find /admin/vaults. Hardcoded — they live in the
|
|
@@ -617,7 +617,7 @@ const DISCOVERY_SCRIPT = `<script>
|
|
|
617
617
|
});
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
-
// Alphabetical-by-displayName per
|
|
620
|
+
// Alphabetical-by-displayName per docs/contracts/module-json-extensibility.md.
|
|
621
621
|
// Stable for shared-prefix labels (Notes, Notes-Lite would sort that way).
|
|
622
622
|
const tiles = Array.from(byShort.values()).sort((a, b) =>
|
|
623
623
|
a.title.localeCompare(b.title),
|
|
@@ -630,7 +630,7 @@ const DISCOVERY_SCRIPT = `<script>
|
|
|
630
630
|
empty.innerHTML =
|
|
631
631
|
'No services with a UI declared yet. Modules surface their UIs by ' +
|
|
632
632
|
'declaring <code>uiUrl</code> in <code>module.json</code> ' +
|
|
633
|
-
'(see
|
|
633
|
+
'(see parachute-hub/docs/contracts/module-json-extensibility.md).';
|
|
634
634
|
servicesGrid.appendChild(empty);
|
|
635
635
|
return;
|
|
636
636
|
}
|
package/src/module-manifest.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* module. Author-controlled, shipped in the published artifact, read by the
|
|
4
4
|
* CLI on `parachute install <package>`.
|
|
5
5
|
*
|
|
6
|
-
* The shape mirrors `
|
|
6
|
+
* The shape mirrors `docs/contracts/module-json-extensibility.md`.
|
|
7
7
|
* Third-party modules are first-class: no `@openparachute/` scope or
|
|
8
8
|
* `parachute-*` prefix required — `module.json` is what makes a package a
|
|
9
9
|
* module. First-party modules will eventually ship their own `module.json`
|
|
@@ -271,7 +271,7 @@ export interface ModuleManifest {
|
|
|
271
271
|
readonly configSchema?: ConfigSchema;
|
|
272
272
|
/**
|
|
273
273
|
* Where the module's admin UI lives. Hub renders a "Manage" link when set
|
|
274
|
-
* (see `
|
|
274
|
+
* (see `docs/contracts/module-json-extensibility.md`).
|
|
275
275
|
*
|
|
276
276
|
* Three shapes (unified URL-resolution semantics — B4 of the 2026-06-09
|
|
277
277
|
* hub-module-boundary migration):
|
|
@@ -293,7 +293,7 @@ export interface ModuleManifest {
|
|
|
293
293
|
/**
|
|
294
294
|
* Where the module's primary user-facing UI lives. Hub renders a tile on
|
|
295
295
|
* the discovery page (`/`) Services section when set (see
|
|
296
|
-
* `
|
|
296
|
+
* `docs/contracts/module-json-extensibility.md` and the
|
|
297
297
|
* `loadUiUrls` resolver in `hub-server.ts`).
|
|
298
298
|
*
|
|
299
299
|
* Two shapes — same rules as `managementUrl`:
|
package/src/resource-binding.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* vault's named scopes, lock the picker to `<name>`, and mint named scopes so
|
|
26
26
|
* `inferAudience` stamps `aud=vault.<name>`.
|
|
27
27
|
*
|
|
28
|
-
* Source of truth for scope shape: `
|
|
28
|
+
* Source of truth for scope shape: `docs/contracts/oauth-scopes.md`.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { VAULT_VERBS } from "./jwt-audience.ts";
|
package/src/scope-registry.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* downstream service should be rejected, but defense-in-depth says don't
|
|
8
8
|
* sign claims you don't understand. This module is the gate.
|
|
9
9
|
*
|
|
10
|
-
* Source of truth for scope shape: `
|
|
10
|
+
* Source of truth for scope shape: `docs/contracts/oauth-scopes.md`.
|
|
11
11
|
*
|
|
12
12
|
* Declared scopes come from two places:
|
|
13
13
|
* 1. `FIRST_PARTY_SCOPES` — the canonical Parachute scopes hardcoded in
|
package/src/services-manifest.ts
CHANGED
|
@@ -25,8 +25,8 @@ export type PublicExposure = "allowed" | "loopback" | "auth-required";
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Visible-to-discovery state of a UI sub-unit. Aligns with the four-state
|
|
28
|
-
* supervisor vocabulary in [
|
|
29
|
-
* §6](../
|
|
28
|
+
* supervisor vocabulary in [docs/contracts/design-system.md
|
|
29
|
+
* §6](../docs/contracts/design-system.md) (workstream F).
|
|
30
30
|
* Absent → hub treats the sub-unit as "active" (the discovery default).
|
|
31
31
|
*
|
|
32
32
|
* "active" — UI is installed, OAuth client is approved, ready to serve.
|
|
@@ -827,7 +827,7 @@ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; c
|
|
|
827
827
|
});
|
|
828
828
|
|
|
829
829
|
console.error(
|
|
830
|
-
`${where}: dropped legacy short-name row(s) [${dropped.join(", ")}] in favor of same-port manifestName row(s). This is the parachute-app#13 / parachute-runner#4 self-register fixup. See parachute-
|
|
830
|
+
`${where}: dropped legacy short-name row(s) [${dropped.join(", ")}] in favor of same-port manifestName row(s). This is the parachute-app#13 / parachute-runner#4 self-register fixup. See parachute-hub/docs/contracts/services-json-row-conventions.md.`,
|
|
831
831
|
);
|
|
832
832
|
|
|
833
833
|
return {
|
package/src/well-known.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface WellKnownVaultEntry {
|
|
|
47
47
|
version: string;
|
|
48
48
|
/**
|
|
49
49
|
* Where the vault's admin SPA lives. Path-or-URL per
|
|
50
|
-
* `
|
|
50
|
+
* `docs/contracts/module-json-extensibility.md`. Hub renders
|
|
51
51
|
* a "Manage" link when present. Sourced from the vault module's
|
|
52
52
|
* `.parachute/module.json:managementUrl`.
|
|
53
53
|
*/
|