@openparachute/hub 0.7.0 → 0.7.2-rc.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/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- 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-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
|
@@ -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
|
+
);
|