@openparachute/hub 0.7.3-rc.10 → 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 +1 -1
- package/src/__tests__/hub-command.test.ts +314 -17
- package/src/__tests__/hub-server.test.ts +240 -0
- package/src/__tests__/setup-wizard.test.ts +121 -1
- package/src/commands/hub.ts +248 -12
- package/src/hub-server.ts +46 -0
- package/src/setup-wizard.ts +20 -0
package/package.json
CHANGED
|
@@ -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"],
|
|
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/"],
|
|
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"],
|
|
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"],
|
|
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([],
|
|
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"],
|
|
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
|
|
87
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -3,6 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
_resetBootstrapTokenForTests,
|
|
8
|
+
generateBootstrapToken,
|
|
9
|
+
} from "../bootstrap-token.ts";
|
|
6
10
|
import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
7
11
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
8
12
|
import { createDbHolder } from "../hub-db-liveness.ts";
|
|
@@ -3676,6 +3680,74 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
|
|
|
3676
3680
|
});
|
|
3677
3681
|
expect(layerOf(r)).toBe("public");
|
|
3678
3682
|
});
|
|
3683
|
+
|
|
3684
|
+
// Caddy/nginx-direct deploy (hub#704). A same-box reverse proxy dials
|
|
3685
|
+
// loopback (peer 127.0.0.1) and stamps NO cf/tailscale header — but it DOES
|
|
3686
|
+
// set a standard reverse-proxy forwarding header. That header is the
|
|
3687
|
+
// discriminator: a loopback peer carrying one is a proxied PUBLIC request and
|
|
3688
|
+
// must NOT be "loopback" (which would leak the bootstrap token + drop the
|
|
3689
|
+
// loopback-exposure cloak for every public visitor on a Caddy-direct box).
|
|
3690
|
+
test("loopback peer + X-Forwarded-For → public (Caddy-direct, NOT loopback) [#704]", () => {
|
|
3691
|
+
const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3692
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3693
|
+
});
|
|
3694
|
+
|
|
3695
|
+
test("loopback peer + X-Forwarded-Host → public (Caddy-direct) [#704]", () => {
|
|
3696
|
+
const r = req("/", { headers: { "X-Forwarded-Host": "hub.example.com" } });
|
|
3697
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3698
|
+
});
|
|
3699
|
+
|
|
3700
|
+
test("loopback peer + Forwarded (RFC 7239) → public (Caddy-direct) [#704]", () => {
|
|
3701
|
+
const r = req("/", { headers: { Forwarded: "for=203.0.113.7;host=hub.example.com" } });
|
|
3702
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
test("IPv6-mapped loopback peer + X-Forwarded-For → public (Caddy-direct) [#704]", () => {
|
|
3706
|
+
const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3707
|
+
expect(layerOf(r, "::ffff:127.0.0.1")).toBe("public");
|
|
3708
|
+
});
|
|
3709
|
+
|
|
3710
|
+
// Security edge: an EMPTY / whitespace-only forwarding header (a misconfigured
|
|
3711
|
+
// or stripped-but-present proxy header) must still err toward "public", never
|
|
3712
|
+
// back to "loopback". `layerOf` uses a presence check (`!== null`), not a
|
|
3713
|
+
// trim — keep it that way: the safe direction for a trust classifier is to
|
|
3714
|
+
// DOWNGRADE on ambiguity. A future "tidy up with .trim()" refactor that
|
|
3715
|
+
// flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
|
|
3716
|
+
test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
|
|
3717
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
|
|
3718
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
|
|
3719
|
+
});
|
|
3720
|
+
|
|
3721
|
+
// The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
|
|
3722
|
+
// probe `curl 127.0.0.1/admin/setup`, hub self-requests) sets NO forwarding
|
|
3723
|
+
// header and MUST stay loopback. This is the not-broken half of the fix.
|
|
3724
|
+
test("loopback peer + NO forwarding header → loopback (genuine on-box caller) [#704]", () => {
|
|
3725
|
+
expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
|
|
3726
|
+
expect(layerOf(req("/"), "::1")).toBe("loopback");
|
|
3727
|
+
});
|
|
3728
|
+
|
|
3729
|
+
// No spoof vector: forwarding headers can only DOWNGRADE a loopback caller.
|
|
3730
|
+
// A NON-loopback peer is already "public" regardless of headers, so adding
|
|
3731
|
+
// X-Forwarded-* never upgrades a network peer to a more-trusted layer.
|
|
3732
|
+
test("non-loopback peer + X-Forwarded-For → public (no upgrade; spoof-proof) [#704]", () => {
|
|
3733
|
+
const r = req("/", { headers: { "X-Forwarded-For": "127.0.0.1" } });
|
|
3734
|
+
expect(layerOf(r, "203.0.113.7")).toBe("public");
|
|
3735
|
+
});
|
|
3736
|
+
|
|
3737
|
+
// CF/TS header checks run FIRST — a Caddy-style forwarding header alongside a
|
|
3738
|
+
// cf/tailscale header doesn't change those (still public/tailnet). Order
|
|
3739
|
+
// preserved.
|
|
3740
|
+
test("CF-Ray + X-Forwarded-For from loopback peer → public (CF check wins, order preserved)", () => {
|
|
3741
|
+
const r = req("/", { headers: { "CF-Ray": "abc123-DEN", "X-Forwarded-For": "203.0.113.7" } });
|
|
3742
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3743
|
+
});
|
|
3744
|
+
|
|
3745
|
+
test("Tailscale-User-Login + X-Forwarded-For from loopback peer → tailnet (TS check wins)", () => {
|
|
3746
|
+
const r = req("/", {
|
|
3747
|
+
headers: { "Tailscale-User-Login": "alice@example.com", "X-Forwarded-For": "100.64.0.1" },
|
|
3748
|
+
});
|
|
3749
|
+
expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
|
|
3750
|
+
});
|
|
3679
3751
|
});
|
|
3680
3752
|
|
|
3681
3753
|
describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
@@ -3823,6 +3895,74 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3823
3895
|
}
|
|
3824
3896
|
});
|
|
3825
3897
|
|
|
3898
|
+
// hub#704 — Caddy/nginx-direct. A same-box reverse proxy dials loopback
|
|
3899
|
+
// (peer 127.0.0.1) and stamps NO cf/tailscale header, but DOES set
|
|
3900
|
+
// X-Forwarded-For. That request is a PUBLIC visitor proxied to the box and
|
|
3901
|
+
// must hit the loopback-exposure cloak (404) — otherwise a Caddy-direct box
|
|
3902
|
+
// leaks every loopback-only service/vault to the network.
|
|
3903
|
+
test("publicExposure: loopback + X-Forwarded-For + loopback peer → 404 (Caddy-direct cloak fires) [#704]", async () => {
|
|
3904
|
+
const h = makeHarness();
|
|
3905
|
+
const upstream = startUpstream("loopback-only");
|
|
3906
|
+
try {
|
|
3907
|
+
writeManifest(
|
|
3908
|
+
{
|
|
3909
|
+
services: [
|
|
3910
|
+
{
|
|
3911
|
+
name: "loopback-only",
|
|
3912
|
+
port: upstream.port,
|
|
3913
|
+
paths: ["/loopback-only"],
|
|
3914
|
+
health: "/loopback-only/health",
|
|
3915
|
+
version: "0.1.0",
|
|
3916
|
+
publicExposure: "loopback",
|
|
3917
|
+
},
|
|
3918
|
+
],
|
|
3919
|
+
},
|
|
3920
|
+
h.manifestPath,
|
|
3921
|
+
);
|
|
3922
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3923
|
+
// Caddy: peer is 127.0.0.1 (reverse_proxy 127.0.0.1:1939) but the request
|
|
3924
|
+
// carries X-Forwarded-For for the real public client.
|
|
3925
|
+
const r = req("/loopback-only/health", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3926
|
+
const res = await fetcher(r, fakeServer("127.0.0.1"));
|
|
3927
|
+
expect(res.status).toBe(404);
|
|
3928
|
+
} finally {
|
|
3929
|
+
upstream.stop();
|
|
3930
|
+
h.cleanup();
|
|
3931
|
+
}
|
|
3932
|
+
});
|
|
3933
|
+
|
|
3934
|
+
// The not-broken half: a header-less loopback peer (genuine on-box caller)
|
|
3935
|
+
// still reaches the loopback-only service through the cloak.
|
|
3936
|
+
test("publicExposure: loopback + NO forwarding header + loopback peer → reaches upstream [#704]", async () => {
|
|
3937
|
+
const h = makeHarness();
|
|
3938
|
+
const upstream = startUpstream("loopback-only");
|
|
3939
|
+
try {
|
|
3940
|
+
writeManifest(
|
|
3941
|
+
{
|
|
3942
|
+
services: [
|
|
3943
|
+
{
|
|
3944
|
+
name: "loopback-only",
|
|
3945
|
+
port: upstream.port,
|
|
3946
|
+
paths: ["/loopback-only"],
|
|
3947
|
+
health: "/loopback-only/health",
|
|
3948
|
+
version: "0.1.0",
|
|
3949
|
+
publicExposure: "loopback",
|
|
3950
|
+
},
|
|
3951
|
+
],
|
|
3952
|
+
},
|
|
3953
|
+
h.manifestPath,
|
|
3954
|
+
);
|
|
3955
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3956
|
+
const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
|
|
3957
|
+
expect(res.status).toBe(200);
|
|
3958
|
+
const body = (await res.json()) as { tag: string };
|
|
3959
|
+
expect(body.tag).toBe("loopback-only");
|
|
3960
|
+
} finally {
|
|
3961
|
+
upstream.stop();
|
|
3962
|
+
h.cleanup();
|
|
3963
|
+
}
|
|
3964
|
+
});
|
|
3965
|
+
|
|
3826
3966
|
test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
|
|
3827
3967
|
const h = makeHarness();
|
|
3828
3968
|
const upstream = startUpstream("allowed");
|
|
@@ -5850,3 +5990,103 @@ describe("resolveClientIp (H2)", () => {
|
|
|
5850
5990
|
expect(resolveClientIp(r, null)).toBeNull();
|
|
5851
5991
|
});
|
|
5852
5992
|
});
|
|
5993
|
+
|
|
5994
|
+
describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + Caddy-direct #704)", () => {
|
|
5995
|
+
// The hub#576 token probe: a LOOPBACK caller (the on-box operator's own
|
|
5996
|
+
// shell / `parachute init`) reading GET /admin/setup with Accept:
|
|
5997
|
+
// application/json gets the actual bootstrap token in the JSON envelope; a
|
|
5998
|
+
// public/tailnet caller does not. The hub derives requestIsLoopback from
|
|
5999
|
+
// `layerOf(req, peerAddr) === "loopback"` (hub-server.ts ~2296), so the
|
|
6000
|
+
// Caddy-direct fix (#704) flows straight through: a same-box reverse proxy
|
|
6001
|
+
// carrying X-Forwarded-For now classifies "public" and the token is withheld
|
|
6002
|
+
// even though the peer is 127.0.0.1.
|
|
6003
|
+
|
|
6004
|
+
// Fake Bun Server handle (mirrors the cloak suite) so the fetch fn resolves
|
|
6005
|
+
// the peer address. The on-box operator connects from 127.0.0.1; Caddy
|
|
6006
|
+
// reverse_proxy also dials 127.0.0.1 but stamps X-Forwarded-For.
|
|
6007
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
6008
|
+
|
|
6009
|
+
test("loopback peer + NO forwarding header → bootstrapToken present (on-box operator) [#576]", async () => {
|
|
6010
|
+
const h = makeHarness();
|
|
6011
|
+
_resetBootstrapTokenForTests();
|
|
6012
|
+
const token = generateBootstrapToken();
|
|
6013
|
+
try {
|
|
6014
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6015
|
+
try {
|
|
6016
|
+
// No admin row → wizard mode → GET /admin/setup JSON probe is live.
|
|
6017
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6018
|
+
req("/admin/setup", { headers: { accept: "application/json" } }),
|
|
6019
|
+
fakeServer("127.0.0.1"),
|
|
6020
|
+
);
|
|
6021
|
+
expect(res.status).toBe(200);
|
|
6022
|
+
const body = (await res.json()) as {
|
|
6023
|
+
requireBootstrapToken?: boolean;
|
|
6024
|
+
bootstrapToken?: string;
|
|
6025
|
+
};
|
|
6026
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
6027
|
+
expect(body.bootstrapToken).toBe(token);
|
|
6028
|
+
} finally {
|
|
6029
|
+
db.close();
|
|
6030
|
+
}
|
|
6031
|
+
} finally {
|
|
6032
|
+
_resetBootstrapTokenForTests();
|
|
6033
|
+
h.cleanup();
|
|
6034
|
+
}
|
|
6035
|
+
});
|
|
6036
|
+
|
|
6037
|
+
test("loopback peer + X-Forwarded-For → bootstrapToken WITHHELD (Caddy-direct public visitor) [#704]", async () => {
|
|
6038
|
+
const h = makeHarness();
|
|
6039
|
+
_resetBootstrapTokenForTests();
|
|
6040
|
+
generateBootstrapToken();
|
|
6041
|
+
try {
|
|
6042
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6043
|
+
try {
|
|
6044
|
+
// Caddy: peer 127.0.0.1, but the request carries the public client's
|
|
6045
|
+
// X-Forwarded-For. Pre-fix this leaked the token to any public visitor.
|
|
6046
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6047
|
+
req("/admin/setup", {
|
|
6048
|
+
headers: { accept: "application/json", "x-forwarded-for": "203.0.113.7" },
|
|
6049
|
+
}),
|
|
6050
|
+
fakeServer("127.0.0.1"),
|
|
6051
|
+
);
|
|
6052
|
+
expect(res.status).toBe(200);
|
|
6053
|
+
const body = (await res.json()) as {
|
|
6054
|
+
requireBootstrapToken?: boolean;
|
|
6055
|
+
bootstrapToken?: string;
|
|
6056
|
+
};
|
|
6057
|
+
// The gate still SAYS a token is required (so the form renders the
|
|
6058
|
+
// field) — it just doesn't hand the value to the public caller.
|
|
6059
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
6060
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
6061
|
+
} finally {
|
|
6062
|
+
db.close();
|
|
6063
|
+
}
|
|
6064
|
+
} finally {
|
|
6065
|
+
_resetBootstrapTokenForTests();
|
|
6066
|
+
h.cleanup();
|
|
6067
|
+
}
|
|
6068
|
+
});
|
|
6069
|
+
|
|
6070
|
+
test("non-loopback peer → bootstrapToken WITHHELD (direct public/tailnet)", async () => {
|
|
6071
|
+
const h = makeHarness();
|
|
6072
|
+
_resetBootstrapTokenForTests();
|
|
6073
|
+
generateBootstrapToken();
|
|
6074
|
+
try {
|
|
6075
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6076
|
+
try {
|
|
6077
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6078
|
+
req("/admin/setup", { headers: { accept: "application/json" } }),
|
|
6079
|
+
fakeServer("203.0.113.9"),
|
|
6080
|
+
);
|
|
6081
|
+
expect(res.status).toBe(200);
|
|
6082
|
+
const body = (await res.json()) as { bootstrapToken?: string };
|
|
6083
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
6084
|
+
} finally {
|
|
6085
|
+
db.close();
|
|
6086
|
+
}
|
|
6087
|
+
} finally {
|
|
6088
|
+
_resetBootstrapTokenForTests();
|
|
6089
|
+
h.cleanup();
|
|
6090
|
+
}
|
|
6091
|
+
});
|
|
6092
|
+
});
|
|
@@ -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/commands/hub.ts
CHANGED
|
@@ -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`,
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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>
|
|
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.
|
|
167
|
-
|
|
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
|
}
|
package/src/hub-server.ts
CHANGED
|
@@ -578,6 +578,16 @@ export type RequestLayer = "loopback" | "tailnet" | "public";
|
|
|
578
578
|
* the cloak fires. When `peerAddr` is unknown (null/undefined — no Server
|
|
579
579
|
* threaded, e.g. a unit test calling `layerOf(req)` directly), we fail CLOSED
|
|
580
580
|
* to `public` rather than open to `loopback`.
|
|
581
|
+
*
|
|
582
|
+
* Caddy/nginx-direct (hub#704): a SAME-BOX reverse proxy dials loopback (peer
|
|
583
|
+
* is 127.0.0.1) but, unlike cloudflared/tailscale, sets NO cf/tailscale header
|
|
584
|
+
* — so a header-only-or-peer-only classifier would call every public request
|
|
585
|
+
* through it "loopback" (most-trusted). The discriminator is the standard
|
|
586
|
+
* reverse-proxy forwarding headers (X-Forwarded-For / X-Forwarded-Host /
|
|
587
|
+
* Forwarded): a loopback peer that carries one is a proxied PUBLIC request →
|
|
588
|
+
* `public`; a header-less loopback peer (direct on-box caller — CLI, probes,
|
|
589
|
+
* the init bootstrap-token loopback probe) stays `loopback`. See the inline
|
|
590
|
+
* comment in the function for the full rationale + spoof analysis.
|
|
581
591
|
*/
|
|
582
592
|
export function layerOf(req: Request, peerAddr?: string | null): RequestLayer {
|
|
583
593
|
const h = req.headers;
|
|
@@ -590,6 +600,42 @@ export function layerOf(req: Request, peerAddr?: string | null): RequestLayer {
|
|
|
590
600
|
// value to compare against, hence the presence-check above.
|
|
591
601
|
if (h.get("tailscale-funnel-request") === "?1") return "public";
|
|
592
602
|
if (h.get("tailscale-user-login") !== null) return "tailnet";
|
|
603
|
+
// Caddy/nginx-direct deploy (hub#704): a same-box reverse proxy terminates
|
|
604
|
+
// TLS and `reverse_proxy 127.0.0.1:1939` — so it dials loopback (peer is
|
|
605
|
+
// 127.0.0.1) and, unlike cloudflared/tailscale, stamps NO cf/tailscale
|
|
606
|
+
// header. Without this branch every PUBLIC request through such a proxy
|
|
607
|
+
// would classify "loopback" (the MOST-trusted layer): the GET /admin/setup
|
|
608
|
+
// bootstrap-token JSON probe would hand the token to any public visitor, and
|
|
609
|
+
// the publicExposure:"loopback" 404-cloak would stop hiding loopback-only
|
|
610
|
+
// services/vaults from the network.
|
|
611
|
+
//
|
|
612
|
+
// The discriminator is the standard reverse-proxy forwarding headers. A
|
|
613
|
+
// same-box proxy carrying a PUBLIC request sets X-Forwarded-For /
|
|
614
|
+
// X-Forwarded-Host / Forwarded; a direct on-box caller (the CLI, health
|
|
615
|
+
// probes, the init bootstrap-token loopback probe `curl 127.0.0.1/admin/setup`,
|
|
616
|
+
// the hub's own loopback self-requests) sets none of them — the hub never
|
|
617
|
+
// injects X-Forwarded-* on the INBOUND request it classifies (it only stamps
|
|
618
|
+
// X-Forwarded-Host/Proto on OUTBOUND proxy requests to modules). So a
|
|
619
|
+
// loopback peer that ALSO carries a forwarding header is a proxied public
|
|
620
|
+
// request → "public"; a header-less loopback peer stays "loopback".
|
|
621
|
+
//
|
|
622
|
+
// No spoof vector: a NON-loopback peer is already "public" regardless of
|
|
623
|
+
// headers (the branch below), so adding these headers can only DOWNGRADE a
|
|
624
|
+
// loopback caller (the on-box operator hurting only their own request) —
|
|
625
|
+
// never upgrade a network peer to "loopback".
|
|
626
|
+
//
|
|
627
|
+
// Presence check (`!== null`), NOT a trim: an empty/whitespace forwarding
|
|
628
|
+
// header still means "a proxy is in front" → err to public. Downgrading on
|
|
629
|
+
// ambiguity is the safe direction for a trust classifier; a future ".trim()
|
|
630
|
+
// tidy-up" that let an empty XFF fall back to loopback would re-open the leak.
|
|
631
|
+
if (
|
|
632
|
+
isLoopbackPeer(peerAddr) &&
|
|
633
|
+
(h.get("x-forwarded-for") !== null ||
|
|
634
|
+
h.get("x-forwarded-host") !== null ||
|
|
635
|
+
h.get("forwarded") !== null)
|
|
636
|
+
) {
|
|
637
|
+
return "public";
|
|
638
|
+
}
|
|
593
639
|
// No proxy headers — classify by peer address, failing closed when unknown.
|
|
594
640
|
return isLoopbackPeer(peerAddr) ? "loopback" : "public";
|
|
595
641
|
}
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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 —
|