@openparachute/hub 0.7.3-rc.11 → 0.7.3-rc.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.11",
3
+ "version": "0.7.3-rc.12",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -9,12 +9,13 @@
9
9
  * SPA's `validateHubOrigin`), and it warns-but-allows loopback.
10
10
  */
11
11
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
- import { mkdtempSync, rmSync } from "node:fs";
12
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
- import { hub, hubSetOrigin } from "../commands/hub.ts";
15
+ import { hub, hubSetOrigin, rewriteCaddyfileHost } from "../commands/hub.ts";
16
16
  import { hubDbPath, openHubDb } from "../hub-db.ts";
17
17
  import { getHubOrigin } from "../hub-settings.ts";
18
+ import type { CommandResult } from "../tailscale/run.ts";
18
19
 
19
20
  describe("parachute hub set-origin", () => {
20
21
  let dir: string;
@@ -27,6 +28,29 @@ describe("parachute hub set-origin", () => {
27
28
  });
28
29
  afterEach(() => rmSync(dir, { recursive: true, force: true }));
29
30
 
31
+ /**
32
+ * Hermetic seams for the non-DB side effects: point the Caddyfile at a
33
+ * non-existent path (→ "no managed Caddyfile" branch), and mock the reload
34
+ * Runner + module restart so NOTHING real reloads Caddy or drives the live
35
+ * supervisor. The DB write is the only real side effect (scoped to the temp
36
+ * configDir). Tests that exercise Caddy specifically override these.
37
+ */
38
+ function safeSeams(): {
39
+ configDir: string;
40
+ log: (line: string) => void;
41
+ caddyfilePath: string;
42
+ run: (cmd: readonly string[]) => Promise<{ code: number; stdout: string; stderr: string }>;
43
+ restartModules: () => Promise<number>;
44
+ } {
45
+ return {
46
+ configDir: dir,
47
+ log: collect,
48
+ caddyfilePath: join(dir, "no-caddyfile-here"),
49
+ run: async () => ({ code: 0, stdout: "", stderr: "" }),
50
+ restartModules: async () => 0,
51
+ };
52
+ }
53
+
30
54
  /** Open the configDir's hub.db and read the persisted origin. */
31
55
  function persisted(): string | null {
32
56
  const db = openHubDb(hubDbPath(dir));
@@ -38,55 +62,60 @@ describe("parachute hub set-origin", () => {
38
62
  }
39
63
 
40
64
  test("persists a valid public origin to hub_settings.hub_origin", async () => {
41
- const code = await hubSetOrigin(["https://box.sslip.io"], { configDir: dir, log: collect });
65
+ const code = await hubSetOrigin(["https://box.sslip.io"], safeSeams());
42
66
  expect(code).toBe(0);
43
67
  expect(persisted()).toBe("https://box.sslip.io");
44
68
  });
45
69
 
46
70
  test("strips a trailing slash (canonical bare-origin form)", async () => {
47
- const code = await hubSetOrigin(["https://box.example.com/"], { configDir: dir, log: collect });
71
+ const code = await hubSetOrigin(["https://box.example.com/"], safeSeams());
48
72
  expect(code).toBe(0);
49
73
  expect(persisted()).toBe("https://box.example.com");
50
74
  });
51
75
 
52
76
  test("rejects a non-http(s) scheme without writing", async () => {
53
- const code = await hubSetOrigin(["ftp://box.example.com"], { configDir: dir, log: collect });
77
+ const code = await hubSetOrigin(["ftp://box.example.com"], safeSeams());
54
78
  expect(code).toBe(1);
55
79
  expect(persisted()).toBeNull();
56
80
  });
57
81
 
58
82
  test("rejects a URL with a path without writing", async () => {
59
- const code = await hubSetOrigin(["https://box.example.com/admin"], {
60
- configDir: dir,
61
- log: collect,
62
- });
83
+ const code = await hubSetOrigin(["https://box.example.com/admin"], safeSeams());
63
84
  expect(code).toBe(1);
64
85
  expect(persisted()).toBeNull();
65
86
  });
66
87
 
67
88
  test("rejects a bare hostname (no scheme) without writing", async () => {
68
- const code = await hubSetOrigin(["box.example.com"], { configDir: dir, log: collect });
89
+ const code = await hubSetOrigin(["box.example.com"], safeSeams());
69
90
  expect(code).toBe(1);
70
91
  expect(persisted()).toBeNull();
71
92
  });
72
93
 
73
94
  test("missing the URL argument is a usage error, no write", async () => {
74
- const code = await hubSetOrigin([], { configDir: dir, log: collect });
95
+ const code = await hubSetOrigin([], safeSeams());
75
96
  expect(code).toBe(1);
76
97
  expect(persisted()).toBeNull();
77
98
  });
78
99
 
79
100
  test("warns but ALLOWS a loopback origin (dev/test escape hatch)", async () => {
80
- const code = await hubSetOrigin(["http://127.0.0.1:1939"], { configDir: dir, log: collect });
101
+ const code = await hubSetOrigin(["http://127.0.0.1:1939"], safeSeams());
81
102
  expect(code).toBe(0);
82
103
  expect(persisted()).toBe("http://127.0.0.1:1939");
83
104
  expect(log.some((l) => l.toLowerCase().includes("loopback"))).toBe(true);
84
105
  });
85
106
 
86
- test("prints the restart note so the operator knows running modules need a restart", async () => {
87
- await hubSetOrigin(["https://box.sslip.io"], { configDir: dir, log: collect });
107
+ test("prints a restart instruction when no managed Caddyfile is present", async () => {
108
+ let restartCalls = 0;
109
+ await hubSetOrigin(["https://box.sslip.io"], {
110
+ ...safeSeams(),
111
+ restartModules: async () => {
112
+ restartCalls++;
113
+ return 1; // simulate restart not landing → prints the manual hint
114
+ },
115
+ });
88
116
  const joined = log.join("\n");
89
- expect(joined).toContain("parachute restart vault");
117
+ expect(restartCalls).toBe(1);
118
+ expect(joined).toContain("parachute restart");
90
119
  });
91
120
 
92
121
  test("openDb seam is honored (no touch of the live ~/.parachute)", async () => {
@@ -98,8 +127,7 @@ describe("parachute hub set-origin", () => {
98
127
  try {
99
128
  let opened = 0;
100
129
  const code = await hubSetOrigin(["https://box.sslip.io"], {
101
- configDir: dir,
102
- log: collect,
130
+ ...safeSeams(),
103
131
  openDb: () => {
104
132
  opened++;
105
133
  return openHubDb(hubDbPath(altDir));
@@ -134,3 +162,272 @@ describe("parachute hub dispatcher", () => {
134
162
  expect(await hub(["frobnicate"])).toBe(1);
135
163
  });
136
164
  });
165
+
166
+ // --- rewriteCaddyfileHost (pure) -----------------------------------------
167
+
168
+ const MANAGED_CADDYFILE = `# Managed by Parachute install (install/digitalocean.sh). Re-run to refresh.
169
+ old.example.com {
170
+ reverse_proxy 127.0.0.1:1939 {
171
+ # Strip client-supplied layer-spoofing trust signals — only a real
172
+ # Cloudflare / Tailscale edge may set these; this Caddy is the edge.
173
+ header_up -Cf-Ray
174
+ header_up -Cf-Connecting-Ip
175
+ header_up -Tailscale-Funnel-Request
176
+ header_up -Tailscale-User-Login
177
+ }
178
+ }
179
+ `;
180
+
181
+ describe("rewriteCaddyfileHost", () => {
182
+ test("rewrites ONLY the site-address line, body + header strips intact", () => {
183
+ const r = rewriteCaddyfileHost(MANAGED_CADDYFILE, "new.example.com");
184
+ expect(r.kind).toBe("rewritten");
185
+ if (r.kind !== "rewritten") throw new Error("unreachable");
186
+ expect(r.content).toContain("new.example.com {");
187
+ expect(r.content).not.toContain("old.example.com");
188
+ // The reverse_proxy body + the security-load-bearing header strips survive.
189
+ expect(r.content).toContain("reverse_proxy 127.0.0.1:1939 {");
190
+ expect(r.content).toContain("header_up -Cf-Ray");
191
+ expect(r.content).toContain("header_up -Cf-Connecting-Ip");
192
+ expect(r.content).toContain("header_up -Tailscale-Funnel-Request");
193
+ expect(r.content).toContain("header_up -Tailscale-User-Login");
194
+ // Only one line changed (host line); everything else byte-identical.
195
+ const before = MANAGED_CADDYFILE.split("\n");
196
+ const after = r.content.split("\n");
197
+ const diff = before.filter((l, i) => l !== after[i]);
198
+ expect(diff).toEqual(["old.example.com {"]);
199
+ });
200
+
201
+ test("idempotent — host already matches → unchanged (no reload needed)", () => {
202
+ expect(rewriteCaddyfileHost(MANAGED_CADDYFILE, "old.example.com").kind).toBe("unchanged");
203
+ });
204
+
205
+ test("an unmanaged Caddyfile (no marker) → not-managed", () => {
206
+ const unmanaged = "example.com {\n reverse_proxy 127.0.0.1:1939\n}\n";
207
+ expect(rewriteCaddyfileHost(unmanaged, "new.example.com").kind).toBe("not-managed");
208
+ });
209
+
210
+ test("marker present but no site-opener line (hand-edited) → customized", () => {
211
+ const odd = "# Managed by Parachute install (foo)\nreverse_proxy 127.0.0.1:1939\n";
212
+ expect(rewriteCaddyfileHost(odd, "new.example.com").kind).toBe("customized");
213
+ });
214
+
215
+ test("injection-shaped host can't break the rewrite (host comes from validated URL)", () => {
216
+ // The caller only ever passes `new URL(origin).host` from an already-validated
217
+ // origin, so a host with spaces / braces never reaches here — but even a weird
218
+ // token is written verbatim as the bare site address (no shell, plain file edit).
219
+ const r = rewriteCaddyfileHost(MANAGED_CADDYFILE, "weird.host:8443");
220
+ expect(r.kind).toBe("rewritten");
221
+ if (r.kind !== "rewritten") throw new Error("unreachable");
222
+ expect(r.content).toContain("weird.host:8443 {");
223
+ // The body is still intact — the regex only touched the site opener.
224
+ expect(r.content).toContain("header_up -Cf-Ray");
225
+ });
226
+ });
227
+
228
+ // --- set-origin Caddy rewrite + reload + restart -------------------------
229
+
230
+ const okResult = (): CommandResult => ({ code: 0, stdout: "", stderr: "" });
231
+
232
+ describe("parachute hub set-origin — Caddy automation", () => {
233
+ let dir: string;
234
+ let log: string[];
235
+ const collect = (line: string) => log.push(line);
236
+
237
+ beforeEach(() => {
238
+ dir = mkdtempSync(join(tmpdir(), "hub-set-origin-caddy-"));
239
+ log = [];
240
+ });
241
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
242
+
243
+ function caddyFixture(): string {
244
+ const p = join(dir, "Caddyfile");
245
+ writeFileSync(p, MANAGED_CADDYFILE);
246
+ return p;
247
+ }
248
+
249
+ test("managed Caddyfile → host line rewritten, reload + restart invoked (mocked)", async () => {
250
+ const caddyfilePath = caddyFixture();
251
+ const runCalls: ReadonlyArray<string>[] = [];
252
+ let restartCalls = 0;
253
+ const code = await hubSetOrigin(["https://new.example.com"], {
254
+ configDir: dir,
255
+ log: collect,
256
+ caddyfilePath,
257
+ getuid: () => 0, // simulate root so the rewrite path runs
258
+ run: async (cmd) => {
259
+ runCalls.push(cmd);
260
+ return okResult();
261
+ },
262
+ restartModules: async () => {
263
+ restartCalls++;
264
+ return 0;
265
+ },
266
+ });
267
+ expect(code).toBe(0);
268
+ // DB origin persisted.
269
+ const db = openHubDb(hubDbPath(dir));
270
+ try {
271
+ expect(getHubOrigin(db)).toBe("https://new.example.com");
272
+ } finally {
273
+ db.close();
274
+ }
275
+ // Host line rewritten on disk; body + header strips preserved.
276
+ const written = readFileSync(caddyfilePath, "utf8");
277
+ expect(written).toContain("new.example.com {");
278
+ expect(written).not.toContain("old.example.com");
279
+ expect(written).toContain("header_up -Cf-Connecting-Ip");
280
+ // Reload + restart both invoked.
281
+ expect(runCalls).toContainEqual(["systemctl", "reload", "caddy"]);
282
+ expect(restartCalls).toBe(1);
283
+ });
284
+
285
+ test("NO managed Caddyfile → origin persisted, manual steps printed, reload/restart NOT invoked", async () => {
286
+ const runCalls: ReadonlyArray<string>[] = [];
287
+ let restartCalls = 0;
288
+ const code = await hubSetOrigin(["https://new.example.com"], {
289
+ configDir: dir,
290
+ log: collect,
291
+ caddyfilePath: join(dir, "does-not-exist"),
292
+ getuid: () => 0,
293
+ run: async (cmd) => {
294
+ runCalls.push(cmd);
295
+ return okResult();
296
+ },
297
+ restartModules: async () => {
298
+ restartCalls++;
299
+ return 0;
300
+ },
301
+ });
302
+ expect(code).toBe(0);
303
+ const db = openHubDb(hubDbPath(dir));
304
+ try {
305
+ expect(getHubOrigin(db)).toBe("https://new.example.com");
306
+ } finally {
307
+ db.close();
308
+ }
309
+ // No managed Caddyfile → reload never runs. (restart still runs by design;
310
+ // it's the module-propagation step, independent of Caddy.)
311
+ expect(runCalls).toEqual([]);
312
+ expect(restartCalls).toBe(1);
313
+ expect(log.join("\n")).toContain("No Parachute-managed Caddyfile");
314
+ });
315
+
316
+ test("--no-caddy → Caddy untouched (no read/write/reload), restart still runs", async () => {
317
+ const caddyfilePath = caddyFixture();
318
+ const runCalls: ReadonlyArray<string>[] = [];
319
+ let restartCalls = 0;
320
+ const code = await hubSetOrigin(["https://new.example.com", "--no-caddy"], {
321
+ configDir: dir,
322
+ log: collect,
323
+ caddyfilePath,
324
+ getuid: () => 0,
325
+ run: async (cmd) => {
326
+ runCalls.push(cmd);
327
+ return okResult();
328
+ },
329
+ restartModules: async () => {
330
+ restartCalls++;
331
+ return 0;
332
+ },
333
+ });
334
+ expect(code).toBe(0);
335
+ // Caddyfile left byte-identical (not rewritten).
336
+ expect(readFileSync(caddyfilePath, "utf8")).toBe(MANAGED_CADDYFILE);
337
+ expect(runCalls).toEqual([]);
338
+ expect(restartCalls).toBe(1);
339
+ expect(log.join("\n")).toContain("--no-caddy");
340
+ });
341
+
342
+ test("--no-restart → modules not restarted", async () => {
343
+ const caddyfilePath = caddyFixture();
344
+ let restartCalls = 0;
345
+ const code = await hubSetOrigin(["https://new.example.com", "--no-restart"], {
346
+ configDir: dir,
347
+ log: collect,
348
+ caddyfilePath,
349
+ getuid: () => 0,
350
+ run: async () => okResult(),
351
+ restartModules: async () => {
352
+ restartCalls++;
353
+ return 0;
354
+ },
355
+ });
356
+ expect(code).toBe(0);
357
+ expect(restartCalls).toBe(0);
358
+ expect(log.join("\n")).toContain("--no-restart");
359
+ });
360
+
361
+ test("non-root with a managed Caddyfile → graceful, non-fatal, NOT rewritten/reloaded", async () => {
362
+ const caddyfilePath = caddyFixture();
363
+ const runCalls: ReadonlyArray<string>[] = [];
364
+ const code = await hubSetOrigin(["https://new.example.com"], {
365
+ configDir: dir,
366
+ log: collect,
367
+ caddyfilePath,
368
+ getuid: () => 1000, // non-root
369
+ run: async (cmd) => {
370
+ runCalls.push(cmd);
371
+ return okResult();
372
+ },
373
+ restartModules: async () => 0,
374
+ });
375
+ expect(code).toBe(0); // non-fatal: origin still persisted
376
+ // File untouched, reload never attempted.
377
+ expect(readFileSync(caddyfilePath, "utf8")).toBe(MANAGED_CADDYFILE);
378
+ expect(runCalls).toEqual([]);
379
+ expect(log.join("\n").toLowerCase()).toContain("not running as root");
380
+ });
381
+
382
+ test("reload fails → graceful, non-fatal, file still rewritten, manual reload hinted", async () => {
383
+ const caddyfilePath = caddyFixture();
384
+ const code = await hubSetOrigin(["https://new.example.com"], {
385
+ configDir: dir,
386
+ log: collect,
387
+ caddyfilePath,
388
+ getuid: () => 0,
389
+ run: async () => ({ code: 1, stdout: "", stderr: "caddy: config error" }),
390
+ restartModules: async () => 0,
391
+ });
392
+ expect(code).toBe(0); // non-fatal
393
+ expect(readFileSync(caddyfilePath, "utf8")).toContain("new.example.com {");
394
+ const joined = log.join("\n");
395
+ expect(joined).toContain("reload");
396
+ expect(joined).toContain("caddy: config error");
397
+ });
398
+
399
+ test("restart fails → graceful, non-fatal, prints the manual restart hint", async () => {
400
+ const caddyfilePath = caddyFixture();
401
+ const code = await hubSetOrigin(["https://new.example.com"], {
402
+ configDir: dir,
403
+ log: collect,
404
+ caddyfilePath,
405
+ getuid: () => 0,
406
+ run: async () => okResult(),
407
+ restartModules: async () => 1, // restart reports failure
408
+ });
409
+ expect(code).toBe(0); // non-fatal — origin persisted, Caddy reloaded
410
+ expect(log.join("\n")).toContain("parachute restart");
411
+ });
412
+
413
+ test("a managed Caddyfile already on the new host → unchanged, reload skipped", async () => {
414
+ const caddyfilePath = caddyFixture();
415
+ const runCalls: ReadonlyArray<string>[] = [];
416
+ // Set origin to the host already in the fixture (old.example.com).
417
+ const code = await hubSetOrigin(["https://old.example.com"], {
418
+ configDir: dir,
419
+ log: collect,
420
+ caddyfilePath,
421
+ getuid: () => 0,
422
+ run: async (cmd) => {
423
+ runCalls.push(cmd);
424
+ return okResult();
425
+ },
426
+ restartModules: async () => 0,
427
+ });
428
+ expect(code).toBe(0);
429
+ // Host already matched → no reload.
430
+ expect(runCalls).toEqual([]);
431
+ expect(log.join("\n")).toContain("already points at old.example.com");
432
+ });
433
+ });
@@ -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 ----------------------------------------------------
@@ -27,12 +27,26 @@
27
27
  */
28
28
 
29
29
  import type { Database } from "bun:sqlite";
30
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
30
31
  import { validateHubOrigin } from "../api-settings-hub-origin.ts";
32
+ import { restart } from "../commands/lifecycle.ts";
31
33
  import { CONFIG_DIR } from "../config.ts";
32
34
  import { hubDbPath, openHubDb } from "../hub-db.ts";
33
35
  import { setHubOrigin } from "../hub-settings.ts";
36
+ import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
34
37
  import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
35
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
+
36
50
  export interface HubCommandDeps {
37
51
  configDir?: string;
38
52
  log?: (line: string) => void;
@@ -42,14 +56,97 @@ export interface HubCommandDeps {
42
56
  * write is exercised without touching the operator's live `~/.parachute`.
43
57
  */
44
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" };
45
139
  }
46
140
 
47
141
  /**
48
142
  * `parachute hub set-origin <url>`. Validates + canonicalizes the URL, persists
49
- * it to `hub_settings.hub_origin`, and prints the restart note (already-running
50
- * supervised modules pick up the widened `PARACHUTE_HUB_ORIGINS` set only on
51
- * their next `parachute restart`, because that set is injected at child-spawn
52
- * time). Returns 0 on success, 1 on a validation / write failure.
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.
53
150
  */
54
151
  export async function hubSetOrigin(
55
152
  args: readonly string[],
@@ -59,7 +156,20 @@ export async function hubSetOrigin(
59
156
  const log = deps.log ?? ((line) => console.log(line));
60
157
  const err = (line: string) => console.error(line);
61
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: {} }));
62
170
 
171
+ const noCaddy = args.includes("--no-caddy");
172
+ const noRestart = args.includes("--no-restart");
63
173
  const positional = args.filter((a) => !a.startsWith("-"));
64
174
  const raw = positional[0];
65
175
  if (raw === undefined) {
@@ -114,14 +224,129 @@ export async function hubSetOrigin(
114
224
  log(`✓ Canonical hub origin set to ${origin}.`);
115
225
  log(" Stored in hub_settings — the OAuth issuer (`iss`) hub stamps on every token,");
116
226
  log(" and the origin set injected into supervised modules so they accept it.");
117
- log("");
118
- log("Already-running modules (vault, scribe) pick up the widened origin set only on");
119
- log("their next restart (it's injected at spawn time). Restart them to apply now:");
120
- log(" parachute restart vault");
121
- log(" parachute restart scribe # if installed");
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
+
122
338
  return 0;
123
339
  }
124
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
+
125
350
  /**
126
351
  * `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
127
352
  * router over subcommand handlers, each catching its own errors).
@@ -152,7 +377,7 @@ export function hubHelp(): string {
152
377
  return `parachute hub — hub-local configuration
153
378
 
154
379
  Usage:
155
- parachute hub set-origin <url> set the canonical public hub origin
380
+ parachute hub set-origin <url> [--no-caddy] [--no-restart]
156
381
 
157
382
  Subcommands:
158
383
  set-origin <url> Persist the canonical public origin (OAuth issuer) to the
@@ -163,11 +388,22 @@ Subcommands:
163
388
  over a public HTTPS URL — no admin browser session needed.
164
389
 
165
390
  The URL must be http(s), with a hostname and no trailing
166
- slash / path / query. After it's set, restart already-running
167
- modules so they pick up the widened origin set.
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.
168
403
 
169
404
  Examples:
170
405
  parachute hub set-origin https://box.sslip.io
171
406
  parachute hub set-origin https://parachute.example.com
407
+ parachute hub set-origin https://parachute.example.com --no-caddy
172
408
  `;
173
409
  }
@@ -60,6 +60,7 @@ import {
60
60
  SETUP_EXPOSE_MODES,
61
61
  type SetupExposeMode,
62
62
  deleteSetting,
63
+ getHubOrigin,
63
64
  getSetting,
64
65
  isSetupExposeMode,
65
66
  openFirstClientAutoApproveWindow,
@@ -89,6 +90,7 @@ import {
89
90
  } from "./sessions.ts";
90
91
  import type { Supervisor } from "./supervisor.ts";
91
92
  import { createUser, userCount } from "./users.ts";
93
+ import { sanitizePublicOrigin } from "./vault-hub-origin-env.ts";
92
94
  import { DEFAULT_VAULT_NAME, validateVaultName } from "./vault-name.ts";
93
95
 
94
96
  // --- shared chrome --------------------------------------------------------
@@ -352,6 +354,24 @@ export function deriveWizardState(deps: {
352
354
  setSetting(deps.db, "setup_expose_mode", seeded);
353
355
  }
354
356
  }
357
+ // hub Caddy-direct case: a reverse-proxy box (`parachute init --hub-origin
358
+ // https://<host>`, or `parachute hub set-origin`) persists a real public
359
+ // origin to `hub_settings.hub_origin` (also honored from PARACHUTE_HUB_ORIGIN).
360
+ // That origin already answers "how is this hub reached?" — Caddy/Let's-Encrypt
361
+ // fronts the loopback hub, no `parachute expose` and no Render/Fly env var.
362
+ // Auto-seed `setup_expose_mode = "public"` so the wizard skips the expose step
363
+ // exactly as the Render/Fly + live-tailscale branches do. `sanitizePublicOrigin`
364
+ // (the SAME guard `resolveIssuer`/`exposeIssuerOrigin` use) requires an absolute
365
+ // http(s) URL and REJECTS loopback/0.0.0.0 — so a laptop/loopback/unset origin
366
+ // returns undefined and the expose step still renders (the operator chooses).
367
+ if (getSetting(deps.db, "setup_expose_mode") === undefined) {
368
+ const configured =
369
+ sanitizePublicOrigin(getHubOrigin(deps.db) ?? undefined) ??
370
+ sanitizePublicOrigin(deps.env?.PARACHUTE_HUB_ORIGIN ?? process.env.PARACHUTE_HUB_ORIGIN);
371
+ if (configured !== undefined) {
372
+ setSetting(deps.db, "setup_expose_mode", "public");
373
+ }
374
+ }
355
375
  const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
356
376
  let step: WizardStep;
357
377
  // Note: `"account"` is a visual-only step in the progress header —