@openparachute/hub 0.7.0 → 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 +276 -6
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- 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 +266 -0
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- 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/admin-connections.ts +916 -14
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-vaults.ts +9 -0
- package/src/api-invites.ts +92 -12
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/connections-store.ts +32 -2
- 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 +501 -12
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +7 -1
- package/src/module-manifest.ts +107 -0
- package/src/origin-check.ts +7 -4
- package/src/services-manifest.ts +97 -0
- 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/index.html +1 -1
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
package/src/ws-bridge.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-native WebSocket upgrade bridge (H1, surface-runtime design §"Hub work
|
|
3
|
+
* items").
|
|
4
|
+
*
|
|
5
|
+
* The hub's HTTP proxy is fetch-based, and WebSocket upgrades don't traverse
|
|
6
|
+
* fetch — so until now the route table simply couldn't forward them (the
|
|
7
|
+
* hub-server docstring acknowledged it). This module is the transport half of
|
|
8
|
+
* the fix: when `hub-server.ts` accepts an upgrade for a service mount whose
|
|
9
|
+
* module DECLARES the capability (`websocket: true` on its services.json row
|
|
10
|
+
* or module.json — deny-by-default), it calls `server.upgrade(req, { data })`
|
|
11
|
+
* with a {@link WsBridgeData} payload, and these handlers pipe frames
|
|
12
|
+
* bidirectionally between the client socket and a Bun WebSocket client
|
|
13
|
+
* connected to the upstream daemon (same path, loopback).
|
|
14
|
+
*
|
|
15
|
+
* Scope discipline: this is TRANSPORT, not features —
|
|
16
|
+
*
|
|
17
|
+
* - The upstream connect carries the substrate trust headers (H2:
|
|
18
|
+
* X-Parachute-Layer / X-Parachute-Client-IP) plus the client's own
|
|
19
|
+
* headers (cookie, authorization) so the module authenticates the
|
|
20
|
+
* connection itself; the hub adds no WS-level auth of its own beyond the
|
|
21
|
+
* route gates that ran BEFORE the upgrade (publicExposure cloak,
|
|
22
|
+
* audience gate).
|
|
23
|
+
* - Subprotocol negotiation (`Sec-WebSocket-Protocol`) is NOT forwarded in
|
|
24
|
+
* v1 — none of the in-design consumers (y-websocket / Hocuspocus manual
|
|
25
|
+
* pumping) require it; forwarding it correctly needs a negotiation
|
|
26
|
+
* round-trip the first real consumer can motivate.
|
|
27
|
+
* - Backpressure is a blunt cap, not flow control: when either side's
|
|
28
|
+
* buffered amount exceeds {@link DEFAULT_MAX_BUFFERED_BYTES}, the bridge
|
|
29
|
+
* closes BOTH sides (1011). A slow consumer should reconnect rather than
|
|
30
|
+
* let the hub buffer unboundedly.
|
|
31
|
+
* - Admission control is upstream of this module (hub#649): the gate in
|
|
32
|
+
* `maybeUpgradeWebSocket` enforces per-client-IP + total connection caps
|
|
33
|
+
* (defaults 32 / 512, env-overridable via PARACHUTE_WS_MAX_PER_IP /
|
|
34
|
+
* PARACHUTE_WS_MAX_TOTAL — see `ws-connection-caps.ts`) BEFORE
|
|
35
|
+
* `server.upgrade()`. The bridge's part of the contract is release: the
|
|
36
|
+
* `close` handler below is the single funnel every accepted socket
|
|
37
|
+
* passes through, so it invokes {@link WsBridgeData.releaseCap} first
|
|
38
|
+
* thing — whatever the teardown reason — and the counters churn back to
|
|
39
|
+
* zero with the sockets.
|
|
40
|
+
*
|
|
41
|
+
* Lifecycle: either side closing tears down the other (close code + reason
|
|
42
|
+
* propagated where the RFC 6455 rules allow), and an upstream connect failure
|
|
43
|
+
* closes the client with 1011. The bridge holds no per-connection state
|
|
44
|
+
* outside `ws.data`, so a dropped socket leaks nothing.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { ServerWebSocket, WebSocketHandler } from "bun";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Default cap on either side's buffered (un-flushed) bytes before the bridge
|
|
51
|
+
* gives up and closes both sockets. 8 MiB comfortably covers CRDT sync bursts
|
|
52
|
+
* (a full Yjs document state vector is typically KBs) while bounding what a
|
|
53
|
+
* slow or stalled consumer can pin in hub memory per connection.
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Per-connection payload attached at `server.upgrade(req, { data })` time by
|
|
59
|
+
* the hub's dispatch (which already ran the route gates). Everything the
|
|
60
|
+
* bridge needs to dial the upstream — it never re-derives routing or trust.
|
|
61
|
+
*/
|
|
62
|
+
export interface WsBridgeData {
|
|
63
|
+
/** Absolute ws:// URL of the upstream daemon (same path + query, loopback). */
|
|
64
|
+
upstreamUrl: string;
|
|
65
|
+
/**
|
|
66
|
+
* Headers presented on the upstream connect: the client's own headers
|
|
67
|
+
* (minus hop-by-hop + WS handshake headers, which the Bun client re-mints)
|
|
68
|
+
* plus the H2 substrate trust stamps.
|
|
69
|
+
*/
|
|
70
|
+
upstreamHeaders: Record<string, string>;
|
|
71
|
+
/**
|
|
72
|
+
* Releases this connection's slot in the connection-cap accounting
|
|
73
|
+
* (hub#649) — attached by the acquire site in `maybeUpgradeWebSocket`,
|
|
74
|
+
* invoked by the `close` handler. Self-disarming (safe to call more than
|
|
75
|
+
* once). Optional so handler-level unit tests that fake `ws.data` don't
|
|
76
|
+
* have to care about caps.
|
|
77
|
+
*/
|
|
78
|
+
releaseCap?: () => void;
|
|
79
|
+
/** Internal bridge state — attached by `open()`, owned by this module. */
|
|
80
|
+
_bridge?: BridgeState;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface BridgeState {
|
|
84
|
+
upstream: WebSocket;
|
|
85
|
+
/** Client frames received while the upstream is still CONNECTING. */
|
|
86
|
+
pending: (string | Uint8Array)[];
|
|
87
|
+
pendingBytes: number;
|
|
88
|
+
/** Set once either side initiated teardown — makes close idempotent. */
|
|
89
|
+
closed: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface WsBridgeOptions {
|
|
93
|
+
/** Override the buffered-bytes cap (tests use a tiny value). */
|
|
94
|
+
maxBufferedBytes?: number;
|
|
95
|
+
/** Test seam for the upstream WebSocket constructor. */
|
|
96
|
+
connectUpstream?: (url: string, headers: Record<string, string>) => WebSocket;
|
|
97
|
+
logger?: Pick<Console, "warn">;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* RFC 6455: only codes 1000–4999 may be sent on the wire, and 1004/1005/1006/
|
|
102
|
+
* 1015 are reserved (never sent). A close event surfacing one of those (e.g.
|
|
103
|
+
* 1006 abnormal closure) is re-mapped to a no-code close, which the peer
|
|
104
|
+
* observes as 1005.
|
|
105
|
+
*/
|
|
106
|
+
function sendableCloseCode(code: number | undefined): number | undefined {
|
|
107
|
+
if (code === undefined) return undefined;
|
|
108
|
+
if (code < 1000 || code > 4999) return undefined;
|
|
109
|
+
if (code === 1004 || code === 1005 || code === 1006 || code === 1015) return undefined;
|
|
110
|
+
return code;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Close reasons are capped at 123 bytes on the wire (RFC 6455 §5.5.1). */
|
|
114
|
+
function trimReason(reason: string | undefined): string {
|
|
115
|
+
if (!reason) return "";
|
|
116
|
+
// Trim by UTF-8 byte length, not string length.
|
|
117
|
+
let out = reason;
|
|
118
|
+
while (Buffer.byteLength(out, "utf8") > 123) out = out.slice(0, -1);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function closeQuietly(close: () => void): void {
|
|
123
|
+
try {
|
|
124
|
+
close();
|
|
125
|
+
} catch {
|
|
126
|
+
// Already closed / closing — teardown is best-effort by design.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Tear down both sides exactly once. */
|
|
131
|
+
function closeBoth(
|
|
132
|
+
ws: ServerWebSocket<WsBridgeData>,
|
|
133
|
+
state: BridgeState,
|
|
134
|
+
code: number,
|
|
135
|
+
reason: string,
|
|
136
|
+
): void {
|
|
137
|
+
if (state.closed) return;
|
|
138
|
+
state.closed = true;
|
|
139
|
+
closeQuietly(() => state.upstream.close(sendableCloseCode(code), trimReason(reason)));
|
|
140
|
+
closeQuietly(() => ws.close(sendableCloseCode(code), trimReason(reason)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function frameBytes(frame: string | Uint8Array | ArrayBuffer): number {
|
|
144
|
+
if (typeof frame === "string") return Buffer.byteLength(frame, "utf8");
|
|
145
|
+
return frame instanceof ArrayBuffer ? frame.byteLength : frame.byteLength;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build the Bun.serve `websocket` handler set implementing the bridge. One
|
|
150
|
+
* handler object serves every bridged connection; per-connection state lives
|
|
151
|
+
* on `ws.data`.
|
|
152
|
+
*/
|
|
153
|
+
export function createWsBridgeHandlers(opts: WsBridgeOptions = {}): WebSocketHandler<WsBridgeData> {
|
|
154
|
+
const cap = opts.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
155
|
+
const logger = opts.logger ?? console;
|
|
156
|
+
const connect =
|
|
157
|
+
opts.connectUpstream ??
|
|
158
|
+
((url: string, headers: Record<string, string>) =>
|
|
159
|
+
// Bun's WebSocket client accepts custom headers (a Bun extension over
|
|
160
|
+
// the WHATWG constructor) — this is what carries the H2 trust stamps +
|
|
161
|
+
// the client's cookies/authorization to the upstream daemon.
|
|
162
|
+
new WebSocket(url, { headers } as unknown as string[]));
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
open(ws) {
|
|
166
|
+
let upstream: WebSocket;
|
|
167
|
+
try {
|
|
168
|
+
upstream = connect(ws.data.upstreamUrl, ws.data.upstreamHeaders);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
logger.warn(
|
|
171
|
+
`[ws-bridge] upstream connect threw for ${ws.data.upstreamUrl}: ${
|
|
172
|
+
err instanceof Error ? err.message : String(err)
|
|
173
|
+
}`,
|
|
174
|
+
);
|
|
175
|
+
closeQuietly(() => ws.close(1011, "upstream connect failed"));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
upstream.binaryType = "arraybuffer";
|
|
179
|
+
const state: BridgeState = { upstream, pending: [], pendingBytes: 0, closed: false };
|
|
180
|
+
ws.data._bridge = state;
|
|
181
|
+
|
|
182
|
+
upstream.addEventListener("open", () => {
|
|
183
|
+
if (state.closed) {
|
|
184
|
+
closeQuietly(() => upstream.close(1000, ""));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for (const frame of state.pending) upstream.send(frame);
|
|
188
|
+
state.pending = [];
|
|
189
|
+
state.pendingBytes = 0;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
upstream.addEventListener("message", (ev: MessageEvent) => {
|
|
193
|
+
if (state.closed) return;
|
|
194
|
+
const data = ev.data as string | ArrayBuffer;
|
|
195
|
+
ws.send(typeof data === "string" ? data : new Uint8Array(data));
|
|
196
|
+
// Backpressure: the client isn't draining what the upstream sends.
|
|
197
|
+
if (ws.getBufferedAmount() > cap) {
|
|
198
|
+
closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
upstream.addEventListener("close", (ev: CloseEvent) => {
|
|
203
|
+
if (state.closed) return;
|
|
204
|
+
state.closed = true;
|
|
205
|
+
closeQuietly(() => ws.close(sendableCloseCode(ev.code), trimReason(ev.reason)));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
upstream.addEventListener("error", () => {
|
|
209
|
+
// A connect refusal (upstream not listening) surfaces here before
|
|
210
|
+
// any close event. Tear down the client; the close listener above is
|
|
211
|
+
// a no-op afterwards thanks to the `closed` latch.
|
|
212
|
+
closeBoth(ws, state, 1011, "upstream error");
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
message(ws, message) {
|
|
217
|
+
const state = ws.data._bridge;
|
|
218
|
+
if (!state || state.closed) return;
|
|
219
|
+
const frame: string | Uint8Array =
|
|
220
|
+
typeof message === "string" ? message : new Uint8Array(message);
|
|
221
|
+
const { upstream } = state;
|
|
222
|
+
if (upstream.readyState === WebSocket.OPEN) {
|
|
223
|
+
upstream.send(frame);
|
|
224
|
+
// Backpressure: the upstream isn't draining what the client sends.
|
|
225
|
+
if (upstream.bufferedAmount > cap) {
|
|
226
|
+
closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
|
|
227
|
+
}
|
|
228
|
+
} else if (upstream.readyState === WebSocket.CONNECTING) {
|
|
229
|
+
state.pending.push(frame);
|
|
230
|
+
state.pendingBytes += frameBytes(frame);
|
|
231
|
+
if (state.pendingBytes > cap) {
|
|
232
|
+
closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// CLOSING / CLOSED: drop the frame — teardown is already in flight.
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
close(ws, code, reason) {
|
|
239
|
+
// Connection-cap release FIRST (hub#649) — before the bridge-state
|
|
240
|
+
// early-returns, because this callback fires even for sockets whose
|
|
241
|
+
// upstream connect threw in open() (no `_bridge` attached) and is the
|
|
242
|
+
// one teardown funnel every accepted socket passes through. The
|
|
243
|
+
// closure latches, so re-entry is harmless.
|
|
244
|
+
ws.data.releaseCap?.();
|
|
245
|
+
// Client → upstream close propagation. Note: Bun's server-side close
|
|
246
|
+
// callback delivers the client's close CODE but an empty `reason`
|
|
247
|
+
// (verified on Bun 1.3.13), so only the code propagates upstream in
|
|
248
|
+
// this direction. Upstream → client propagation (the close listener in
|
|
249
|
+
// open()) carries both.
|
|
250
|
+
const state = ws.data._bridge;
|
|
251
|
+
if (!state || state.closed) return;
|
|
252
|
+
state.closed = true;
|
|
253
|
+
closeQuietly(() => state.upstream.close(sendableCloseCode(code), trimReason(reason)));
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket connection caps for the upgrade bridge (hub#649) — the gate
|
|
3
|
+
* before any backed surface goes public-facing.
|
|
4
|
+
*
|
|
5
|
+
* The H1 bridge (`ws-bridge.ts`) holds one client socket + one upstream
|
|
6
|
+
* socket per connection, and the only per-connection bound before this
|
|
7
|
+
* module was the 8 MiB buffered-byte backpressure cap — a public client
|
|
8
|
+
* could hold upgrade slots open indefinitely at near-zero send rate and
|
|
9
|
+
* exhaust daemon memory at ~KB/connection. With the `surface` audience tier
|
|
10
|
+
* (H3, hub#651) anonymous WS is REACHABLE BY DESIGN on surface-audience
|
|
11
|
+
* mounts, so admission control can't lean on auth: the hub needs a blunt
|
|
12
|
+
* concurrent-connection bound of its own.
|
|
13
|
+
*
|
|
14
|
+
* Enforcement lives in `maybeUpgradeWebSocket` (hub-server.ts), which calls
|
|
15
|
+
* {@link WsConnectionTracker.tryAcquire} synchronously right before
|
|
16
|
+
* `server.upgrade()` — over-cap upgrades are refused with a clean HTTP 429
|
|
17
|
+
* (the upgrade never happens, so a normal Response is the correct refusal
|
|
18
|
+
* shape in Bun) BEFORE the proxy commits any socket or upstream-dial
|
|
19
|
+
* resources. The refusal body is generic: it never reveals counts, caps, or
|
|
20
|
+
* which cap tripped (the hub log carries the specifics for the operator).
|
|
21
|
+
*
|
|
22
|
+
* Release rides the bridge's socket lifecycle: the acquire site threads a
|
|
23
|
+
* self-disarming release closure into `ws.data` ({@link
|
|
24
|
+
* WsBridgeData.releaseCap}), and the bridge's Bun-level `close` handler —
|
|
25
|
+
* the single funnel every accepted socket passes through, whatever the
|
|
26
|
+
* teardown reason (client close, upstream close, backpressure, connect
|
|
27
|
+
* failure) — invokes it first thing. A failed `server.upgrade()` releases
|
|
28
|
+
* inline (no socket ⇒ no close callback). The closure latches, so a stray
|
|
29
|
+
* double-close can't corrupt the counters.
|
|
30
|
+
*
|
|
31
|
+
* Defaults (overridable via env, the hub's config precedent —
|
|
32
|
+
* `PARACHUTE_HUB_ORIGIN` et al.):
|
|
33
|
+
*
|
|
34
|
+
* - {@link DEFAULT_WS_MAX_PER_IP} = 32 per client IP
|
|
35
|
+
* (`PARACHUTE_WS_MAX_PER_IP`). An owner-operated box realistically
|
|
36
|
+
* serves a handful of humans; a collab surface opens a socket or two
|
|
37
|
+
* per tab, so 32 covers a small team behind one NAT egress IP with
|
|
38
|
+
* headroom, while turning a single-source flood into a rotate-IPs
|
|
39
|
+
* problem.
|
|
40
|
+
* - {@link DEFAULT_WS_MAX_TOTAL} = 512 total (`PARACHUTE_WS_MAX_TOTAL`).
|
|
41
|
+
* Bounds worst-case hub memory under a distributed flood (512 bridged
|
|
42
|
+
* pairs ≈ low MBs idle) at a ceiling far above any realistic legitimate
|
|
43
|
+
* concurrent load for a single-operator hub.
|
|
44
|
+
*
|
|
45
|
+
* Keying: callers derive the bucket key with `wsCapBucketKey`
|
|
46
|
+
* (hub-server.ts), which follows the hub's substrate trust model — forwarded
|
|
47
|
+
* IP headers are only believed when the peer is an on-box (loopback)
|
|
48
|
+
* forwarder; direct network peers key by their socket address no matter
|
|
49
|
+
* what headers they inject; an underivable IP lands in one shared bucket
|
|
50
|
+
* (fail closed, same posture as rate-limit.ts's UNKNOWN_IP_SENTINEL).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/** Default cap on concurrent bridged WS connections per client-IP bucket. */
|
|
54
|
+
export const DEFAULT_WS_MAX_PER_IP = 32;
|
|
55
|
+
/** Default cap on concurrent bridged WS connections across all clients. */
|
|
56
|
+
export const DEFAULT_WS_MAX_TOTAL = 512;
|
|
57
|
+
|
|
58
|
+
/** Env var overriding {@link DEFAULT_WS_MAX_PER_IP}. */
|
|
59
|
+
export const WS_MAX_PER_IP_ENV = "PARACHUTE_WS_MAX_PER_IP";
|
|
60
|
+
/** Env var overriding {@link DEFAULT_WS_MAX_TOTAL}. */
|
|
61
|
+
export const WS_MAX_TOTAL_ENV = "PARACHUTE_WS_MAX_TOTAL";
|
|
62
|
+
|
|
63
|
+
export interface WsCaps {
|
|
64
|
+
maxPerIp: number;
|
|
65
|
+
maxTotal: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse the cap overrides from an env bag. Only positive integers are
|
|
70
|
+
* honored; absent / malformed / non-positive values fall back to the
|
|
71
|
+
* defaults (an operator typo must not silently disable the gate — fail to
|
|
72
|
+
* the safe defaults, never to "unlimited").
|
|
73
|
+
*/
|
|
74
|
+
export function wsCapsFromEnv(env: NodeJS.ProcessEnv = process.env): WsCaps {
|
|
75
|
+
return {
|
|
76
|
+
maxPerIp: parseCap(env[WS_MAX_PER_IP_ENV], DEFAULT_WS_MAX_PER_IP),
|
|
77
|
+
maxTotal: parseCap(env[WS_MAX_TOTAL_ENV], DEFAULT_WS_MAX_TOTAL),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseCap(raw: string | undefined, fallback: number): number {
|
|
82
|
+
if (raw === undefined) return fallback;
|
|
83
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
84
|
+
if (!Number.isSafeInteger(n) || n <= 0 || String(n) !== raw.trim()) return fallback;
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The verdict of {@link WsConnectionTracker.tryAcquire}. */
|
|
89
|
+
export type WsAcquireResult =
|
|
90
|
+
| { ok: true; release: () => void }
|
|
91
|
+
| { ok: false; reason: "per_ip_cap" | "total_cap" };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Concurrent-connection accounting: a per-key map + a global counter.
|
|
95
|
+
*
|
|
96
|
+
* Acquire and release are synchronous and O(1); `tryAcquire` is called in
|
|
97
|
+
* the same synchronous block as `server.upgrade()` (no await between check
|
|
98
|
+
* and commit), so the counters can't race the admission decision.
|
|
99
|
+
*
|
|
100
|
+
* The returned `release` closure is latched — calling it twice decrements
|
|
101
|
+
* once. That makes leaks structurally hard: the caller doesn't need to know
|
|
102
|
+
* which teardown paths can double-fire; any number of invocations after the
|
|
103
|
+
* first are no-ops, and a key's bucket entry is deleted at zero so an
|
|
104
|
+
* attacker cycling keys can't grow the map without also holding sockets.
|
|
105
|
+
*/
|
|
106
|
+
export class WsConnectionTracker {
|
|
107
|
+
private readonly perKey = new Map<string, number>();
|
|
108
|
+
private total = 0;
|
|
109
|
+
|
|
110
|
+
constructor(
|
|
111
|
+
private readonly maxPerKey: number = DEFAULT_WS_MAX_PER_IP,
|
|
112
|
+
private readonly maxTotal: number = DEFAULT_WS_MAX_TOTAL,
|
|
113
|
+
) {}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Admit-or-refuse a would-be connection for `key`. On admission the slot
|
|
117
|
+
* is counted immediately and the caller MUST either hand the returned
|
|
118
|
+
* `release` to the socket's close path or invoke it inline when the
|
|
119
|
+
* upgrade fails to complete.
|
|
120
|
+
*/
|
|
121
|
+
tryAcquire(key: string): WsAcquireResult {
|
|
122
|
+
if (this.total >= this.maxTotal) return { ok: false, reason: "total_cap" };
|
|
123
|
+
const current = this.perKey.get(key) ?? 0;
|
|
124
|
+
if (current >= this.maxPerKey) return { ok: false, reason: "per_ip_cap" };
|
|
125
|
+
this.perKey.set(key, current + 1);
|
|
126
|
+
this.total += 1;
|
|
127
|
+
|
|
128
|
+
let released = false;
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
release: () => {
|
|
132
|
+
if (released) return;
|
|
133
|
+
released = true;
|
|
134
|
+
const n = this.perKey.get(key) ?? 0;
|
|
135
|
+
if (n <= 1) this.perKey.delete(key);
|
|
136
|
+
else this.perKey.set(key, n - 1);
|
|
137
|
+
if (this.total > 0) this.total -= 1;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Current global connection count (observability + tests). */
|
|
143
|
+
get totalCount(): number {
|
|
144
|
+
return this.total;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Current count for one key (observability + tests). 0 when absent. */
|
|
148
|
+
countFor(key: string): number {
|
|
149
|
+
return this.perKey.get(key) ?? 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Number of distinct keys currently holding connections (leak probe). */
|
|
153
|
+
get keyCount(): number {
|
|
154
|
+
return this.perKey.size;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The production tracker — one per hub process, shared by every upgrade.
|
|
160
|
+
* Caps come from the env at module load (the hub reads its config once at
|
|
161
|
+
* boot; changing the env requires a restart, same as every other
|
|
162
|
+
* `PARACHUTE_*` knob). Tests construct their own trackers and inject them
|
|
163
|
+
* via `HubFetchDeps.wsConnectionTracker` so they never consume (or depend
|
|
164
|
+
* on) the shared process-level counters.
|
|
165
|
+
*/
|
|
166
|
+
const bootCaps = wsCapsFromEnv();
|
|
167
|
+
export const defaultWsConnectionTracker = new WsConnectionTracker(
|
|
168
|
+
bootCaps.maxPerIp,
|
|
169
|
+
bootCaps.maxTotal,
|
|
170
|
+
);
|