@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.
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +121 -0
- package/src/__tests__/api-modules.test.ts +67 -0
- package/src/__tests__/host-admin-token-validation.test.ts +218 -0
- package/src/__tests__/managed-unit.test.ts +23 -3
- package/src/__tests__/migrate-cutover.test.ts +60 -1
- package/src/__tests__/migrate.test.ts +16 -0
- package/src/__tests__/operator-token.test.ts +277 -0
- package/src/__tests__/stale-module-units.test.ts +286 -0
- package/src/api-modules-ops.ts +28 -2
- package/src/api-modules.ts +25 -2
- package/src/cloudflare/connector-service.ts +13 -2
- package/src/commands/migrate-cutover.ts +48 -0
- package/src/host-admin-token-validation.ts +96 -0
- package/src/hub-server.ts +19 -3
- package/src/managed-unit.ts +24 -4
- package/src/operator-token.ts +96 -5
- package/src/origin-check.ts +10 -0
- package/src/stale-module-units.ts +374 -0
|
@@ -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
|
+
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/api-modules.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
84
|
+
const LAUNCHD_LABEL_PREFIX = CLOUDFLARED_LAUNCHD_LABEL_PREFIX;
|
|
74
85
|
/** systemd unit name prefix. */
|
|
75
|
-
const SYSTEMD_UNIT_PREFIX =
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|