@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
package/src/managed-unit.ts
CHANGED
|
@@ -648,10 +648,25 @@ export interface BuildHubManagedUnitOpts {
|
|
|
648
648
|
*
|
|
649
649
|
* Resolves the absolute `bun` path via the `which` seam (launchd/systemd don't
|
|
650
650
|
* search `$PATH` — mirrors how the connector resolves cloudflared). The env
|
|
651
|
-
* carries `
|
|
652
|
-
* OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale
|
|
653
|
-
* iss-mismatch class; `resolveStartupIssuer`
|
|
654
|
-
* the operator token + vault `.env` to the
|
|
651
|
+
* carries `PARACHUTE_BIND_HOST` / `PARACHUTE_HOME` / `PORT` / `PATH` /
|
|
652
|
+
* `BUN_INSTALL` — and INTENTIONALLY OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale
|
|
653
|
+
* origin here would re-create the iss-mismatch class; `resolveStartupIssuer`
|
|
654
|
+
* derives it and start-hub self-heals the operator token + vault `.env` to the
|
|
655
|
+
* current origin (design §4.1 comment).
|
|
656
|
+
*
|
|
657
|
+
* BIND HOST — `PARACHUTE_BIND_HOST=127.0.0.1` is forced here so every
|
|
658
|
+
* self-hosted supervised hub binds loopback. `parachute serve` itself defaults
|
|
659
|
+
* the bind host to `0.0.0.0` (serve.ts), which is correct for the container
|
|
660
|
+
* shape (the platform's HTTP forwarder must reach the hub) but WRONG for a
|
|
661
|
+
* self-hosted box — bare `serve` would expose the admin/OAuth surfaces on every
|
|
662
|
+
* interface, contradicting the pre-supervisor detached behavior and the trust
|
|
663
|
+
* model `layerOf` (hub-server.ts) assumes (header-absent ⇒ "loopback"). The
|
|
664
|
+
* container path never calls this builder (the Dockerfile pins
|
|
665
|
+
* `ENV PARACHUTE_BIND_HOST=0.0.0.0` + runs `serve` directly), so it stays
|
|
666
|
+
* 0.0.0.0. The canonical expose path is unaffected: cloudflared/tailscale dial
|
|
667
|
+
* `127.0.0.1:<port>` from the same host, and the hub's own proxy targets
|
|
668
|
+
* `http://127.0.0.1:<port>` (hub-server.ts). An operator who genuinely wants
|
|
669
|
+
* all-interfaces can override the generated unit; the default is loopback.
|
|
655
670
|
*
|
|
656
671
|
* NOT called by any command in this PR (additive — Phase 3 wires it into `init`).
|
|
657
672
|
*/
|
|
@@ -676,6 +691,11 @@ export function buildHubManagedUnit(opts: BuildHubManagedUnitOpts): ManagedUnit
|
|
|
676
691
|
systemdDescription: "Parachute hub (serve + supervisor)",
|
|
677
692
|
execStart: [bunPath, opts.cliPath, "serve"],
|
|
678
693
|
env: {
|
|
694
|
+
// Force loopback on every self-hosted supervised hub. serve.ts defaults
|
|
695
|
+
// to 0.0.0.0 (container-first); a self-hosted box must NOT bare-serve
|
|
696
|
+
// all-interfaces. Container path bypasses this builder (Dockerfile pins
|
|
697
|
+
// its own 0.0.0.0). See the docstring for the full trust-model rationale.
|
|
698
|
+
PARACHUTE_BIND_HOST: "127.0.0.1",
|
|
679
699
|
// PARACHUTE_HOME captured at install time (design §4.2) — NOT the default.
|
|
680
700
|
PARACHUTE_HOME: opts.parachuteHome,
|
|
681
701
|
PORT: String(port),
|
package/src/operator-token.ts
CHANGED
|
@@ -30,7 +30,12 @@ import type { Database } from "bun:sqlite";
|
|
|
30
30
|
import { promises as fs } from "node:fs";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { configDir } from "./config.ts";
|
|
33
|
+
import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
|
|
34
|
+
import { validateHostAdminToken } from "./host-admin-token-validation.ts";
|
|
35
|
+
import { readHubPort } from "./hub-control.ts";
|
|
36
|
+
import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
|
|
33
37
|
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
38
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
34
39
|
import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
|
|
35
40
|
|
|
36
41
|
export const OPERATOR_TOKEN_FILENAME = "operator.token";
|
|
@@ -279,7 +284,14 @@ export class OperatorTokenExpiredError extends Error {
|
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
export interface UseOperatorTokenOpts {
|
|
282
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Hub origin the caller resolved. Required. As of hub#516 this is a SEED of
|
|
289
|
+
* the known-issuer SET the token's `iss` is validated against
|
|
290
|
+
* ({@link buildKnownIssuersForOperatorToken}), not the sole `iss` validator —
|
|
291
|
+
* so a caller that resolves loopback (`status`) still accepts a public-`iss`
|
|
292
|
+
* operator token from `expose-state.json`, and vice versa. It also remains
|
|
293
|
+
* the `iss` stamped on an auto-rotated re-mint.
|
|
294
|
+
*/
|
|
283
295
|
issuer: string;
|
|
284
296
|
/** configDir override (where operator.token lives). Defaults to `configDir()`. */
|
|
285
297
|
configDir?: string;
|
|
@@ -345,9 +357,78 @@ export interface UsedOperatorToken {
|
|
|
345
357
|
status: RotationStatus;
|
|
346
358
|
}
|
|
347
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Compose the Fly.io default public origin from `FLY_APP_NAME`, mirroring the
|
|
362
|
+
* server-side `flyDefaultOrigin` (hub-server.ts) so the client-side
|
|
363
|
+
* known-issuer set matches the origin the hub stamps tokens with on Fly. Kept
|
|
364
|
+
* local (a one-liner) rather than imported to avoid pulling hub-server.ts /
|
|
365
|
+
* serve.ts into the CLI auth path. Fly slugs never contain `/`; anything with
|
|
366
|
+
* one is spoofed or malformed.
|
|
367
|
+
*/
|
|
368
|
+
function flyDefaultOrigin(env: NodeJS.ProcessEnv): string | undefined {
|
|
369
|
+
const app = env.FLY_APP_NAME;
|
|
370
|
+
if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
|
|
371
|
+
return `https://${app}.fly.dev`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Assemble the SET of origins this hub legitimately answers on, from on-disk
|
|
376
|
+
* client state — the client-side mirror of hub-server.ts's per-request
|
|
377
|
+
* `buildHubBoundOrigins` call (hub#516). The operator token's `iss` is
|
|
378
|
+
* validated against this set rather than a single issuer, so a token whose
|
|
379
|
+
* `iss` is the hub's PUBLIC origin (stamped after `parachute expose`) is
|
|
380
|
+
* accepted even when the CLI command resolved a loopback `issuer` (the
|
|
381
|
+
* `status` case) — and vice versa.
|
|
382
|
+
*
|
|
383
|
+
* The set is:
|
|
384
|
+
* - `seedIssuer` — the issuer the caller resolved (loopback for `status`,
|
|
385
|
+
* `r.hubOrigin` / public for lifecycle). Kept as a seed so callers that
|
|
386
|
+
* pass a value still contribute it; never the sole gate.
|
|
387
|
+
* - loopback aliases — `http://127.0.0.1:<port>` AND `http://localhost:<port>`
|
|
388
|
+
* for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`),
|
|
389
|
+
* matching the hub's own loopback alias set (`buildHubBoundOrigins`).
|
|
390
|
+
* - the expose-state public origin — `expose-state.json`'s `hubOrigin`, the
|
|
391
|
+
* public URL the hub stamps on tokens once exposed.
|
|
392
|
+
* - the platform/env public origin — `PARACHUTE_HUB_ORIGIN` ∪
|
|
393
|
+
* `RENDER_EXTERNAL_URL` ∪ the composed Fly default — for container deploys
|
|
394
|
+
* where the public origin comes from the platform, not expose-state.
|
|
395
|
+
*
|
|
396
|
+
* Provenance is NOT established here: `validateHostAdminToken` runs the JWKS
|
|
397
|
+
* signature check FIRST and unconditionally. This set is the belt-and-suspenders
|
|
398
|
+
* `iss` allowlist layered on top — a foreign `iss` (not loopback / expose /
|
|
399
|
+
* env) is rejected, and an empty set fails closed.
|
|
400
|
+
*/
|
|
401
|
+
export function buildKnownIssuersForOperatorToken(
|
|
402
|
+
configDirOverride: string | undefined,
|
|
403
|
+
seedIssuer: string,
|
|
404
|
+
): readonly string[] {
|
|
405
|
+
const dir = configDirOverride ?? configDir();
|
|
406
|
+
const loopbackPort = readHubPort(dir) ?? HUB_UNIT_DEFAULT_PORT;
|
|
407
|
+
let exposeHubOrigin: string | undefined;
|
|
408
|
+
try {
|
|
409
|
+
exposeHubOrigin = readExposeState(join(dir, "expose-state.json"))?.hubOrigin;
|
|
410
|
+
} catch {
|
|
411
|
+
// A malformed expose-state.json must never lock the operator out of the
|
|
412
|
+
// CLI — the seed issuer + loopback aliases already cover legitimate
|
|
413
|
+
// loopback access; treat it as "no public origin known."
|
|
414
|
+
exposeHubOrigin = undefined;
|
|
415
|
+
}
|
|
416
|
+
const platformOrigin =
|
|
417
|
+
process.env.PARACHUTE_HUB_ORIGIN ??
|
|
418
|
+
process.env.RENDER_EXTERNAL_URL ??
|
|
419
|
+
flyDefaultOrigin(process.env);
|
|
420
|
+
return buildHubBoundOrigins({
|
|
421
|
+
issuer: seedIssuer,
|
|
422
|
+
loopbackPort,
|
|
423
|
+
...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
|
|
424
|
+
...(platformOrigin !== undefined ? { platformOrigin } : {}),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
348
428
|
/**
|
|
349
429
|
* The canonical "use the operator token in a CLI flow" helper. Reads
|
|
350
|
-
* `~/.parachute/operator.token`, validates against `db` +
|
|
430
|
+
* `~/.parachute/operator.token`, validates against `db` + the hub's
|
|
431
|
+
* known-issuer SET, and:
|
|
351
432
|
*
|
|
352
433
|
* - If the token has fully expired: throws `OperatorTokenExpiredError`
|
|
353
434
|
* with an actionable message. Does NOT auto-rotate from a dead token —
|
|
@@ -397,9 +478,19 @@ export async function useOperatorTokenWithAutoRotate(
|
|
|
397
478
|
if (!token) return null;
|
|
398
479
|
const now = opts.now ?? (() => new Date());
|
|
399
480
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
481
|
+
// Validate against the hub's KNOWN-ISSUER SET, not a single `opts.issuer`
|
|
482
|
+
// (hub#516). The operator token is the hub's OWN self-issued credential; its
|
|
483
|
+
// `iss` is the hub's loopback origin before `expose` and its PUBLIC origin
|
|
484
|
+
// after. Callers resolve `opts.issuer` inconsistently — `status` hardcodes
|
|
485
|
+
// loopback, lifecycle uses `r.hubOrigin` (public when exposed) — so a single
|
|
486
|
+
// per-issuer check rejected the public-`iss` operator token on `status` even
|
|
487
|
+
// though `restart` worked. `validateHostAdminToken` (the #517 helper) gates
|
|
488
|
+
// on the JWKS SIGNATURE first+unconditionally (provenance), then accepts the
|
|
489
|
+
// `iss` if it's ANY origin the hub legitimately answers on. Validation
|
|
490
|
+
// failures (signature mismatch, missing kid, expired-by-jose, revoked, or an
|
|
491
|
+
// `iss` foreign to the whole set) bubble out for the caller to render.
|
|
492
|
+
const knownIssuers = buildKnownIssuersForOperatorToken(opts.configDir, opts.issuer);
|
|
493
|
+
const validated = await validateHostAdminToken(db, token, knownIssuers);
|
|
403
494
|
const { payload } = validated;
|
|
404
495
|
|
|
405
496
|
const exp = typeof payload.exp === "number" ? payload.exp : 0;
|
package/src/origin-check.ts
CHANGED
|
@@ -74,6 +74,16 @@ export function buildHubBoundOrigins(opts: {
|
|
|
74
74
|
// Malformed URL — skip.
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
|
+
// `opts.issuer` is the PER-REQUEST issuer, which `resolveIssuer` derives
|
|
78
|
+
// from the request's Host header (hub-server.ts, "closes #245"). Including a
|
|
79
|
+
// Host-derived value in the known-issuers set is SAFE — it is NOT a
|
|
80
|
+
// forged-`iss` bypass. Token provenance is signature-gated: the JWKS verify
|
|
81
|
+
// in `validateAccessToken` / `validateHostAdminToken` runs UNCONDITIONALLY
|
|
82
|
+
// FIRST, before `iss` is ever checked against this set. So a token whose
|
|
83
|
+
// `iss` matches an attacker-injected Host (and thus lands in this set) but
|
|
84
|
+
// which isn't signed by THIS hub's key is still rejected at the signature
|
|
85
|
+
// step. The known-issuers membership check is belt-and-suspenders layered on
|
|
86
|
+
// top of the signature gate, never a substitute for it.
|
|
77
87
|
add(opts.issuer);
|
|
78
88
|
add(opts.exposeHubOrigin);
|
|
79
89
|
add(opts.platformOrigin);
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect + disable STALE per-module autostart units during the
|
|
3
|
+
* detached→supervised cutover + teardown (hub#522, design
|
|
4
|
+
* `parachute.computer/design/2026-06-01-hub-as-supervisor-unification.md` §7.2).
|
|
5
|
+
*
|
|
6
|
+
* THE BUG (validated hands-on on friends.parachute.computer): after a box
|
|
7
|
+
* migrates to the supervised model, a leftover STANDALONE per-module autostart
|
|
8
|
+
* unit from the pre-supervisor era — a systemd user unit `parachute-vault.service`
|
|
9
|
+
* with `Restart=always`, or a launchd `computer.parachute.vault` LaunchAgent with
|
|
10
|
+
* `KeepAlive` — keeps RESPAWNING an unsupervised vault that binds port 1940. The
|
|
11
|
+
* supervised hub's own vault child then can't bind → EADDRINUSE crash-loop →
|
|
12
|
+
* `crashed`, giving up. Killing the squatting PROCESS is whack-a-mole: the unit's
|
|
13
|
+
* KeepAlive / Restart=always resurrects it within seconds, serving OLD code.
|
|
14
|
+
*
|
|
15
|
+
* THE FIX (the load-bearing half of #522): the cutover must DISABLE THE UNIT, not
|
|
16
|
+
* just kill the process. Disabling deregisters the keep-alive intent so the
|
|
17
|
+
* module stays down and the supervised hub owns the port. The complementary half
|
|
18
|
+
* — the supervisor reclaiming its own port on EADDRINUSE at every start — is a
|
|
19
|
+
* separate follow-on; THIS module is the unit-disable that stops the respawn at
|
|
20
|
+
* the source.
|
|
21
|
+
*
|
|
22
|
+
* SCOPE + OWNERSHIP SAFETY (the hard constraint): we ONLY ever disable a unit
|
|
23
|
+
* whose name EXACTLY matches `parachute-<short>.service` (systemd) or
|
|
24
|
+
* `computer.parachute.<short>` (launchd) for a KNOWN module short
|
|
25
|
+
* (`knownServices()` — vault / scribe / runner / surface / notes / channel). We
|
|
26
|
+
* NEVER disable an arbitrary or unrecognized unit — an unknown unit is invisible
|
|
27
|
+
* to this sweep by construction (we look up exact names, never enumerate-and-
|
|
28
|
+
* match-loosely). On top of that we EXPLICITLY exclude the units the supervised
|
|
29
|
+
* model legitimately owns:
|
|
30
|
+
* - the hub unit (`computer.parachute.hub` / `parachute-hub.service`), and
|
|
31
|
+
* - the cloudflared connector (`computer.parachute.cloudflared.*` /
|
|
32
|
+
* `parachute-cloudflared-*`, owned by `expose off --cloudflare`).
|
|
33
|
+
* The skip-list reuses the canonical name constants (HUB_* + the cloudflared
|
|
34
|
+
* prefixes) so it can't drift.
|
|
35
|
+
*
|
|
36
|
+
* BEHAVIOR per platform (reuses the `ManagedUnitDeps` seam — `which` / `run`):
|
|
37
|
+
* - systemd (Linux): for each known short, query the USER unit
|
|
38
|
+
* `systemctl --user is-enabled parachute-<short>.service`. If it reads
|
|
39
|
+
* enabled (`enabled` / `enabled-runtime` / `static` / `alias`/`indirect`-ish)
|
|
40
|
+
* → `systemctl --user disable --now parachute-<short>.service`. A SYSTEM-level
|
|
41
|
+
* unit of the same name (detected via `systemctl is-enabled` without --user)
|
|
42
|
+
* is NOT touched (migrate has no sudo) — we WARN with the exact manual
|
|
43
|
+
* `sudo systemctl disable --now …` command instead.
|
|
44
|
+
* - launchd (Mac): for each known short, `launchctl print
|
|
45
|
+
* gui/<uid>/computer.parachute.<short>`; if the label is loaded → `launchctl
|
|
46
|
+
* bootout gui/<uid>/computer.parachute.<short>`.
|
|
47
|
+
*
|
|
48
|
+
* IDEMPOTENT: a unit that's already disabled / not-enabled / absent is a clean
|
|
49
|
+
* no-op (we never report disabling it). NON-FATAL: a disable that fails (perms,
|
|
50
|
+
* launchctl quirk) WARNS + continues — it never aborts the cutover. EVERYTHING
|
|
51
|
+
* behind the injectable `ManagedUnitDeps` seam so tests never touch real
|
|
52
|
+
* systemctl/launchctl.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
CLOUDFLARED_LAUNCHD_LABEL_PREFIX,
|
|
57
|
+
CLOUDFLARED_SYSTEMD_UNIT_PREFIX,
|
|
58
|
+
} from "./cloudflare/connector-service.ts";
|
|
59
|
+
import {
|
|
60
|
+
HUB_LAUNCHD_LABEL,
|
|
61
|
+
HUB_SYSTEMD_UNIT_NAME,
|
|
62
|
+
type ManagedUnitDeps,
|
|
63
|
+
defaultManagedUnitDeps,
|
|
64
|
+
} from "./managed-unit.ts";
|
|
65
|
+
import { knownServices } from "./service-spec.ts";
|
|
66
|
+
|
|
67
|
+
/** systemd unit name for a module short, e.g. `vault` → `parachute-vault.service`. */
|
|
68
|
+
export function moduleSystemdUnitName(short: string): string {
|
|
69
|
+
return `parachute-${short}.service`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** launchd label for a module short, e.g. `vault` → `computer.parachute.vault`. */
|
|
73
|
+
export function moduleLaunchdLabel(short: string): string {
|
|
74
|
+
return `computer.parachute.${short}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is this systemd unit name one the supervised model legitimately owns (and the
|
|
79
|
+
* sweep must therefore NEVER disable)? The hub unit + any cloudflared connector
|
|
80
|
+
* unit. Reuses the canonical name constants so the skip can't drift.
|
|
81
|
+
*/
|
|
82
|
+
function isProtectedSystemdUnit(unitName: string): boolean {
|
|
83
|
+
return unitName === HUB_SYSTEMD_UNIT_NAME || unitName.startsWith(CLOUDFLARED_SYSTEMD_UNIT_PREFIX);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Is this launchd label one the supervised model legitimately owns? The hub
|
|
88
|
+
* label + any cloudflared connector label (`computer.parachute.cloudflared.*`).
|
|
89
|
+
*/
|
|
90
|
+
function isProtectedLaunchdLabel(label: string): boolean {
|
|
91
|
+
return (
|
|
92
|
+
label === HUB_LAUNCHD_LABEL ||
|
|
93
|
+
label === CLOUDFLARED_LAUNCHD_LABEL_PREFIX ||
|
|
94
|
+
label.startsWith(`${CLOUDFLARED_LAUNCHD_LABEL_PREFIX}.`)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The module shorts whose stale standalone autostart units the sweep targets.
|
|
100
|
+
* Derived from `knownServices()` (the canonical FIRST_PARTY_FALLBACKS +
|
|
101
|
+
* KNOWN_MODULES list — vault / scribe / runner / surface / notes / channel), so
|
|
102
|
+
* a future module is covered automatically. `hub` is deliberately NOT in that
|
|
103
|
+
* list — the hub unit is the supervised model itself; we never disable it. As a
|
|
104
|
+
* defensive double-check we also drop any short whose derived unit name lands in
|
|
105
|
+
* the protected skip-list (so the sweep can never disable the hub / cloudflared
|
|
106
|
+
* even if a future short collided).
|
|
107
|
+
*/
|
|
108
|
+
export function targetModuleShorts(): string[] {
|
|
109
|
+
return knownServices().filter(
|
|
110
|
+
(short) =>
|
|
111
|
+
!isProtectedSystemdUnit(moduleSystemdUnitName(short)) &&
|
|
112
|
+
!isProtectedLaunchdLabel(moduleLaunchdLabel(short)),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* systemd `is-enabled` tokens that mean "this unit will autostart" — i.e. the
|
|
118
|
+
* stale-unit problem we're disabling. `disabled` / `masked` / `not-found` (and a
|
|
119
|
+
* nonzero exit with empty stdout) mean it won't, so they're a no-op.
|
|
120
|
+
*
|
|
121
|
+
* `static` and `indirect` units have no [Install] section / are pulled in by
|
|
122
|
+
* another unit; a standalone leftover `parachute-vault.service` written by the
|
|
123
|
+
* old per-module autostall path always carried `[Install] WantedBy=…` so reads
|
|
124
|
+
* `enabled` — but we treat `static`/`indirect` as "present + active intent" too
|
|
125
|
+
* so an oddly-written leftover still gets cleaned. `linked`/`generated` likewise.
|
|
126
|
+
*/
|
|
127
|
+
const SYSTEMD_ENABLED_TOKENS = new Set([
|
|
128
|
+
"enabled",
|
|
129
|
+
"enabled-runtime",
|
|
130
|
+
"static",
|
|
131
|
+
"indirect",
|
|
132
|
+
"linked",
|
|
133
|
+
"linked-runtime",
|
|
134
|
+
"generated",
|
|
135
|
+
"alias",
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
/** Outcome of one unit's detect-and-disable attempt. */
|
|
139
|
+
export interface StaleUnitAction {
|
|
140
|
+
/** The module short the unit belongs to. */
|
|
141
|
+
short: string;
|
|
142
|
+
/** "launchd" | "systemd-user" | "systemd-system". */
|
|
143
|
+
kind: "launchd" | "systemd-user" | "systemd-system";
|
|
144
|
+
/** The unit/label name acted on. */
|
|
145
|
+
unit: string;
|
|
146
|
+
/**
|
|
147
|
+
* "disabled" → we disabled it (report it; the operator sees what changed).
|
|
148
|
+
* "warn-system" → a system-level systemd unit we can't disable without sudo;
|
|
149
|
+
* we warn with the manual command. Non-fatal.
|
|
150
|
+
* "failed" → the disable command failed (perms/quirk); we warn + continue.
|
|
151
|
+
*/
|
|
152
|
+
result: "disabled" | "warn-system" | "failed";
|
|
153
|
+
/** The exact line(s) the caller should surface (report / warning). */
|
|
154
|
+
messages: string[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface DisableStaleModuleUnitsOpts {
|
|
158
|
+
/** Injectable platform deps (defaults to production). */
|
|
159
|
+
deps?: ManagedUnitDeps;
|
|
160
|
+
/** Sink for human-readable report / warning lines. */
|
|
161
|
+
log?: (line: string) => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface DisableStaleModuleUnitsResult {
|
|
165
|
+
/** Every unit we acted on (disabled / warned / failed). Empty = clean no-op. */
|
|
166
|
+
actions: StaleUnitAction[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Detect + disable any STALE per-module autostart unit on this platform (#522).
|
|
171
|
+
* Idempotent + non-fatal: already-disabled/absent units are silent no-ops, and a
|
|
172
|
+
* failed disable warns + continues. Returns the list of actions taken; the caller
|
|
173
|
+
* surfaces the messages (the cutover threads them through its own `log`).
|
|
174
|
+
*
|
|
175
|
+
* Dispatch mirrors `managed-unit.ts`: darwin → launchctl, linux → systemctl.
|
|
176
|
+
* Other platforms (no per-module unit possible) → empty no-op.
|
|
177
|
+
*/
|
|
178
|
+
export function disableStaleModuleUnits(
|
|
179
|
+
opts: DisableStaleModuleUnitsOpts = {},
|
|
180
|
+
): DisableStaleModuleUnitsResult {
|
|
181
|
+
const deps = opts.deps ?? defaultManagedUnitDeps;
|
|
182
|
+
const log = opts.log ?? (() => {});
|
|
183
|
+
const actions: StaleUnitAction[] = [];
|
|
184
|
+
|
|
185
|
+
const record = (action: StaleUnitAction): void => {
|
|
186
|
+
actions.push(action);
|
|
187
|
+
for (const m of action.messages) log(m);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (deps.platform === "darwin") {
|
|
191
|
+
if (deps.which("launchctl") === null) return { actions };
|
|
192
|
+
const uid = deps.getuid() ?? 0;
|
|
193
|
+
for (const short of targetModuleShorts()) {
|
|
194
|
+
const label = moduleLaunchdLabel(short);
|
|
195
|
+
// Belt-and-suspenders: never touch a protected (hub / cloudflared) label.
|
|
196
|
+
if (isProtectedLaunchdLabel(label)) continue;
|
|
197
|
+
const action = disableStaleLaunchdUnit(short, label, uid, deps);
|
|
198
|
+
if (action) record(action);
|
|
199
|
+
}
|
|
200
|
+
return { actions };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (deps.platform === "linux") {
|
|
204
|
+
if (deps.which("systemctl") === null) return { actions };
|
|
205
|
+
for (const short of targetModuleShorts()) {
|
|
206
|
+
const unit = moduleSystemdUnitName(short);
|
|
207
|
+
if (isProtectedSystemdUnit(unit)) continue;
|
|
208
|
+
const action = disableStaleSystemdUnit(short, unit, deps);
|
|
209
|
+
if (action) record(action);
|
|
210
|
+
}
|
|
211
|
+
return { actions };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// No per-platform manager (container / init-less / Windows) → nothing to do.
|
|
215
|
+
return { actions };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* launchd arm: probe `launchctl print gui/<uid>/<label>`. The label is LOADED
|
|
220
|
+
* (a stale KeepAlive LaunchAgent) when the print succeeds with non-empty output;
|
|
221
|
+
* we then `launchctl bootout` it (unload + stop → KeepAlive can't resurrect it).
|
|
222
|
+
* An unloaded/absent label prints empty/nonzero → clean no-op (returns undefined).
|
|
223
|
+
*/
|
|
224
|
+
function disableStaleLaunchdUnit(
|
|
225
|
+
short: string,
|
|
226
|
+
label: string,
|
|
227
|
+
uid: number,
|
|
228
|
+
deps: ManagedUnitDeps,
|
|
229
|
+
): StaleUnitAction | undefined {
|
|
230
|
+
let printed: { code: number; stdout: string; stderr: string };
|
|
231
|
+
try {
|
|
232
|
+
printed = deps.run(["launchctl", "print", `gui/${uid}/${label}`]);
|
|
233
|
+
} catch {
|
|
234
|
+
// launchctl threw (ENOENT between which() and run, or a quirk) — non-fatal.
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
// Not loaded → nothing to disable. `launchctl print` is nonzero + empty when
|
|
238
|
+
// the label isn't bootstrapped.
|
|
239
|
+
if (printed.stdout.trim().length === 0) return undefined;
|
|
240
|
+
|
|
241
|
+
let booted: { code: number; stdout: string; stderr: string };
|
|
242
|
+
try {
|
|
243
|
+
booted = deps.run(["launchctl", "bootout", `gui/${uid}/${label}`]);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return {
|
|
246
|
+
short,
|
|
247
|
+
kind: "launchd",
|
|
248
|
+
unit: label,
|
|
249
|
+
result: "failed",
|
|
250
|
+
messages: [
|
|
251
|
+
` ⚠ Could not disable the stale LaunchAgent ${label} (${err instanceof Error ? err.message : String(err)}).`,
|
|
252
|
+
` Run it yourself: launchctl bootout gui/${uid}/${label}`,
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (booted.code !== 0) {
|
|
257
|
+
const detail = booted.stderr.trim() || booted.stdout.trim() || "unknown error";
|
|
258
|
+
return {
|
|
259
|
+
short,
|
|
260
|
+
kind: "launchd",
|
|
261
|
+
unit: label,
|
|
262
|
+
result: "failed",
|
|
263
|
+
messages: [
|
|
264
|
+
` ⚠ Could not disable the stale LaunchAgent ${label} (${detail}).`,
|
|
265
|
+
` Run it yourself: launchctl bootout gui/${uid}/${label}`,
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
short,
|
|
271
|
+
kind: "launchd",
|
|
272
|
+
unit: label,
|
|
273
|
+
result: "disabled",
|
|
274
|
+
messages: [
|
|
275
|
+
` ✓ Disabled stale ${label} (it was fighting the supervised hub for ${short}'s port).`,
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* systemd arm: a stale standalone module unit can live at USER scope (the common
|
|
282
|
+
* pre-supervisor leftover, no sudo to write) or SYSTEM scope (rarer). We probe
|
|
283
|
+
* both:
|
|
284
|
+
* - USER (`systemctl --user is-enabled <unit>`): if enabled → `--user disable
|
|
285
|
+
* --now`. This is the path migrate can actually fix.
|
|
286
|
+
* - SYSTEM (`systemctl is-enabled <unit>`): if enabled but USER wasn't → migrate
|
|
287
|
+
* has no sudo, so WARN with the exact `sudo systemctl disable --now …` command
|
|
288
|
+
* (never attempt sudo).
|
|
289
|
+
* An absent/disabled unit at both scopes → clean no-op (returns undefined).
|
|
290
|
+
*/
|
|
291
|
+
function disableStaleSystemdUnit(
|
|
292
|
+
short: string,
|
|
293
|
+
unit: string,
|
|
294
|
+
deps: ManagedUnitDeps,
|
|
295
|
+
): StaleUnitAction | undefined {
|
|
296
|
+
// --- USER scope first (what migrate can actually disable). ---
|
|
297
|
+
if (systemdUnitEnabled(unit, ["--user"], deps)) {
|
|
298
|
+
let res: { code: number; stdout: string; stderr: string };
|
|
299
|
+
try {
|
|
300
|
+
res = deps.run(["systemctl", "--user", "disable", "--now", unit]);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return {
|
|
303
|
+
short,
|
|
304
|
+
kind: "systemd-user",
|
|
305
|
+
unit,
|
|
306
|
+
result: "failed",
|
|
307
|
+
messages: [
|
|
308
|
+
` ⚠ Could not disable the stale user unit ${unit} (${err instanceof Error ? err.message : String(err)}).`,
|
|
309
|
+
` Run it yourself: systemctl --user disable --now ${unit}`,
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (res.code !== 0) {
|
|
314
|
+
const detail = res.stderr.trim() || res.stdout.trim() || "unknown error";
|
|
315
|
+
return {
|
|
316
|
+
short,
|
|
317
|
+
kind: "systemd-user",
|
|
318
|
+
unit,
|
|
319
|
+
result: "failed",
|
|
320
|
+
messages: [
|
|
321
|
+
` ⚠ Could not disable the stale user unit ${unit} (${detail}).`,
|
|
322
|
+
` Run it yourself: systemctl --user disable --now ${unit}`,
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
short,
|
|
328
|
+
kind: "systemd-user",
|
|
329
|
+
unit,
|
|
330
|
+
result: "disabled",
|
|
331
|
+
messages: [
|
|
332
|
+
` ✓ Disabled stale ${unit} (it was fighting the supervised hub for ${short}'s port).`,
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- SYSTEM scope: detect-only + warn (no sudo in migrate). ---
|
|
338
|
+
if (systemdUnitEnabled(unit, [], deps)) {
|
|
339
|
+
return {
|
|
340
|
+
short,
|
|
341
|
+
kind: "systemd-system",
|
|
342
|
+
unit,
|
|
343
|
+
result: "warn-system",
|
|
344
|
+
messages: [
|
|
345
|
+
` ⚠ A SYSTEM-level ${unit} is enabled and may fight the supervised hub for ${short}'s port.`,
|
|
346
|
+
" Migrate can't disable a system unit (it needs root). Disable it yourself:",
|
|
347
|
+
` sudo systemctl disable --now ${unit}`,
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `systemctl [--user] is-enabled <unit>` → true iff the printed token means the
|
|
357
|
+
* unit will autostart (see `SYSTEMD_ENABLED_TOKENS`). `is-enabled` exits nonzero
|
|
358
|
+
* for non-enabled states and prints the token to stdout regardless of exit, so
|
|
359
|
+
* we classify from the stdout token. A throw (ENOENT/quirk) → treated as
|
|
360
|
+
* not-enabled (non-fatal; the sweep continues).
|
|
361
|
+
*/
|
|
362
|
+
function systemdUnitEnabled(unit: string, scope: string[], deps: ManagedUnitDeps): boolean {
|
|
363
|
+
let res: { code: number; stdout: string; stderr: string };
|
|
364
|
+
try {
|
|
365
|
+
res = deps.run(["systemctl", ...scope, "is-enabled", unit]);
|
|
366
|
+
} catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
const token = res.stdout.trim() || res.stderr.trim();
|
|
370
|
+
if (token.length === 0) return false;
|
|
371
|
+
// `is-enabled` can print the token then a hint on a second line; read line 1.
|
|
372
|
+
const first = token.split("\n")[0]?.trim() ?? "";
|
|
373
|
+
return SYSTEMD_ENABLED_TOKENS.has(first);
|
|
374
|
+
}
|