@floegence/flowersec-core 0.24.0 → 0.25.0
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 +6 -0
- package/dist/client-connect/transportSecurity.js +1 -1
- package/dist/endpoint/index.js +185 -33
- package/dist/proxy/appWindow.js +93 -7
- package/dist/proxy/controllerWindow.js +219 -78
- package/dist/proxy/portStream.d.ts +2 -1
- package/dist/proxy/portStream.js +173 -74
- package/dist/proxy/server.d.ts +2 -0
- package/dist/proxy/server.js +346 -105
- package/dist/proxy/windowBridgeProtocol.d.ts +4 -3
- package/dist/proxy/windowBridgeProtocol.js +1 -1
- package/package.json +1 -1
package/dist/proxy/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_WS_FRAME_B
|
|
|
7
7
|
import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders, isSafeHeaderValue, isValidHeaderName, normalizeHeaderName } from "./headerPolicy.js";
|
|
8
8
|
export async function serveProxySession(session, options, signal) {
|
|
9
9
|
const compiled = compileOptions(options);
|
|
10
|
+
const requestBodyBudget = createRequestBodyBudget(compiled.maxBufferedRequestBodyBytes);
|
|
10
11
|
const active = new Set();
|
|
11
12
|
try {
|
|
12
13
|
while (!signal?.aborted) {
|
|
@@ -30,7 +31,7 @@ export async function serveProxySession(session, options, signal) {
|
|
|
30
31
|
await accepted.stream.reset(new Error("proxy stream concurrency exhausted"));
|
|
31
32
|
continue;
|
|
32
33
|
}
|
|
33
|
-
const task = serveProxyStreamCompiled(accepted.kind, accepted.stream, compiled, signal)
|
|
34
|
+
const task = serveProxyStreamCompiled(accepted.kind, accepted.stream, compiled, requestBodyBudget, signal)
|
|
34
35
|
.catch(() => { })
|
|
35
36
|
.finally(() => active.delete(task));
|
|
36
37
|
active.add(task);
|
|
@@ -50,12 +51,13 @@ async function serveRPCStream(stream, router, options, signal) {
|
|
|
50
51
|
await server.serve(signal);
|
|
51
52
|
}
|
|
52
53
|
export function serveProxyStream(kind, stream, options, signal) {
|
|
53
|
-
|
|
54
|
+
const compiled = compileOptions(options);
|
|
55
|
+
return serveProxyStreamCompiled(kind, stream, compiled, createRequestBodyBudget(compiled.maxBufferedRequestBodyBytes), signal);
|
|
54
56
|
}
|
|
55
|
-
async function serveProxyStreamCompiled(kind, stream, options, signal) {
|
|
57
|
+
async function serveProxyStreamCompiled(kind, stream, options, requestBodyBudget, signal) {
|
|
56
58
|
try {
|
|
57
59
|
if (kind === PROXY_KIND_HTTP1)
|
|
58
|
-
await serveHTTP(stream, options, signal);
|
|
60
|
+
await serveHTTP(stream, options, requestBodyBudget, signal);
|
|
59
61
|
else
|
|
60
62
|
await serveWebSocket(stream, options, signal);
|
|
61
63
|
}
|
|
@@ -66,79 +68,102 @@ async function serveProxyStreamCompiled(kind, stream, options, signal) {
|
|
|
66
68
|
catch { /* The peer may already have reset the stream. */ }
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
|
-
async function serveHTTP(stream, options, signal) {
|
|
71
|
+
async function serveHTTP(stream, options, requestBodyBudget, signal) {
|
|
70
72
|
const reader = createByteReader(stream, signal === undefined ? {} : { signal });
|
|
71
73
|
let requestId = "unknown";
|
|
72
74
|
try {
|
|
73
75
|
const meta = assertHTTPRequestMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
|
|
74
76
|
requestId = meta.request_id;
|
|
75
77
|
const path = parsePath(meta.path);
|
|
76
|
-
const
|
|
77
|
-
const headers = requestHeaders(meta.headers, options);
|
|
78
|
-
applyExternalOrigin(headers, meta.external_origin);
|
|
79
|
-
const target = new URL(options.upstream);
|
|
80
|
-
target.pathname = path.pathname;
|
|
81
|
-
target.search = path.search;
|
|
82
|
-
target.hash = "";
|
|
83
|
-
const timeoutMs = resolveTimeout(meta.timeout_ms, options);
|
|
84
|
-
const controller = new AbortController();
|
|
85
|
-
const onAbort = () => controller.abort(signal?.reason);
|
|
86
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
87
|
-
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new Error("upstream request timeout")), timeoutMs) : undefined;
|
|
78
|
+
const bufferedBody = await readBody(reader, options.maxChunkBytes, options.maxBodyBytes, requestBodyBudget);
|
|
88
79
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
80
|
+
if (signal?.aborted)
|
|
81
|
+
throw new ProxyServerError("canceled", "proxy request canceled");
|
|
82
|
+
const headers = requestHeaders(meta.headers, options);
|
|
83
|
+
applyExternalOrigin(headers, meta.external_origin);
|
|
84
|
+
const target = new URL(options.upstream);
|
|
85
|
+
target.pathname = path.pathname;
|
|
86
|
+
target.search = path.search;
|
|
87
|
+
target.hash = "";
|
|
88
|
+
const timeoutMs = resolveTimeout(meta.timeout_ms, options);
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const onAbort = () => controller.abort(new ProxyServerError("canceled", "proxy request canceled"));
|
|
91
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
92
|
+
if (signal?.aborted)
|
|
93
|
+
onAbort();
|
|
94
|
+
const timer = timeoutMs > 0
|
|
95
|
+
? setTimeout(() => controller.abort(new ProxyServerError("timeout", "upstream request timeout")), timeoutMs)
|
|
96
|
+
: undefined;
|
|
97
|
+
try {
|
|
98
|
+
const method = meta.method.trim().toUpperCase();
|
|
99
|
+
let response;
|
|
100
|
+
try {
|
|
101
|
+
if (controller.signal.aborted)
|
|
102
|
+
throw controller.signal.reason;
|
|
103
|
+
response = await options.fetch(target, {
|
|
104
|
+
method,
|
|
105
|
+
headers,
|
|
106
|
+
redirect: "manual",
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
...((method === "GET" || method === "HEAD")
|
|
109
|
+
? {}
|
|
110
|
+
: { body: bufferedBody.bytes.buffer }),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
bufferedBody.release();
|
|
115
|
+
}
|
|
116
|
+
const responseHeaders = collectResponseHeaders(response.headers, options);
|
|
117
|
+
const responseMeta = {
|
|
118
|
+
v: PROXY_PROTOCOL_VERSION,
|
|
119
|
+
request_id: requestId,
|
|
120
|
+
ok: true,
|
|
121
|
+
status: response.status,
|
|
122
|
+
headers: responseHeaders,
|
|
123
|
+
};
|
|
124
|
+
await writeJsonFrame(stream, responseMeta);
|
|
125
|
+
if (response.body == null) {
|
|
126
|
+
await stream.write(u32be(0));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const bodyReader = response.body.getReader();
|
|
130
|
+
let total = 0;
|
|
131
|
+
while (true) {
|
|
132
|
+
const next = await bodyReader.read();
|
|
133
|
+
if (next.done)
|
|
134
|
+
break;
|
|
135
|
+
const bytes = next.value;
|
|
136
|
+
total += bytes.length;
|
|
137
|
+
if (total > options.maxBodyBytes)
|
|
138
|
+
throw new ProxyServerError("response_body_too_large", "response body too large");
|
|
139
|
+
for (let offset = 0; offset < bytes.length; offset += options.maxChunkBytes) {
|
|
140
|
+
const chunk = bytes.subarray(offset, Math.min(bytes.length, offset + options.maxChunkBytes));
|
|
141
|
+
await stream.write(u32be(chunk.length));
|
|
142
|
+
await stream.write(chunk);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
107
145
|
await stream.write(u32be(0));
|
|
108
|
-
return;
|
|
109
146
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (next.done)
|
|
115
|
-
break;
|
|
116
|
-
const bytes = next.value;
|
|
117
|
-
total += bytes.length;
|
|
118
|
-
if (total > options.maxBodyBytes)
|
|
119
|
-
throw new ProxyServerError("response_body_too_large", "response body too large");
|
|
120
|
-
for (let offset = 0; offset < bytes.length; offset += options.maxChunkBytes) {
|
|
121
|
-
const chunk = bytes.subarray(offset, Math.min(bytes.length, offset + options.maxChunkBytes));
|
|
122
|
-
await stream.write(u32be(chunk.length));
|
|
123
|
-
await stream.write(chunk);
|
|
124
|
-
}
|
|
147
|
+
finally {
|
|
148
|
+
if (timer != null)
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
signal?.removeEventListener("abort", onAbort);
|
|
125
151
|
}
|
|
126
|
-
await stream.write(u32be(0));
|
|
127
152
|
}
|
|
128
153
|
finally {
|
|
129
|
-
|
|
130
|
-
clearTimeout(timer);
|
|
131
|
-
signal?.removeEventListener("abort", onAbort);
|
|
154
|
+
bufferedBody.release();
|
|
132
155
|
}
|
|
133
156
|
}
|
|
134
157
|
catch (error) {
|
|
135
|
-
await writeHTTPError(stream, requestId, classifyHTTPError(error)
|
|
158
|
+
await writeHTTPError(stream, requestId, classifyHTTPError(error));
|
|
136
159
|
}
|
|
137
160
|
}
|
|
138
161
|
async function serveWebSocket(stream, options, signal) {
|
|
139
162
|
const reader = createByteReader(stream, signal === undefined ? {} : { signal });
|
|
140
163
|
let connId = "unknown";
|
|
141
164
|
let raw;
|
|
165
|
+
let upstreamOpened = false;
|
|
166
|
+
let removePostOpenAbortListener;
|
|
142
167
|
try {
|
|
143
168
|
const meta = assertWSOpenMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
|
|
144
169
|
connId = meta.conn_id;
|
|
@@ -164,55 +189,137 @@ async function serveWebSocket(stream, options, signal) {
|
|
|
164
189
|
headers,
|
|
165
190
|
maxPayload: options.maxWsFrameBytes,
|
|
166
191
|
perMessageDeflate: false,
|
|
167
|
-
handshakeTimeout:
|
|
192
|
+
handshakeTimeout: resolveTimeout(undefined, options),
|
|
168
193
|
});
|
|
169
|
-
await waitForUpstreamOpen(raw, signal);
|
|
170
|
-
const response = {
|
|
171
|
-
v: PROXY_PROTOCOL_VERSION,
|
|
172
|
-
conn_id: connId,
|
|
173
|
-
ok: true,
|
|
174
|
-
protocol: String(raw.protocol ?? ""),
|
|
175
|
-
};
|
|
176
|
-
await writeJsonFrame(stream, response);
|
|
177
194
|
let writeChain = Promise.resolve();
|
|
195
|
+
let queuedWriteBytes = 0;
|
|
196
|
+
let terminalSettled = false;
|
|
197
|
+
let terminalError;
|
|
178
198
|
let terminalResolve;
|
|
179
199
|
const terminal = new Promise((resolve) => { terminalResolve = resolve; });
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
const settleTerminal = (error) => {
|
|
201
|
+
if (terminalSettled)
|
|
202
|
+
return;
|
|
203
|
+
terminalSettled = true;
|
|
204
|
+
terminalError = error;
|
|
205
|
+
terminalResolve(error);
|
|
206
|
+
};
|
|
207
|
+
const queueFrame = (op, payload, allowCloseFrame = false) => {
|
|
208
|
+
if (terminalSettled)
|
|
209
|
+
return Promise.reject(new Error("proxy WebSocket is closed"));
|
|
210
|
+
const frameBytes = payload.byteLength + 5;
|
|
211
|
+
const nextQueuedWriteBytes = queuedWriteBytes + frameBytes;
|
|
212
|
+
if (!Number.isSafeInteger(nextQueuedWriteBytes)
|
|
213
|
+
|| (!allowCloseFrame && nextQueuedWriteBytes > options.maxWsQueuedBytes)) {
|
|
214
|
+
const error = new ProxyServerError("resource_exhausted", "proxy WebSocket write queue exhausted");
|
|
215
|
+
settleTerminal(error);
|
|
216
|
+
try {
|
|
217
|
+
raw.close(1011, "proxy buffer limit exceeded");
|
|
218
|
+
}
|
|
219
|
+
catch { /* Best effort. */ }
|
|
220
|
+
return Promise.reject(error);
|
|
221
|
+
}
|
|
222
|
+
queuedWriteBytes = nextQueuedWriteBytes;
|
|
223
|
+
const write = writeChain
|
|
224
|
+
.then(() => {
|
|
225
|
+
if (terminalSettled)
|
|
226
|
+
throw terminalError ?? new Error("proxy WebSocket is closed");
|
|
227
|
+
return writeWSFrame(stream, op, payload, options.maxWsFrameBytes);
|
|
228
|
+
})
|
|
229
|
+
.finally(() => {
|
|
230
|
+
queuedWriteBytes = Math.max(0, queuedWriteBytes - frameBytes);
|
|
231
|
+
});
|
|
232
|
+
writeChain = write;
|
|
233
|
+
void write.catch((error) => settleTerminal(asError(error)));
|
|
234
|
+
return write;
|
|
235
|
+
};
|
|
236
|
+
const queueEventFrame = (op, payload) => {
|
|
237
|
+
void queueFrame(op, payload).catch(() => { });
|
|
184
238
|
};
|
|
185
|
-
raw.on("message", (data, isBinary) => writeFrame(isBinary ? 2 : 1, toBytes(data)));
|
|
186
|
-
raw.on("ping", (data) => writeFrame(9, toBytes(data)));
|
|
187
|
-
raw.on("pong", (data) => writeFrame(10, toBytes(data)));
|
|
188
239
|
raw.on("close", (code, reason) => {
|
|
240
|
+
if (terminalSettled)
|
|
241
|
+
return;
|
|
242
|
+
if (!upstreamOpened) {
|
|
243
|
+
settleTerminal(new Error("upstream WebSocket closed before open"));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
189
246
|
const reasonBytes = toBytes(reason);
|
|
190
247
|
const payload = new Uint8Array(2 + reasonBytes.length);
|
|
191
248
|
payload[0] = (code >>> 8) & 0xff;
|
|
192
249
|
payload[1] = code & 0xff;
|
|
193
250
|
payload.set(reasonBytes, 2);
|
|
194
|
-
|
|
195
|
-
terminalResolve();
|
|
251
|
+
void queueFrame(8, payload, true).then(() => settleTerminal(), (error) => settleTerminal(asError(error)));
|
|
196
252
|
});
|
|
197
|
-
raw.on("error", (error) =>
|
|
253
|
+
raw.on("error", (error) => settleTerminal(error));
|
|
254
|
+
await waitForUpstreamOpen(raw, signal);
|
|
255
|
+
upstreamOpened = true;
|
|
256
|
+
const response = {
|
|
257
|
+
v: PROXY_PROTOCOL_VERSION,
|
|
258
|
+
conn_id: connId,
|
|
259
|
+
ok: true,
|
|
260
|
+
protocol: String(raw.protocol ?? ""),
|
|
261
|
+
};
|
|
262
|
+
const openResponseWrite = writeJsonFrame(stream, response);
|
|
263
|
+
writeChain = openResponseWrite;
|
|
264
|
+
void openResponseWrite.catch((error) => settleTerminal(asError(error)));
|
|
265
|
+
raw.on("message", (data, isBinary) => queueEventFrame(isBinary ? 2 : 1, toBytes(data)));
|
|
266
|
+
raw.on("ping", (data) => queueEventFrame(9, toBytes(data)));
|
|
267
|
+
raw.on("pong", (data) => queueEventFrame(10, toBytes(data)));
|
|
268
|
+
const onPostOpenAbort = () => {
|
|
269
|
+
const error = asError(signal?.reason ?? new Error("proxy WebSocket aborted"));
|
|
270
|
+
settleTerminal(error);
|
|
271
|
+
try {
|
|
272
|
+
if (typeof raw.terminate === "function")
|
|
273
|
+
raw.terminate();
|
|
274
|
+
else
|
|
275
|
+
raw.close();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// The terminal error is already authoritative.
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
if (signal != null) {
|
|
282
|
+
signal.addEventListener("abort", onPostOpenAbort, { once: true });
|
|
283
|
+
removePostOpenAbortListener = () => signal.removeEventListener("abort", onPostOpenAbort);
|
|
284
|
+
if (signal.aborted)
|
|
285
|
+
onPostOpenAbort();
|
|
286
|
+
}
|
|
287
|
+
const openResponseResult = await Promise.race([
|
|
288
|
+
openResponseWrite.then(() => undefined),
|
|
289
|
+
terminal,
|
|
290
|
+
]);
|
|
291
|
+
if (openResponseResult != null)
|
|
292
|
+
throw openResponseResult;
|
|
293
|
+
try {
|
|
294
|
+
if (!terminalSettled && typeof raw.resume === "function")
|
|
295
|
+
raw.resume();
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
settleTerminal(asError(error));
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
198
301
|
const inbound = (async () => {
|
|
199
302
|
while (true) {
|
|
200
303
|
const frame = await readWSFrame(reader, options.maxWsFrameBytes);
|
|
201
304
|
switch (frame.op) {
|
|
202
305
|
case 1:
|
|
203
|
-
raw
|
|
306
|
+
await sendUpstreamFrame(raw, 1, frame.payload);
|
|
204
307
|
break;
|
|
205
308
|
case 2:
|
|
206
|
-
raw
|
|
309
|
+
await sendUpstreamFrame(raw, 2, frame.payload);
|
|
207
310
|
break;
|
|
208
|
-
case 8:
|
|
311
|
+
case 8: {
|
|
209
312
|
raw.close(frame.payload.length >= 2 ? (frame.payload[0] << 8) | frame.payload[1] : 1000, new TextDecoder().decode(frame.payload.subarray(2)));
|
|
313
|
+
const closeError = await terminal;
|
|
314
|
+
if (closeError != null)
|
|
315
|
+
throw closeError;
|
|
210
316
|
return;
|
|
317
|
+
}
|
|
211
318
|
case 9:
|
|
212
|
-
raw
|
|
319
|
+
await sendUpstreamFrame(raw, 9, frame.payload);
|
|
213
320
|
break;
|
|
214
321
|
case 10:
|
|
215
|
-
raw
|
|
322
|
+
await sendUpstreamFrame(raw, 10, frame.payload);
|
|
216
323
|
break;
|
|
217
324
|
default: throw new Error("invalid websocket frame operation");
|
|
218
325
|
}
|
|
@@ -224,10 +331,11 @@ async function serveWebSocket(stream, options, signal) {
|
|
|
224
331
|
await writeChain;
|
|
225
332
|
}
|
|
226
333
|
catch (error) {
|
|
227
|
-
if (
|
|
228
|
-
await writeWSOpenError(stream, connId, classifyWSError(error)
|
|
334
|
+
if (!upstreamOpened)
|
|
335
|
+
await writeWSOpenError(stream, connId, classifyWSError(error));
|
|
229
336
|
}
|
|
230
337
|
finally {
|
|
338
|
+
removePostOpenAbortListener?.();
|
|
231
339
|
try {
|
|
232
340
|
raw?.close();
|
|
233
341
|
}
|
|
@@ -246,7 +354,14 @@ function compileOptions(input) {
|
|
|
246
354
|
const maxJsonFrameBytes = positive(input.maxJsonFrameBytes, DEFAULT_MAX_JSON_FRAME_BYTES, "maxJsonFrameBytes");
|
|
247
355
|
const maxChunkBytes = positive(input.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES, "maxChunkBytes");
|
|
248
356
|
const maxBodyBytes = positive(input.maxBodyBytes, DEFAULT_MAX_BODY_BYTES, "maxBodyBytes");
|
|
357
|
+
const maxBufferedRequestBodyBytes = positive(input.maxBufferedRequestBodyBytes, maxBodyBytes, "maxBufferedRequestBodyBytes");
|
|
249
358
|
const maxWsFrameBytes = positive(input.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES, "maxWsFrameBytes");
|
|
359
|
+
const defaultMaxWsQueuedBytes = maxWsFrameBytes <= Number.MAX_SAFE_INTEGER - 5
|
|
360
|
+
? maxWsFrameBytes + 5
|
|
361
|
+
: maxWsFrameBytes;
|
|
362
|
+
const maxWsQueuedBytes = positive(input.maxWsQueuedBytes, defaultMaxWsQueuedBytes, "maxWsQueuedBytes");
|
|
363
|
+
if (maxWsQueuedBytes < 5)
|
|
364
|
+
throw new Error("maxWsQueuedBytes must be at least 5");
|
|
250
365
|
const defaultTimeoutMs = nonNegative(input.defaultTimeoutMs, 30_000, "defaultTimeoutMs");
|
|
251
366
|
const maxTimeoutMs = nonNegative(input.maxTimeoutMs, 300_000, "maxTimeoutMs");
|
|
252
367
|
if (maxTimeoutMs > 0 && defaultTimeoutMs > maxTimeoutMs)
|
|
@@ -257,7 +372,9 @@ function compileOptions(input) {
|
|
|
257
372
|
maxJsonFrameBytes,
|
|
258
373
|
maxChunkBytes,
|
|
259
374
|
maxBodyBytes,
|
|
375
|
+
maxBufferedRequestBodyBytes,
|
|
260
376
|
maxWsFrameBytes,
|
|
377
|
+
maxWsQueuedBytes,
|
|
261
378
|
defaultTimeoutMs,
|
|
262
379
|
maxTimeoutMs,
|
|
263
380
|
maxConcurrentStreams: positive(input.maxConcurrentStreams, 64, "maxConcurrentStreams"),
|
|
@@ -349,30 +466,70 @@ function applyExternalOrigin(headers, input) {
|
|
|
349
466
|
headers.set("x-forwarded-proto", new URL(external).protocol.slice(0, -1));
|
|
350
467
|
headers.set("host", new URL(external).host);
|
|
351
468
|
}
|
|
352
|
-
|
|
469
|
+
function createRequestBodyBudget(maxBytes) {
|
|
470
|
+
let usedBytes = 0;
|
|
471
|
+
return {
|
|
472
|
+
reserve: (bytes) => {
|
|
473
|
+
const next = usedBytes + bytes;
|
|
474
|
+
if (!Number.isSafeInteger(next) || next > maxBytes) {
|
|
475
|
+
throw new ProxyServerError("resource_exhausted", "proxy request body buffer exhausted");
|
|
476
|
+
}
|
|
477
|
+
usedBytes = next;
|
|
478
|
+
},
|
|
479
|
+
release: (bytes) => {
|
|
480
|
+
usedBytes = Math.max(0, usedBytes - bytes);
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
async function readBody(reader, maxChunkBytes, maxBodyBytes, budget) {
|
|
353
485
|
const chunks = [];
|
|
354
486
|
let total = 0;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
487
|
+
let handedOff = false;
|
|
488
|
+
try {
|
|
489
|
+
while (true) {
|
|
490
|
+
const length = readU32be(await reader.readExactly(4), 0);
|
|
491
|
+
if (length === 0)
|
|
492
|
+
break;
|
|
493
|
+
if (length > maxChunkBytes)
|
|
494
|
+
throw new ProxyServerError("request_body_too_large", "request chunk too large");
|
|
495
|
+
const next = total + length;
|
|
496
|
+
if (!Number.isSafeInteger(next) || next > maxBodyBytes) {
|
|
497
|
+
throw new ProxyServerError("request_body_too_large", "request body too large");
|
|
498
|
+
}
|
|
499
|
+
budget.reserve(length);
|
|
500
|
+
total = next;
|
|
501
|
+
chunks.push(await reader.readExactly(length));
|
|
502
|
+
}
|
|
503
|
+
const body = new Uint8Array(total);
|
|
504
|
+
let offset = 0;
|
|
505
|
+
for (const chunk of chunks) {
|
|
506
|
+
body.set(chunk, offset);
|
|
507
|
+
offset += chunk.length;
|
|
508
|
+
}
|
|
509
|
+
let released = false;
|
|
510
|
+
handedOff = true;
|
|
511
|
+
return {
|
|
512
|
+
bytes: body,
|
|
513
|
+
release: () => {
|
|
514
|
+
if (released)
|
|
515
|
+
return;
|
|
516
|
+
released = true;
|
|
517
|
+
budget.release(total);
|
|
518
|
+
},
|
|
519
|
+
};
|
|
365
520
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
body.set(chunk, offset);
|
|
370
|
-
offset += chunk.length;
|
|
521
|
+
finally {
|
|
522
|
+
if (!handedOff)
|
|
523
|
+
budget.release(total);
|
|
371
524
|
}
|
|
372
|
-
return body;
|
|
373
525
|
}
|
|
374
|
-
async function writeHTTPError(stream, requestId, code
|
|
375
|
-
const meta = {
|
|
526
|
+
async function writeHTTPError(stream, requestId, code) {
|
|
527
|
+
const meta = {
|
|
528
|
+
v: PROXY_PROTOCOL_VERSION,
|
|
529
|
+
request_id: requestId.trim() || "unknown",
|
|
530
|
+
ok: false,
|
|
531
|
+
error: { code, message: publicHTTPErrorMessage(code) },
|
|
532
|
+
};
|
|
376
533
|
try {
|
|
377
534
|
await writeJsonFrame(stream, meta);
|
|
378
535
|
await stream.write(u32be(0));
|
|
@@ -396,8 +553,13 @@ async function writeWSFrame(stream, op, payload, maxBytes) {
|
|
|
396
553
|
if (payload.length > 0)
|
|
397
554
|
await stream.write(payload);
|
|
398
555
|
}
|
|
399
|
-
async function writeWSOpenError(stream, connId, code
|
|
400
|
-
const response = {
|
|
556
|
+
async function writeWSOpenError(stream, connId, code) {
|
|
557
|
+
const response = {
|
|
558
|
+
v: PROXY_PROTOCOL_VERSION,
|
|
559
|
+
conn_id: connId.trim() || "unknown",
|
|
560
|
+
ok: false,
|
|
561
|
+
error: { code, message: publicWSErrorMessage(code) },
|
|
562
|
+
};
|
|
401
563
|
try {
|
|
402
564
|
await writeJsonFrame(stream, response);
|
|
403
565
|
}
|
|
@@ -405,13 +567,65 @@ async function writeWSOpenError(stream, connId, code, error) {
|
|
|
405
567
|
}
|
|
406
568
|
function waitForUpstreamOpen(websocket, signal) {
|
|
407
569
|
return new Promise((resolve, reject) => {
|
|
408
|
-
const cleanup = () => {
|
|
409
|
-
|
|
570
|
+
const cleanup = () => {
|
|
571
|
+
websocket.off("open", onOpen);
|
|
572
|
+
websocket.off("error", onError);
|
|
573
|
+
websocket.off("close", onClose);
|
|
574
|
+
signal?.removeEventListener("abort", onAbort);
|
|
575
|
+
};
|
|
576
|
+
const onOpen = () => {
|
|
577
|
+
try {
|
|
578
|
+
if (typeof websocket.pause === "function")
|
|
579
|
+
websocket.pause();
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
cleanup();
|
|
583
|
+
reject(asError(error));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
cleanup();
|
|
587
|
+
resolve();
|
|
588
|
+
};
|
|
410
589
|
const onError = (error) => { cleanup(); reject(error); };
|
|
590
|
+
const onClose = () => { cleanup(); reject(new Error("upstream WebSocket closed before open")); };
|
|
411
591
|
const onAbort = () => { cleanup(); reject(signal?.reason ?? new Error("aborted")); };
|
|
412
592
|
websocket.once("open", onOpen);
|
|
413
593
|
websocket.once("error", onError);
|
|
594
|
+
websocket.once("close", onClose);
|
|
414
595
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
596
|
+
if (signal?.aborted)
|
|
597
|
+
onAbort();
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
function sendUpstreamFrame(websocket, op, payload) {
|
|
601
|
+
return new Promise((resolve, reject) => {
|
|
602
|
+
const finish = (error) => {
|
|
603
|
+
if (error == null)
|
|
604
|
+
resolve();
|
|
605
|
+
else
|
|
606
|
+
reject(asError(error));
|
|
607
|
+
};
|
|
608
|
+
try {
|
|
609
|
+
switch (op) {
|
|
610
|
+
case 1:
|
|
611
|
+
websocket.send(payload, { binary: false }, finish);
|
|
612
|
+
return;
|
|
613
|
+
case 2:
|
|
614
|
+
websocket.send(payload, { binary: true }, finish);
|
|
615
|
+
return;
|
|
616
|
+
case 9:
|
|
617
|
+
websocket.ping(payload, undefined, finish);
|
|
618
|
+
return;
|
|
619
|
+
case 10:
|
|
620
|
+
websocket.pong(payload, undefined, finish);
|
|
621
|
+
return;
|
|
622
|
+
default:
|
|
623
|
+
reject(new Error("invalid upstream WebSocket operation"));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
reject(asError(error));
|
|
628
|
+
}
|
|
415
629
|
});
|
|
416
630
|
}
|
|
417
631
|
function resolveTimeout(input, options) {
|
|
@@ -458,6 +672,33 @@ function classifyWSError(error) {
|
|
|
458
672
|
return error.code;
|
|
459
673
|
return "upstream_ws_dial_failed";
|
|
460
674
|
}
|
|
675
|
+
function publicHTTPErrorMessage(code) {
|
|
676
|
+
switch (code) {
|
|
677
|
+
case "invalid_request_meta":
|
|
678
|
+
return "invalid proxy request";
|
|
679
|
+
case "request_body_too_large":
|
|
680
|
+
case "response_body_too_large":
|
|
681
|
+
return "proxy body limit exceeded";
|
|
682
|
+
case "resource_exhausted":
|
|
683
|
+
return "proxy resource limit exceeded";
|
|
684
|
+
case "timeout":
|
|
685
|
+
return "upstream request timed out";
|
|
686
|
+
case "canceled":
|
|
687
|
+
return "proxy request canceled";
|
|
688
|
+
default:
|
|
689
|
+
return "upstream request failed";
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function publicWSErrorMessage(code) {
|
|
693
|
+
switch (code) {
|
|
694
|
+
case "invalid_ws_open_meta":
|
|
695
|
+
return "invalid proxy WebSocket request";
|
|
696
|
+
case "resource_exhausted":
|
|
697
|
+
return "proxy WebSocket resource limit exceeded";
|
|
698
|
+
default:
|
|
699
|
+
return "upstream WebSocket connection failed";
|
|
700
|
+
}
|
|
701
|
+
}
|
|
461
702
|
function toBytes(input) {
|
|
462
703
|
if (input instanceof Uint8Array)
|
|
463
704
|
return input;
|
|
@@ -3,7 +3,7 @@ export declare const PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE = "flowersec-proxy:wind
|
|
|
3
3
|
export declare const PROXY_WINDOW_FETCH_MSG_TYPE = "flowersec-proxy:fetch";
|
|
4
4
|
export declare const PROXY_WINDOW_WS_OPEN_MSG_TYPE = "flowersec-proxy:ws_open";
|
|
5
5
|
export declare const PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE = "flowersec-proxy:ws_open_ack";
|
|
6
|
-
export declare const
|
|
6
|
+
export declare const PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY = "stream_bidirectional_ack_v2";
|
|
7
7
|
export declare const PROXY_WINDOW_WS_ERROR_MSG_TYPE = "flowersec-proxy:ws_error";
|
|
8
8
|
export declare const PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE = "flowersec-proxy:stream_chunk";
|
|
9
9
|
export declare const PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE = "flowersec-proxy:stream_write_ack";
|
|
@@ -32,12 +32,13 @@ export type ProxyWindowWsOpenMsg = Readonly<{
|
|
|
32
32
|
type: typeof PROXY_WINDOW_WS_OPEN_MSG_TYPE;
|
|
33
33
|
path: string;
|
|
34
34
|
protocols?: readonly string[];
|
|
35
|
+
capabilities: readonly string[];
|
|
35
36
|
capabilityNonce?: string;
|
|
36
37
|
}>;
|
|
37
38
|
export type ProxyWindowWsOpenAckMsg = Readonly<{
|
|
38
39
|
type: typeof PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE;
|
|
39
40
|
protocol: string;
|
|
40
|
-
capabilities
|
|
41
|
+
capabilities: readonly string[];
|
|
41
42
|
}>;
|
|
42
43
|
export type ProxyWindowWsErrorMsg = Readonly<{
|
|
43
44
|
type: typeof PROXY_WINDOW_WS_ERROR_MSG_TYPE;
|
|
@@ -46,7 +47,7 @@ export type ProxyWindowWsErrorMsg = Readonly<{
|
|
|
46
47
|
export type ProxyWindowStreamChunkMsg = Readonly<{
|
|
47
48
|
type: typeof PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE;
|
|
48
49
|
data: ArrayBuffer;
|
|
49
|
-
writeId
|
|
50
|
+
writeId: number;
|
|
50
51
|
}>;
|
|
51
52
|
export type ProxyWindowStreamWriteAckMsg = Readonly<{
|
|
52
53
|
type: typeof PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE;
|
|
@@ -2,7 +2,7 @@ export const PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE = "flowersec-proxy:window_fetch
|
|
|
2
2
|
export const PROXY_WINDOW_FETCH_MSG_TYPE = "flowersec-proxy:fetch";
|
|
3
3
|
export const PROXY_WINDOW_WS_OPEN_MSG_TYPE = "flowersec-proxy:ws_open";
|
|
4
4
|
export const PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE = "flowersec-proxy:ws_open_ack";
|
|
5
|
-
export const
|
|
5
|
+
export const PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY = "stream_bidirectional_ack_v2";
|
|
6
6
|
export const PROXY_WINDOW_WS_ERROR_MSG_TYPE = "flowersec-proxy:ws_error";
|
|
7
7
|
export const PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE = "flowersec-proxy:stream_chunk";
|
|
8
8
|
export const PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE = "flowersec-proxy:stream_write_ack";
|