@openparachute/hub 0.5.2 → 0.5.7
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__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +648 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +147 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
|
@@ -115,18 +115,21 @@ describe("setup", () => {
|
|
|
115
115
|
const h = makeHarness();
|
|
116
116
|
try {
|
|
117
117
|
// Pre-seed every first-party shortname so survey returns all-installed.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"parachute-
|
|
122
|
-
"parachute-
|
|
123
|
-
|
|
118
|
+
// Distinct canonical ports per service — services-manifest.ts now
|
|
119
|
+
// rejects duplicate ports between distinct services (hub#195).
|
|
120
|
+
const seeds: Array<{ name: string; port: number }> = [
|
|
121
|
+
{ name: "parachute-vault", port: 1940 },
|
|
122
|
+
{ name: "parachute-notes", port: 1942 },
|
|
123
|
+
{ name: "parachute-scribe", port: 1943 },
|
|
124
|
+
{ name: "parachute-channel", port: 1941 },
|
|
125
|
+
];
|
|
126
|
+
for (const s of seeds) {
|
|
124
127
|
upsertService(
|
|
125
128
|
{
|
|
126
|
-
name:
|
|
129
|
+
name: s.name,
|
|
127
130
|
version: "0.0.0",
|
|
128
|
-
port:
|
|
129
|
-
paths: [`/${
|
|
131
|
+
port: s.port,
|
|
132
|
+
paths: [`/${s.name.replace(/^parachute-/, "")}`],
|
|
130
133
|
health: "/health",
|
|
131
134
|
},
|
|
132
135
|
h.manifestPath,
|
|
@@ -317,6 +317,179 @@ describe("status", () => {
|
|
|
317
317
|
}
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
// Canonical-port drift warning (hub#195). When a known service ends up at
|
|
321
|
+
// a non-canonical port (because of an upgrade rewrite, a port-walk fallback,
|
|
322
|
+
// or an operator edit), surface it in `parachute status` so a silent miswire
|
|
323
|
+
// is operator-visible. Warning, not error — operators may have moved the
|
|
324
|
+
// service deliberately to dodge a third-party clash.
|
|
325
|
+
describe("canonical-port drift warning", () => {
|
|
326
|
+
test("warns when scribe is at non-canonical port (1944 instead of 1943)", async () => {
|
|
327
|
+
const { path, cleanup } = makeTempPath();
|
|
328
|
+
try {
|
|
329
|
+
upsertService(
|
|
330
|
+
{
|
|
331
|
+
name: "parachute-scribe",
|
|
332
|
+
port: 1944,
|
|
333
|
+
paths: ["/scribe"],
|
|
334
|
+
health: "/scribe/health",
|
|
335
|
+
version: "0.4.0",
|
|
336
|
+
},
|
|
337
|
+
path,
|
|
338
|
+
);
|
|
339
|
+
const lines: string[] = [];
|
|
340
|
+
await status({
|
|
341
|
+
manifestPath: path,
|
|
342
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
343
|
+
print: (l) => lines.push(l),
|
|
344
|
+
});
|
|
345
|
+
expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
|
|
346
|
+
} finally {
|
|
347
|
+
cleanup();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("does not warn when service is on its canonical port", async () => {
|
|
352
|
+
const { path, cleanup } = makeTempPath();
|
|
353
|
+
try {
|
|
354
|
+
upsertService(
|
|
355
|
+
{
|
|
356
|
+
name: "parachute-scribe",
|
|
357
|
+
port: 1943,
|
|
358
|
+
paths: ["/scribe"],
|
|
359
|
+
health: "/scribe/health",
|
|
360
|
+
version: "0.4.0",
|
|
361
|
+
},
|
|
362
|
+
path,
|
|
363
|
+
);
|
|
364
|
+
const lines: string[] = [];
|
|
365
|
+
await status({
|
|
366
|
+
manifestPath: path,
|
|
367
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
368
|
+
print: (l) => lines.push(l),
|
|
369
|
+
});
|
|
370
|
+
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
371
|
+
} finally {
|
|
372
|
+
cleanup();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("does not warn for third-party services with no canonical port", async () => {
|
|
377
|
+
const { path, cleanup } = makeTempPath();
|
|
378
|
+
try {
|
|
379
|
+
upsertService(
|
|
380
|
+
{
|
|
381
|
+
name: "third-party-thing",
|
|
382
|
+
port: 9000,
|
|
383
|
+
paths: ["/widget"],
|
|
384
|
+
health: "/health",
|
|
385
|
+
version: "1.0.0",
|
|
386
|
+
},
|
|
387
|
+
path,
|
|
388
|
+
);
|
|
389
|
+
const lines: string[] = [];
|
|
390
|
+
await status({
|
|
391
|
+
manifestPath: path,
|
|
392
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
393
|
+
print: (l) => lines.push(l),
|
|
394
|
+
});
|
|
395
|
+
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
396
|
+
} finally {
|
|
397
|
+
cleanup();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("warning does not affect exit code (status stays 0 when healthy)", async () => {
|
|
402
|
+
const { path, cleanup } = makeTempPath();
|
|
403
|
+
try {
|
|
404
|
+
upsertService(
|
|
405
|
+
{
|
|
406
|
+
name: "parachute-scribe",
|
|
407
|
+
port: 1944,
|
|
408
|
+
paths: ["/scribe"],
|
|
409
|
+
health: "/scribe/health",
|
|
410
|
+
version: "0.4.0",
|
|
411
|
+
},
|
|
412
|
+
path,
|
|
413
|
+
);
|
|
414
|
+
const code = await status({
|
|
415
|
+
manifestPath: path,
|
|
416
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
417
|
+
print: () => {},
|
|
418
|
+
});
|
|
419
|
+
// Drift is informational. A healthy probed service still returns 0
|
|
420
|
+
// even when the port has drifted off canonical.
|
|
421
|
+
expect(code).toBe(0);
|
|
422
|
+
} finally {
|
|
423
|
+
cleanup();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("warning still fires when service is stopped (probe skipped)", async () => {
|
|
428
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
429
|
+
try {
|
|
430
|
+
upsertService(
|
|
431
|
+
{
|
|
432
|
+
name: "parachute-scribe",
|
|
433
|
+
port: 1944,
|
|
434
|
+
paths: ["/scribe"],
|
|
435
|
+
health: "/scribe/health",
|
|
436
|
+
version: "0.4.0",
|
|
437
|
+
},
|
|
438
|
+
path,
|
|
439
|
+
);
|
|
440
|
+
writePid("scribe", 4242, configDir);
|
|
441
|
+
const lines: string[] = [];
|
|
442
|
+
await status({
|
|
443
|
+
manifestPath: path,
|
|
444
|
+
configDir,
|
|
445
|
+
alive: () => false,
|
|
446
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
447
|
+
print: (l) => lines.push(l),
|
|
448
|
+
});
|
|
449
|
+
// Drift is computed from services.json, not from the probe — a
|
|
450
|
+
// stopped service with a drifted port should still surface the
|
|
451
|
+
// warning so operators see the miswire even before they start it.
|
|
452
|
+
expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
|
|
453
|
+
} finally {
|
|
454
|
+
cleanup();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("multi-vault instance rows do not surface a drift warning (intentional gap)", async () => {
|
|
459
|
+
// Pinning the documented gap: `parachute-vault-default` is not
|
|
460
|
+
// a canonical manifest name in FIRST_PARTY_FALLBACKS, so
|
|
461
|
+
// `canonicalPortForManifest` returns undefined and no drift
|
|
462
|
+
// warning fires — even when the row's port differs from the
|
|
463
|
+
// canonical `parachute-vault` port (1940). Rationale lives on
|
|
464
|
+
// `canonicalPortForManifest` in service-spec.ts; this test pins
|
|
465
|
+
// the behavior so a future change to the lookup shape doesn't
|
|
466
|
+
// accidentally start emitting drift on every multi-vault row
|
|
467
|
+
// without an explicit decision.
|
|
468
|
+
const { path, cleanup } = makeTempPath();
|
|
469
|
+
try {
|
|
470
|
+
upsertService(
|
|
471
|
+
{
|
|
472
|
+
name: "parachute-vault-default",
|
|
473
|
+
port: 1944,
|
|
474
|
+
paths: ["/vault/default"],
|
|
475
|
+
health: "/vault/default/health",
|
|
476
|
+
version: "0.2.4",
|
|
477
|
+
},
|
|
478
|
+
path,
|
|
479
|
+
);
|
|
480
|
+
const lines: string[] = [];
|
|
481
|
+
await status({
|
|
482
|
+
manifestPath: path,
|
|
483
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
484
|
+
print: (l) => lines.push(l),
|
|
485
|
+
});
|
|
486
|
+
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
487
|
+
} finally {
|
|
488
|
+
cleanup();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
320
493
|
test("stopped services still render a URL line so the user knows where to point clients post-start", async () => {
|
|
321
494
|
const { path, configDir, cleanup } = makeTempPath();
|
|
322
495
|
try {
|
package/src/admin-handlers.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { restart as lifecycleRestart } from "./commands/lifecycle.ts";
|
|
|
31
31
|
import { CONFIG_DIR } from "./config.ts";
|
|
32
32
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
33
33
|
import type { ModuleManifest } from "./module-manifest.ts";
|
|
34
|
+
import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
|
|
34
35
|
import {
|
|
35
36
|
type ServicesManifest,
|
|
36
37
|
readManifest as readServicesManifest,
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
buildSessionCookie,
|
|
42
43
|
createSession,
|
|
43
44
|
deleteSession,
|
|
44
|
-
|
|
45
|
+
findActiveSession,
|
|
45
46
|
parseSessionCookie,
|
|
46
47
|
} from "./sessions.ts";
|
|
47
48
|
import { getUserByUsername, verifyPassword } from "./users.ts";
|
|
@@ -74,15 +75,6 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
|
|
|
74
75
|
|
|
75
76
|
// --- session gate ----------------------------------------------------------
|
|
76
77
|
|
|
77
|
-
/**
|
|
78
|
-
* Return the active session for this request, or null. Caller decides what
|
|
79
|
-
* to do on null — most paths should redirect to `/admin/login?next=<path>`.
|
|
80
|
-
*/
|
|
81
|
-
function activeSession(db: Database, req: Request) {
|
|
82
|
-
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
83
|
-
return sid ? findSession(db, sid) : null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
78
|
function loginRedirect(req: Request, extra: Record<string, string> = {}): Response {
|
|
87
79
|
const url = new URL(req.url);
|
|
88
80
|
const next = `${url.pathname}${url.search}`;
|
|
@@ -106,7 +98,11 @@ export function handleAdminLoginGet(_db: Database, req: Request): Response {
|
|
|
106
98
|
return htmlResponse(renderAdminLogin({ next, csrfToken: csrf.token }), 200, extra);
|
|
107
99
|
}
|
|
108
100
|
|
|
109
|
-
export async function handleAdminLoginPost(
|
|
101
|
+
export async function handleAdminLoginPost(
|
|
102
|
+
db: Database,
|
|
103
|
+
req: Request,
|
|
104
|
+
deps: AdminLoginDeps = {},
|
|
105
|
+
): Promise<Response> {
|
|
110
106
|
const form = await req.formData();
|
|
111
107
|
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
112
108
|
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
@@ -118,6 +114,24 @@ export async function handleAdminLoginPost(db: Database, req: Request): Promise<
|
|
|
118
114
|
400,
|
|
119
115
|
);
|
|
120
116
|
}
|
|
117
|
+
// Rate-limit gate fires *after* CSRF (so a junk cross-site POST doesn't
|
|
118
|
+
// burn a bucket slot for the victim's IP) but *before* credential check.
|
|
119
|
+
// Every legitimate login attempt — wrong password, missing user, eventually
|
|
120
|
+
// failed-2FA (#186) — counts toward the same bucket so an attacker can't
|
|
121
|
+
// partition the cooldown across stages.
|
|
122
|
+
const clientIp = clientIpFromRequest(req);
|
|
123
|
+
const now = deps.now ? deps.now() : new Date();
|
|
124
|
+
const gate = checkAndRecord(clientIp, now);
|
|
125
|
+
if (!gate.allowed) {
|
|
126
|
+
return htmlResponse(
|
|
127
|
+
renderAdminError({
|
|
128
|
+
title: "Too many login attempts",
|
|
129
|
+
message: `Too many login attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
|
|
130
|
+
}),
|
|
131
|
+
429,
|
|
132
|
+
{ "retry-after": String(gate.retryAfterSeconds ?? 1) },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
121
135
|
const username = String(form.get("username") ?? "");
|
|
122
136
|
const password = String(form.get("password") ?? "");
|
|
123
137
|
const next = safeNext(String(form.get("next") ?? ""));
|
|
@@ -147,6 +161,17 @@ export async function handleAdminLoginPost(db: Database, req: Request): Promise<
|
|
|
147
161
|
return redirect(next, { "set-cookie": cookie });
|
|
148
162
|
}
|
|
149
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Test-injection seam for `handleAdminLoginPost`. Production callers omit
|
|
166
|
+
* `deps`; tests pass a deterministic clock so the rate-limit assertions
|
|
167
|
+
* don't race wall-clock time. Kept narrow — login doesn't share the wider
|
|
168
|
+
* `AdminDeps` because it doesn't load services / module manifests.
|
|
169
|
+
*/
|
|
170
|
+
export interface AdminLoginDeps {
|
|
171
|
+
/** Test seam — defaults to real clock. */
|
|
172
|
+
now?: () => Date;
|
|
173
|
+
}
|
|
174
|
+
|
|
150
175
|
// --- /admin/logout ---------------------------------------------------------
|
|
151
176
|
|
|
152
177
|
/**
|
|
@@ -183,7 +208,7 @@ export async function handleAdminConfigGet(
|
|
|
183
208
|
req: Request,
|
|
184
209
|
deps: AdminDeps = {},
|
|
185
210
|
): Promise<Response> {
|
|
186
|
-
const session =
|
|
211
|
+
const session = findActiveSession(db, req);
|
|
187
212
|
if (!session) return loginRedirect(req);
|
|
188
213
|
|
|
189
214
|
const csrf = ensureCsrfToken(req);
|
|
@@ -207,7 +232,7 @@ export async function handleAdminConfigPost(
|
|
|
207
232
|
moduleName: string,
|
|
208
233
|
deps: AdminDeps = {},
|
|
209
234
|
): Promise<Response> {
|
|
210
|
-
const session =
|
|
235
|
+
const session = findActiveSession(db, req);
|
|
211
236
|
if (!session) return loginRedirect(req);
|
|
212
237
|
|
|
213
238
|
const form = await req.formData();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-exposure 2FA-enrollment warning (#186). Lands as the next layer of
|
|
3
|
+
* defense after #188's `/admin/login` rate-limit floor: once the operator
|
|
4
|
+
* brings up cloudflare or Tailscale Funnel, `/admin/login` is reachable from
|
|
5
|
+
* the public internet on every layer admitting traffic. 2FA is the difference
|
|
6
|
+
* between "password is the only wall" and "password + something-you-have."
|
|
7
|
+
*
|
|
8
|
+
* Why this is a warning, not a hard gate: hard-gating would surprise operators
|
|
9
|
+
* mid-flow — they ran `parachute expose public` to expose, not to be told
|
|
10
|
+
* "set up 2FA first." A loud, contextual warning + a clear one-line
|
|
11
|
+
* remediation is the right shape; the operator decides whether to act now or
|
|
12
|
+
* later. The tunnel is up regardless.
|
|
13
|
+
*
|
|
14
|
+
* Why the source-of-truth is vault's `config.yaml`: 2FA enrollment lives in
|
|
15
|
+
* `parachute-vault` (the hub forwards `parachute auth 2fa enroll` to vault —
|
|
16
|
+
* see `commands/auth.ts` `VAULT_FORWARDED_SUBCOMMANDS`). The hub's `users`
|
|
17
|
+
* table has no TOTP column today; it will gain one when hub-admin login
|
|
18
|
+
* verifies TOTP against vault. Until then, "is 2FA enrolled?" maps cleanly
|
|
19
|
+
* to "does vault's config.yaml carry a non-empty `totp_secret`?", which is
|
|
20
|
+
* exactly what `readVaultAuthStatus().hasTotp` returns.
|
|
21
|
+
*
|
|
22
|
+
* If vault isn't installed at all (rare for the cloudflare path — it requires
|
|
23
|
+
* a vault entry — but possible on the tailnet/funnel path): `hasTotp` comes
|
|
24
|
+
* back `false` and the warning still fires. The remediation
|
|
25
|
+
* `parachute auth 2fa enroll` then surfaces vault's "install vault first"
|
|
26
|
+
* error, which is the right next step regardless.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.ts";
|
|
30
|
+
|
|
31
|
+
export interface Public2FAWarningOpts {
|
|
32
|
+
/** Pre-computed status to skip on-disk probe (tests). Production omits. */
|
|
33
|
+
status?: VaultAuthStatus;
|
|
34
|
+
/** Forwarded to {@link readVaultAuthStatus} when `status` is not supplied. */
|
|
35
|
+
vaultHome?: string;
|
|
36
|
+
/** Sink for the warning lines. Defaults to console.log. */
|
|
37
|
+
log?: (line: string) => void;
|
|
38
|
+
/** Public URL the operator just brought up — embedded in the warning. */
|
|
39
|
+
publicUrl: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `true` when `totp_secret` is present and non-empty in vault's config.yaml,
|
|
44
|
+
* `false` otherwise (missing vault, missing config.yaml, empty value).
|
|
45
|
+
*
|
|
46
|
+
* Source-of-truth note: TOTP storage is the vault's, not the hub's. See the
|
|
47
|
+
* module-level doc comment.
|
|
48
|
+
*/
|
|
49
|
+
export function is2FAEnrolled(
|
|
50
|
+
opts: { vaultHome?: string; status?: VaultAuthStatus } = {},
|
|
51
|
+
): boolean {
|
|
52
|
+
const status =
|
|
53
|
+
opts.status ?? readVaultAuthStatus(opts.vaultHome ? { vaultHome: opts.vaultHome } : {});
|
|
54
|
+
return status.hasTotp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Print a 2FA-enrollment warning to `log` when not enrolled. No-op when
|
|
59
|
+
* enrolled. Returns `true` if the warning fired, `false` if suppressed —
|
|
60
|
+
* primarily to make integration tests assert the branch without scraping log
|
|
61
|
+
* text.
|
|
62
|
+
*/
|
|
63
|
+
export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
|
|
64
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
65
|
+
if (
|
|
66
|
+
is2FAEnrolled({
|
|
67
|
+
...(opts.vaultHome ? { vaultHome: opts.vaultHome } : {}),
|
|
68
|
+
...(opts.status ? { status: opts.status } : {}),
|
|
69
|
+
})
|
|
70
|
+
) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
log("");
|
|
74
|
+
log("⚠ 2FA is not enrolled. /admin is now reachable on the public internet");
|
|
75
|
+
log(` (${opts.publicUrl}/admin/login). Anyone who guesses your password`);
|
|
76
|
+
log(" is in. Strongly recommended:");
|
|
77
|
+
log("");
|
|
78
|
+
log(" parachute auth 2fa enroll");
|
|
79
|
+
log("");
|
|
80
|
+
log(" Adds TOTP + backup codes. Takes 30 seconds.");
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
@@ -30,6 +30,8 @@ import { SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
|
30
30
|
import { type AliveFn, defaultAlive } from "../process-state.ts";
|
|
31
31
|
import { readManifest } from "../services-manifest.ts";
|
|
32
32
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
33
|
+
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
34
|
+
import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
|
|
33
35
|
|
|
34
36
|
const AUTH_DOC_URL =
|
|
35
37
|
"https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
|
|
@@ -106,6 +108,18 @@ export interface ExposeCloudflareOpts {
|
|
|
106
108
|
/** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
|
|
107
109
|
cloudflaredHome?: string;
|
|
108
110
|
now?: () => Date;
|
|
111
|
+
/**
|
|
112
|
+
* Override `~/.parachute/vault` for the 2FA-enrollment probe. Tests
|
|
113
|
+
* point at a tmp dir; production omits and the probe defaults to the
|
|
114
|
+
* resolved vault home. (#186)
|
|
115
|
+
*/
|
|
116
|
+
vaultHome?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Pre-computed vault auth status, primarily for tests. When set,
|
|
119
|
+
* `printPublic2FAWarning` consults this instead of reading
|
|
120
|
+
* `<vaultHome>/config.yaml` from disk. (#186)
|
|
121
|
+
*/
|
|
122
|
+
vaultAuthStatus?: VaultAuthStatus;
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
interface Resolved {
|
|
@@ -121,6 +135,8 @@ interface Resolved {
|
|
|
121
135
|
logPath: string;
|
|
122
136
|
cloudflaredHome: string;
|
|
123
137
|
now: () => Date;
|
|
138
|
+
vaultHome: string | undefined;
|
|
139
|
+
vaultAuthStatus: VaultAuthStatus | undefined;
|
|
124
140
|
}
|
|
125
141
|
|
|
126
142
|
function resolve(opts: ExposeCloudflareOpts): Resolved {
|
|
@@ -139,6 +155,8 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
|
|
|
139
155
|
logPath: opts.logPath ?? paths.logPath,
|
|
140
156
|
cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
|
|
141
157
|
now: opts.now ?? (() => new Date()),
|
|
158
|
+
vaultHome: opts.vaultHome,
|
|
159
|
+
vaultAuthStatus: opts.vaultAuthStatus,
|
|
142
160
|
};
|
|
143
161
|
}
|
|
144
162
|
|
|
@@ -313,6 +331,15 @@ export async function exposeCloudflareUp(
|
|
|
313
331
|
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
314
332
|
r.log(` ${vaultUrl}`);
|
|
315
333
|
printAuthGuidance(r.log, vaultUrl);
|
|
334
|
+
// 2FA-enrollment warning when /admin/login is now reachable on the public
|
|
335
|
+
// internet but the operator hasn't enrolled TOTP. Cloudflare exposure is
|
|
336
|
+
// always public; tailnet/funnel mirrors this in `expose.ts`. See #186.
|
|
337
|
+
printPublic2FAWarning({
|
|
338
|
+
log: r.log,
|
|
339
|
+
publicUrl: baseUrl,
|
|
340
|
+
...(r.vaultHome !== undefined ? { vaultHome: r.vaultHome } : {}),
|
|
341
|
+
...(r.vaultAuthStatus !== undefined ? { status: r.vaultAuthStatus } : {}),
|
|
342
|
+
});
|
|
316
343
|
return 0;
|
|
317
344
|
}
|
|
318
345
|
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import { DEFAULT_CLOUDFLARED_HOME } from "../cloudflare/detect.ts";
|
|
32
32
|
import {
|
|
33
|
-
type ProviderAvailability,
|
|
34
33
|
type DetectProvidersOpts,
|
|
34
|
+
type ProviderAvailability,
|
|
35
35
|
detectProviders,
|
|
36
36
|
isCloudflareReady,
|
|
37
37
|
isTailnetReady,
|
|
@@ -143,9 +143,7 @@ function reportCloudflareNeedsDomain(r: Resolved): number {
|
|
|
143
143
|
r.log("Re-run with the hostname:");
|
|
144
144
|
r.log(" parachute expose public --cloudflare --domain vault.example.com");
|
|
145
145
|
r.log("");
|
|
146
|
-
r.log(
|
|
147
|
-
"The hostname's apex domain must already be a zone on your Cloudflare account.",
|
|
148
|
-
);
|
|
146
|
+
r.log("The hostname's apex domain must already be a zone on your Cloudflare account.");
|
|
149
147
|
return 1;
|
|
150
148
|
}
|
|
151
149
|
|
|
@@ -154,9 +152,7 @@ function reportCloudflareNeedsDomain(r: Resolved): number {
|
|
|
154
152
|
* (`--tailnet` / `--cloudflare`) was supplied AND we're not in a TTY (the TTY
|
|
155
153
|
* path runs `expose-interactive.ts` instead).
|
|
156
154
|
*/
|
|
157
|
-
export async function exposePublicAutoPick(
|
|
158
|
-
opts: ExposePublicAutoOpts = {},
|
|
159
|
-
): Promise<number> {
|
|
155
|
+
export async function exposePublicAutoPick(opts: ExposePublicAutoOpts = {}): Promise<number> {
|
|
160
156
|
const r = resolve(opts);
|
|
161
157
|
const availability = await r.detectProvidersImpl({ cloudflaredHome: r.cloudflaredHome });
|
|
162
158
|
const tsReady = isTailnetReady(availability);
|