@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
package/package.json
CHANGED
|
@@ -83,6 +83,48 @@ describe("cli", () => {
|
|
|
83
83
|
expect(code).toBe(1);
|
|
84
84
|
expect(stderr).toMatch(/unknown command/);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
// hub#693: --hub-origin is persisted to hub_settings.hub_origin and stamps the
|
|
88
|
+
// OAuth `iss` claim, so the CLI must validate it to the same shape as
|
|
89
|
+
// `hub set-origin` BEFORE it reaches init() / the DB. These reject at the arg
|
|
90
|
+
// layer (exit 1) before any daemon work runs.
|
|
91
|
+
test("init --hub-origin with a path component exits 1", async () => {
|
|
92
|
+
const { code, stderr } = await runCli([
|
|
93
|
+
"init",
|
|
94
|
+
"--hub-origin",
|
|
95
|
+
"https://host.example/some/path",
|
|
96
|
+
]);
|
|
97
|
+
expect(code).toBe(1);
|
|
98
|
+
expect(stderr).toMatch(/invalid --hub-origin/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("init --hub-origin with a non-http(s) scheme exits 1", async () => {
|
|
102
|
+
const { code, stderr } = await runCli(["init", "--hub-origin", "ftp://bad.example"]);
|
|
103
|
+
expect(code).toBe(1);
|
|
104
|
+
expect(stderr).toMatch(/invalid --hub-origin/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// hub#694 bug 2: `init --channel` mirrors `install --channel` — rejected at
|
|
108
|
+
// the arg layer (exit 1) before any daemon work, so these are hermetic.
|
|
109
|
+
test("init --channel without a value exits 1 (hub#694)", async () => {
|
|
110
|
+
const { code, stderr } = await runCli(["init", "--channel"]);
|
|
111
|
+
expect(code).toBe(1);
|
|
112
|
+
expect(stderr).toMatch(/--channel requires a value/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("init --channel with an invalid value exits 1 (hub#694)", async () => {
|
|
116
|
+
const { code, stderr } = await runCli(["init", "--channel", "banana"]);
|
|
117
|
+
expect(code).toBe(1);
|
|
118
|
+
expect(stderr).toMatch(/--channel must be "rc" or "latest"/);
|
|
119
|
+
expect(stderr).toMatch(/banana/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("init --help documents --channel (hub#694)", async () => {
|
|
123
|
+
const { code, stdout } = await runCli(["init", "--help"]);
|
|
124
|
+
expect(code).toBe(0);
|
|
125
|
+
expect(stdout).toMatch(/--channel/);
|
|
126
|
+
expect(stdout).toMatch(/rc\|latest/);
|
|
127
|
+
});
|
|
86
128
|
});
|
|
87
129
|
|
|
88
130
|
describe("cli per-subcommand help", () => {
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute hub set-origin <url>` (onboarding-streamline 2026-06-25,
|
|
3
|
+
* Caddy-direct zero-SSH path).
|
|
4
|
+
*
|
|
5
|
+
* The verb persists the operator's canonical public origin to
|
|
6
|
+
* `hub_settings.hub_origin` from the CLI — closing the "set the issuer without
|
|
7
|
+
* an admin browser session" gap on a headless reverse-proxy box. These tests
|
|
8
|
+
* pin: it writes the row, it validates + canonicalizes the URL (reusing the
|
|
9
|
+
* SPA's `validateHubOrigin`), and it warns-but-allows loopback.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { hub, hubSetOrigin, rewriteCaddyfileHost } from "../commands/hub.ts";
|
|
16
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
+
import { getHubOrigin } from "../hub-settings.ts";
|
|
18
|
+
import type { CommandResult } from "../tailscale/run.ts";
|
|
19
|
+
|
|
20
|
+
describe("parachute hub set-origin", () => {
|
|
21
|
+
let dir: string;
|
|
22
|
+
let log: string[];
|
|
23
|
+
const collect = (line: string) => log.push(line);
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
dir = mkdtempSync(join(tmpdir(), "hub-set-origin-"));
|
|
27
|
+
log = [];
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
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
|
+
|
|
54
|
+
/** Open the configDir's hub.db and read the persisted origin. */
|
|
55
|
+
function persisted(): string | null {
|
|
56
|
+
const db = openHubDb(hubDbPath(dir));
|
|
57
|
+
try {
|
|
58
|
+
return getHubOrigin(db);
|
|
59
|
+
} finally {
|
|
60
|
+
db.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("persists a valid public origin to hub_settings.hub_origin", async () => {
|
|
65
|
+
const code = await hubSetOrigin(["https://box.sslip.io"], safeSeams());
|
|
66
|
+
expect(code).toBe(0);
|
|
67
|
+
expect(persisted()).toBe("https://box.sslip.io");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("strips a trailing slash (canonical bare-origin form)", async () => {
|
|
71
|
+
const code = await hubSetOrigin(["https://box.example.com/"], safeSeams());
|
|
72
|
+
expect(code).toBe(0);
|
|
73
|
+
expect(persisted()).toBe("https://box.example.com");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rejects a non-http(s) scheme without writing", async () => {
|
|
77
|
+
const code = await hubSetOrigin(["ftp://box.example.com"], safeSeams());
|
|
78
|
+
expect(code).toBe(1);
|
|
79
|
+
expect(persisted()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("rejects a URL with a path without writing", async () => {
|
|
83
|
+
const code = await hubSetOrigin(["https://box.example.com/admin"], safeSeams());
|
|
84
|
+
expect(code).toBe(1);
|
|
85
|
+
expect(persisted()).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("rejects a bare hostname (no scheme) without writing", async () => {
|
|
89
|
+
const code = await hubSetOrigin(["box.example.com"], safeSeams());
|
|
90
|
+
expect(code).toBe(1);
|
|
91
|
+
expect(persisted()).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("missing the URL argument is a usage error, no write", async () => {
|
|
95
|
+
const code = await hubSetOrigin([], safeSeams());
|
|
96
|
+
expect(code).toBe(1);
|
|
97
|
+
expect(persisted()).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("warns but ALLOWS a loopback origin (dev/test escape hatch)", async () => {
|
|
101
|
+
const code = await hubSetOrigin(["http://127.0.0.1:1939"], safeSeams());
|
|
102
|
+
expect(code).toBe(0);
|
|
103
|
+
expect(persisted()).toBe("http://127.0.0.1:1939");
|
|
104
|
+
expect(log.some((l) => l.toLowerCase().includes("loopback"))).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
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
|
+
});
|
|
116
|
+
const joined = log.join("\n");
|
|
117
|
+
expect(restartCalls).toBe(1);
|
|
118
|
+
expect(joined).toContain("parachute restart");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("openDb seam is honored (no touch of the live ~/.parachute)", async () => {
|
|
122
|
+
// Point the seam at a SEPARATE on-disk DB so we can assert (a) the seam was
|
|
123
|
+
// consulted, and (b) the write landed in the seam's DB — never the
|
|
124
|
+
// configDir's default path. (A real Database can't be Proxy-wrapped: Bun's
|
|
125
|
+
// prepared-statement private fields break under a Proxy.)
|
|
126
|
+
const altDir = mkdtempSync(join(tmpdir(), "hub-set-origin-alt-"));
|
|
127
|
+
try {
|
|
128
|
+
let opened = 0;
|
|
129
|
+
const code = await hubSetOrigin(["https://box.sslip.io"], {
|
|
130
|
+
...safeSeams(),
|
|
131
|
+
openDb: () => {
|
|
132
|
+
opened++;
|
|
133
|
+
return openHubDb(hubDbPath(altDir));
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
expect(code).toBe(0);
|
|
137
|
+
expect(opened).toBe(1);
|
|
138
|
+
// The write landed in the seam's DB, NOT the configDir's default DB.
|
|
139
|
+
const alt = openHubDb(hubDbPath(altDir));
|
|
140
|
+
try {
|
|
141
|
+
expect(getHubOrigin(alt)).toBe("https://box.sslip.io");
|
|
142
|
+
} finally {
|
|
143
|
+
alt.close();
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(altDir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("parachute hub dispatcher", () => {
|
|
152
|
+
test("no subcommand prints help, exits 0", async () => {
|
|
153
|
+
const code = await hub([]);
|
|
154
|
+
expect(code).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("--help exits 0", async () => {
|
|
158
|
+
expect(await hub(["--help"])).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("unknown subcommand exits 1", async () => {
|
|
162
|
+
expect(await hub(["frobnicate"])).toBe(1);
|
|
163
|
+
});
|
|
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
|
+
});
|
|
@@ -21,7 +21,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
21
21
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
+
import { resolveStartupIssuer } from "../commands/serve.ts";
|
|
25
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
24
26
|
import { parseHubOrigins, serializeHubOrigins } from "../hub-origin.ts";
|
|
27
|
+
import { getHubOrigin, setHubOrigin } from "../hub-settings.ts";
|
|
25
28
|
import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
|
|
26
29
|
|
|
27
30
|
describe("serializeHubOrigins / parseHubOrigins round-trip", () => {
|
|
@@ -197,3 +200,74 @@ describe("buildHubOriginsEnvValue — assembles the hub's legitimate-origin set"
|
|
|
197
200
|
});
|
|
198
201
|
});
|
|
199
202
|
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* THE CRUX (onboarding-streamline 2026-06-25, Caddy-direct zero-SSH path):
|
|
206
|
+
* a box whose ONLY canonical-origin source is the DB row `hub_settings.hub_origin`
|
|
207
|
+
* (no PARACHUTE_HUB_ORIGIN env, no expose-state, no RENDER/FLY platform var —
|
|
208
|
+
* the bare-droplet-behind-Caddy shape) MUST inject that public origin into the
|
|
209
|
+
* supervised modules' PARACHUTE_HUB_ORIGINS. Otherwise vault/scribe accept only
|
|
210
|
+
* loopback `iss` and reject every token the hub mints under the public origin
|
|
211
|
+
* (which the per-request resolveIssuer DOES stamp from the DB).
|
|
212
|
+
*
|
|
213
|
+
* These tests prove the full boot chain: DB row → `resolveStartupIssuer`
|
|
214
|
+
* (boot-time issuer seed) → `buildHubOriginsEnvValue` (the env injected at
|
|
215
|
+
* child spawn) CONTAINS the public origin.
|
|
216
|
+
*/
|
|
217
|
+
describe("DB hub_origin flows into the injected PARACHUTE_HUB_ORIGINS (Caddy-direct boot chain)", () => {
|
|
218
|
+
let dir: string;
|
|
219
|
+
const noExpose = (): string | undefined => undefined;
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
dir = mkdtempSync(join(tmpdir(), "hub-origins-db-"));
|
|
223
|
+
});
|
|
224
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
225
|
+
|
|
226
|
+
test("DB-persisted public origin lands in the assembled origin set", () => {
|
|
227
|
+
// Persist the Caddy public origin to the DB exactly as `hub set-origin` /
|
|
228
|
+
// `init --hub-origin` would.
|
|
229
|
+
const db = openHubDb(hubDbPath(dir));
|
|
230
|
+
setHubOrigin(db, "https://box.sslip.io");
|
|
231
|
+
db.close();
|
|
232
|
+
|
|
233
|
+
// Boot-time issuer resolution reads the DB row (passed as dbHubOrigin) —
|
|
234
|
+
// no env, no expose-state, no platform var (the bare-Caddy shape).
|
|
235
|
+
const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
|
|
236
|
+
const issuer = resolveStartupIssuer(
|
|
237
|
+
{ ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
|
|
238
|
+
{},
|
|
239
|
+
noExpose,
|
|
240
|
+
);
|
|
241
|
+
expect(issuer).toBe("https://box.sslip.io");
|
|
242
|
+
|
|
243
|
+
// That issuer seeds the env injected into vault/scribe — the public origin
|
|
244
|
+
// MUST be in their accepted-`iss` set.
|
|
245
|
+
const set = parseHubOrigins(
|
|
246
|
+
buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
|
|
247
|
+
);
|
|
248
|
+
expect(set).toContain("https://box.sslip.io");
|
|
249
|
+
// Loopback aliases stay present (co-located CLI / loopback proxy path).
|
|
250
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
251
|
+
expect(set).toContain("http://localhost:1939");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("no DB origin AND nothing else → loopback-only (the regression this guards: no public origin would leak in)", () => {
|
|
255
|
+
// Fresh DB, no row, no env, no expose-state: the boot issuer is undefined
|
|
256
|
+
// and the set is loopback-only. Proves the fix doesn't fabricate an origin.
|
|
257
|
+
const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
|
|
258
|
+
const issuer = resolveStartupIssuer(
|
|
259
|
+
{ ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
|
|
260
|
+
{},
|
|
261
|
+
noExpose,
|
|
262
|
+
);
|
|
263
|
+
expect(issuer).toBeUndefined();
|
|
264
|
+
const set = parseHubOrigins(
|
|
265
|
+
buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
|
|
266
|
+
);
|
|
267
|
+
// Loopback-only — no public origin fabricated. (Order is Set-insertion
|
|
268
|
+
// dependent; assert membership + size rather than a brittle exact array.)
|
|
269
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
270
|
+
expect(set).toContain("http://localhost:1939");
|
|
271
|
+
expect(set).toHaveLength(2);
|
|
272
|
+
});
|
|
273
|
+
});
|