@openparachute/hub 0.7.3-rc.9 → 0.7.4-rc.1
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__/cli.test.ts +42 -0
- package/src/__tests__/hub-command.test.ts +433 -0
- package/src/__tests__/hub-origins-env-set.test.ts +74 -0
- package/src/__tests__/hub-server.test.ts +240 -0
- package/src/__tests__/init.test.ts +502 -1
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/setup-wizard.test.ts +121 -1
- package/src/cli.ts +62 -6
- package/src/commands/hub.ts +409 -0
- package/src/commands/init.ts +179 -37
- package/src/commands/serve.ts +39 -3
- package/src/help.ts +14 -0
- package/src/hub-server.ts +46 -0
- package/src/setup-wizard.ts +20 -0
|
@@ -26,7 +26,7 @@ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
|
26
26
|
import { type ExposeState, readExposeState, writeExposeState } from "../expose-state.ts";
|
|
27
27
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
28
28
|
import { hubFetch } from "../hub-server.ts";
|
|
29
|
-
import { getSetting, setSetting } from "../hub-settings.ts";
|
|
29
|
+
import { getSetting, setHubOrigin, setSetting } from "../hub-settings.ts";
|
|
30
30
|
import { validateAccessToken } from "../jwt-sign.ts";
|
|
31
31
|
import {
|
|
32
32
|
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
@@ -537,6 +537,126 @@ describe("deriveWizardState", () => {
|
|
|
537
537
|
db.close();
|
|
538
538
|
}
|
|
539
539
|
});
|
|
540
|
+
|
|
541
|
+
// --- Caddy-direct: a configured public origin already answers "how reached?"
|
|
542
|
+
// Seed a real vault row so the only step left to decide is expose.
|
|
543
|
+
const seedAdminAndVault = async (db: ReturnType<typeof openHubDb>) => {
|
|
544
|
+
await createUser(db, "owner", "pw");
|
|
545
|
+
writeManifest(
|
|
546
|
+
{
|
|
547
|
+
services: [
|
|
548
|
+
{
|
|
549
|
+
name: "parachute-vault",
|
|
550
|
+
version: "0.1.0",
|
|
551
|
+
port: 1940,
|
|
552
|
+
paths: ["/vault/default"],
|
|
553
|
+
health: "/health",
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
h.manifestPath,
|
|
558
|
+
);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
test("auto-skips expose when a public hub_origin is configured (Caddy-direct / init --hub-origin)", async () => {
|
|
562
|
+
// Caddy-direct box: `parachute init --hub-origin https://host` persisted a
|
|
563
|
+
// real public origin to hub_settings.hub_origin BEFORE the wizard opened.
|
|
564
|
+
// No Render/Fly env var, no live tailscale exposure. That origin already
|
|
565
|
+
// answers "how will this hub be reached?" — the wizard must skip the expose
|
|
566
|
+
// step (account → vault → done) rather than re-asking.
|
|
567
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
568
|
+
try {
|
|
569
|
+
await seedAdminAndVault(db);
|
|
570
|
+
setHubOrigin(db, "https://box.example.com");
|
|
571
|
+
const s = deriveWizardState({
|
|
572
|
+
db,
|
|
573
|
+
manifestPath: h.manifestPath,
|
|
574
|
+
env: {},
|
|
575
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
576
|
+
});
|
|
577
|
+
expect(s.step).toBe("done");
|
|
578
|
+
expect(s.hasExposeMode).toBe(true);
|
|
579
|
+
// Seeded "public" (not localhost/tailnet) — a Caddy-fronted box is public.
|
|
580
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("public");
|
|
581
|
+
} finally {
|
|
582
|
+
db.close();
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("auto-skips expose when PARACHUTE_HUB_ORIGIN env is a public origin (env-var Caddy box)", async () => {
|
|
587
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
588
|
+
try {
|
|
589
|
+
await seedAdminAndVault(db);
|
|
590
|
+
const s = deriveWizardState({
|
|
591
|
+
db,
|
|
592
|
+
manifestPath: h.manifestPath,
|
|
593
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://box.example.com" },
|
|
594
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
595
|
+
});
|
|
596
|
+
expect(s.step).toBe("done");
|
|
597
|
+
expect(s.hasExposeMode).toBe(true);
|
|
598
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("public");
|
|
599
|
+
} finally {
|
|
600
|
+
db.close();
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("does NOT skip expose when hub_origin is loopback (laptop / dev box still asks)", async () => {
|
|
605
|
+
// A loopback hub_origin must NOT count as "reached publicly" —
|
|
606
|
+
// sanitizePublicOrigin rejects 127.0.0.1/localhost/::1/0.0.0.0, so the
|
|
607
|
+
// expose step still renders and the operator chooses.
|
|
608
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
609
|
+
try {
|
|
610
|
+
await seedAdminAndVault(db);
|
|
611
|
+
setHubOrigin(db, "http://127.0.0.1:1939");
|
|
612
|
+
const s = deriveWizardState({
|
|
613
|
+
db,
|
|
614
|
+
manifestPath: h.manifestPath,
|
|
615
|
+
env: {},
|
|
616
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
617
|
+
});
|
|
618
|
+
expect(s.step).toBe("expose");
|
|
619
|
+
expect(s.hasExposeMode).toBe(false);
|
|
620
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
621
|
+
} finally {
|
|
622
|
+
db.close();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("does NOT skip expose when no origin is configured (unset → still asks)", async () => {
|
|
627
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
628
|
+
try {
|
|
629
|
+
await seedAdminAndVault(db);
|
|
630
|
+
const s = deriveWizardState({
|
|
631
|
+
db,
|
|
632
|
+
manifestPath: h.manifestPath,
|
|
633
|
+
env: {},
|
|
634
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
635
|
+
});
|
|
636
|
+
expect(s.step).toBe("expose");
|
|
637
|
+
expect(s.hasExposeMode).toBe(false);
|
|
638
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
639
|
+
} finally {
|
|
640
|
+
db.close();
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("does NOT skip expose when PARACHUTE_HUB_ORIGIN env is loopback", async () => {
|
|
645
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
646
|
+
try {
|
|
647
|
+
await seedAdminAndVault(db);
|
|
648
|
+
const s = deriveWizardState({
|
|
649
|
+
db,
|
|
650
|
+
manifestPath: h.manifestPath,
|
|
651
|
+
env: { PARACHUTE_HUB_ORIGIN: "http://localhost:1939" },
|
|
652
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
653
|
+
});
|
|
654
|
+
expect(s.step).toBe("expose");
|
|
655
|
+
expect(s.hasExposeMode).toBe(false);
|
|
656
|
+
} finally {
|
|
657
|
+
db.close();
|
|
658
|
+
}
|
|
659
|
+
});
|
|
540
660
|
});
|
|
541
661
|
|
|
542
662
|
// --- GET /admin/setup ----------------------------------------------------
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { MissingDependencyError } from "@openparachute/depcheck";
|
|
10
10
|
import pkg from "../package.json" with { type: "json" };
|
|
11
|
+
import { validateHubOrigin } from "./api-settings-hub-origin.ts";
|
|
11
12
|
import { CloudflaredStateError } from "./cloudflare/state.ts";
|
|
12
13
|
// Command-implementation modules are loaded LAZILY inside their switch arms (see
|
|
13
14
|
// `loadCommand` + each `case`), so a module that throws at eval-time is isolated
|
|
@@ -360,7 +361,32 @@ async function main(argv: string[]): Promise<number> {
|
|
|
360
361
|
console.log(initHelp());
|
|
361
362
|
return 0;
|
|
362
363
|
}
|
|
363
|
-
const
|
|
364
|
+
const originExtract = extractHubOrigin(rest);
|
|
365
|
+
if (originExtract.error) {
|
|
366
|
+
console.error(`parachute init: ${originExtract.error}`);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
// Validate --hub-origin to the SAME shape `hub set-origin` enforces — the
|
|
370
|
+
// value is persisted to hub_settings.hub_origin and stamps the OAuth `iss`
|
|
371
|
+
// claim on every minted token, so a malformed path/scheme/credential must
|
|
372
|
+
// never reach the DB. Strip a trailing slash first (browser copy-paste
|
|
373
|
+
// ergonomics), then validate; pass the normalized form downstream.
|
|
374
|
+
let validatedHubOrigin: string | undefined;
|
|
375
|
+
if (originExtract.hubOrigin !== undefined) {
|
|
376
|
+
const result = validateHubOrigin(originExtract.hubOrigin.replace(/\/+$/, ""));
|
|
377
|
+
if (!result.ok) {
|
|
378
|
+
console.error(`parachute init: invalid --hub-origin: ${result.description}`);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
if (result.normalized === null) {
|
|
382
|
+
console.error(
|
|
383
|
+
"parachute init: invalid --hub-origin: an empty value is not a valid public origin.",
|
|
384
|
+
);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
validatedHubOrigin = result.normalized;
|
|
388
|
+
}
|
|
389
|
+
const exposeExtract = extractNamedFlag(originExtract.rest, "--expose");
|
|
364
390
|
if (exposeExtract.error) {
|
|
365
391
|
console.error(`parachute init: ${exposeExtract.error}`);
|
|
366
392
|
return 1;
|
|
@@ -376,22 +402,42 @@ async function main(argv: string[]): Promise<number> {
|
|
|
376
402
|
);
|
|
377
403
|
return 1;
|
|
378
404
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
405
|
+
// hub#694 bug 2: `--channel rc|latest` picks the dist-tag init installs the
|
|
406
|
+
// vault module from. Without it, init always resolved @latest — DOWNGRADING
|
|
407
|
+
// vault below an rc-tracking hub. Mirrors `parachute install --channel`.
|
|
408
|
+
const channelExtract = extractNamedFlag(exposeExtract.rest, "--channel");
|
|
409
|
+
if (channelExtract.error) {
|
|
410
|
+
console.error(`parachute init: ${channelExtract.error}`);
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
channelExtract.value !== undefined &&
|
|
415
|
+
channelExtract.value !== "rc" &&
|
|
416
|
+
channelExtract.value !== "latest"
|
|
417
|
+
) {
|
|
418
|
+
console.error(
|
|
419
|
+
`parachute init: --channel must be "rc" or "latest" (got "${channelExtract.value}")`,
|
|
420
|
+
);
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
const noBrowser = channelExtract.rest.includes("--no-browser");
|
|
424
|
+
const noExposePrompt = channelExtract.rest.includes("--no-expose-prompt");
|
|
425
|
+
const cliWizard = channelExtract.rest.includes("--cli-wizard");
|
|
426
|
+
const browserWizard = channelExtract.rest.includes("--browser-wizard");
|
|
383
427
|
const known = new Set([
|
|
384
428
|
"--no-browser",
|
|
385
429
|
"--no-expose-prompt",
|
|
386
430
|
"--cli-wizard",
|
|
387
431
|
"--browser-wizard",
|
|
388
432
|
]);
|
|
389
|
-
const unknown =
|
|
433
|
+
const unknown = channelExtract.rest.find((a) => !known.has(a));
|
|
390
434
|
if (unknown !== undefined) {
|
|
391
435
|
console.error(`parachute init: unknown argument "${unknown}"`);
|
|
392
436
|
console.error(
|
|
393
437
|
"usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
|
|
394
438
|
" [--expose none|tailnet|cloudflare]\n" +
|
|
439
|
+
" [--channel rc|latest]\n" +
|
|
440
|
+
" [--hub-origin <url>]\n" +
|
|
395
441
|
" [--cli-wizard | --browser-wizard]",
|
|
396
442
|
);
|
|
397
443
|
return 1;
|
|
@@ -403,9 +449,13 @@ async function main(argv: string[]): Promise<number> {
|
|
|
403
449
|
const initOpts: Parameters<typeof init>[0] = {};
|
|
404
450
|
if (noBrowser) initOpts.noBrowser = true;
|
|
405
451
|
if (noExposePrompt) initOpts.noExposePrompt = true;
|
|
452
|
+
if (validatedHubOrigin) initOpts.hubOrigin = validatedHubOrigin;
|
|
406
453
|
if (exposeExtract.value) {
|
|
407
454
|
initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
|
|
408
455
|
}
|
|
456
|
+
if (channelExtract.value === "rc" || channelExtract.value === "latest") {
|
|
457
|
+
initOpts.channel = channelExtract.value;
|
|
458
|
+
}
|
|
409
459
|
if (cliWizard) initOpts.wizardChoice = "cli";
|
|
410
460
|
else if (browserWizard) initOpts.wizardChoice = "browser";
|
|
411
461
|
const mod = await loadCommand("init", () => import("./commands/init.ts"));
|
|
@@ -931,6 +981,12 @@ async function main(argv: string[]): Promise<number> {
|
|
|
931
981
|
return await mod.auth(rest);
|
|
932
982
|
}
|
|
933
983
|
|
|
984
|
+
case "hub": {
|
|
985
|
+
const mod = await loadCommand("hub", () => import("./commands/hub.ts"));
|
|
986
|
+
if (!mod) return 1;
|
|
987
|
+
return await mod.hub(rest);
|
|
988
|
+
}
|
|
989
|
+
|
|
934
990
|
case "vault": {
|
|
935
991
|
const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
|
|
936
992
|
if (!mod) return 1;
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute hub <subcommand>` — hub-local configuration verbs that write
|
|
3
|
+
* `~/.parachute/hub.db`.
|
|
4
|
+
*
|
|
5
|
+
* Subcommands:
|
|
6
|
+
* - `set-origin <url>` — persist the operator's canonical public hub origin
|
|
7
|
+
* into `hub_settings.hub_origin`. That row is tier-1 in `resolveIssuer`
|
|
8
|
+
* (hub-server.ts) — the OAuth `iss` claim hub stamps into every JWT — and,
|
|
9
|
+
* since the onboarding-streamline 2026-06-25 boot-issuer fix, also seeds the
|
|
10
|
+
* `PARACHUTE_HUB_ORIGINS` set hub injects into supervised modules
|
|
11
|
+
* (vault/scribe) so they accept tokens minted under the public origin.
|
|
12
|
+
*
|
|
13
|
+
* The headline use case is the zero-SSH Caddy-direct DigitalOcean path: a bare
|
|
14
|
+
* droplet runs Caddy (Let's Encrypt TLS terminator + reverse proxy to the
|
|
15
|
+
* loopback hub), and the hub never does TLS. The hub binds loopback and reads
|
|
16
|
+
* its public origin from the DB. Before this verb the only way to set
|
|
17
|
+
* `hub_origin` was the admin SPA's `PUT /api/settings/hub-origin` — which needs
|
|
18
|
+
* a browser session you don't have on a freshly-provisioned headless box. The
|
|
19
|
+
* CLI verb closes that gap so a cloud-init script (or an operator over the DO
|
|
20
|
+
* console) can record the public origin without SSHing into the admin UI.
|
|
21
|
+
*
|
|
22
|
+
* Validation reuses `validateHubOrigin` (api-settings-hub-origin.ts) so the CLI
|
|
23
|
+
* and the SPA enforce the EXACT same shape: http(s) scheme, a hostname, no
|
|
24
|
+
* trailing slash / path / query / fragment / credentials. A loopback value is
|
|
25
|
+
* allowed (a dev/test box may legitimately set one) but warned about, since a
|
|
26
|
+
* loopback `hub_origin` would advertise a non-public issuer.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Database } from "bun:sqlite";
|
|
30
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { validateHubOrigin } from "../api-settings-hub-origin.ts";
|
|
32
|
+
import { restart } from "../commands/lifecycle.ts";
|
|
33
|
+
import { CONFIG_DIR } from "../config.ts";
|
|
34
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
35
|
+
import { setHubOrigin } from "../hub-settings.ts";
|
|
36
|
+
import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
37
|
+
import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
|
|
38
|
+
|
|
39
|
+
/** Default path to the Parachute-managed Caddyfile (install/digitalocean.sh). */
|
|
40
|
+
const DEFAULT_CADDYFILE_PATH = "/etc/caddy/Caddyfile";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Stable prefix of the marker comment the install script writes as line 1 of a
|
|
44
|
+
* Parachute-managed Caddyfile (`# Managed by Parachute install …`). We match a
|
|
45
|
+
* prefix, not the whole line, so a future heredoc tweak to the trailing text
|
|
46
|
+
* doesn't break detection.
|
|
47
|
+
*/
|
|
48
|
+
const CADDY_MARKER_PREFIX = "# Managed by Parachute install";
|
|
49
|
+
|
|
50
|
+
export interface HubCommandDeps {
|
|
51
|
+
configDir?: string;
|
|
52
|
+
log?: (line: string) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Test seam: open the hub DB. Production opens `hub.db` under the resolved
|
|
55
|
+
* `configDir` (running migrations). Tests pass an in-memory / temp DB so the
|
|
56
|
+
* write is exercised without touching the operator's live `~/.parachute`.
|
|
57
|
+
*/
|
|
58
|
+
openDb?: (configDir: string) => Database;
|
|
59
|
+
/**
|
|
60
|
+
* Path to the Parachute-managed Caddyfile. Defaults to the install script's
|
|
61
|
+
* hardcoded `/etc/caddy/Caddyfile`. Tests point it at a temp fixture.
|
|
62
|
+
*/
|
|
63
|
+
caddyfilePath?: string;
|
|
64
|
+
/** fs read seam (default `node:fs`). Returns undefined when the file is absent. */
|
|
65
|
+
readFile?: (path: string) => string | undefined;
|
|
66
|
+
/** fs write seam (default `node:fs`). */
|
|
67
|
+
writeFile?: (path: string, content: string) => void;
|
|
68
|
+
/** fs existence seam (default `node:fs`). */
|
|
69
|
+
exists?: (path: string) => boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Runner for `systemctl reload caddy` + the module restart fan-out. Canonical
|
|
72
|
+
* {@link Runner} seam (the same one `expose.ts` injects). Tests mock it so
|
|
73
|
+
* nothing real reloads.
|
|
74
|
+
*/
|
|
75
|
+
run?: Runner;
|
|
76
|
+
/** Effective uid (default `process.getuid`). Non-root → skip the write/reload. */
|
|
77
|
+
getuid?: () => number | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Restart supervised modules so `PARACHUTE_HUB_ORIGINS` re-injects with the
|
|
80
|
+
* fresh origin. Defaults to `restart(undefined, …)` (restarts the hub unit,
|
|
81
|
+
* re-booting all modules). Tests mock it so the live supervisor isn't driven.
|
|
82
|
+
*/
|
|
83
|
+
restartModules?: (configDir: string) => Promise<number>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Outcome of an attempted in-place Caddyfile hostname rewrite. */
|
|
87
|
+
type CaddyRewriteResult =
|
|
88
|
+
| { kind: "rewritten"; content: string; host: string }
|
|
89
|
+
| { kind: "unchanged" } // host already matches — idempotent, no reload needed
|
|
90
|
+
| { kind: "not-managed" } // no file, or no `# Managed by Parachute install` marker
|
|
91
|
+
| { kind: "customized" }; // marker present but the site-address line shape is unrecognized
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Rewrite ONLY the site-address (hostname) line of a Parachute-managed Caddyfile
|
|
95
|
+
* to `host`, leaving the `reverse_proxy` body + the security-load-bearing
|
|
96
|
+
* `header_up -…` trust-signal strips byte-intact.
|
|
97
|
+
*
|
|
98
|
+
* The managed shape (install/digitalocean.sh write_caddyfile) is:
|
|
99
|
+
*
|
|
100
|
+
* # Managed by Parachute install (install/digitalocean.sh). Re-run to refresh.
|
|
101
|
+
* <hostname> {
|
|
102
|
+
* reverse_proxy 127.0.0.1:<port> {
|
|
103
|
+
* header_up -Cf-Ray
|
|
104
|
+
* …
|
|
105
|
+
* }
|
|
106
|
+
* }
|
|
107
|
+
*
|
|
108
|
+
* Strategy: confirm the first non-empty line carries the marker prefix; then
|
|
109
|
+
* find the first top-level site-opener line (`<token> {`) that is NOT the
|
|
110
|
+
* `reverse_proxy …` line and replace its host token with `host`, preserving
|
|
111
|
+
* indentation + the trailing ` {`. If the marker is present but no such line
|
|
112
|
+
* matches (operator hand-edited), return `customized` and let the caller fall
|
|
113
|
+
* back to manual steps rather than guess.
|
|
114
|
+
*/
|
|
115
|
+
export function rewriteCaddyfileHost(content: string, host: string): CaddyRewriteResult {
|
|
116
|
+
const lines = content.split("\n");
|
|
117
|
+
const firstNonEmpty = lines.find((l) => l.trim().length > 0);
|
|
118
|
+
if (firstNonEmpty === undefined || !firstNonEmpty.trim().startsWith(CADDY_MARKER_PREFIX)) {
|
|
119
|
+
return { kind: "not-managed" };
|
|
120
|
+
}
|
|
121
|
+
// The site-address opener is the first `<token> {`-terminated line that is NOT
|
|
122
|
+
// the `reverse_proxy …` line. A bare host token: no whitespace, no scheme.
|
|
123
|
+
const siteOpener = /^(\s*)(\S+)(\s*\{\s*)$/;
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
if (line === undefined) continue;
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (trimmed.startsWith("#") || trimmed.length === 0) continue;
|
|
129
|
+
if (trimmed.startsWith("reverse_proxy")) continue;
|
|
130
|
+
const m = line.match(siteOpener);
|
|
131
|
+
if (!m) continue;
|
|
132
|
+
const [, indent, token, brace] = m;
|
|
133
|
+
if (indent === undefined || token === undefined || brace === undefined) continue;
|
|
134
|
+
if (token === host) return { kind: "unchanged" };
|
|
135
|
+
lines[i] = `${indent}${host}${brace}`;
|
|
136
|
+
return { kind: "rewritten", content: lines.join("\n"), host };
|
|
137
|
+
}
|
|
138
|
+
return { kind: "customized" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `parachute hub set-origin <url>`. Validates + canonicalizes the URL, persists
|
|
143
|
+
* it to `hub_settings.hub_origin`, then — on a Parachute-managed Caddy-direct
|
|
144
|
+
* box — rewrites the Caddyfile hostname, reloads Caddy, and restarts the
|
|
145
|
+
* supervised modules so they re-pick the origin. Every post-DB step is
|
|
146
|
+
* best-effort: on any miss (no managed Caddyfile, `--no-caddy`, non-root, a
|
|
147
|
+
* failed reload/restart) the origin still persisted and the manual steps are
|
|
148
|
+
* printed. Returns 0 on success (incl. soft Caddy/restart misses), 1 only on a
|
|
149
|
+
* validation / DB-write failure.
|
|
150
|
+
*/
|
|
151
|
+
export async function hubSetOrigin(
|
|
152
|
+
args: readonly string[],
|
|
153
|
+
deps: HubCommandDeps = {},
|
|
154
|
+
): Promise<number> {
|
|
155
|
+
const configDir = deps.configDir ?? CONFIG_DIR;
|
|
156
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
157
|
+
const err = (line: string) => console.error(line);
|
|
158
|
+
const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
|
|
159
|
+
const caddyfilePath = deps.caddyfilePath ?? DEFAULT_CADDYFILE_PATH;
|
|
160
|
+
const exists = deps.exists ?? ((path: string) => existsSync(path));
|
|
161
|
+
const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf8"));
|
|
162
|
+
const writeFile =
|
|
163
|
+
deps.writeFile ?? ((path: string, content: string) => writeFileSync(path, content));
|
|
164
|
+
const run = deps.run ?? defaultRunner;
|
|
165
|
+
const getuid =
|
|
166
|
+
deps.getuid ?? (() => (typeof process.getuid === "function" ? process.getuid() : undefined));
|
|
167
|
+
const restartModules =
|
|
168
|
+
deps.restartModules ??
|
|
169
|
+
((dir: string) => restart(undefined, { configDir: dir, supervisor: {} }));
|
|
170
|
+
|
|
171
|
+
const noCaddy = args.includes("--no-caddy");
|
|
172
|
+
const noRestart = args.includes("--no-restart");
|
|
173
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
174
|
+
const raw = positional[0];
|
|
175
|
+
if (raw === undefined) {
|
|
176
|
+
err("usage: parachute hub set-origin <url>");
|
|
177
|
+
err("example: parachute hub set-origin https://box.sslip.io");
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
if (positional.length > 1) {
|
|
181
|
+
err(`parachute hub set-origin: unexpected argument "${positional[1]}"`);
|
|
182
|
+
err("usage: parachute hub set-origin <url>");
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Strip a trailing slash up front so the convenient `https://host/` form is
|
|
187
|
+
// accepted (the operator copy-pastes a browser URL). `validateHubOrigin`
|
|
188
|
+
// itself REJECTS a trailing slash (it's the SPA's PUT body validator, where a
|
|
189
|
+
// strict shape is wanted) — we canonicalize here, then validate the rest of
|
|
190
|
+
// the shape (scheme / hostname / no-path / no-credentials) through the shared
|
|
191
|
+
// validator so the CLI and SPA agree on everything else.
|
|
192
|
+
const trimmed = raw.replace(/\/+$/, "");
|
|
193
|
+
const result = validateHubOrigin(trimmed);
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
err(`parachute hub set-origin: ${result.description}`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
// `validateHubOrigin` maps empty / null to `normalized: null` (clear the row).
|
|
199
|
+
// `set-origin` with an explicit non-empty arg should never land there — a
|
|
200
|
+
// present positional that normalizes to null means the operator passed an
|
|
201
|
+
// empty string, which is not a valid public origin for this verb.
|
|
202
|
+
if (result.normalized === null) {
|
|
203
|
+
err("parachute hub set-origin: a non-empty origin URL is required");
|
|
204
|
+
err("usage: parachute hub set-origin <url>");
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const origin = result.normalized;
|
|
208
|
+
|
|
209
|
+
// Loopback is allowed (a dev/test box may set one deliberately) but warned —
|
|
210
|
+
// a loopback hub_origin advertises a non-public issuer, so remote devices
|
|
211
|
+
// and supervised resource servers reached over a public URL would reject it.
|
|
212
|
+
if (isLoopbackOrigin(origin)) {
|
|
213
|
+
log(`⚠ ${origin} is a loopback origin — it won't be reachable from other devices.`);
|
|
214
|
+
log(" Set the box's PUBLIC URL (e.g. https://<droplet-ip>.sslip.io) for a real deploy.");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const db = openDb(configDir);
|
|
218
|
+
try {
|
|
219
|
+
setHubOrigin(db, origin);
|
|
220
|
+
} finally {
|
|
221
|
+
db.close();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
log(`✓ Canonical hub origin set to ${origin}.`);
|
|
225
|
+
log(" Stored in hub_settings — the OAuth issuer (`iss`) hub stamps on every token,");
|
|
226
|
+
log(" and the origin set injected into supervised modules so they accept it.");
|
|
227
|
+
|
|
228
|
+
// The DB write is the load-bearing contract; everything below is best-effort
|
|
229
|
+
// automation of the manual restart/rewrite steps. Any miss falls back to
|
|
230
|
+
// printing the manual instructions and still returns 0.
|
|
231
|
+
//
|
|
232
|
+
// The Caddy site address is a BARE host (Caddy auto-TLS on HTTPS-default),
|
|
233
|
+
// taken from the ALREADY-VALIDATED URL — `new URL(origin).host` (no scheme),
|
|
234
|
+
// never a raw arg, so there's nothing to inject (the rewrite is a file edit,
|
|
235
|
+
// the reload/restart are argv-form Bun.spawn, no shell).
|
|
236
|
+
const host = new URL(origin).host;
|
|
237
|
+
|
|
238
|
+
if (noCaddy) {
|
|
239
|
+
log("");
|
|
240
|
+
log("(--no-caddy) Skipping the Caddyfile rewrite + reload.");
|
|
241
|
+
} else if (!exists(caddyfilePath)) {
|
|
242
|
+
// Tailscale / Cloudflare / manual-proxy box — no managed Caddyfile to touch.
|
|
243
|
+
log("");
|
|
244
|
+
log(
|
|
245
|
+
`No Parachute-managed Caddyfile at ${caddyfilePath} — origin set; restart modules to apply.`,
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
let content: string | undefined;
|
|
249
|
+
try {
|
|
250
|
+
content = readFile(caddyfilePath);
|
|
251
|
+
} catch {
|
|
252
|
+
content = undefined;
|
|
253
|
+
}
|
|
254
|
+
const rewrite =
|
|
255
|
+
content === undefined
|
|
256
|
+
? ({ kind: "not-managed" } as const)
|
|
257
|
+
: rewriteCaddyfileHost(content, host);
|
|
258
|
+
if (rewrite.kind === "not-managed") {
|
|
259
|
+
log("");
|
|
260
|
+
log(
|
|
261
|
+
`No Parachute-managed Caddyfile at ${caddyfilePath} — origin set; restart modules to apply.`,
|
|
262
|
+
);
|
|
263
|
+
} else if (rewrite.kind === "customized") {
|
|
264
|
+
log("");
|
|
265
|
+
log(`The Caddyfile at ${caddyfilePath} looks customized — not rewriting its host line.`);
|
|
266
|
+
log(` Update the site address to "${host}" by hand, then: sudo systemctl reload caddy`);
|
|
267
|
+
} else if (rewrite.kind === "unchanged") {
|
|
268
|
+
// Host already matches — nothing to write or reload.
|
|
269
|
+
log("");
|
|
270
|
+
log(`Caddyfile already points at ${host} — no change needed.`);
|
|
271
|
+
} else if (getuid() !== undefined && getuid() !== 0) {
|
|
272
|
+
// Writing /etc/caddy/Caddyfile + `systemctl reload` need root; the hub
|
|
273
|
+
// never shells `sudo` itself. Persist + print the manual steps.
|
|
274
|
+
log("");
|
|
275
|
+
log(`Not running as root — can't rewrite ${caddyfilePath} or reload Caddy.`);
|
|
276
|
+
log(
|
|
277
|
+
` As root: set the Caddyfile site address to "${host}", then: sudo systemctl reload caddy`,
|
|
278
|
+
);
|
|
279
|
+
} else {
|
|
280
|
+
// Root + managed file + host changed → rewrite, then (only if the write
|
|
281
|
+
// landed) reload.
|
|
282
|
+
let wrote = false;
|
|
283
|
+
try {
|
|
284
|
+
writeFile(caddyfilePath, rewrite.content);
|
|
285
|
+
wrote = true;
|
|
286
|
+
} catch (e) {
|
|
287
|
+
log("");
|
|
288
|
+
log(`Could not write ${caddyfilePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
289
|
+
log(` Update its site address to "${host}" by hand, then: sudo systemctl reload caddy`);
|
|
290
|
+
}
|
|
291
|
+
if (wrote) {
|
|
292
|
+
// `runCaddyReload` → defaultRunner pre-flights `systemctl` and THROWS
|
|
293
|
+
// (friendly missing-dep error) on a non-systemd box, before its own
|
|
294
|
+
// try/catch. Wrap it so a throw degrades to the manual-reload hint
|
|
295
|
+
// instead of escaping past the module restart below — the origin is
|
|
296
|
+
// already persisted, so the restart must still run.
|
|
297
|
+
let reload: CommandResult;
|
|
298
|
+
try {
|
|
299
|
+
reload = await runCaddyReload(run);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
reload = { code: 1, stdout: "", stderr: e instanceof Error ? e.message : String(e) };
|
|
302
|
+
}
|
|
303
|
+
if (reload.code === 0) {
|
|
304
|
+
log("");
|
|
305
|
+
log(`✓ Rewrote ${caddyfilePath} → ${host}, reloaded Caddy.`);
|
|
306
|
+
} else {
|
|
307
|
+
log("");
|
|
308
|
+
log(`✓ Rewrote ${caddyfilePath} → ${host}, but Caddy reload reported an error:`);
|
|
309
|
+
if (reload.stderr.trim().length > 0) log(` ${reload.stderr.trim()}`);
|
|
310
|
+
log(" Reload it manually: sudo systemctl reload caddy");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Restart supervised modules so they re-pick the origin via PARACHUTE_HUB_ORIGINS
|
|
317
|
+
// (injected at child-spawn time). One hub-unit restart re-boots all modules —
|
|
318
|
+
// strictly better than the old "restart vault / restart scribe" two-step.
|
|
319
|
+
if (noRestart) {
|
|
320
|
+
log("");
|
|
321
|
+
log("(--no-restart) Skipping the module restart. Apply with: parachute restart");
|
|
322
|
+
} else {
|
|
323
|
+
let restarted = false;
|
|
324
|
+
try {
|
|
325
|
+
restarted = (await restartModules(configDir)) === 0;
|
|
326
|
+
} catch {
|
|
327
|
+
restarted = false;
|
|
328
|
+
}
|
|
329
|
+
if (restarted) {
|
|
330
|
+
log("✓ Restarted modules (vault, scribe) so they accept the new origin.");
|
|
331
|
+
} else {
|
|
332
|
+
log("");
|
|
333
|
+
log("Restart already-running modules so they pick up the new origin:");
|
|
334
|
+
log(" parachute restart");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* `systemctl reload caddy` through the injected Runner. Kept tiny so the call
|
|
343
|
+
* site reads as one step; the Runner default (`defaultRunner`) env-inherits +
|
|
344
|
+
* pre-flights the binary, and tests mock it.
|
|
345
|
+
*/
|
|
346
|
+
async function runCaddyReload(run: Runner): Promise<CommandResult> {
|
|
347
|
+
return run(["systemctl", "reload", "caddy"]);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
|
|
352
|
+
* router over subcommand handlers, each catching its own errors).
|
|
353
|
+
*/
|
|
354
|
+
export async function hub(args: readonly string[], deps: HubCommandDeps = {}): Promise<number> {
|
|
355
|
+
const sub = args[0];
|
|
356
|
+
if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
|
|
357
|
+
console.log(hubHelp());
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
if (sub === "set-origin") {
|
|
361
|
+
try {
|
|
362
|
+
return await hubSetOrigin(args.slice(1), deps);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error(
|
|
365
|
+
`parachute hub set-origin: ${err instanceof Error ? err.message : String(err)}`,
|
|
366
|
+
);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
console.error(`parachute hub: unknown subcommand "${sub}"`);
|
|
371
|
+
console.error("");
|
|
372
|
+
console.error(hubHelp());
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function hubHelp(): string {
|
|
377
|
+
return `parachute hub — hub-local configuration
|
|
378
|
+
|
|
379
|
+
Usage:
|
|
380
|
+
parachute hub set-origin <url> [--no-caddy] [--no-restart]
|
|
381
|
+
|
|
382
|
+
Subcommands:
|
|
383
|
+
set-origin <url> Persist the canonical public origin (OAuth issuer) to the
|
|
384
|
+
hub DB. This is the URL the hub stamps as the \`iss\` claim
|
|
385
|
+
on every token AND the origin it tells supervised modules
|
|
386
|
+
(vault, scribe) to accept. Use it on a reverse-proxy /
|
|
387
|
+
Caddy-direct box where the hub binds loopback but is reached
|
|
388
|
+
over a public HTTPS URL — no admin browser session needed.
|
|
389
|
+
|
|
390
|
+
The URL must be http(s), with a hostname and no trailing
|
|
391
|
+
slash / path / query.
|
|
392
|
+
|
|
393
|
+
On a Parachute-managed Caddy-direct box (a
|
|
394
|
+
/etc/caddy/Caddyfile written by the install script), this
|
|
395
|
+
ALSO rewrites the Caddyfile's hostname line to the new
|
|
396
|
+
origin (the reverse_proxy body + trust-signal header strips
|
|
397
|
+
are left intact), reloads Caddy, and restarts the modules so
|
|
398
|
+
the new origin propagates — a one-command domain change. If
|
|
399
|
+
no managed Caddyfile is present, the rewrite is skipped and
|
|
400
|
+
the manual steps are printed. Pass --no-caddy to skip the
|
|
401
|
+
Caddyfile rewrite + reload, or --no-restart to skip the
|
|
402
|
+
module restart.
|
|
403
|
+
|
|
404
|
+
Examples:
|
|
405
|
+
parachute hub set-origin https://box.sslip.io
|
|
406
|
+
parachute hub set-origin https://parachute.example.com
|
|
407
|
+
parachute hub set-origin https://parachute.example.com --no-caddy
|
|
408
|
+
`;
|
|
409
|
+
}
|