@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.
@@ -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
- for (const m of [
119
- "parachute-vault",
120
- "parachute-notes",
121
- "parachute-scribe",
122
- "parachute-channel",
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: m,
129
+ name: s.name,
127
130
  version: "0.0.0",
128
- port: 1940,
129
- paths: [`/${m.replace(/^parachute-/, "")}`],
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 {
@@ -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
- findSession,
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(db: Database, req: Request): Promise<Response> {
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 = activeSession(db, req);
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 = activeSession(db, req);
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);