@openparachute/hub 0.6.3-rc.2 → 0.6.3-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.
@@ -0,0 +1,286 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ManagedUnitDeps, ServiceCommandResult } from "../managed-unit.ts";
3
+ import {
4
+ disableStaleModuleUnits,
5
+ moduleLaunchdLabel,
6
+ moduleSystemdUnitName,
7
+ targetModuleShorts,
8
+ } from "../stale-module-units.ts";
9
+
10
+ /**
11
+ * #522 — migrate/teardown must DETECT + DISABLE any stale per-module autostart
12
+ * unit (a leftover `parachute-<short>.service` systemd KeepAlive / a
13
+ * `computer.parachute.<short>` launchd KeepAlive) so it stops respawning an
14
+ * unsupervised module that fights the supervised hub for the module's port.
15
+ *
16
+ * ALL tests run against a stubbed `ManagedUnitDeps.run` — NO real
17
+ * systemctl/launchctl. Each fake records the commands it received so we can
18
+ * assert exactly which units were disabled, which were skipped, and that a
19
+ * disable failure / system-level unit is non-fatal.
20
+ */
21
+
22
+ type RunResponder = (cmd: readonly string[]) => ServiceCommandResult;
23
+
24
+ function ok(stdout = ""): ServiceCommandResult {
25
+ return { code: 0, stdout, stderr: "" };
26
+ }
27
+ function fail(stderr = "boom", code = 1): ServiceCommandResult {
28
+ return { code, stdout: "", stderr };
29
+ }
30
+
31
+ function makeDeps(platform: NodeJS.Platform, respond: RunResponder) {
32
+ const calls: string[][] = [];
33
+ const deps: ManagedUnitDeps = {
34
+ platform,
35
+ getuid: () => 501,
36
+ homeDir: () => "/home/op",
37
+ userName: () => "op",
38
+ which: (b) => (b === "systemctl" || b === "launchctl" ? `/usr/bin/${b}` : null),
39
+ run: (cmd) => {
40
+ calls.push([...cmd]);
41
+ return respond(cmd);
42
+ },
43
+ writeFile: () => {},
44
+ removeFile: () => {},
45
+ readFile: () => undefined,
46
+ exists: () => false,
47
+ };
48
+ return { deps, calls };
49
+ }
50
+
51
+ function joined(calls: string[][]): string[] {
52
+ return calls.map((c) => c.join(" "));
53
+ }
54
+
55
+ describe("targetModuleShorts() — known module shorts, never hub/cloudflared", () => {
56
+ test("includes the canonical module shorts and excludes hub", () => {
57
+ const shorts = targetModuleShorts();
58
+ // The canonical knownServices() set: vault / scribe / runner / surface / notes / channel.
59
+ expect(shorts).toContain("vault");
60
+ expect(shorts).toContain("scribe");
61
+ expect(shorts).toContain("surface");
62
+ expect(shorts).toContain("notes");
63
+ // hub is the supervised model itself — never a target.
64
+ expect(shorts).not.toContain("hub");
65
+ expect(shorts).not.toContain("cloudflared");
66
+ });
67
+ });
68
+
69
+ describe("disableStaleModuleUnits — systemd (Linux)", () => {
70
+ test("a stale ENABLED user unit parachute-vault.service is disabled --now + reported", () => {
71
+ const { deps, calls } = makeDeps("linux", (cmd) => {
72
+ const line = cmd.join(" ");
73
+ // Only vault's USER unit reads enabled; everything else is disabled.
74
+ if (line === "systemctl --user is-enabled parachute-vault.service") return ok("enabled\n");
75
+ if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
76
+ if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
77
+ if (line === "systemctl --user disable --now parachute-vault.service") return ok();
78
+ return ok();
79
+ });
80
+ const log: string[] = [];
81
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
82
+
83
+ // Exactly one unit acted on: vault, disabled at user scope.
84
+ expect(res.actions).toHaveLength(1);
85
+ expect(res.actions[0]).toMatchObject({
86
+ short: "vault",
87
+ kind: "systemd-user",
88
+ unit: "parachute-vault.service",
89
+ result: "disabled",
90
+ });
91
+ // The disable --now command was actually invoked.
92
+ expect(joined(calls)).toContain("systemctl --user disable --now parachute-vault.service");
93
+ // The action is reported so the operator sees what changed.
94
+ expect(log.join("\n")).toContain("Disabled stale parachute-vault.service");
95
+ expect(log.join("\n")).toContain("vault's port");
96
+ });
97
+
98
+ test("SKIPS the hub unit + cloudflared units — never queried, never disabled", () => {
99
+ const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
100
+ disableStaleModuleUnits({ deps });
101
+ const lines = joined(calls);
102
+ // The hub unit is never probed or disabled by this sweep.
103
+ expect(lines.some((l) => l.includes("parachute-hub.service"))).toBe(false);
104
+ // No cloudflared connector unit is touched.
105
+ expect(lines.some((l) => l.includes("parachute-cloudflared"))).toBe(false);
106
+ // And no disable command runs at all (everything reads disabled).
107
+ expect(lines.some((l) => l.includes("disable"))).toBe(false);
108
+ });
109
+
110
+ test("a non-matching / arbitrary unit is never touched (only parachute-<known-short> is queried)", () => {
111
+ const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
112
+ disableStaleModuleUnits({ deps });
113
+ const lines = joined(calls);
114
+ // Every is-enabled probe targets a parachute-<known-short>.service and nothing else.
115
+ const probes = lines.filter((l) => l.includes("is-enabled"));
116
+ expect(probes.length).toBeGreaterThan(0);
117
+ for (const probe of probes) {
118
+ const m = probe.match(/is-enabled (parachute-[a-z]+\.service)$/);
119
+ expect(m).not.toBeNull();
120
+ const unit = m?.[1] ?? "";
121
+ // The probed unit must be a known module short, and never the hub/cloudflared.
122
+ expect(targetModuleShorts().map(moduleSystemdUnitName)).toContain(unit);
123
+ expect(unit).not.toBe("parachute-hub.service");
124
+ }
125
+ });
126
+
127
+ test("idempotent: every unit already disabled → clean no-op (no disable, no actions)", () => {
128
+ const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
129
+ const res = disableStaleModuleUnits({ deps });
130
+ expect(res.actions).toHaveLength(0);
131
+ expect(joined(calls).some((l) => l.includes("disable"))).toBe(false);
132
+ });
133
+
134
+ test("system-level unit (no --user enabled, system enabled) → WARNS with manual sudo command, doesn't abort, doesn't sudo", () => {
135
+ const { deps, calls } = makeDeps("linux", (cmd) => {
136
+ const line = cmd.join(" ");
137
+ // vault's USER unit is NOT enabled, but the SYSTEM unit IS.
138
+ if (line === "systemctl --user is-enabled parachute-vault.service")
139
+ return fail("disabled", 1);
140
+ if (line === "systemctl is-enabled parachute-vault.service") return ok("enabled\n");
141
+ if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
142
+ if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
143
+ return ok();
144
+ });
145
+ const log: string[] = [];
146
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
147
+
148
+ const vaultAction = res.actions.find((a) => a.short === "vault");
149
+ expect(vaultAction).toMatchObject({ kind: "systemd-system", result: "warn-system" });
150
+ const out = log.join("\n");
151
+ // The exact manual command is surfaced.
152
+ expect(out).toContain("sudo systemctl disable --now parachute-vault.service");
153
+ // It NEVER attempted a sudo / system disable itself.
154
+ expect(joined(calls)).not.toContain("systemctl disable --now parachute-vault.service");
155
+ expect(joined(calls).some((l) => l.startsWith("sudo"))).toBe(false);
156
+ });
157
+
158
+ test("non-fatal: a disable command that fails → warn + continue (the other units are still swept)", () => {
159
+ const { deps, calls } = makeDeps("linux", (cmd) => {
160
+ const line = cmd.join(" ");
161
+ // Both vault + scribe user units read enabled; vault's disable FAILS.
162
+ if (line === "systemctl --user is-enabled parachute-vault.service") return ok("enabled\n");
163
+ if (line === "systemctl --user is-enabled parachute-scribe.service") return ok("enabled\n");
164
+ if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
165
+ if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
166
+ if (line === "systemctl --user disable --now parachute-vault.service")
167
+ return fail("permission denied");
168
+ if (line === "systemctl --user disable --now parachute-scribe.service") return ok();
169
+ return ok();
170
+ });
171
+ const log: string[] = [];
172
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
173
+
174
+ const vault = res.actions.find((a) => a.short === "vault");
175
+ const scribe = res.actions.find((a) => a.short === "scribe");
176
+ // vault's disable failed → warned, not fatal; scribe was still disabled.
177
+ expect(vault?.result).toBe("failed");
178
+ expect(scribe?.result).toBe("disabled");
179
+ // Both disable attempts ran — the failure didn't abort the sweep.
180
+ expect(joined(calls)).toContain("systemctl --user disable --now parachute-vault.service");
181
+ expect(joined(calls)).toContain("systemctl --user disable --now parachute-scribe.service");
182
+ expect(log.join("\n")).toContain("Could not disable");
183
+ });
184
+
185
+ test("no systemctl on the box → clean no-op", () => {
186
+ const calls: string[][] = [];
187
+ const deps: ManagedUnitDeps = {
188
+ platform: "linux",
189
+ getuid: () => 501,
190
+ homeDir: () => "/home/op",
191
+ userName: () => "op",
192
+ which: () => null, // no systemctl
193
+ run: (cmd) => {
194
+ calls.push([...cmd]);
195
+ return ok();
196
+ },
197
+ writeFile: () => {},
198
+ removeFile: () => {},
199
+ readFile: () => undefined,
200
+ exists: () => false,
201
+ };
202
+ const res = disableStaleModuleUnits({ deps });
203
+ expect(res.actions).toHaveLength(0);
204
+ expect(calls).toHaveLength(0);
205
+ });
206
+ });
207
+
208
+ describe("disableStaleModuleUnits — launchd (Mac)", () => {
209
+ test("a loaded computer.parachute.vault LaunchAgent is booted out + reported", () => {
210
+ const { deps, calls } = makeDeps("darwin", (cmd) => {
211
+ const line = cmd.join(" ");
212
+ // Only vault's label is loaded (print succeeds with content); others print empty.
213
+ if (line === "launchctl print gui/501/computer.parachute.vault")
214
+ return ok("com.apple...\nstate = running\n");
215
+ if (line.startsWith("launchctl print")) return ok(""); // not loaded
216
+ if (line === "launchctl bootout gui/501/computer.parachute.vault") return ok();
217
+ return ok();
218
+ });
219
+ const log: string[] = [];
220
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
221
+
222
+ expect(res.actions).toHaveLength(1);
223
+ expect(res.actions[0]).toMatchObject({
224
+ short: "vault",
225
+ kind: "launchd",
226
+ unit: "computer.parachute.vault",
227
+ result: "disabled",
228
+ });
229
+ expect(joined(calls)).toContain("launchctl bootout gui/501/computer.parachute.vault");
230
+ expect(log.join("\n")).toContain("Disabled stale computer.parachute.vault");
231
+ });
232
+
233
+ test("SKIPS the hub label + cloudflared labels — never printed, never booted out", () => {
234
+ const { deps, calls } = makeDeps("darwin", () => ok("")); // nothing loaded
235
+ disableStaleModuleUnits({ deps });
236
+ const lines = joined(calls);
237
+ expect(lines.some((l) => l.includes("computer.parachute.hub"))).toBe(false);
238
+ expect(lines.some((l) => l.includes("computer.parachute.cloudflared"))).toBe(false);
239
+ // Every print targets a parachute.<known-short> label.
240
+ const prints = lines.filter((l) => l.startsWith("launchctl print"));
241
+ for (const p of prints) {
242
+ const m = p.match(/computer\.parachute\.([a-z]+)$/);
243
+ expect(m).not.toBeNull();
244
+ expect(targetModuleShorts()).toContain(m?.[1] ?? "");
245
+ }
246
+ });
247
+
248
+ test("idempotent: nothing loaded → clean no-op (no bootout, no actions)", () => {
249
+ const { deps, calls } = makeDeps("darwin", () => ok("")); // print returns empty for all
250
+ const res = disableStaleModuleUnits({ deps });
251
+ expect(res.actions).toHaveLength(0);
252
+ expect(joined(calls).some((l) => l.includes("bootout"))).toBe(false);
253
+ });
254
+
255
+ test("non-fatal: a bootout that fails → warn + continue", () => {
256
+ const { deps } = makeDeps("darwin", (cmd) => {
257
+ const line = cmd.join(" ");
258
+ if (line === "launchctl print gui/501/computer.parachute.vault")
259
+ return ok("state = running\n");
260
+ if (line.startsWith("launchctl print")) return ok("");
261
+ if (line === "launchctl bootout gui/501/computer.parachute.vault")
262
+ return fail("Operation not permitted");
263
+ return ok();
264
+ });
265
+ const log: string[] = [];
266
+ const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
267
+ expect(res.actions.find((a) => a.short === "vault")?.result).toBe("failed");
268
+ expect(log.join("\n")).toContain("Could not disable the stale LaunchAgent");
269
+ });
270
+ });
271
+
272
+ describe("disableStaleModuleUnits — unsupported platform", () => {
273
+ test("no per-platform manager (e.g. win32) → clean no-op", () => {
274
+ const { deps, calls } = makeDeps("win32", () => ok());
275
+ const res = disableStaleModuleUnits({ deps });
276
+ expect(res.actions).toHaveLength(0);
277
+ expect(calls).toHaveLength(0);
278
+ });
279
+ });
280
+
281
+ describe("unit-name helpers", () => {
282
+ test("moduleSystemdUnitName / moduleLaunchdLabel build the exact per-module names", () => {
283
+ expect(moduleSystemdUnitName("vault")).toBe("parachute-vault.service");
284
+ expect(moduleLaunchdLabel("vault")).toBe("computer.parachute.vault");
285
+ });
286
+ });
@@ -40,8 +40,8 @@ import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
40
40
  import { isLinked as defaultIsLinked } from "./bun-link.ts";
