@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
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
14
14
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
15
15
|
import type { ConfigSchema, ModuleManifest } from "../module-manifest.ts";
|
|
16
|
+
import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
|
|
16
17
|
import type { ServicesManifest } from "../services-manifest.ts";
|
|
17
18
|
import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
|
|
18
19
|
import { createUser } from "../users.ts";
|
|
@@ -106,6 +107,10 @@ function fakeReadManifest(installDir: string): Promise<ModuleManifest | null> {
|
|
|
106
107
|
let harness: Harness;
|
|
107
108
|
beforeEach(() => {
|
|
108
109
|
harness = makeHarness();
|
|
110
|
+
// Per-test rate-limit state — login tests share the UNKNOWN_IP sentinel
|
|
111
|
+
// bucket since they don't set CF-Connecting-IP, so without a reset the
|
|
112
|
+
// 6th test in this file would 429 spuriously.
|
|
113
|
+
resetRateLimit();
|
|
109
114
|
});
|
|
110
115
|
afterEach(() => {
|
|
111
116
|
harness.cleanup();
|
|
@@ -222,6 +227,93 @@ describe("handleAdminLoginPost", () => {
|
|
|
222
227
|
expect(res.status).toBe(302);
|
|
223
228
|
expect(res.headers.get("location")).toBe("/admin/config");
|
|
224
229
|
});
|
|
230
|
+
|
|
231
|
+
// hub#185 — per-IP rate-limit (5 attempts / 15 min) on POST /admin/login.
|
|
232
|
+
test("6 rapid POSTs from same IP get 200/401×4/429 and the 429 carries Retry-After", async () => {
|
|
233
|
+
await createUser(harness.db, "admin", "correct-pw");
|
|
234
|
+
const buildReq = (password: string) => {
|
|
235
|
+
const { body, headers } = formBody({
|
|
236
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
237
|
+
username: "admin",
|
|
238
|
+
password,
|
|
239
|
+
next: "/admin/config",
|
|
240
|
+
});
|
|
241
|
+
return new Request("http://hub.test/admin/login", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.42" },
|
|
244
|
+
body,
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
// First attempt: correct password → 302. Counts as attempt #1.
|
|
248
|
+
const first = await handleAdminLoginPost(harness.db, buildReq("correct-pw"));
|
|
249
|
+
expect(first.status).toBe(302);
|
|
250
|
+
// Attempts 2–5: wrong password → 401 each.
|
|
251
|
+
for (let i = 2; i <= 5; i++) {
|
|
252
|
+
const r = await handleAdminLoginPost(harness.db, buildReq("wrong"));
|
|
253
|
+
expect(r.status).toBe(401);
|
|
254
|
+
}
|
|
255
|
+
// Attempt 6: rate-limit fires before credential check → 429 + Retry-After.
|
|
256
|
+
const denied = await handleAdminLoginPost(harness.db, buildReq("wrong"));
|
|
257
|
+
expect(denied.status).toBe(429);
|
|
258
|
+
const retryAfter = denied.headers.get("retry-after");
|
|
259
|
+
expect(retryAfter).not.toBeNull();
|
|
260
|
+
const seconds = Number(retryAfter);
|
|
261
|
+
expect(seconds).toBeGreaterThan(0);
|
|
262
|
+
// Window is 15 min = 900s, so retry-after sits in (0, 900].
|
|
263
|
+
expect(seconds).toBeLessThanOrEqual(900);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("rate-limit is per-IP: a different IP can still log in after another's bucket fills", async () => {
|
|
267
|
+
await createUser(harness.db, "admin", "pw");
|
|
268
|
+
const buildReq = (ip: string, password: string) => {
|
|
269
|
+
const { body, headers } = formBody({
|
|
270
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
271
|
+
username: "admin",
|
|
272
|
+
password,
|
|
273
|
+
next: "/admin/config",
|
|
274
|
+
});
|
|
275
|
+
return new Request("http://hub.test/admin/login", {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": ip },
|
|
278
|
+
body,
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
// Exhaust ip-a's bucket with 5 wrong-password attempts, then confirm 429.
|
|
282
|
+
for (let i = 0; i < 5; i++) {
|
|
283
|
+
await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
|
|
284
|
+
}
|
|
285
|
+
const aDenied = await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
|
|
286
|
+
expect(aDenied.status).toBe(429);
|
|
287
|
+
// Different IP: fresh bucket, correct credentials → 302.
|
|
288
|
+
const bOk = await handleAdminLoginPost(harness.db, buildReq("198.51.100.99", "pw"));
|
|
289
|
+
expect(bOk.status).toBe(302);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("rate-limit fires before credential check (denied request never touches DB)", async () => {
|
|
293
|
+
// No user exists in the harness DB. First 5 attempts should be 401
|
|
294
|
+
// ("Invalid credentials" — no such user). 6th should be 429 with the
|
|
295
|
+
// rate-limit body, NOT a credential-failure body.
|
|
296
|
+
const buildReq = () => {
|
|
297
|
+
const { body, headers } = formBody({
|
|
298
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
299
|
+
username: "ghost",
|
|
300
|
+
password: "x",
|
|
301
|
+
next: "/admin/config",
|
|
302
|
+
});
|
|
303
|
+
return new Request("http://hub.test/admin/login", {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.99" },
|
|
306
|
+
body,
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
for (let i = 0; i < 5; i++) {
|
|
310
|
+
const r = await handleAdminLoginPost(harness.db, buildReq());
|
|
311
|
+
expect(r.status).toBe(401);
|
|
312
|
+
}
|
|
313
|
+
const denied = await handleAdminLoginPost(harness.db, buildReq());
|
|
314
|
+
expect(denied.status).toBe(429);
|
|
315
|
+
expect(await denied.text()).toContain("Too many login attempts");
|
|
316
|
+
});
|
|
225
317
|
});
|
|
226
318
|
|
|
227
319
|
describe("handleAdminLogoutPost (#113)", () => {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { is2FAEnrolled, printPublic2FAWarning } from "../commands/expose-2fa-warning.ts";
|
|
6
|
+
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
7
|
+
|
|
8
|
+
function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
|
|
9
|
+
return {
|
|
10
|
+
hasOwnerPassword: false,
|
|
11
|
+
hasTotp: false,
|
|
12
|
+
tokenCount: 0,
|
|
13
|
+
vaultNames: [],
|
|
14
|
+
...partial,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("is2FAEnrolled", () => {
|
|
19
|
+
test("returns true when status carries hasTotp: true", () => {
|
|
20
|
+
expect(is2FAEnrolled({ status: status({ hasTotp: true }) })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns false when status carries hasTotp: false", () => {
|
|
24
|
+
expect(is2FAEnrolled({ status: status({ hasTotp: false }) })).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("reads totp_secret from vaultHome's config.yaml when status not supplied", () => {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(join(dir, "config.yaml"), 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
|
|
31
|
+
expect(is2FAEnrolled({ vaultHome: dir })).toBe(true);
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("missing config.yaml → not enrolled (false)", () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
39
|
+
try {
|
|
40
|
+
// No config.yaml written.
|
|
41
|
+
expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
|
|
42
|
+
} finally {
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("empty totp_secret value → not enrolled (matches vault's readGlobalConfig)", () => {
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(join(dir, "config.yaml"), 'totp_secret: ""\n');
|
|
51
|
+
expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("malformed config.yaml → not enrolled (safer fail mode, fires warning)", () => {
|
|
58
|
+
// The probe parses config.yaml with a line-anchored regex (no YAML
|
|
59
|
+
// dependency), so junk content simply doesn't match `totp_secret: "..."`
|
|
60
|
+
// and resolves to `hasTotp: false` — which fires the public-exposure
|
|
61
|
+
// warning rather than silently suppressing it. Pin that contract so a
|
|
62
|
+
// future refactor of auth-status.ts can't quietly invert it.
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(join(dir, "config.yaml"), "totp_secret: [unbalanced\n ::: not yaml\n");
|
|
66
|
+
expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("printPublic2FAWarning", () => {
|
|
74
|
+
test("not enrolled → fires warning, returns true", () => {
|
|
75
|
+
const logs: string[] = [];
|
|
76
|
+
const fired = printPublic2FAWarning({
|
|
77
|
+
status: status({ hasTotp: false }),
|
|
78
|
+
log: (l) => logs.push(l),
|
|
79
|
+
publicUrl: "https://vault.example.com",
|
|
80
|
+
});
|
|
81
|
+
expect(fired).toBe(true);
|
|
82
|
+
const joined = logs.join("\n");
|
|
83
|
+
expect(joined).toContain("2FA is not enrolled");
|
|
84
|
+
expect(joined).toContain("https://vault.example.com/admin/login");
|
|
85
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("enrolled → suppressed, returns false, logs nothing", () => {
|
|
89
|
+
const logs: string[] = [];
|
|
90
|
+
const fired = printPublic2FAWarning({
|
|
91
|
+
status: status({ hasTotp: true }),
|
|
92
|
+
log: (l) => logs.push(l),
|
|
93
|
+
publicUrl: "https://vault.example.com",
|
|
94
|
+
});
|
|
95
|
+
expect(fired).toBe(false);
|
|
96
|
+
expect(logs).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("password-also-missing case still fires (warning is layer-independent of password state)", () => {
|
|
100
|
+
// The wide-open state (no password, no 2FA) hits this branch too — the
|
|
101
|
+
// hub's own `printAuthGuidance` (cloudflare) and `runAuthPreflight`
|
|
102
|
+
// (interactive wizard) cover the password remediation; this warning is
|
|
103
|
+
// strictly about 2FA.
|
|
104
|
+
const logs: string[] = [];
|
|
105
|
+
const fired = printPublic2FAWarning({
|
|
106
|
+
status: status({ hasOwnerPassword: false, hasTotp: false }),
|
|
107
|
+
log: (l) => logs.push(l),
|
|
108
|
+
publicUrl: "https://vault.example.com",
|
|
109
|
+
});
|
|
110
|
+
expect(fired).toBe(true);
|
|
111
|
+
expect(logs.some((l) => l.includes("2FA is not enrolled"))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("embeds the supplied publicUrl into the /admin/login pointer", () => {
|
|
115
|
+
const logs: string[] = [];
|
|
116
|
+
printPublic2FAWarning({
|
|
117
|
+
status: status({ hasTotp: false }),
|
|
118
|
+
log: (l) => logs.push(l),
|
|
119
|
+
publicUrl: "https://parachute.taildf9ce2.ts.net",
|
|
120
|
+
});
|
|
121
|
+
expect(logs.some((l) => l.includes("https://parachute.taildf9ce2.ts.net/admin/login"))).toBe(
|
|
122
|
+
true,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -511,6 +511,107 @@ describe("exposeCloudflareUp", () => {
|
|
|
511
511
|
env.cleanup();
|
|
512
512
|
}
|
|
513
513
|
});
|
|
514
|
+
|
|
515
|
+
// 2FA-enrollment warning (#186). The cloudflare path is always public —
|
|
516
|
+
// every successful bringup makes /admin/login reachable on the open
|
|
517
|
+
// internet, where 2FA is the primary defense beyond #188's rate-limit floor.
|
|
518
|
+
describe("2FA-enrollment warning", () => {
|
|
519
|
+
test("not enrolled → warning fires after the success block", async () => {
|
|
520
|
+
const env = makeEnv();
|
|
521
|
+
try {
|
|
522
|
+
const uuid = "cccccccc-0000-0000-0000-000000000003";
|
|
523
|
+
const { runner } = queueRunner([
|
|
524
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
525
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
526
|
+
{
|
|
527
|
+
code: 0,
|
|
528
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
529
|
+
stderr: "",
|
|
530
|
+
},
|
|
531
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
532
|
+
]);
|
|
533
|
+
const { spawner } = fakeSpawner(42100);
|
|
534
|
+
const logs: string[] = [];
|
|
535
|
+
|
|
536
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
537
|
+
runner,
|
|
538
|
+
spawner,
|
|
539
|
+
alive: () => false,
|
|
540
|
+
kill: () => {},
|
|
541
|
+
log: (l) => logs.push(l),
|
|
542
|
+
manifestPath: env.manifestPath,
|
|
543
|
+
statePath: env.statePath,
|
|
544
|
+
configPath: env.configPath,
|
|
545
|
+
logPath: env.logPath,
|
|
546
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
547
|
+
// No password, no 2FA — fully wide open. The warning should still
|
|
548
|
+
// fire; password-recovery copy already lives in `printAuthGuidance`.
|
|
549
|
+
vaultAuthStatus: {
|
|
550
|
+
hasOwnerPassword: false,
|
|
551
|
+
hasTotp: false,
|
|
552
|
+
tokenCount: 0,
|
|
553
|
+
vaultNames: [],
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(code).toBe(0);
|
|
558
|
+
const joined = logs.join("\n");
|
|
559
|
+
expect(joined).toContain("2FA is not enrolled");
|
|
560
|
+
expect(joined).toContain("https://vault.example.com/admin/login");
|
|
561
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
562
|
+
} finally {
|
|
563
|
+
env.cleanup();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("enrolled → warning suppressed (no '2FA is not enrolled' line)", async () => {
|
|
568
|
+
const env = makeEnv();
|
|
569
|
+
try {
|
|
570
|
+
const uuid = "dddddddd-0000-0000-0000-000000000004";
|
|
571
|
+
const { runner } = queueRunner([
|
|
572
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
573
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
574
|
+
{
|
|
575
|
+
code: 0,
|
|
576
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
577
|
+
stderr: "",
|
|
578
|
+
},
|
|
579
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
580
|
+
]);
|
|
581
|
+
const { spawner } = fakeSpawner(42101);
|
|
582
|
+
const logs: string[] = [];
|
|
583
|
+
|
|
584
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
585
|
+
runner,
|
|
586
|
+
spawner,
|
|
587
|
+
alive: () => false,
|
|
588
|
+
kill: () => {},
|
|
589
|
+
log: (l) => logs.push(l),
|
|
590
|
+
manifestPath: env.manifestPath,
|
|
591
|
+
statePath: env.statePath,
|
|
592
|
+
configPath: env.configPath,
|
|
593
|
+
logPath: env.logPath,
|
|
594
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
595
|
+
vaultAuthStatus: {
|
|
596
|
+
hasOwnerPassword: true,
|
|
597
|
+
hasTotp: true,
|
|
598
|
+
tokenCount: 1,
|
|
599
|
+
vaultNames: ["default"],
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
expect(code).toBe(0);
|
|
604
|
+
const joined = logs.join("\n");
|
|
605
|
+
expect(joined).not.toContain("2FA is not enrolled");
|
|
606
|
+
// The existing `printAuthGuidance` 2FA-recommend bullet is unrelated
|
|
607
|
+
// to the new contextual warning and stays in place — assert it on a
|
|
608
|
+
// shape that doesn't collide with the warning text.
|
|
609
|
+
expect(joined).toContain("(recommended) TOTP + backup codes");
|
|
610
|
+
} finally {
|
|
611
|
+
env.cleanup();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
});
|
|
514
615
|
});
|
|
515
616
|
|
|
516
617
|
describe("exposeCloudflareOff", () => {
|