@openparachute/hub 0.6.5-rc.8 → 0.7.1
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__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hub#649 — per-client-IP + total WebSocket connection caps at the
|
|
3
|
+
* upgrade gate (`maybeUpgradeWebSocket` via the fetch fn), with release
|
|
4
|
+
* riding the ws-bridge close path.
|
|
5
|
+
*
|
|
6
|
+
* Three tiers:
|
|
7
|
+
* - UNIT (fetch fn + spy upgrade): per-IP cap refusal (the HEADLINE test —
|
|
8
|
+
* it FAILED pre-fix with all 40 upgrades accepted), global cap refusal,
|
|
9
|
+
* generic 429 body, forwarded-header trust (spoofed XFF on an untrusted
|
|
10
|
+
* layer can't escape its bucket), release on a failed `server.upgrade`,
|
|
11
|
+
* env-configurable caps.
|
|
12
|
+
* - UNIT (pure): `wsCapBucketKey` derivation + `WsConnectionTracker`
|
|
13
|
+
* accounting (double-release latch, key eviction at zero).
|
|
14
|
+
* - INTEGRATION (real Bun.serve hub + real WS echo upstream): a churn of
|
|
15
|
+
* open/close cycles returns the counters to zero — including the
|
|
16
|
+
* unreachable-upstream teardown path, where the bridge (not the client)
|
|
17
|
+
* initiates the close.
|
|
18
|
+
*
|
|
19
|
+
* Every test injects its OWN tracker via `HubFetchDeps.wsConnectionTracker`
|
|
20
|
+
* so nothing here consumes (or depends on) the process-wide default
|
|
21
|
+
* tracker's counters.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, expect, test } from "bun:test";
|
|
24
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { WS_CAP_SHARED_BUCKET, hubFetch, wsCapBucketKey } from "../hub-server.ts";
|
|
28
|
+
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
29
|
+
import { type WsBridgeData, createWsBridgeHandlers } from "../ws-bridge.ts";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_WS_MAX_PER_IP,
|
|
32
|
+
DEFAULT_WS_MAX_TOTAL,
|
|
33
|
+
WsConnectionTracker,
|
|
34
|
+
wsCapsFromEnv,
|
|
35
|
+
} from "../ws-connection-caps.ts";
|
|
36
|
+
|
|
37
|
+
interface Harness {
|
|
38
|
+
dir: string;
|
|
39
|
+
manifestPath: string;
|
|
40
|
+
cleanup: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeHarness(): Harness {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-ws-caps-"));
|
|
45
|
+
return {
|
|
46
|
+
dir,
|
|
47
|
+
manifestPath: join(dir, "services.json"),
|
|
48
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function wsEntry(port: number, extra: Partial<ServiceEntry> = {}): ServiceEntry {
|
|
53
|
+
return {
|
|
54
|
+
name: "wsmod",
|
|
55
|
+
port,
|
|
56
|
+
paths: ["/wsmod"],
|
|
57
|
+
health: "/wsmod/health",
|
|
58
|
+
version: "0.1.0",
|
|
59
|
+
websocket: true,
|
|
60
|
+
...extra,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function upgradeReq(path: string, headers: Record<string, string> = {}): Request {
|
|
65
|
+
return new Request(`http://127.0.0.1${path}`, {
|
|
66
|
+
headers: { upgrade: "websocket", connection: "Upgrade", ...headers },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface UpgradeSpy {
|
|
71
|
+
requestIP: () => { address: string };
|
|
72
|
+
upgrade: (req: Request, options: { data: WsBridgeData }) => boolean;
|
|
73
|
+
calls: { data: WsBridgeData }[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function upgradeSpy(address: string, accept = true): UpgradeSpy {
|
|
77
|
+
const calls: { data: WsBridgeData }[] = [];
|
|
78
|
+
return {
|
|
79
|
+
requestIP: () => ({ address }),
|
|
80
|
+
upgrade: (_req, options) => {
|
|
81
|
+
calls.push(options);
|
|
82
|
+
return accept;
|
|
83
|
+
},
|
|
84
|
+
calls,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
while (!cond()) {
|
|
91
|
+
if (Date.now() > deadline) throw new Error("waitFor timed out");
|
|
92
|
+
await Bun.sleep(10);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ===========================================================================
|
|
97
|
+
// Unit — cap refusal at the upgrade gate
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
|
|
100
|
+
describe("WS connection caps — upgrade-gate refusal (hub#649)", () => {
|
|
101
|
+
test("HEADLINE: a same-IP upgrade flood is refused past the default per-IP cap (429), not accepted unboundedly", async () => {
|
|
102
|
+
// Pre-fix evidence: run against the unmodified tree (with the default
|
|
103
|
+
// tracker), this expressed-behavior test failed with accepted=40 — the
|
|
104
|
+
// hub had NO connection bound at all.
|
|
105
|
+
const h = makeHarness();
|
|
106
|
+
try {
|
|
107
|
+
writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
|
|
108
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
109
|
+
const tracker = new WsConnectionTracker(); // built-in defaults: 32 / 512
|
|
110
|
+
const fetcher = hubFetch(h.dir, {
|
|
111
|
+
manifestPath: h.manifestPath,
|
|
112
|
+
wsConnectionTracker: tracker,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
let accepted = 0;
|
|
116
|
+
const refusals: Response[] = [];
|
|
117
|
+
for (let i = 0; i < 40; i++) {
|
|
118
|
+
// Loopback peer = trusted forwarder (tailscale serve / funnel
|
|
119
|
+
// shape) — all 40 attempts attribute to ONE public client IP.
|
|
120
|
+
const res = await fetcher(
|
|
121
|
+
upgradeReq("/wsmod/ws", { "x-forwarded-for": "198.51.100.7" }),
|
|
122
|
+
spy,
|
|
123
|
+
);
|
|
124
|
+
if (res === undefined) accepted++;
|
|
125
|
+
else refusals.push(res);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
expect(accepted).toBe(DEFAULT_WS_MAX_PER_IP); // 32
|
|
129
|
+
expect(refusals.length).toBe(8);
|
|
130
|
+
expect(spy.calls.length).toBe(DEFAULT_WS_MAX_PER_IP); // upgrade never attempted over-cap
|
|
131
|
+
for (const res of refusals) expect(res.status).toBe(429);
|
|
132
|
+
|
|
133
|
+
// The refusal is generic: no counts, no cap identity, no numbers.
|
|
134
|
+
const body = (await refusals[0]!.json()) as { error: string; error_description: string };
|
|
135
|
+
expect(body.error).toBe("too_many_connections");
|
|
136
|
+
expect(JSON.stringify(body)).not.toMatch(/\d/);
|
|
137
|
+
} finally {
|
|
138
|
+
h.cleanup();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("global cap: distinct client IPs are refused once the total is saturated", async () => {
|
|
143
|
+
const h = makeHarness();
|
|
144
|
+
try {
|
|
145
|
+
writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
|
|
146
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
147
|
+
const tracker = new WsConnectionTracker(10, 3); // per-IP generous, total=3
|
|
148
|
+
const fetcher = hubFetch(h.dir, {
|
|
149
|
+
manifestPath: h.manifestPath,
|
|
150
|
+
wsConnectionTracker: tracker,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < 3; i++) {
|
|
154
|
+
const res = await fetcher(
|
|
155
|
+
upgradeReq("/wsmod/ws", { "x-forwarded-for": `198.51.100.${i}` }),
|
|
156
|
+
spy,
|
|
157
|
+
);
|
|
158
|
+
expect(res).toBeUndefined(); // upgraded
|
|
159
|
+
}
|
|
160
|
+
const res = await fetcher(
|
|
161
|
+
upgradeReq("/wsmod/ws", { "x-forwarded-for": "198.51.100.99" }),
|
|
162
|
+
spy,
|
|
163
|
+
);
|
|
164
|
+
expect(res?.status).toBe(429);
|
|
165
|
+
expect(spy.calls.length).toBe(3);
|
|
166
|
+
expect(tracker.totalCount).toBe(3);
|
|
167
|
+
} finally {
|
|
168
|
+
h.cleanup();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("forwarded-header trust: spoofed XFF from a direct (non-loopback) peer does NOT escape the peer's bucket", async () => {
|
|
173
|
+
const h = makeHarness();
|
|
174
|
+
try {
|
|
175
|
+
writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
|
|
176
|
+
// Direct public peer — its XFF is attacker-controlled and must be ignored.
|
|
177
|
+
const spy = upgradeSpy("203.0.113.50");
|
|
178
|
+
const tracker = new WsConnectionTracker(2, 100);
|
|
179
|
+
const fetcher = hubFetch(h.dir, {
|
|
180
|
+
manifestPath: h.manifestPath,
|
|
181
|
+
wsConnectionTracker: tracker,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const r1 = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": "1.1.1.1" }), spy);
|
|
185
|
+
const r2 = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": "2.2.2.2" }), spy);
|
|
186
|
+
const r3 = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": "3.3.3.3" }), spy);
|
|
187
|
+
expect(r1).toBeUndefined();
|
|
188
|
+
expect(r2).toBeUndefined();
|
|
189
|
+
expect(r3?.status).toBe(429); // rotating XFF minted no fresh buckets
|
|
190
|
+
expect(tracker.countFor("203.0.113.50")).toBe(2);
|
|
191
|
+
} finally {
|
|
192
|
+
h.cleanup();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("forwarded-header trust: XFF from a loopback (trusted forwarder) peer DOES key distinct buckets", async () => {
|
|
197
|
+
const h = makeHarness();
|
|
198
|
+
try {
|
|
199
|
+
writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
|
|
200
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
201
|
+
const tracker = new WsConnectionTracker(2, 100);
|
|
202
|
+
const fetcher = hubFetch(h.dir, {
|
|
203
|
+
manifestPath: h.manifestPath,
|
|
204
|
+
wsConnectionTracker: tracker,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Two different real clients behind the on-box forwarder: each gets
|
|
208
|
+
// its own per-IP allotment.
|
|
209
|
+
for (const ip of ["198.51.100.1", "198.51.100.1", "198.51.100.2", "198.51.100.2"]) {
|
|
210
|
+
const res = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": ip }), spy);
|
|
211
|
+
expect(res).toBeUndefined();
|
|
212
|
+
}
|
|
213
|
+
expect(tracker.countFor("198.51.100.1")).toBe(2);
|
|
214
|
+
expect(tracker.countFor("198.51.100.2")).toBe(2);
|
|
215
|
+
} finally {
|
|
216
|
+
h.cleanup();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a failed server.upgrade releases the slot (no leak without a socket)", async () => {
|
|
221
|
+
const h = makeHarness();
|
|
222
|
+
try {
|
|
223
|
+
writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
|
|
224
|
+
const spy = upgradeSpy("127.0.0.1", false); // handshake malformed → upgrade() = false
|
|
225
|
+
const tracker = new WsConnectionTracker(1, 1);
|
|
226
|
+
const fetcher = hubFetch(h.dir, {
|
|
227
|
+
manifestPath: h.manifestPath,
|
|
228
|
+
wsConnectionTracker: tracker,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const r1 = await fetcher(upgradeReq("/wsmod/ws"), spy);
|
|
232
|
+
expect(r1?.status).toBe(400);
|
|
233
|
+
expect(tracker.totalCount).toBe(0); // released inline
|
|
234
|
+
// The (1,1)-capped tracker still admits the next attempt.
|
|
235
|
+
const r2 = await fetcher(upgradeReq("/wsmod/ws"), spy);
|
|
236
|
+
expect(r2?.status).toBe(400);
|
|
237
|
+
expect(tracker.totalCount).toBe(0);
|
|
238
|
+
} finally {
|
|
239
|
+
h.cleanup();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("caps are configurable: an env-built tracker enforces the overridden values", async () => {
|
|
244
|
+
const h = makeHarness();
|
|
245
|
+
try {
|
|
246
|
+
writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
|
|
247
|
+
const caps = wsCapsFromEnv({
|
|
248
|
+
PARACHUTE_WS_MAX_PER_IP: "2",
|
|
249
|
+
PARACHUTE_WS_MAX_TOTAL: "50",
|
|
250
|
+
} as NodeJS.ProcessEnv);
|
|
251
|
+
const tracker = new WsConnectionTracker(caps.maxPerIp, caps.maxTotal);
|
|
252
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
253
|
+
const fetcher = hubFetch(h.dir, {
|
|
254
|
+
manifestPath: h.manifestPath,
|
|
255
|
+
wsConnectionTracker: tracker,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const headers = { "x-forwarded-for": "198.51.100.7" };
|
|
259
|
+
expect(await fetcher(upgradeReq("/wsmod/ws", headers), spy)).toBeUndefined();
|
|
260
|
+
expect(await fetcher(upgradeReq("/wsmod/ws", headers), spy)).toBeUndefined();
|
|
261
|
+
const res = await fetcher(upgradeReq("/wsmod/ws", headers), spy);
|
|
262
|
+
expect(res?.status).toBe(429);
|
|
263
|
+
} finally {
|
|
264
|
+
h.cleanup();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ===========================================================================
|
|
270
|
+
// Unit — env parsing, bucket-key derivation, tracker accounting
|
|
271
|
+
// ===========================================================================
|
|
272
|
+
|
|
273
|
+
describe("wsCapsFromEnv", () => {
|
|
274
|
+
test("absent env → defaults", () => {
|
|
275
|
+
expect(wsCapsFromEnv({} as NodeJS.ProcessEnv)).toEqual({
|
|
276
|
+
maxPerIp: DEFAULT_WS_MAX_PER_IP,
|
|
277
|
+
maxTotal: DEFAULT_WS_MAX_TOTAL,
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("valid positive integers are honored", () => {
|
|
282
|
+
expect(
|
|
283
|
+
wsCapsFromEnv({
|
|
284
|
+
PARACHUTE_WS_MAX_PER_IP: "8",
|
|
285
|
+
PARACHUTE_WS_MAX_TOTAL: "1024",
|
|
286
|
+
} as NodeJS.ProcessEnv),
|
|
287
|
+
).toEqual({ maxPerIp: 8, maxTotal: 1024 });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("malformed / non-positive values fall back to the defaults, never to unlimited", () => {
|
|
291
|
+
for (const bad of ["", "abc", "0", "-3", "12.5", "1e3", "32x"]) {
|
|
292
|
+
const caps = wsCapsFromEnv({
|
|
293
|
+
PARACHUTE_WS_MAX_PER_IP: bad,
|
|
294
|
+
PARACHUTE_WS_MAX_TOTAL: bad,
|
|
295
|
+
} as NodeJS.ProcessEnv);
|
|
296
|
+
expect(caps).toEqual({ maxPerIp: DEFAULT_WS_MAX_PER_IP, maxTotal: DEFAULT_WS_MAX_TOTAL });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("wsCapBucketKey — trust-model keying", () => {
|
|
302
|
+
const req = (headers: Record<string, string> = {}) =>
|
|
303
|
+
new Request("http://127.0.0.1/wsmod/ws", { headers });
|
|
304
|
+
|
|
305
|
+
test("loopback peer: CF-Connecting-IP wins, then XFF first hop, then the peer itself", () => {
|
|
306
|
+
expect(
|
|
307
|
+
wsCapBucketKey(
|
|
308
|
+
req({ "cf-connecting-ip": "198.51.100.9", "x-forwarded-for": "10.0.0.1" }),
|
|
309
|
+
"127.0.0.1",
|
|
310
|
+
),
|
|
311
|
+
).toBe("198.51.100.9");
|
|
312
|
+
expect(wsCapBucketKey(req({ "x-forwarded-for": "198.51.100.7, 10.0.0.1" }), "127.0.0.1")).toBe(
|
|
313
|
+
"198.51.100.7",
|
|
314
|
+
);
|
|
315
|
+
expect(wsCapBucketKey(req(), "::1")).toBe("::1");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("non-loopback peer: keyed by peer address, injected headers ignored", () => {
|
|
319
|
+
expect(
|
|
320
|
+
wsCapBucketKey(
|
|
321
|
+
req({ "cf-connecting-ip": "1.2.3.4", "x-forwarded-for": "5.6.7.8" }),
|
|
322
|
+
"203.0.113.50",
|
|
323
|
+
),
|
|
324
|
+
).toBe("203.0.113.50");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("underivable (no peer) → the shared fail-closed bucket, headers still ignored", () => {
|
|
328
|
+
expect(wsCapBucketKey(req({ "x-forwarded-for": "5.6.7.8" }), null)).toBe(WS_CAP_SHARED_BUCKET);
|
|
329
|
+
expect(wsCapBucketKey(req(), " ")).toBe(WS_CAP_SHARED_BUCKET);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("WsConnectionTracker accounting", () => {
|
|
334
|
+
test("release is latched: double-release decrements once, never below zero", () => {
|
|
335
|
+
const tracker = new WsConnectionTracker(2, 2);
|
|
336
|
+
const a = tracker.tryAcquire("k");
|
|
337
|
+
const b = tracker.tryAcquire("k");
|
|
338
|
+
if (!a.ok || !b.ok) throw new Error("expected both acquires to succeed");
|
|
339
|
+
a.release();
|
|
340
|
+
a.release(); // latched no-op
|
|
341
|
+
expect(tracker.totalCount).toBe(1);
|
|
342
|
+
expect(tracker.countFor("k")).toBe(1);
|
|
343
|
+
b.release();
|
|
344
|
+
b.release();
|
|
345
|
+
expect(tracker.totalCount).toBe(0);
|
|
346
|
+
expect(tracker.countFor("k")).toBe(0);
|
|
347
|
+
expect(tracker.keyCount).toBe(0); // bucket evicted at zero — no map growth
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("refusals don't mutate counts", () => {
|
|
351
|
+
const tracker = new WsConnectionTracker(1, 10);
|
|
352
|
+
const a = tracker.tryAcquire("k");
|
|
353
|
+
if (!a.ok) throw new Error("expected acquire to succeed");
|
|
354
|
+
const refused = tracker.tryAcquire("k");
|
|
355
|
+
expect(refused.ok).toBe(false);
|
|
356
|
+
if (!refused.ok) expect(refused.reason).toBe("per_ip_cap");
|
|
357
|
+
expect(tracker.totalCount).toBe(1);
|
|
358
|
+
a.release();
|
|
359
|
+
expect(tracker.totalCount).toBe(0);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ===========================================================================
|
|
364
|
+
// Integration — real sockets: churn returns the counters to zero
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
|
|
367
|
+
function startWsEchoUpstream(): { port: number; stop: () => void } {
|
|
368
|
+
const server = Bun.serve({
|
|
369
|
+
port: 0,
|
|
370
|
+
hostname: "127.0.0.1",
|
|
371
|
+
fetch(req, srv) {
|
|
372
|
+
if (srv.upgrade(req)) return undefined as unknown as Response;
|
|
373
|
+
return new Response("expected websocket", { status: 400 });
|
|
374
|
+
},
|
|
375
|
+
websocket: {
|
|
376
|
+
message(ws, msg) {
|
|
377
|
+
ws.send(typeof msg === "string" ? `echo:${msg}` : msg);
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function startHub(h: Harness, tracker: WsConnectionTracker): { port: number; stop: () => void } {
|
|
385
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, wsConnectionTracker: tracker });
|
|
386
|
+
const server = Bun.serve<WsBridgeData>({
|
|
387
|
+
port: 0,
|
|
388
|
+
hostname: "127.0.0.1",
|
|
389
|
+
fetch: (req, srv) => fetcher(req, srv),
|
|
390
|
+
websocket: createWsBridgeHandlers(),
|
|
391
|
+
});
|
|
392
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
describe("WS connection caps — integration (real sockets, churn to zero)", () => {
|
|
396
|
+
test("open/close churn returns per-IP + total counters to zero every cycle", async () => {
|
|
397
|
+
const h = makeHarness();
|
|
398
|
+
const upstream = startWsEchoUpstream();
|
|
399
|
+
let hub: { port: number; stop: () => void } | undefined;
|
|
400
|
+
try {
|
|
401
|
+
writeManifest({ services: [wsEntry(upstream.port)] }, h.manifestPath);
|
|
402
|
+
const tracker = new WsConnectionTracker(); // defaults — churn never nears them
|
|
403
|
+
hub = startHub(h, tracker);
|
|
404
|
+
|
|
405
|
+
for (let cycle = 0; cycle < 3; cycle++) {
|
|
406
|
+
const clients: WebSocket[] = [];
|
|
407
|
+
for (let i = 0; i < 3; i++) {
|
|
408
|
+
const ws = new WebSocket(`ws://127.0.0.1:${hub.port}/wsmod/ws`);
|
|
409
|
+
const opened = new Promise<void>((resolve, reject) => {
|
|
410
|
+
ws.addEventListener("open", () => resolve());
|
|
411
|
+
ws.addEventListener("error", () => reject(new Error("client failed to connect")));
|
|
412
|
+
});
|
|
413
|
+
clients.push(ws);
|
|
414
|
+
await opened;
|
|
415
|
+
}
|
|
416
|
+
// Slots held while the sockets live (all test clients are direct
|
|
417
|
+
// loopback peers → one shared bucket).
|
|
418
|
+
expect(tracker.totalCount).toBe(3);
|
|
419
|
+
expect(tracker.countFor("127.0.0.1")).toBe(3);
|
|
420
|
+
|
|
421
|
+
for (const ws of clients) ws.close(1000, "done");
|
|
422
|
+
await waitFor(() => tracker.totalCount === 0);
|
|
423
|
+
expect(tracker.keyCount).toBe(0); // bucket map fully drained — no leak
|
|
424
|
+
}
|
|
425
|
+
} finally {
|
|
426
|
+
hub?.stop();
|
|
427
|
+
upstream.stop();
|
|
428
|
+
h.cleanup();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("bridge-initiated teardown (unreachable upstream) also releases the slot", async () => {
|
|
433
|
+
const h = makeHarness();
|
|
434
|
+
// Bind a port + release it so the upstream connect gets ECONNREFUSED.
|
|
435
|
+
const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") });
|
|
436
|
+
const deadPort = probe.port as number;
|
|
437
|
+
probe.stop(true);
|
|
438
|
+
let hub: { port: number; stop: () => void } | undefined;
|
|
439
|
+
try {
|
|
440
|
+
writeManifest({ services: [wsEntry(deadPort)] }, h.manifestPath);
|
|
441
|
+
const tracker = new WsConnectionTracker();
|
|
442
|
+
hub = startHub(h, tracker);
|
|
443
|
+
|
|
444
|
+
const ws = new WebSocket(`ws://127.0.0.1:${hub.port}/wsmod/ws`);
|
|
445
|
+
const closed = new Promise<number>((resolve) => {
|
|
446
|
+
ws.addEventListener("close", (ev) => resolve(ev.code));
|
|
447
|
+
});
|
|
448
|
+
expect(await closed).toBe(1011);
|
|
449
|
+
await waitFor(() => tracker.totalCount === 0);
|
|
450
|
+
expect(tracker.keyCount).toBe(0);
|
|
451
|
+
} finally {
|
|
452
|
+
hub?.stop();
|
|
453
|
+
h.cleanup();
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
});
|
package/src/account-setup.ts
CHANGED
|
@@ -13,9 +13,20 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Redeem ORDERING (the re-usability guarantee, mirroring the wizard):
|
|
15
15
|
* 1. lookup + validate the invite (not-found/expired/used/revoked)
|
|
16
|
-
* 2. validate username/password (+ vault name)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 2. validate username/password (+ vault name). A pre-named invite
|
|
17
|
+
* (invite.username set) ENFORCES the username — the form field is
|
|
18
|
+
* ignored and the invite's name is used; if it's been taken since
|
|
19
|
+
* mint, the invitee is told to ask the operator for a new link
|
|
20
|
+
* (the invite stays unconsumed so the operator can revoke + re-mint).
|
|
21
|
+
* 3. resolve the vault:
|
|
22
|
+
* - provision_vault=1 → provision (must FRESHLY CREATE — reject a
|
|
23
|
+
* pre-existing name; silently attaching the new user to someone
|
|
24
|
+
* else's vault would be a cross-tenant breach)
|
|
25
|
+
* - provision_vault=0 + vault_name → SHARED-VAULT invite: assign the
|
|
26
|
+
* admin's EXISTING vault at the invite's role (no provisioning).
|
|
27
|
+
* The vault must still exist in services.json (the vault-delete
|
|
28
|
+
* cascade revokes pending invites, so this re-check is defense in
|
|
29
|
+
* depth against manual manifest edits).
|
|
19
30
|
* 4. createUser (the commit point)
|
|
20
31
|
* 5. consumeInvite — stamp used_at + redeemed_user_id ONLY AFTER (4) commits
|
|
21
32
|
* 6. createSession + cookie + 302
|
|
@@ -25,15 +36,21 @@
|
|
|
25
36
|
* invite re-usable — the invitee can simply retry. `consumeInvite`'s
|
|
26
37
|
* `used_at IS NULL` guard makes the stamp itself single-use / race-free.
|
|
27
38
|
*
|
|
28
|
-
* What an invite pre-authorizes: EXACTLY one account + the one named/created
|
|
29
|
-
* vault at the baked-in role — NEVER host:admin, NEVER another vault.
|
|
30
|
-
* user gets `assignedVaults:[that vault]` with the invite's role;
|
|
31
|
-
* grants admin posture (the first-admin-by-earliest-row heuristic is
|
|
32
|
-
* untouched — an invited user is never the earliest row).
|
|
39
|
+
* What an invite pre-authorizes: EXACTLY one account + the one named/created/
|
|
40
|
+
* shared vault at the baked-in role — NEVER host:admin, NEVER another vault.
|
|
41
|
+
* The new user gets `assignedVaults:[that vault]` with the invite's role;
|
|
42
|
+
* nothing grants admin posture (the first-admin-by-earliest-row heuristic is
|
|
43
|
+
* untouched — an invited user is never the earliest row). The shared-vault
|
|
44
|
+
* shape is admin-authorized by construction: only a host:admin bearer can
|
|
45
|
+
* mint an invite, and that same authority can already assign any user to any
|
|
46
|
+
* vault via POST /api/users. The role ('read' or 'write') is enforced at
|
|
47
|
+
* every mint chokepoint via `vaultVerbsForRole` and at the vault by
|
|
48
|
+
* scope-guard.
|
|
33
49
|
*/
|
|
34
50
|
import type { Database } from "bun:sqlite";
|
|
35
51
|
import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
|
|
36
52
|
import { type RunResult, provisionVault } from "./admin-vaults.ts";
|
|
53
|
+
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
37
54
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
38
55
|
import {
|
|
39
56
|
InviteExpiredError,
|
|
@@ -55,6 +72,7 @@ import {
|
|
|
55
72
|
validateUsername,
|
|
56
73
|
} from "./users.ts";
|
|
57
74
|
import { validateVaultName } from "./vault-name.ts";
|
|
75
|
+
import { listVaultNamesFromPath } from "./vault-names.ts";
|
|
58
76
|
|
|
59
77
|
export interface AccountSetupDeps {
|
|
60
78
|
db: Database;
|
|
@@ -145,6 +163,8 @@ export function handleAccountSetupGet(
|
|
|
145
163
|
token: rawToken,
|
|
146
164
|
csrfToken: csrf.token,
|
|
147
165
|
pinnedVaultName: invite.vaultName,
|
|
166
|
+
pinnedUsername: invite.username,
|
|
167
|
+
role: invite.role,
|
|
148
168
|
provisionVault: invite.provisionVault,
|
|
149
169
|
}),
|
|
150
170
|
200,
|
|
@@ -196,7 +216,11 @@ export async function handleAccountSetupPost(
|
|
|
196
216
|
);
|
|
197
217
|
}
|
|
198
218
|
|
|
199
|
-
|
|
219
|
+
// Pre-named invite → the invite's username is ENFORCED (the form renders it
|
|
220
|
+
// read-only and any submitted value is ignored — server is the source of
|
|
221
|
+
// truth, same posture as the pinned vault name).
|
|
222
|
+
const username =
|
|
223
|
+
invite.username !== null ? invite.username : String(form.get("username") ?? "").trim();
|
|
200
224
|
const password = String(form.get("password") ?? "");
|
|
201
225
|
const confirm = String(form.get("password_confirm") ?? "");
|
|
202
226
|
|
|
@@ -209,6 +233,8 @@ export async function handleAccountSetupPost(
|
|
|
209
233
|
token: rawToken,
|
|
210
234
|
csrfToken: csrf.token,
|
|
211
235
|
pinnedVaultName: invite.vaultName,
|
|
236
|
+
pinnedUsername: invite.username,
|
|
237
|
+
role: invite.role,
|
|
212
238
|
provisionVault: invite.provisionVault,
|
|
213
239
|
username,
|
|
214
240
|
...(vaultNameEcho !== undefined ? { vaultName: vaultNameEcho } : {}),
|
|
@@ -218,12 +244,16 @@ export async function handleAccountSetupPost(
|
|
|
218
244
|
setCookie,
|
|
219
245
|
);
|
|
220
246
|
|
|
221
|
-
// (2) Validate credentials with the SAME validators as /api/users.
|
|
247
|
+
// (2) Validate credentials with the SAME validators as /api/users. Runs for
|
|
248
|
+
// the pre-named case too — defense in depth against a hand-edited invites
|
|
249
|
+
// row carrying a name the vocabulary forbids.
|
|
222
250
|
const u = validateUsername(username);
|
|
223
251
|
if (!u.valid) {
|
|
224
252
|
return rerender(
|
|
225
253
|
400,
|
|
226
|
-
|
|
254
|
+
invite.username !== null
|
|
255
|
+
? "This invite's pre-set username is not valid. Ask your hub operator for a new invite."
|
|
256
|
+
: "Username must be 2–32 lowercase letters, digits, _ or - (and not a reserved word).",
|
|
227
257
|
);
|
|
228
258
|
}
|
|
229
259
|
if (password.length > PASSWORD_MAX_LEN) {
|
|
@@ -236,9 +266,25 @@ export async function handleAccountSetupPost(
|
|
|
236
266
|
if (password !== confirm) {
|
|
237
267
|
return rerender(400, "The two passwords don't match.");
|
|
238
268
|
}
|
|
239
|
-
// Case-insensitive uniqueness — same gate as /api/users.
|
|
269
|
+
// Case-insensitive uniqueness — same gate as /api/users. For a pre-named
|
|
270
|
+
// invite the name can't be changed by the invitee, so a collision (someone
|
|
271
|
+
// took the name between mint and redeem) needs the operator to revoke +
|
|
272
|
+
// re-mint; the invite stays unconsumed either way.
|
|
273
|
+
//
|
|
274
|
+
// Username-existence oracle, accepted trade-off: a 409 here confirms to the
|
|
275
|
+
// bearer that a given name is taken. The probe is gated behind a single-use,
|
|
276
|
+
// unexpired invite token (256-bit, sha256-at-rest) — not an open endpoint —
|
|
277
|
+
// and for the pre-named case the probed name was chosen by the ADMIN at
|
|
278
|
+
// mint (where it was already validated against existing users), not
|
|
279
|
+
// attacker-supplied. The same disclosure already exists for the (more
|
|
280
|
+
// privileged) callers of /api/users and the setup wizard.
|
|
240
281
|
if (getUserByUsernameCI(deps.db, username) !== null) {
|
|
241
|
-
return rerender(
|
|
282
|
+
return rerender(
|
|
283
|
+
409,
|
|
284
|
+
invite.username !== null
|
|
285
|
+
? `The username "${username}" chosen for this invite is already taken. Ask your hub operator for a new invite.`
|
|
286
|
+
: `The username "${username}" is already taken. Pick another.`,
|
|
287
|
+
);
|
|
242
288
|
}
|
|
243
289
|
|
|
244
290
|
// Resolve the vault name: pinned by the invite, or chosen by the invitee.
|
|
@@ -248,6 +294,18 @@ export async function handleAccountSetupPost(
|
|
|
248
294
|
// at the vault CLI with a generic provision error.
|
|
249
295
|
let vaultName: string | null = null;
|
|
250
296
|
if (invite.provisionVault) {
|
|
297
|
+
// Defense in depth against a hand-edited invites row: the API refuses to
|
|
298
|
+
// MINT provision_vault=1 with role != 'write' (a fresh vault's SOLE user
|
|
299
|
+
// must hold write — a read-only owner would leave the new vault
|
|
300
|
+
// permanently un-writable). Honor the same invariant at redeem so a row
|
|
301
|
+
// that bypassed the API can't create that dead-end. The invite stays
|
|
302
|
+
// unconsumed; the operator re-mints a valid one.
|
|
303
|
+
if (invite.role !== "write") {
|
|
304
|
+
return rerender(
|
|
305
|
+
400,
|
|
306
|
+
"This invite is not valid (a new vault's owner must have write access). Ask your hub operator for a new invite.",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
251
309
|
if (invite.vaultName !== null) {
|
|
252
310
|
vaultName = invite.vaultName;
|
|
253
311
|
} else {
|
|
@@ -268,16 +326,29 @@ export async function handleAccountSetupPost(
|
|
|
268
326
|
vaultName = v.name;
|
|
269
327
|
}
|
|
270
328
|
} else if (invite.vaultName !== null) {
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
329
|
+
// SHARED-VAULT invite: assign the redeemer to the admin's EXISTING vault
|
|
330
|
+
// at the invite's baked-in role ('read' or 'write') — no provisioning.
|
|
331
|
+
// Admin-authorized by construction: only a host:admin bearer can mint an
|
|
332
|
+
// invite, and that same authority can already assign any user to any
|
|
333
|
+
// vault via POST /api/users; the invite packages that assignment as a
|
|
334
|
+
// deliverable link. The role is enforced downstream at every mint
|
|
335
|
+
// chokepoint (`vaultVerbsForRole`: read → ["read"]) and at the vault by
|
|
336
|
+
// scope-guard — a read-role redeemer can never obtain a write-capable
|
|
337
|
+
// token for this (or any other) vault.
|
|
338
|
+
//
|
|
339
|
+
// The vault must still exist: the vault-delete cascade
|
|
340
|
+
// (`revokeInvitesForVault`) revokes pending invites pinned to a deleted
|
|
341
|
+
// vault, so this re-check is defense in depth (manual services.json
|
|
342
|
+
// edits, restored DB). The invite stays unconsumed on this rejection.
|
|
343
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
344
|
+
const known = new Set(listVaultNamesFromPath(manifestPath));
|
|
345
|
+
if (!known.has(invite.vaultName)) {
|
|
346
|
+
return rerender(
|
|
347
|
+
400,
|
|
348
|
+
"The vault this invite shares no longer exists on this hub. Ask your hub operator for a new invite.",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
vaultName = invite.vaultName;
|
|
281
352
|
}
|
|
282
353
|
|
|
283
354
|
// (3) Provision the vault — must FRESHLY CREATE it (see the security
|