@openparachute/hub 0.7.4-rc.14 → 0.7.4-rc.16
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__/api-account-2fa.test.ts +72 -0
- package/src/__tests__/doctor.test.ts +464 -0
- package/src/api-account-2fa.ts +23 -1
- package/src/cli.ts +18 -0
- package/src/commands/doctor.ts +894 -0
- package/src/help.ts +38 -0
- package/src/rate-limit.ts +28 -0
package/package.json
CHANGED
|
@@ -312,6 +312,78 @@ describe("/api/account/2fa start + confirm", () => {
|
|
|
312
312
|
const body = (await res.json()) as { error: string };
|
|
313
313
|
expect(body.error).toBe("setup_expired");
|
|
314
314
|
});
|
|
315
|
+
|
|
316
|
+
test("confirm is rate-limited after 10 attempts → 429 (lenient, #712)", async () => {
|
|
317
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
318
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
319
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
320
|
+
// 10 honest mistypes are admitted (each 400 invalid_code) — the lenient
|
|
321
|
+
// bucket doesn't punish a fumbling enroller.
|
|
322
|
+
for (let i = 0; i < 10; i++) {
|
|
323
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
324
|
+
__csrf: TEST_CSRF,
|
|
325
|
+
secret,
|
|
326
|
+
code: "000000",
|
|
327
|
+
});
|
|
328
|
+
expect(r.status).toBe(400);
|
|
329
|
+
}
|
|
330
|
+
// 11th is denied by the limiter BEFORE the code is checked.
|
|
331
|
+
const denied = await post("/2fa/confirm", cookie, {
|
|
332
|
+
__csrf: TEST_CSRF,
|
|
333
|
+
secret,
|
|
334
|
+
code: "000000",
|
|
335
|
+
});
|
|
336
|
+
expect(denied.status).toBe(429);
|
|
337
|
+
const body = (await denied.json()) as { error: string };
|
|
338
|
+
expect(body.error).toBe("too_many_attempts");
|
|
339
|
+
expect(denied.headers.get("retry-after")).toBeTruthy();
|
|
340
|
+
// The grind never touched enrollment.
|
|
341
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("malformed-secret POSTs don't burn the confirm budget (#712)", async () => {
|
|
345
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
346
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
347
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
348
|
+
// 10 junk POSTs are rejected by the format guard BEFORE the limiter runs.
|
|
349
|
+
for (let i = 0; i < 10; i++) {
|
|
350
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
351
|
+
__csrf: TEST_CSRF,
|
|
352
|
+
secret: "not-base32!!",
|
|
353
|
+
code: "000000",
|
|
354
|
+
});
|
|
355
|
+
expect(r.status).toBe(400);
|
|
356
|
+
}
|
|
357
|
+
// Budget untouched — the legit live code still enrolls on the next attempt.
|
|
358
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
359
|
+
__csrf: TEST_CSRF,
|
|
360
|
+
secret,
|
|
361
|
+
code: liveCode(secret),
|
|
362
|
+
});
|
|
363
|
+
expect(ok.status).toBe(200);
|
|
364
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a few mistypes then the live code within budget still enrolls (lenient)", async () => {
|
|
368
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
369
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
370
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
371
|
+
for (let i = 0; i < 3; i++) {
|
|
372
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
373
|
+
__csrf: TEST_CSRF,
|
|
374
|
+
secret,
|
|
375
|
+
code: "000000",
|
|
376
|
+
});
|
|
377
|
+
expect(r.status).toBe(400);
|
|
378
|
+
}
|
|
379
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
380
|
+
__csrf: TEST_CSRF,
|
|
381
|
+
secret,
|
|
382
|
+
code: liveCode(secret),
|
|
383
|
+
});
|
|
384
|
+
expect(ok.status).toBe(200);
|
|
385
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
386
|
+
});
|
|
315
387
|
});
|
|
316
388
|
|
|
317
389
|
describe("/api/account/2fa/disable", () => {
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type CheckResult, type DoctorDeps, doctor } from "../commands/doctor.ts";
|
|
6
|
+
import { writePid } from "../process-state.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Doctor tests. The headline is the fresh-install-green guard (#717): a
|
|
10
|
+
* sandboxed PARACHUTE_HOME with a minimal-but-current services.json + a valid
|
|
11
|
+
* operator.token → ALL GREEN, zero WARN/FAIL. Every other test drives ONE
|
|
12
|
+
* failure mode and asserts that check fails while the others stay green.
|
|
13
|
+
*
|
|
14
|
+
* Every external side effect is stubbed through the `deps` seam — no real
|
|
15
|
+
* network probe, no real launchd/systemd query, no touching `~/.parachute`. The
|
|
16
|
+
* only real fs is the sandboxed PARACHUTE_HOME (services.json / operator.token /
|
|
17
|
+
* pidfiles) so the on-disk readers exercise genuine state.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface Harness {
|
|
21
|
+
configDir: string;
|
|
22
|
+
manifestPath: string;
|
|
23
|
+
cleanup: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeHarness(): Harness {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "parachute-doctor-test-"));
|
|
28
|
+
return {
|
|
29
|
+
configDir: dir,
|
|
30
|
+
manifestPath: join(dir, "services.json"),
|
|
31
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A minimal-but-current services.json with the canonical vault row. */
|
|
36
|
+
function seedCurrentManifest(manifestPath: string): void {
|
|
37
|
+
const services = [
|
|
38
|
+
{
|
|
39
|
+
name: "parachute-vault",
|
|
40
|
+
port: 1940,
|
|
41
|
+
paths: ["/vault/default"],
|
|
42
|
+
health: "/health",
|
|
43
|
+
version: "0.7.4",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
writeFileSync(manifestPath, JSON.stringify({ services }, null, 2));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A hand-rolled (unsigned) JWT — doctor DECODES `iss`, never verifies it. */
|
|
50
|
+
function fakeOperatorToken(iss: string): string {
|
|
51
|
+
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
52
|
+
return [
|
|
53
|
+
b64({ alg: "none", typ: "JWT" }),
|
|
54
|
+
b64({ iss, aud: "operator", sub: "u1", pa_scope_set: "admin" }),
|
|
55
|
+
"sig",
|
|
56
|
+
].join(".");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function seedOperatorToken(configDir: string, iss = "http://127.0.0.1:1939"): void {
|
|
60
|
+
writeFileSync(join(configDir, "operator.token"), `${fakeOperatorToken(iss)}\n`, { mode: 0o600 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deps for a HEALTHY box: hub answers /health, the vault module answers /health,
|
|
65
|
+
* the manager reports active, every bin resolves on PATH, nothing exposed.
|
|
66
|
+
* Individual tests override one field to drive a specific failure.
|
|
67
|
+
*/
|
|
68
|
+
function healthyDeps(over: Partial<DoctorDeps> = {}): DoctorDeps {
|
|
69
|
+
return {
|
|
70
|
+
probeHubHealth: async () => true,
|
|
71
|
+
probeModuleHealth: async () => true,
|
|
72
|
+
probePublicHealth: async () => true,
|
|
73
|
+
queryHubUnitState: () => ({ state: "active" }),
|
|
74
|
+
// A `which` that resolves everything — so the bin exec-bit check passes
|
|
75
|
+
// without the real module binaries being on the test host's PATH.
|
|
76
|
+
which: (binary) => `/usr/local/bin/${binary}`,
|
|
77
|
+
now: () => new Date("2026-06-27T00:00:00Z"),
|
|
78
|
+
...over,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runDoctor(
|
|
83
|
+
h: Harness,
|
|
84
|
+
deps: DoctorDeps,
|
|
85
|
+
): Promise<{ code: number; checks: CheckResult[] }> {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
const code = await doctor({
|
|
88
|
+
configDir: h.configDir,
|
|
89
|
+
manifestPath: h.manifestPath,
|
|
90
|
+
print: (l) => lines.push(l),
|
|
91
|
+
json: true,
|
|
92
|
+
deps,
|
|
93
|
+
});
|
|
94
|
+
const payload = JSON.parse(lines.join("\n")) as { checks: CheckResult[] };
|
|
95
|
+
return { code, checks: payload.checks };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function byName(checks: CheckResult[], name: string): CheckResult | undefined {
|
|
99
|
+
return checks.find((c) => c.name === name);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expectNoUnexpectedNonPass(checks: CheckResult[], allowedFailing: string[]): void {
|
|
103
|
+
const offenders = checks.filter((c) => c.status !== "pass" && !allowedFailing.includes(c.name));
|
|
104
|
+
if (offenders.length > 0) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`unexpected non-pass checks: ${offenders.map((c) => `${c.name}=${c.status}`).join(", ")}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("doctor — the fresh-install-green headline guard (#717)", () => {
|
|
112
|
+
test("a minimal-but-current install with a valid operator.token → ALL GREEN, exit 0", async () => {
|
|
113
|
+
const h = makeHarness();
|
|
114
|
+
try {
|
|
115
|
+
seedCurrentManifest(h.manifestPath);
|
|
116
|
+
seedOperatorToken(h.configDir);
|
|
117
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
118
|
+
|
|
119
|
+
// The load-bearing assertion: not a single WARN or FAIL anywhere.
|
|
120
|
+
const nonPass = checks.filter((c) => c.status !== "pass");
|
|
121
|
+
expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
|
|
122
|
+
expect(checks.every((c) => c.status === "pass")).toBe(true);
|
|
123
|
+
expect(code).toBe(0);
|
|
124
|
+
} finally {
|
|
125
|
+
h.cleanup();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a brand-new box (no services.json, no operator.token) → ALL GREEN, exit 0", async () => {
|
|
130
|
+
const h = makeHarness();
|
|
131
|
+
try {
|
|
132
|
+
// Nothing seeded at all — the truly-fresh case before `parachute init`.
|
|
133
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
134
|
+
const nonPass = checks.filter((c) => c.status !== "pass");
|
|
135
|
+
expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
|
|
136
|
+
expect(code).toBe(0);
|
|
137
|
+
} finally {
|
|
138
|
+
h.cleanup();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("an EXPOSED + reachable box stays GREEN", async () => {
|
|
143
|
+
const h = makeHarness();
|
|
144
|
+
try {
|
|
145
|
+
seedCurrentManifest(h.manifestPath);
|
|
146
|
+
seedOperatorToken(h.configDir, "https://vault.example.com");
|
|
147
|
+
writeFileSync(
|
|
148
|
+
join(h.configDir, "expose-state.json"),
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
version: 1,
|
|
151
|
+
layer: "public",
|
|
152
|
+
mode: "path",
|
|
153
|
+
canonicalFqdn: "vault.example.com",
|
|
154
|
+
port: 1939,
|
|
155
|
+
funnel: true,
|
|
156
|
+
entries: [],
|
|
157
|
+
hubOrigin: "https://vault.example.com",
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
const { code, checks } = await runDoctor(
|
|
161
|
+
h,
|
|
162
|
+
healthyDeps({ probePublicHealth: async () => true }),
|
|
163
|
+
);
|
|
164
|
+
const nonPass = checks.filter((c) => c.status !== "pass");
|
|
165
|
+
expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
|
|
166
|
+
expect(code).toBe(0);
|
|
167
|
+
} finally {
|
|
168
|
+
h.cleanup();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("doctor — failure modes (each detected in isolation; others stay green)", () => {
|
|
174
|
+
test("hub down → hub-reachable FAILs, exit 1, modules check WARNs (not N fails)", async () => {
|
|
175
|
+
const h = makeHarness();
|
|
176
|
+
try {
|
|
177
|
+
seedCurrentManifest(h.manifestPath);
|
|
178
|
+
seedOperatorToken(h.configDir);
|
|
179
|
+
const { code, checks } = await runDoctor(
|
|
180
|
+
h,
|
|
181
|
+
healthyDeps({
|
|
182
|
+
probeHubHealth: async () => false,
|
|
183
|
+
queryHubUnitState: () => ({ state: "inactive" }),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
expect(byName(checks, "hub-reachable")?.status).toBe("fail");
|
|
187
|
+
// A down hub → don't pile N module FAILs; surface one WARN pointing at the hub.
|
|
188
|
+
expect(byName(checks, "modules-alive")?.status).toBe("warn");
|
|
189
|
+
expect(code).toBe(1);
|
|
190
|
+
// Everything else stays green.
|
|
191
|
+
expectNoUnexpectedNonPass(checks, ["hub-reachable", "modules-alive"]);
|
|
192
|
+
} finally {
|
|
193
|
+
h.cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a configured module that doesn't answer /health on a healthy hub → that module FAILs", async () => {
|
|
198
|
+
const h = makeHarness();
|
|
199
|
+
try {
|
|
200
|
+
seedCurrentManifest(h.manifestPath);
|
|
201
|
+
seedOperatorToken(h.configDir);
|
|
202
|
+
const { code, checks } = await runDoctor(
|
|
203
|
+
h,
|
|
204
|
+
healthyDeps({ probeModuleHealth: async () => false }),
|
|
205
|
+
);
|
|
206
|
+
const mod = byName(checks, "module-alive:vault");
|
|
207
|
+
expect(mod?.status).toBe("fail");
|
|
208
|
+
expect(mod?.fix).toContain("parachute restart vault");
|
|
209
|
+
expect(code).toBe(1);
|
|
210
|
+
expectNoUnexpectedNonPass(checks, ["module-alive:vault"]);
|
|
211
|
+
} finally {
|
|
212
|
+
h.cleanup();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("missing operator token → operator-token PASSES (feature-not-configured, NOT a failure)", async () => {
|
|
217
|
+
const h = makeHarness();
|
|
218
|
+
try {
|
|
219
|
+
seedCurrentManifest(h.manifestPath);
|
|
220
|
+
// No operator.token seeded.
|
|
221
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
222
|
+
expect(byName(checks, "operator-token")?.status).toBe("pass");
|
|
223
|
+
expect(code).toBe(0);
|
|
224
|
+
} finally {
|
|
225
|
+
h.cleanup();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("corrupt operator token (not a JWT) → operator-token FAILs, exit 1", async () => {
|
|
230
|
+
const h = makeHarness();
|
|
231
|
+
try {
|
|
232
|
+
seedCurrentManifest(h.manifestPath);
|
|
233
|
+
writeFileSync(join(h.configDir, "operator.token"), "not-a-jwt\n", { mode: 0o600 });
|
|
234
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
235
|
+
expect(byName(checks, "operator-token")?.status).toBe("fail");
|
|
236
|
+
expect(byName(checks, "operator-token")?.detail).toContain("decodable");
|
|
237
|
+
expect(code).toBe(1);
|
|
238
|
+
expectNoUnexpectedNonPass(checks, ["operator-token"]);
|
|
239
|
+
} finally {
|
|
240
|
+
h.cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("issuer mismatch (foreign iss) → operator-token FAILs with the 'not signed in' detail", async () => {
|
|
245
|
+
const h = makeHarness();
|
|
246
|
+
try {
|
|
247
|
+
seedCurrentManifest(h.manifestPath);
|
|
248
|
+
// An `iss` that is neither loopback nor any exposed/env origin.
|
|
249
|
+
seedOperatorToken(h.configDir, "https://stale.example.com");
|
|
250
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
251
|
+
const op = byName(checks, "operator-token");
|
|
252
|
+
expect(op?.status).toBe("fail");
|
|
253
|
+
expect(op?.detail).toContain("not signed in");
|
|
254
|
+
expect(op?.fix).toContain("start hub");
|
|
255
|
+
expect(code).toBe(1);
|
|
256
|
+
expectNoUnexpectedNonPass(checks, ["operator-token"]);
|
|
257
|
+
} finally {
|
|
258
|
+
h.cleanup();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("module bin missing the exec bit (100644) → module-bin FAILs with a chmod +x fix", async () => {
|
|
263
|
+
const h = makeHarness();
|
|
264
|
+
try {
|
|
265
|
+
seedCurrentManifest(h.manifestPath);
|
|
266
|
+
seedOperatorToken(h.configDir);
|
|
267
|
+
// `which` returns null (Bun.which requires X_OK → null on a 100644 bin),
|
|
268
|
+
// and the secondary probe finds the present-but-non-executable file.
|
|
269
|
+
const { code, checks } = await runDoctor(
|
|
270
|
+
h,
|
|
271
|
+
healthyDeps({
|
|
272
|
+
which: () => null,
|
|
273
|
+
findNonExecutable: (binary) => `/usr/local/bin/${binary}`,
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
const bin = byName(checks, "module-bin:vault");
|
|
277
|
+
expect(bin?.status).toBe("fail");
|
|
278
|
+
expect(bin?.detail).toContain("NOT executable");
|
|
279
|
+
expect(bin?.fix).toContain("chmod +x");
|
|
280
|
+
expect(code).toBe(1);
|
|
281
|
+
expectNoUnexpectedNonPass(checks, ["module-bin:vault"]);
|
|
282
|
+
} finally {
|
|
283
|
+
h.cleanup();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("module bin genuinely not on PATH → module-bin FAILs with a reinstall fix", async () => {
|
|
288
|
+
const h = makeHarness();
|
|
289
|
+
try {
|
|
290
|
+
seedCurrentManifest(h.manifestPath);
|
|
291
|
+
seedOperatorToken(h.configDir);
|
|
292
|
+
const { code, checks } = await runDoctor(
|
|
293
|
+
h,
|
|
294
|
+
healthyDeps({ which: () => null, findNonExecutable: () => null }),
|
|
295
|
+
);
|
|
296
|
+
const bin = byName(checks, "module-bin:vault");
|
|
297
|
+
expect(bin?.status).toBe("fail");
|
|
298
|
+
expect(bin?.fix).toContain("parachute install vault");
|
|
299
|
+
expect(code).toBe(1);
|
|
300
|
+
expectNoUnexpectedNonPass(checks, ["module-bin:vault"]);
|
|
301
|
+
} finally {
|
|
302
|
+
h.cleanup();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("malformed services.json → services-manifest FAILs, exit 1", async () => {
|
|
307
|
+
const h = makeHarness();
|
|
308
|
+
try {
|
|
309
|
+
// A row missing the required `port` field → strict readManifest throws.
|
|
310
|
+
writeFileSync(
|
|
311
|
+
h.manifestPath,
|
|
312
|
+
JSON.stringify({
|
|
313
|
+
services: [{ name: "parachute-vault", paths: ["/v"], health: "/health", version: "1" }],
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
seedOperatorToken(h.configDir);
|
|
317
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
318
|
+
expect(byName(checks, "services-manifest")?.status).toBe("fail");
|
|
319
|
+
expect(code).toBe(1);
|
|
320
|
+
} finally {
|
|
321
|
+
h.cleanup();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("legacy detached install (hub pidfile present) → migration WARNs, exit STAYS 0", async () => {
|
|
326
|
+
const h = makeHarness();
|
|
327
|
+
try {
|
|
328
|
+
seedCurrentManifest(h.manifestPath);
|
|
329
|
+
seedOperatorToken(h.configDir);
|
|
330
|
+
// The detached-era fingerprint: a hub pidfile.
|
|
331
|
+
mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
|
|
332
|
+
writePid("hub", 12345, h.configDir);
|
|
333
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
334
|
+
expect(byName(checks, "migration-detached")?.status).toBe("warn");
|
|
335
|
+
expect(byName(checks, "migration-detached")?.fix).toContain("--to-supervised");
|
|
336
|
+
// Title must describe the DETECTED condition, not its absence — a warn
|
|
337
|
+
// titled "No legacy detached install" is the title-vs-status bug.
|
|
338
|
+
const detachedTitle = byName(checks, "migration-detached")?.title ?? "";
|
|
339
|
+
expect(detachedTitle.toLowerCase()).toContain("detached");
|
|
340
|
+
expect(detachedTitle).not.toMatch(/^no /i);
|
|
341
|
+
// A WARN is advisory — exit code stays 0.
|
|
342
|
+
expect(code).toBe(0);
|
|
343
|
+
expectNoUnexpectedNonPass(checks, ["migration-detached"]);
|
|
344
|
+
} finally {
|
|
345
|
+
h.cleanup();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("known cruft at the ecosystem root → migration WARNs, exit STAYS 0", async () => {
|
|
350
|
+
const h = makeHarness();
|
|
351
|
+
try {
|
|
352
|
+
seedCurrentManifest(h.manifestPath);
|
|
353
|
+
seedOperatorToken(h.configDir);
|
|
354
|
+
// `server.yaml` is an explicit KNOWN_CRUFT rule (legacy server config).
|
|
355
|
+
writeFileSync(join(h.configDir, "server.yaml"), "legacy: true\n");
|
|
356
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
357
|
+
expect(byName(checks, "migration-cruft")?.status).toBe("warn");
|
|
358
|
+
expect(byName(checks, "migration-cruft")?.fix).toBe("parachute migrate");
|
|
359
|
+
// Title must describe the DETECTED condition, not its absence.
|
|
360
|
+
const cruftTitle = byName(checks, "migration-cruft")?.title ?? "";
|
|
361
|
+
expect(cruftTitle.toLowerCase()).toContain("cruft");
|
|
362
|
+
expect(cruftTitle).not.toMatch(/^no /i);
|
|
363
|
+
expect(code).toBe(0);
|
|
364
|
+
expectNoUnexpectedNonPass(checks, ["migration-cruft"]);
|
|
365
|
+
} finally {
|
|
366
|
+
h.cleanup();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("an UNKNOWN file at the root does NOT trip migration (allowlist, not blocklist)", async () => {
|
|
371
|
+
const h = makeHarness();
|
|
372
|
+
try {
|
|
373
|
+
seedCurrentManifest(h.manifestPath);
|
|
374
|
+
seedOperatorToken(h.configDir);
|
|
375
|
+
// A file doctor has never heard of — the exact thing #717 forbids flagging.
|
|
376
|
+
writeFileSync(join(h.configDir, "my-own-thing.json"), "{}\n");
|
|
377
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
378
|
+
// Migration stays a single PASS — no false WARN on the unfamiliar file.
|
|
379
|
+
expect(byName(checks, "migration")?.status).toBe("pass");
|
|
380
|
+
expect(code).toBe(0);
|
|
381
|
+
} finally {
|
|
382
|
+
h.cleanup();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("doctor — Tier 2 exposure (guarded hard)", () => {
|
|
388
|
+
test("not exposed → 'loopback only' is benign info (PASS), not a warning", async () => {
|
|
389
|
+
const h = makeHarness();
|
|
390
|
+
try {
|
|
391
|
+
seedCurrentManifest(h.manifestPath);
|
|
392
|
+
seedOperatorToken(h.configDir);
|
|
393
|
+
// No expose-state.json — the loopback-only box.
|
|
394
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
395
|
+
const ex = byName(checks, "exposure");
|
|
396
|
+
expect(ex?.status).toBe("pass");
|
|
397
|
+
expect(ex?.detail).toContain("loopback only");
|
|
398
|
+
expect(code).toBe(0);
|
|
399
|
+
} finally {
|
|
400
|
+
h.cleanup();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("exposed but the public origin doesn't answer → WARN (never FAIL), exit STAYS 0", async () => {
|
|
405
|
+
const h = makeHarness();
|
|
406
|
+
try {
|
|
407
|
+
seedCurrentManifest(h.manifestPath);
|
|
408
|
+
seedOperatorToken(h.configDir, "https://vault.example.com");
|
|
409
|
+
writeFileSync(
|
|
410
|
+
join(h.configDir, "expose-state.json"),
|
|
411
|
+
JSON.stringify({
|
|
412
|
+
version: 1,
|
|
413
|
+
layer: "public",
|
|
414
|
+
mode: "path",
|
|
415
|
+
canonicalFqdn: "vault.example.com",
|
|
416
|
+
port: 1939,
|
|
417
|
+
funnel: true,
|
|
418
|
+
entries: [],
|
|
419
|
+
hubOrigin: "https://vault.example.com",
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
const { code, checks } = await runDoctor(
|
|
423
|
+
h,
|
|
424
|
+
healthyDeps({ probePublicHealth: async () => false }),
|
|
425
|
+
);
|
|
426
|
+
expect(byName(checks, "exposure")?.status).toBe("warn");
|
|
427
|
+
expect(code).toBe(0);
|
|
428
|
+
expectNoUnexpectedNonPass(checks, ["exposure"]);
|
|
429
|
+
} finally {
|
|
430
|
+
h.cleanup();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("doctor — version drift (cosmetic; never FAIL)", () => {
|
|
436
|
+
test("a 0.0.0-linked stopgap version → WARN labeled cosmetic, exit STAYS 0", async () => {
|
|
437
|
+
const h = makeHarness();
|
|
438
|
+
try {
|
|
439
|
+
writeFileSync(
|
|
440
|
+
h.manifestPath,
|
|
441
|
+
JSON.stringify({
|
|
442
|
+
services: [
|
|
443
|
+
{
|
|
444
|
+
name: "parachute-vault",
|
|
445
|
+
port: 1940,
|
|
446
|
+
paths: ["/vault/default"],
|
|
447
|
+
health: "/health",
|
|
448
|
+
version: "0.0.0-linked",
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
seedOperatorToken(h.configDir);
|
|
454
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
455
|
+
const vd = byName(checks, "version-drift");
|
|
456
|
+
expect(vd?.status).toBe("warn");
|
|
457
|
+
expect(vd?.detail).toContain("cosmetic");
|
|
458
|
+
expect(code).toBe(0);
|
|
459
|
+
expectNoUnexpectedNonPass(checks, ["version-drift"]);
|
|
460
|
+
} finally {
|
|
461
|
+
h.cleanup();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
});
|
package/src/api-account-2fa.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { Database } from "bun:sqlite";
|
|
|
40
40
|
import { hash as argonHash } from "@node-rs/argon2";
|
|
41
41
|
import QRCode from "qrcode";
|
|
42
42
|
import { verifyCsrfToken } from "./csrf.ts";
|
|
43
|
-
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
43
|
+
import { changePasswordRateLimiter, totpEnrollConfirmRateLimiter } from "./rate-limit.ts";
|
|
44
44
|
import { findActiveSession } from "./sessions.ts";
|
|
45
45
|
import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
|
|
46
46
|
import {
|
|
@@ -220,6 +220,28 @@ async function handleConfirm(
|
|
|
220
220
|
if (isTotpEnrolled(deps.db, user.id)) {
|
|
221
221
|
return jsonError(409, "already_enrolled", "Two-factor is already enabled.");
|
|
222
222
|
}
|
|
223
|
+
// Bound a hijacked session grinding the in-flight (client-held) secret. Keyed
|
|
224
|
+
// by user.id, lenient (10/15min) so honest enroll mistypes aren't punished —
|
|
225
|
+
// defense-in-depth (#712). Fires AFTER the format + already-enrolled guards so
|
|
226
|
+
// junk/no-op POSTs don't burn the legit enroller's budget, and BEFORE the
|
|
227
|
+
// code verify so the grind window is actually bounded. A SUCCESSFUL confirm
|
|
228
|
+
// also consumes one slot (checkAndRecord counts every attempt) — harmless,
|
|
229
|
+
// since an enrolled account 409s on any further confirm anyway.
|
|
230
|
+
const confirmLimited = totpEnrollConfirmRateLimiter.checkAndRecord(
|
|
231
|
+
user.id,
|
|
232
|
+
deps.now ? deps.now() : new Date(),
|
|
233
|
+
);
|
|
234
|
+
if (!confirmLimited.allowed) {
|
|
235
|
+
const retryAfter = confirmLimited.retryAfterSeconds ?? 1;
|
|
236
|
+
return json(
|
|
237
|
+
429,
|
|
238
|
+
{
|
|
239
|
+
error: "too_many_attempts",
|
|
240
|
+
error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
|
|
241
|
+
},
|
|
242
|
+
{ "retry-after": String(retryAfter) },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
223
245
|
if (!verifyTotpCode(secret, code)) {
|
|
224
246
|
return jsonError(
|
|
225
247
|
400,
|
package/src/cli.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { setup } from "./commands/setup.ts";
|
|
|
22
22
|
import type { upgrade } from "./commands/upgrade.ts";
|
|
23
23
|
import { ExposeStateError } from "./expose-state.ts";
|
|
24
24
|
import {
|
|
25
|
+
doctorHelp,
|
|
25
26
|
exposeHelp,
|
|
26
27
|
initHelp,
|
|
27
28
|
installHelp,
|
|
@@ -574,6 +575,23 @@ async function main(argv: string[]): Promise<number> {
|
|
|
574
575
|
return await mod.status({ supervisor: {} });
|
|
575
576
|
}
|
|
576
577
|
|
|
578
|
+
case "doctor": {
|
|
579
|
+
if (isHelpFlag(rest[0])) {
|
|
580
|
+
console.log(doctorHelp());
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
const json = rest.includes("--json");
|
|
584
|
+
const unknown = rest.find((a) => a !== "--json");
|
|
585
|
+
if (unknown !== undefined) {
|
|
586
|
+
console.error(`parachute doctor: unknown argument "${unknown}"`);
|
|
587
|
+
console.error("usage: parachute doctor [--json]");
|
|
588
|
+
return 1;
|
|
589
|
+
}
|
|
590
|
+
const mod = await loadCommand("doctor", () => import("./commands/doctor.ts"));
|
|
591
|
+
if (!mod) return 1;
|
|
592
|
+
return await mod.doctor({ json });
|
|
593
|
+
}
|
|
594
|
+
|
|
577
595
|
case "expose": {
|
|
578
596
|
const hubExtract = extractHubOrigin(rest);
|
|
579
597
|
if (hubExtract.error) {
|