41
41
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
42
42
  import { buildModuleSpawnRequest } from "./commands/serve-boot.ts";
43
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
43
44
  import { getModuleInstallChannel } from "./hub-settings.ts";
44
- import { validateAccessToken } from "./jwt-sign.ts";
45
45
  import { readModuleManifest } from "./module-manifest.ts";
46
46
  import { refreshWellKnown, stampInstallDirOnRow } from "./post-install.ts";
47
47
  import {
@@ -180,6 +180,21 @@ export interface RunOpts {
180
180
  export interface ApiModulesOpsDeps {
181
181
  db: Database;
182
182
  issuer: string;
183
+ /**
184
+ * The SET of origins the hub legitimately answers on — loopback aliases ∪
185
+ * expose-state public origin ∪ platform/env origin ∪ the per-request
186
+ * `issuer`. The host-admin bearer's `iss` is validated against THIS set, not
187
+ * the single per-request `issuer` (hub#516): the CLI drives these endpoints
188
+ * on loopback presenting the operator token, whose `iss` is the hub's public
189
+ * origin after `expose`. Built via `buildHubBoundOrigins` at the call site.
190
+ *
191
+ * Optional for back-compat with callers that don't construct it (the
192
+ * first-boot wizard's `runInstall`, tests). When absent, `authorize` falls
193
+ * back to the single-element `[issuer]` set — i.e. the prior strict
194
+ * per-request behavior — so the relaxation is opt-in at the HTTP call site
195
+ * and the non-HTTP install path is unaffected.
196
+ */
197
+ knownIssuers?: readonly string[];
183
198
  manifestPath: string;
184
199
  configDir: string;
185
200
  supervisor: Supervisor;
@@ -280,7 +295,18 @@ async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Respons
280
295
  const bearer = auth.slice("Bearer ".length).trim();
281
296
  if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
282
297
  try {
283
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
298
+ // Host-admin (operator / SPA) token validation: accept the `iss` against
299
+ // the SET of origins the hub answers on, not the single per-request issuer
300
+ // (hub#516). This surface only ever accepts the hub's own self-issued
301
+ // host-admin credentials (the `parachute:host:admin` scope below is
302
+ // non-requestable via OAuth), so the relaxation cannot reach an OAuth
303
+ // token's validation. Falls back to the strict single-issuer set when
304
+ // `knownIssuers` isn't wired (non-HTTP install path / tests).
305
+ const validated = await validateHostAdminToken(
306
+ deps.db,
307
+ bearer,
308
+ deps.knownIssuers ?? [deps.issuer],
309
+ );
284
310
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
285
311
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
286
312
  }
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import type { Database } from "bun:sqlite";
28
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
28
29
  import {
29
30
  type ModuleInstallChannel,
30
31
  getModuleInstallChannel,
@@ -116,6 +117,18 @@ export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
116
117
  export interface ApiModulesDeps {
117
118
  db: Database;
118
119
  issuer: string;
120
+ /**
121
+ * The SET of origins the hub legitimately answers on — loopback aliases ∪
122
+ * expose-state public origin ∪ platform/env origin ∪ the per-request
123
+ * `issuer`. The host-admin bearer's `iss` is validated against THIS set, not
124
+ * the single per-request `issuer` (hub#516): `parachute status` reads this
125
+ * endpoint on loopback presenting the operator token, whose `iss` is the
126
+ * hub's public origin after `expose`. Built via `buildHubBoundOrigins` at the
127
+ * call site. When absent, falls back to the single-element `[issuer]` set
128
+ * (the prior strict per-request behavior) so non-HTTP callers / tests are
129
+ * unaffected.
130
+ */
131
+ knownIssuers?: readonly string[];
119
132
  manifestPath: string;
120
133
  supervisor?: Supervisor;
121
134
  /**
@@ -312,10 +325,20 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
312
325
  return jsonError(401, "unauthenticated", "empty bearer token");
313
326
  }
314
327
 
315
- // Bearer validation.
328
+ // Bearer validation. Host-admin (operator / SPA) token: accept the `iss`
329
+ // against the SET of origins the hub answers on, not the single per-request
330
+ // issuer (hub#516) — `parachute status` reads this on loopback presenting the
331
+ // operator token, whose `iss` is the hub's public origin after `expose`. This
332
+ // surface gates on the non-requestable `parachute:host:auth` scope below, so
333
+ // the relaxation only ever touches the hub's own self-issued host-admin
334
+ // credentials and cannot reach an OAuth token's validation.
316
335
  let bearerScopes: string[];
317
336
  try {
318
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
337
+ const validated = await validateHostAdminToken(
338
+ deps.db,
339
+ bearer,
340
+ deps.knownIssuers ?? [deps.issuer],
341
+ );
319
342
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
320
343
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
321
344
  }
@@ -69,10 +69,21 @@ export type ConnectorServiceDeps = ManagedUnitDeps;
69
69
 
70
70
  export const defaultServiceDeps: ConnectorServiceDeps = defaultManagedUnitDeps;
71
71
 
72
+ /**
73
+ * Reverse-DNS prefix for the launchd label + plist filename. Exported so the
74
+ * migrate/teardown stale-per-module-autostart sweep (`src/stale-module-units.ts`,
75
+ * hub#522) can reuse it as a SKIP-list anchor — the connector unit is owned by
76
+ * the supervised model (`expose off --cloudflare` tears it down), and the sweep
77
+ * must never touch it. Reusing the constant keeps the skip-list from drifting if
78
+ * this prefix ever changes.
79
+ */
80
+ export const CLOUDFLARED_LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
81
+ /** systemd unit name prefix. Exported for the same skip-list reason as above. */
82
+ export const CLOUDFLARED_SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
72
83
  /** Reverse-DNS prefix for the launchd label + plist filename. */
73
- const LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
84
+ const LAUNCHD_LABEL_PREFIX = CLOUDFLARED_LAUNCHD_LABEL_PREFIX;
74
85
  /** systemd unit name prefix. */
75
- const SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
86
+ const SYSTEMD_UNIT_PREFIX = CLOUDFLARED_SYSTEMD_UNIT_PREFIX;
76
87
  /** Provenance comment baked into every rendered connector unit file. */
77
88
  const CONNECTOR_HEADER = "Generated by parachute expose public --cloudflare — do not edit by hand.";
78
89
 
@@ -86,6 +86,11 @@ import { type PortListeningFn, defaultPortListening } from "../port-probe.ts";
86
86
  import { type AliveFn, clearPid, readPid } from "../process-state.ts";
87
87
  import { shortNameForManifest } from "../service-spec.ts";
88
88
  import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
89
+ import {
90
+ type DisableStaleModuleUnitsOpts,
91
+ type DisableStaleModuleUnitsResult,
92
+ disableStaleModuleUnits,
93
+ } from "../stale-module-units.ts";
89
94
 
90
95
  /**
91
96
  * Absolute path to this hub checkout's `src/cli.ts` — the entry the hub unit's
@@ -180,6 +185,19 @@ export interface CutoverDeps {
180
185
  sleep: (ms: number) => Promise<void>;
181
186
  /** The hub-unit deps for install / detect / manager calls. */
182
187
  hubUnitDeps: HubUnitDeps;
188
+ /**
189
+ * Detect + DISABLE any stale per-module autostart unit (#522 — the load-bearing
190
+ * fix). A leftover standalone `parachute-<short>.service` (systemd KeepAlive) /
191
+ * `computer.parachute.<short>` (launchd KeepAlive) from the pre-supervisor era
192
+ * keeps RESPAWNING an unsupervised module that binds the module's port — the
193
+ * supervised child then EADDRINUSE-crash-loops. Killing the process is
194
+ * whack-a-mole (the unit resurrects it); we must disable the UNIT. Run in the
195
+ * STOP phase (after the per-module detached stop, before the port-free verify)
196
+ * so the freed port lets the supervised module bind. Ownership-safe (known
197
+ * module shorts only; hub + cloudflared skipped), idempotent, non-fatal.
198
+ * Injectable so tests never touch real systemctl/launchctl.
199
+ */
200
+ disableStaleModuleUnits: (opts?: DisableStaleModuleUnitsOpts) => DisableStaleModuleUnitsResult;
183
201
  }
184
202
 
185
203
  export interface WriteUnitOpts {
@@ -329,6 +347,7 @@ export const defaultCutoverDeps: CutoverDeps = {
329
347
  probeHealth: defaultHubUnitDeps.probeHealth,
330
348
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
331
349
  hubUnitDeps: defaultHubUnitDeps,
350
+ disableStaleModuleUnits,
332
351
  };
333
352
 
334
353
  export interface CutoverOpts {
@@ -673,6 +692,22 @@ export async function cutoverToSupervised(opts: CutoverOpts = {}): Promise<Cutov
673
692
  await stopDetachedModule(target, configDir, deps, timeoutMs, pollMs, log);
674
693
  }
675
694
 
695
+ // --- Step 3b (#522): DISABLE stale per-module autostart UNITS. ---
696
+ // The load-bearing fix for the recurring "port 1940 taken" crash-loop: a
697
+ // leftover standalone `parachute-<short>.service` (systemd KeepAlive) or
698
+ // `computer.parachute.<short>` (launchd KeepAlive) from the pre-supervisor era
699
+ // keeps RESPAWNING an unsupervised module that binds the port — so the
700
+ // per-module stop above (and the orphan sweep below) is whack-a-mole: the unit
701
+ // resurrects the process within seconds, serving OLD code. We must DISABLE the
702
+ // UNIT so the port stays free for the supervised child. MUST run HERE — after
703
+ // the detached stop, BEFORE the verify-ports-free + unit start — so the freed
704
+ // port lets the supervised module bind. Ownership-safe (known module shorts
705
+ // only; hub + cloudflared skipped), idempotent, non-fatal (a failed disable
706
+ // warns + continues; a system-level unit it can't disable → warn with the
707
+ // manual sudo command). Every disabled unit is reported.
708
+ log("Checking for stale per-module autostart units to disable…");
709
+ deps.disableStaleModuleUnits({ deps: deps.hubUnitDeps, log: (l) => log(l) });
710
+
676
711
  // --- Step 4: §7.2 ORPHAN SWEEP — per services.json port + the hub port. ---
677
712
  // The HUB port keeps the pre-existing blind-adopt (mirrors stopHub's 1939
678
713
  // orphan-adoption — out of scope for MUST-FIX 2). The MODULE ports get the
@@ -797,6 +832,13 @@ export interface TeardownOpts {
797
832
  removedLaunchdMessage: (label: string) => string;
798
833
  removedSystemdMessage: (unitName: string) => string;
799
834
  }) => ManagedUnitRemoveResult;
835
+ /**
836
+ * Test seam: the stale-per-module-autostart disable (#522). Teardown also
837
+ * disables any leftover standalone module autostart unit so a rollback to
838
+ * foreground `serve` doesn't leave a competing module respawning at boot.
839
+ * Injectable so tests never touch real systemctl/launchctl.
840
+ */
841
+ disableStaleModuleUnits?: (opts?: DisableStaleModuleUnitsOpts) => DisableStaleModuleUnitsResult;
800
842
  }
801
843
 
802
844
  /**
@@ -815,6 +857,7 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
815
857
  const log = opts.log ?? ((line) => console.log(line));
816
858
  const deps = opts.deps ?? defaultHubUnitDeps;
817
859
  const remove = opts.remove ?? removeManagedUnit;
860
+ const disableStale = opts.disableStaleModuleUnits ?? disableStaleModuleUnits;
818
861
  const res = remove({
819
862
  launchdLabel: HUB_LAUNCHD_LABEL,
820
863
  systemdUnitName: HUB_SYSTEMD_UNIT_NAME,
@@ -824,6 +867,11 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
824
867
  removedSystemdMessage: (unitName) =>
825
868
  `Removed systemd unit ${unitName} — the hub no longer starts on boot.`,
826
869
  });
870
+ // #522: also disable any leftover standalone per-module autostart unit so a
871
+ // rollback to foreground `serve` doesn't leave a competing module respawning at
872
+ // boot to race whatever the operator brings up next. Ownership-safe (known
873
+ // module shorts only; hub + cloudflared skipped), idempotent, non-fatal.
874
+ disableStale({ deps, log });
827
875
  if (res.removed) {
828
876
  for (const m of res.messages) log(m);
829
877
  log("");
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Issuer validation for the hub's OWN host-admin credentials (the operator
3
+ * token + the SPA host-admin token) on the loopback module-ops surfaces.
4
+ *
5
+ * ## Why this is its own helper (hub#516)
6
+ *
7
+ * The on-box CLI drives the hub on loopback (`127.0.0.1:1939`) presenting
8
+ * `~/.parachute/operator.token` — a hub-SELF-issued JWT (`aud: "operator"`,
9
+ * scope-set carries `parachute:host:admin`). After `parachute expose`, the
10
+ * operator token's `iss` is the hub's PUBLIC origin (e.g.
11
+ * `https://parachute.taildf9ce2.ts.net`), because §3.1 self-heals it there so
12
+ * on-box services validating public-origin bearers accept it.
13
+ *
14
+ * But the hub resolves its issuer PER-REQUEST from the Host header
15
+ * (`resolveIssuer` in hub-server.ts, "closes #245") — so a LOOPBACK request
16
+ * resolves the issuer to `http://127.0.0.1:1939`. The strict per-request
17
+ * `validateAccessToken(db, token, <loopback-issuer>)` then rejects the
18
+ * operator token's PUBLIC `iss` as `unexpected "iss" claim value`. Net:
19
+ * `parachute status` / `start|stop|restart <svc>` fail on ANY exposed box
20
+ * (tailnet or Cloudflare), even though the credential is the hub's own,
21
+ * presented on the hub's own loopback.
22
+ *
23
+ * ## The scoped relaxation
24
+ *
25
+ * The operator token (and the SPA host-admin token) are SELF-issued — the hub
26
+ * signs them with its own key, and {@link validateAccessToken} verifies that
27
+ * signature against the hub's JWKS. The signature already proves provenance:
28
+ * the only tokens that can verify are ones THIS hub minted. So for these
29
+ * host-admin credentials, the `iss` claim should be accepted if it matches ANY
30
+ * origin the hub legitimately answers on — loopback ∪ expose-state public
31
+ * origin ∪ platform/env origin — not just the single per-request one.
32
+ *
33
+ * We deliberately do NOT drop the `iss` check entirely (belt-and-suspenders):
34
+ * a token whose `iss` is none of the hub's known origins is still rejected,
35
+ * so a hypothetical hub-signed token minted for a DIFFERENT origin can't be
36
+ * replayed here.
37
+ *
38
+ * ## What this does NOT touch
39
+ *
40
+ * OAuth / access-token validation (vault / MCP tokens, `aud: "vault.<name>"`)
41
+ * stays STRICT per-request-issuer and lives on entirely separate code paths
42
+ * (the resource servers' own validators, hub's `/api/auth/*`, etc.). This
43
+ * helper is invoked ONLY from the two loopback host-admin module surfaces
44
+ * (`/api/modules` GET — the `status` read; `/api/modules/:short/*` POST — the
45
+ * lifecycle ops), both of which already gate on the non-requestable
46
+ * `parachute:host:admin` / `parachute:host:auth` scopes that no OAuth token
47
+ * can carry. The relaxation cannot reach an OAuth token's validation.
48
+ */
49
+ import type { Database } from "bun:sqlite";
50
+ import { type ValidatedAccessToken, validateAccessToken } from "./jwt-sign.ts";
51
+
52
+ /**
53
+ * Validate a host-admin bearer (operator token / SPA host-admin token)
54
+ * presented on a loopback module surface, accepting its `iss` against the SET
55
+ * of origins the hub legitimately answers on rather than the single
56
+ * per-request issuer.
57
+ *
58
+ * Verification order:
59
+ * 1. Signature + `exp` + revocation, via {@link validateAccessToken} WITHOUT
60
+ * an `expectedIssuer` — the signature proves the hub minted it (only this
61
+ * hub's key can produce a JWS that verifies against its JWKS). A throw
62
+ * here (bad/unknown/expired kid, jose `exp`, revoked jti) propagates
63
+ * unchanged.
64
+ * 2. `iss` ∈ `knownIssuers` — belt-and-suspenders. Even though the signature
65
+ * proves provenance, we still require the issuer to be one of the hub's
66
+ * own origins. A foreign/garbage `iss` throws (matching the per-request
67
+ * strict check's message shape so callers' error rendering is unchanged).
68
+ *
69
+ * `knownIssuers` is the hub's own valid origin set — typically built from
70
+ * `buildHubBoundOrigins` (per-request issuer ∪ loopback aliases ∪
71
+ * expose-state public origin ∪ platform/env origin). Empty/garbage entries are
72
+ * the caller's responsibility to filter; an empty set rejects every token
73
+ * (fails closed).
74
+ *
75
+ * @throws Error when the signature/exp/revocation check fails, or when `iss`
76
+ * is absent / not a string / not in `knownIssuers`.
77
+ */
78
+ export async function validateHostAdminToken(
79
+ db: Database,
80
+ token: string,
81
+ knownIssuers: readonly string[],
82
+ ): Promise<ValidatedAccessToken> {
83
+ // Step 1: signature + exp + revocation, NOT pinning iss. Provenance is
84
+ // proved by the signature verifying against the hub's own JWKS.
85
+ const validated = await validateAccessToken(db, token);
86
+
87
+ // Step 2: belt-and-suspenders iss ∈ known-origins. Never widen to arbitrary
88
+ // issuers — the token's iss must be one of the hub's own legitimate origins.
89
+ const iss = validated.payload.iss;
90
+ if (typeof iss !== "string" || !knownIssuers.includes(iss)) {
91
+ // Mirror jose's wording so the CLI's bearer-invalid error path renders the
92
+ // same way it did for the strict per-request check.
93
+ throw new Error('unexpected "iss" claim value');
94
+ }
95
+ return validated;
96
+ }
package/src/hub-server.ts CHANGED
@@ -1783,9 +1783,16 @@ export function hubFetch(
1783
1783
 
1784
1784
  if (pathname === "/api/modules") {
1785
1785
  if (!getDb) return dbNotConfigured();
1786
+ const od = oauthDeps(req);
1786
1787
  const modulesDeps: Parameters<typeof handleApiModules>[1] = {
1787
1788
  db: getDb(),
1788
- issuer: oauthDeps(req).issuer,
1789
+ issuer: od.issuer,
1790
+ // hub#516: validate the host-admin bearer's `iss` against the SET of
1791
+ // origins the hub answers on (loopback ∪ expose-state ∪ env/platform ∪
1792
+ // per-request issuer), so `parachute status` works on an exposed box
1793
+ // where the operator token carries the public origin but the loopback
1794
+ // request resolves the loopback issuer.
1795
+ knownIssuers: od.hubBoundOrigins(),
1789
1796
  manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1790
1797
  };
1791
1798
  if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
@@ -1841,9 +1848,13 @@ export function hubFetch(
1841
1848
  }
1842
1849
  const opId = decodeURIComponent(pathname.slice("/api/modules/operations/".length));
1843
1850
  if (!opId || opId.includes("/")) return new Response("not found", { status: 404 });
1851
+ const od = oauthDeps(req);
1844
1852
  return handleOperationGet(req, opId, {
1845
1853
  db: getDb(),
1846
- issuer: oauthDeps(req).issuer,
1854
+ issuer: od.issuer,
1855
+ // hub#516: see the `/api/modules` deps note — the CLI polls async ops
1856
+ // on loopback with the operator token (public `iss`).
1857
+ knownIssuers: od.hubBoundOrigins(),
1847
1858
  manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1848
1859
  configDir: CONFIG_DIR,
1849
1860
  supervisor: deps.supervisor,
@@ -1888,9 +1899,14 @@ export function hubFetch(
1888
1899
  }
1889
1900
  const match = parseModulesPath(pathname);
1890
1901
  if (!match) return new Response("not found", { status: 404 });
1902
+ const od = oauthDeps(req);
1891
1903
  const opsDeps = {
1892
1904
  db: getDb(),
1893
- issuer: oauthDeps(req).issuer,
1905
+ issuer: od.issuer,
1906
+ // hub#516: the CLI drives start/stop/restart/install/upgrade/uninstall
1907
+ // on loopback with the operator token, whose `iss` is the hub's public
1908
+ // origin after `expose`. Validate against the hub's known-origin set.
1909
+ knownIssuers: od.hubBoundOrigins(),
1894
1910
  manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1895
1911
  configDir: CONFIG_DIR,
1896
1912
  supervisor: deps.supervisor,