@modelprofile.com/browser-runtime 1.0.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/.smartconfig.json +34 -0
- package/changelog.md +11 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/actions.d.ts +3 -0
- package/dist_ts/actions.js +215 -0
- package/dist_ts/classes.artifactstore.d.ts +38 -0
- package/dist_ts/classes.artifactstore.js +344 -0
- package/dist_ts/classes.egressproxy.d.ts +67 -0
- package/dist_ts/classes.egressproxy.js +830 -0
- package/dist_ts/classes.flexprovider.d.ts +9 -0
- package/dist_ts/classes.flexprovider.js +117 -0
- package/dist_ts/classes.framed.d.ts +52 -0
- package/dist_ts/classes.framed.js +557 -0
- package/dist_ts/classes.runtime.d.ts +202 -0
- package/dist_ts/classes.runtime.js +1667 -0
- package/dist_ts/confinement.d.ts +2 -0
- package/dist_ts/confinement.js +63 -0
- package/dist_ts/errors.d.ts +7 -0
- package/dist_ts/errors.js +40 -0
- package/dist_ts/index.d.ts +11 -0
- package/dist_ts/index.js +9 -0
- package/dist_ts/interfaces.d.ts +287 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/internal.testing.d.ts +9 -0
- package/dist_ts/internal.testing.js +2 -0
- package/dist_ts/mcp.d.ts +4 -0
- package/dist_ts/mcp.js +196 -0
- package/dist_ts/plugins.d.ts +20 -0
- package/dist_ts/plugins.js +24 -0
- package/dist_ts/utils.d.ts +25 -0
- package/dist_ts/utils.js +143 -0
- package/license.md +21 -0
- package/package.json +59 -0
- package/readme.hints.md +35 -0
- package/readme.md +181 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/actions.ts +241 -0
- package/ts/classes.artifactstore.ts +432 -0
- package/ts/classes.egressproxy.ts +1005 -0
- package/ts/classes.flexprovider.ts +134 -0
- package/ts/classes.framed.ts +649 -0
- package/ts/classes.runtime.ts +2135 -0
- package/ts/confinement.ts +90 -0
- package/ts/errors.ts +63 -0
- package/ts/index.ts +52 -0
- package/ts/interfaces.ts +375 -0
- package/ts/internal.testing.ts +18 -0
- package/ts/mcp.ts +230 -0
- package/ts/plugins.ts +28 -0
- package/ts/utils.ts +188 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IBrowserEgressConnectionOptions,
|
|
4
|
+
IBrowserEgressProxyOptions,
|
|
5
|
+
IBrowserEgressProxyStats,
|
|
6
|
+
TBrowserDnsResolver,
|
|
7
|
+
TBrowserEgressConnector,
|
|
8
|
+
} from './interfaces.js';
|
|
9
|
+
import { BrowserRuntimeError } from './errors.js';
|
|
10
|
+
import {
|
|
11
|
+
randomId,
|
|
12
|
+
validateBoundedString,
|
|
13
|
+
validateInteger,
|
|
14
|
+
validateOptionalInteger,
|
|
15
|
+
waitBounded,
|
|
16
|
+
} from './utils.js';
|
|
17
|
+
|
|
18
|
+
interface IResolvedTarget {
|
|
19
|
+
address: string;
|
|
20
|
+
family: 4 | 6;
|
|
21
|
+
hostname: string;
|
|
22
|
+
port: number;
|
|
23
|
+
path: string;
|
|
24
|
+
hostHeader: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hopByHopHeaders = new Set([
|
|
28
|
+
'connection',
|
|
29
|
+
'keep-alive',
|
|
30
|
+
'proxy-authenticate',
|
|
31
|
+
'proxy-authorization',
|
|
32
|
+
'proxy-connection',
|
|
33
|
+
'te',
|
|
34
|
+
'trailer',
|
|
35
|
+
'transfer-encoding',
|
|
36
|
+
'upgrade',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export class BrowserEgressProxy {
|
|
40
|
+
private readonly projectId: string;
|
|
41
|
+
private readonly allowedPorts: ReadonlySet<number>;
|
|
42
|
+
private readonly maxConnections: number;
|
|
43
|
+
private readonly maxActiveRequests: number;
|
|
44
|
+
private readonly maxHeaderBytes: number;
|
|
45
|
+
private readonly connectTimeoutMs: number;
|
|
46
|
+
private readonly idleTimeoutMs: number;
|
|
47
|
+
private readonly tunnelLifetimeMs: number;
|
|
48
|
+
private readonly requestTimeoutMs: number;
|
|
49
|
+
private readonly maxRequestBytes: number;
|
|
50
|
+
private readonly maxResponseBytes: number;
|
|
51
|
+
private readonly maxTunnelBytes: number;
|
|
52
|
+
private readonly resolver: TBrowserDnsResolver;
|
|
53
|
+
private readonly connector: TBrowserEgressConnector;
|
|
54
|
+
private readonly username = randomId(18);
|
|
55
|
+
private readonly password = randomId(32);
|
|
56
|
+
private readonly expectedAuthorization: plugins.Buffer;
|
|
57
|
+
private readonly clientSockets = new Set<plugins.net.Socket>();
|
|
58
|
+
private readonly upstreamSockets = new Set<plugins.net.Socket>();
|
|
59
|
+
private readonly activeOperations = new Set<Promise<void>>();
|
|
60
|
+
private server?: plugins.http.Server;
|
|
61
|
+
private listenPort?: number;
|
|
62
|
+
private startPromise?: Promise<void>;
|
|
63
|
+
private stopPromise?: Promise<void>;
|
|
64
|
+
private lifecycleState: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
|
|
65
|
+
private lifecycleController = new AbortController();
|
|
66
|
+
private ordinaryRequests = 0;
|
|
67
|
+
private upgradeRequests = 0;
|
|
68
|
+
private connectRequests = 0;
|
|
69
|
+
private rejectedRequests = 0;
|
|
70
|
+
|
|
71
|
+
constructor(options: IBrowserEgressProxyOptions) {
|
|
72
|
+
this.projectId = validateBoundedString(options.projectId, 'projectId', 1, 128);
|
|
73
|
+
const ports = options.allowedPorts ?? [80, 443];
|
|
74
|
+
if (!Array.isArray(ports) || ports.length < 1 || ports.length > 32) {
|
|
75
|
+
throw new BrowserRuntimeError('INVALID_INPUT', 'allowedPorts must contain 1 to 32 ports');
|
|
76
|
+
}
|
|
77
|
+
this.allowedPorts = new Set(ports.map((port) => validateInteger(port, 'allowedPort', 1, 65535)));
|
|
78
|
+
if (this.allowedPorts.size !== ports.length) {
|
|
79
|
+
throw new BrowserRuntimeError('INVALID_INPUT', 'allowedPorts must not contain duplicates');
|
|
80
|
+
}
|
|
81
|
+
this.maxConnections = validateOptionalInteger(
|
|
82
|
+
options.maxConnections,
|
|
83
|
+
'maxConnections',
|
|
84
|
+
1,
|
|
85
|
+
1024,
|
|
86
|
+
128,
|
|
87
|
+
);
|
|
88
|
+
this.maxActiveRequests = validateOptionalInteger(
|
|
89
|
+
options.maxActiveRequests,
|
|
90
|
+
'maxActiveRequests',
|
|
91
|
+
1,
|
|
92
|
+
1024,
|
|
93
|
+
this.maxConnections,
|
|
94
|
+
);
|
|
95
|
+
this.maxHeaderBytes = validateOptionalInteger(
|
|
96
|
+
options.maxHeaderBytes,
|
|
97
|
+
'maxHeaderBytes',
|
|
98
|
+
1024,
|
|
99
|
+
128 * 1024,
|
|
100
|
+
32 * 1024,
|
|
101
|
+
);
|
|
102
|
+
this.connectTimeoutMs = validateOptionalInteger(
|
|
103
|
+
options.connectTimeoutMs,
|
|
104
|
+
'connectTimeoutMs',
|
|
105
|
+
100,
|
|
106
|
+
60_000,
|
|
107
|
+
10_000,
|
|
108
|
+
);
|
|
109
|
+
this.idleTimeoutMs = validateOptionalInteger(
|
|
110
|
+
options.idleTimeoutMs,
|
|
111
|
+
'idleTimeoutMs',
|
|
112
|
+
1000,
|
|
113
|
+
10 * 60_000,
|
|
114
|
+
60_000,
|
|
115
|
+
);
|
|
116
|
+
this.tunnelLifetimeMs = validateOptionalInteger(
|
|
117
|
+
options.tunnelLifetimeMs,
|
|
118
|
+
'tunnelLifetimeMs',
|
|
119
|
+
1000,
|
|
120
|
+
60 * 60_000,
|
|
121
|
+
10 * 60_000,
|
|
122
|
+
);
|
|
123
|
+
this.requestTimeoutMs = validateOptionalInteger(
|
|
124
|
+
options.requestTimeoutMs,
|
|
125
|
+
'requestTimeoutMs',
|
|
126
|
+
1000,
|
|
127
|
+
60 * 60_000,
|
|
128
|
+
Math.min(this.tunnelLifetimeMs, 5 * 60_000),
|
|
129
|
+
);
|
|
130
|
+
this.maxRequestBytes = validateOptionalInteger(
|
|
131
|
+
options.maxRequestBytes,
|
|
132
|
+
'maxRequestBytes',
|
|
133
|
+
1024,
|
|
134
|
+
256 * 1024 * 1024,
|
|
135
|
+
16 * 1024 * 1024,
|
|
136
|
+
);
|
|
137
|
+
this.maxResponseBytes = validateOptionalInteger(
|
|
138
|
+
options.maxResponseBytes,
|
|
139
|
+
'maxResponseBytes',
|
|
140
|
+
1024,
|
|
141
|
+
1024 * 1024 * 1024,
|
|
142
|
+
64 * 1024 * 1024,
|
|
143
|
+
);
|
|
144
|
+
this.maxTunnelBytes = validateOptionalInteger(
|
|
145
|
+
options.maxTunnelBytes,
|
|
146
|
+
'maxTunnelBytes',
|
|
147
|
+
1024,
|
|
148
|
+
2 * 1024 * 1024 * 1024,
|
|
149
|
+
256 * 1024 * 1024,
|
|
150
|
+
);
|
|
151
|
+
this.resolver = options.resolver ?? (async (hostname, lookupOptions) => (
|
|
152
|
+
plugins.dns.promises.lookup(hostname, {
|
|
153
|
+
all: lookupOptions.all,
|
|
154
|
+
verbatim: lookupOptions.verbatim,
|
|
155
|
+
})
|
|
156
|
+
));
|
|
157
|
+
this.connector = options.connector ?? ((connectionOptions) => plugins.net.connect({
|
|
158
|
+
host: connectionOptions.address,
|
|
159
|
+
family: connectionOptions.family,
|
|
160
|
+
port: connectionOptions.port,
|
|
161
|
+
}));
|
|
162
|
+
this.expectedAuthorization = plugins.Buffer.from(
|
|
163
|
+
`Basic ${plugins.Buffer.from(`${this.username}:${this.password}`, 'utf8').toString('base64')}`,
|
|
164
|
+
'utf8',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public get credentials(): { username: string; password: string } {
|
|
169
|
+
return { username: this.username, password: this.password };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public get port(): number {
|
|
173
|
+
if (this.listenPort === undefined) throw new BrowserRuntimeError('NOT_RUNNING');
|
|
174
|
+
return this.listenPort;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public get project(): string {
|
|
178
|
+
return this.projectId;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
public get allowedTargetPorts(): number[] {
|
|
182
|
+
return [...this.allowedPorts];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public getStats(): IBrowserEgressProxyStats {
|
|
186
|
+
return {
|
|
187
|
+
ordinaryRequests: this.ordinaryRequests,
|
|
188
|
+
upgradeRequests: this.upgradeRequests,
|
|
189
|
+
connectRequests: this.connectRequests,
|
|
190
|
+
rejectedRequests: this.rejectedRequests,
|
|
191
|
+
activeConnections: this.clientSockets.size,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public start(): Promise<void> {
|
|
196
|
+
if (this.lifecycleState === 'running') return Promise.resolve();
|
|
197
|
+
if (this.startPromise) return this.startPromise;
|
|
198
|
+
if (this.stopPromise) return this.stopPromise.then(() => this.start());
|
|
199
|
+
this.stopPromise = undefined;
|
|
200
|
+
this.lifecycleState = 'starting';
|
|
201
|
+
this.lifecycleController = new AbortController();
|
|
202
|
+
this.startPromise = this.startInternal().then(() => {
|
|
203
|
+
if (this.lifecycleState !== 'starting') throw new BrowserRuntimeError('ABORTED');
|
|
204
|
+
this.lifecycleState = 'running';
|
|
205
|
+
}).catch((error) => {
|
|
206
|
+
if (this.lifecycleState === 'starting') this.lifecycleState = 'stopped';
|
|
207
|
+
throw error;
|
|
208
|
+
}).finally(() => {
|
|
209
|
+
this.startPromise = undefined;
|
|
210
|
+
});
|
|
211
|
+
return this.startPromise;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public stop(): Promise<void> {
|
|
215
|
+
if (this.stopPromise) return this.stopPromise;
|
|
216
|
+
if (this.lifecycleState === 'stopped' && !this.server && !this.startPromise) {
|
|
217
|
+
return Promise.resolve();
|
|
218
|
+
}
|
|
219
|
+
this.lifecycleState = 'stopping';
|
|
220
|
+
this.lifecycleController.abort(new BrowserRuntimeError('ABORTED'));
|
|
221
|
+
const startup = this.startPromise;
|
|
222
|
+
this.stopPromise = (async () => {
|
|
223
|
+
await startup?.catch(() => undefined);
|
|
224
|
+
await this.stopInternal();
|
|
225
|
+
this.lifecycleState = 'stopped';
|
|
226
|
+
})().finally(() => {
|
|
227
|
+
this.stopPromise = undefined;
|
|
228
|
+
});
|
|
229
|
+
return this.stopPromise;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async startInternal(): Promise<void> {
|
|
233
|
+
const server = plugins.http.createServer(
|
|
234
|
+
{ maxHeaderSize: this.maxHeaderBytes },
|
|
235
|
+
(request, response) => {
|
|
236
|
+
if (!this.admit()) {
|
|
237
|
+
this.rejectedRequests += 1;
|
|
238
|
+
this.sendSafeFailure(response, 503);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.track(this.handleHttpRequest(request, response));
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
this.server = server;
|
|
245
|
+
server.on('connection', (socket) => {
|
|
246
|
+
if (this.clientSockets.size >= this.maxConnections) {
|
|
247
|
+
socket.destroy();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.clientSockets.add(socket);
|
|
251
|
+
socket.on('error', () => socket.destroy());
|
|
252
|
+
socket.setTimeout(this.idleTimeoutMs, () => socket.destroy());
|
|
253
|
+
socket.once('close', () => this.clientSockets.delete(socket));
|
|
254
|
+
});
|
|
255
|
+
server.on('connect', (request, socket, head) => {
|
|
256
|
+
if (!this.admit()) {
|
|
257
|
+
this.rejectedRequests += 1;
|
|
258
|
+
this.rejectRawSocket(socket, 502, false);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.track(this.handleConnect(request, socket, head));
|
|
262
|
+
});
|
|
263
|
+
server.on('upgrade', (request, socket, head) => {
|
|
264
|
+
if (!this.admit()) {
|
|
265
|
+
this.rejectedRequests += 1;
|
|
266
|
+
this.rejectRawSocket(socket, 502, false);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.track(this.handleUpgrade(request, socket, head));
|
|
270
|
+
});
|
|
271
|
+
server.on('clientError', (_error, socket) => {
|
|
272
|
+
this.rejectedRequests += 1;
|
|
273
|
+
if (socket.writable) {
|
|
274
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
275
|
+
} else {
|
|
276
|
+
socket.destroy();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
server.on('error', () => {
|
|
280
|
+
if (this.lifecycleState === 'running') void this.stop().catch(() => undefined);
|
|
281
|
+
});
|
|
282
|
+
server.headersTimeout = this.requestTimeoutMs;
|
|
283
|
+
server.requestTimeout = this.requestTimeoutMs;
|
|
284
|
+
server.maxRequestsPerSocket = this.maxActiveRequests;
|
|
285
|
+
await new Promise<void>((resolve, reject) => {
|
|
286
|
+
const onError = (error: Error): void => {
|
|
287
|
+
server.off('listening', onListening);
|
|
288
|
+
reject(error);
|
|
289
|
+
};
|
|
290
|
+
const onListening = (): void => {
|
|
291
|
+
server.off('error', onError);
|
|
292
|
+
resolve();
|
|
293
|
+
};
|
|
294
|
+
server.once('error', onError);
|
|
295
|
+
server.once('listening', onListening);
|
|
296
|
+
server.listen(0, '127.0.0.1');
|
|
297
|
+
});
|
|
298
|
+
const address = server.address();
|
|
299
|
+
if (!address || typeof address === 'string' || address.address !== '127.0.0.1') {
|
|
300
|
+
await this.stopInternal();
|
|
301
|
+
throw new BrowserRuntimeError('FENCED', 'proxy did not bind to IPv4 loopback');
|
|
302
|
+
}
|
|
303
|
+
this.listenPort = address.port;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async stopInternal(): Promise<void> {
|
|
307
|
+
const server = this.server;
|
|
308
|
+
this.listenPort = undefined;
|
|
309
|
+
for (const socket of this.clientSockets) socket.destroy();
|
|
310
|
+
for (const socket of this.upstreamSockets) socket.destroy();
|
|
311
|
+
if (server) {
|
|
312
|
+
await new Promise<void>((resolve, reject) => {
|
|
313
|
+
server.close((error) => {
|
|
314
|
+
if (error && (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') {
|
|
315
|
+
reject(error);
|
|
316
|
+
} else {
|
|
317
|
+
resolve();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const settled = await waitBounded(
|
|
323
|
+
Promise.allSettled([...this.activeOperations]).then(() => undefined),
|
|
324
|
+
this.requestTimeoutMs,
|
|
325
|
+
);
|
|
326
|
+
if (!settled.settled) throw new BrowserRuntimeError('TIMEOUT');
|
|
327
|
+
this.server = undefined;
|
|
328
|
+
this.startPromise = undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private track(operation: Promise<void>): void {
|
|
332
|
+
const observed = operation.catch(() => undefined).finally(() => {
|
|
333
|
+
this.activeOperations.delete(observed);
|
|
334
|
+
});
|
|
335
|
+
this.activeOperations.add(observed);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private admit(): boolean {
|
|
339
|
+
return this.lifecycleState !== 'stopping'
|
|
340
|
+
&& this.activeOperations.size < this.maxActiveRequests;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private async handleHttpRequest(
|
|
344
|
+
request: plugins.http.IncomingMessage,
|
|
345
|
+
response: plugins.http.ServerResponse,
|
|
346
|
+
): Promise<void> {
|
|
347
|
+
this.ordinaryRequests += 1;
|
|
348
|
+
if (!this.authorize(request)) {
|
|
349
|
+
this.sendProxyAuthenticationRequired(response);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
request.pause();
|
|
353
|
+
let upstreamRequest: plugins.http.ClientRequest | undefined;
|
|
354
|
+
let upstreamSocket: plugins.net.Socket | undefined;
|
|
355
|
+
let upstreamResponse: plugins.http.IncomingMessage | undefined;
|
|
356
|
+
let agent: plugins.http.Agent | undefined;
|
|
357
|
+
const operation = this.createOperationSignal();
|
|
358
|
+
const abort = (): void => operation.controller.abort(new BrowserRuntimeError('ABORTED'));
|
|
359
|
+
request.once('aborted', abort);
|
|
360
|
+
request.socket.once('close', abort);
|
|
361
|
+
response.once('close', () => {
|
|
362
|
+
if (!response.writableFinished) abort();
|
|
363
|
+
});
|
|
364
|
+
try {
|
|
365
|
+
const contentLength = Number(request.headers['content-length']);
|
|
366
|
+
if (
|
|
367
|
+
Number.isFinite(contentLength)
|
|
368
|
+
&& contentLength > this.maxRequestBytes
|
|
369
|
+
) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
|
|
370
|
+
const target = await this.resolveAbsoluteTarget(request.url, false, operation.signal);
|
|
371
|
+
operation.signal.throwIfAborted();
|
|
372
|
+
upstreamSocket = this.openUpstream(target, operation.signal);
|
|
373
|
+
await this.waitForConnect(upstreamSocket, operation.signal);
|
|
374
|
+
const headers = this.sanitizeHeaders(request.headers);
|
|
375
|
+
headers.host = target.hostHeader;
|
|
376
|
+
agent = new plugins.http.Agent({ keepAlive: false });
|
|
377
|
+
agent.createConnection = () => upstreamSocket!;
|
|
378
|
+
upstreamRequest = plugins.http.request({
|
|
379
|
+
method: request.method,
|
|
380
|
+
host: target.address,
|
|
381
|
+
family: target.family,
|
|
382
|
+
port: target.port,
|
|
383
|
+
path: target.path,
|
|
384
|
+
headers,
|
|
385
|
+
agent,
|
|
386
|
+
maxHeaderSize: this.maxHeaderBytes,
|
|
387
|
+
});
|
|
388
|
+
const onAbort = (): void => {
|
|
389
|
+
upstreamRequest?.destroy(operation.signal.reason);
|
|
390
|
+
upstreamResponse?.destroy(operation.signal.reason);
|
|
391
|
+
upstreamSocket?.destroy(operation.signal.reason);
|
|
392
|
+
};
|
|
393
|
+
operation.signal.addEventListener('abort', onAbort, { once: true });
|
|
394
|
+
let requestBytes = 0;
|
|
395
|
+
request.on('data', (chunk: plugins.Buffer) => {
|
|
396
|
+
requestBytes += chunk.byteLength;
|
|
397
|
+
if (requestBytes > this.maxRequestBytes) {
|
|
398
|
+
operation.controller.abort(new BrowserRuntimeError('QUOTA_EXCEEDED'));
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
const upstreamResponsePromise = new Promise<plugins.http.IncomingMessage>((resolve, reject) => {
|
|
402
|
+
upstreamRequest!.once('response', resolve);
|
|
403
|
+
upstreamRequest!.once('error', reject);
|
|
404
|
+
});
|
|
405
|
+
request.pipe(upstreamRequest);
|
|
406
|
+
upstreamResponse = await this.raceAbort(upstreamResponsePromise, operation.signal);
|
|
407
|
+
const responseLength = Number(upstreamResponse.headers['content-length']);
|
|
408
|
+
if (Number.isFinite(responseLength) && responseLength > this.maxResponseBytes) {
|
|
409
|
+
throw new BrowserRuntimeError('QUOTA_EXCEEDED');
|
|
410
|
+
}
|
|
411
|
+
const responseHeaders = this.sanitizeHeaders(upstreamResponse.headers);
|
|
412
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders);
|
|
413
|
+
let responseBytes = 0;
|
|
414
|
+
upstreamResponse.on('data', (chunk: plugins.Buffer) => {
|
|
415
|
+
responseBytes += chunk.byteLength;
|
|
416
|
+
if (responseBytes > this.maxResponseBytes) {
|
|
417
|
+
operation.controller.abort(new BrowserRuntimeError('QUOTA_EXCEEDED'));
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
upstreamResponse.once('aborted', abort);
|
|
421
|
+
const responseFailure = new Promise<never>((_resolve, reject) => {
|
|
422
|
+
upstreamResponse!.once('error', reject);
|
|
423
|
+
});
|
|
424
|
+
upstreamResponse.pipe(response);
|
|
425
|
+
await Promise.race([this.waitForResponseCompletion(response), responseFailure]);
|
|
426
|
+
operation.signal.throwIfAborted();
|
|
427
|
+
} catch {
|
|
428
|
+
this.rejectedRequests += 1;
|
|
429
|
+
upstreamRequest?.destroy();
|
|
430
|
+
upstreamSocket?.destroy();
|
|
431
|
+
if (!response.headersSent) this.sendSafeFailure(response, 502);
|
|
432
|
+
else response.destroy();
|
|
433
|
+
} finally {
|
|
434
|
+
operation.dispose();
|
|
435
|
+
request.off('aborted', abort);
|
|
436
|
+
request.socket.off('close', abort);
|
|
437
|
+
upstreamResponse?.destroy();
|
|
438
|
+
upstreamRequest?.destroy();
|
|
439
|
+
upstreamSocket?.destroy();
|
|
440
|
+
agent?.destroy();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private async handleConnect(
|
|
445
|
+
request: plugins.http.IncomingMessage,
|
|
446
|
+
clientSocket: plugins.stream.Duplex,
|
|
447
|
+
head: plugins.Buffer,
|
|
448
|
+
): Promise<void> {
|
|
449
|
+
this.connectRequests += 1;
|
|
450
|
+
if (!this.authorize(request)) {
|
|
451
|
+
this.rejectRawSocket(clientSocket, 407, true);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
clientSocket.pause();
|
|
455
|
+
const operation = this.createOperationSignal();
|
|
456
|
+
const abort = (): void => operation.controller.abort(new BrowserRuntimeError('ABORTED'));
|
|
457
|
+
clientSocket.once('close', abort);
|
|
458
|
+
let upstream: plugins.net.Socket | undefined;
|
|
459
|
+
try {
|
|
460
|
+
if (head.byteLength > this.maxTunnelBytes) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
|
|
461
|
+
const target = await this.resolveAuthority(request.url, operation.signal);
|
|
462
|
+
upstream = this.openUpstream(target, operation.signal);
|
|
463
|
+
await this.waitForConnect(upstream, operation.signal);
|
|
464
|
+
operation.signal.throwIfAborted();
|
|
465
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
466
|
+
await this.pipeTunnel(clientSocket, upstream, {
|
|
467
|
+
toUpstream: head,
|
|
468
|
+
toClient: plugins.Buffer.alloc(0),
|
|
469
|
+
}, operation.signal);
|
|
470
|
+
} catch {
|
|
471
|
+
this.rejectedRequests += 1;
|
|
472
|
+
upstream?.destroy();
|
|
473
|
+
this.rejectRawSocket(clientSocket, 502, false);
|
|
474
|
+
} finally {
|
|
475
|
+
operation.dispose();
|
|
476
|
+
clientSocket.off('close', abort);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async handleUpgrade(
|
|
481
|
+
request: plugins.http.IncomingMessage,
|
|
482
|
+
clientSocket: plugins.stream.Duplex,
|
|
483
|
+
head: plugins.Buffer,
|
|
484
|
+
): Promise<void> {
|
|
485
|
+
this.upgradeRequests += 1;
|
|
486
|
+
if (!this.authorize(request)) {
|
|
487
|
+
this.rejectRawSocket(clientSocket, 407, true);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
clientSocket.pause();
|
|
491
|
+
const operation = this.createOperationSignal();
|
|
492
|
+
const abort = (): void => operation.controller.abort(new BrowserRuntimeError('ABORTED'));
|
|
493
|
+
clientSocket.once('close', abort);
|
|
494
|
+
let upstream: plugins.net.Socket | undefined;
|
|
495
|
+
try {
|
|
496
|
+
if (
|
|
497
|
+
request.method !== 'GET'
|
|
498
|
+
|| String(request.headers.upgrade).toLowerCase() !== 'websocket'
|
|
499
|
+
|| !String(request.headers.connection).toLowerCase().split(',')
|
|
500
|
+
.map((value) => value.trim()).includes('upgrade')
|
|
501
|
+
) throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
502
|
+
const webSocketKey = validateBoundedString(
|
|
503
|
+
request.headers['sec-websocket-key'],
|
|
504
|
+
'sec-websocket-key',
|
|
505
|
+
20,
|
|
506
|
+
32,
|
|
507
|
+
);
|
|
508
|
+
if (
|
|
509
|
+
request.headers['sec-websocket-version'] !== '13'
|
|
510
|
+
|| !/^[A-Za-z0-9+/]{22}==$/.test(webSocketKey)
|
|
511
|
+
|| plugins.Buffer.from(webSocketKey, 'base64').toString('base64') !== webSocketKey
|
|
512
|
+
) throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
513
|
+
const expectedAccept = plugins.crypto
|
|
514
|
+
.createHash('sha1')
|
|
515
|
+
.update(`${webSocketKey}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`, 'ascii')
|
|
516
|
+
.digest('base64');
|
|
517
|
+
if (head.byteLength > this.maxTunnelBytes) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
|
|
518
|
+
const target = await this.resolveAbsoluteTarget(request.url, true, operation.signal);
|
|
519
|
+
upstream = this.openUpstream(target, operation.signal);
|
|
520
|
+
await this.waitForConnect(upstream, operation.signal);
|
|
521
|
+
const headers = this.sanitizeHeaders(request.headers);
|
|
522
|
+
headers.host = target.hostHeader;
|
|
523
|
+
headers.connection = 'Upgrade';
|
|
524
|
+
headers.upgrade = 'websocket';
|
|
525
|
+
const headerLines = Object.entries(headers).flatMap(([name, value]) => (
|
|
526
|
+
Array.isArray(value)
|
|
527
|
+
? value.map((entry) => `${name}: ${entry}`)
|
|
528
|
+
: value === undefined
|
|
529
|
+
? []
|
|
530
|
+
: [`${name}: ${value}`]
|
|
531
|
+
));
|
|
532
|
+
upstream.write(
|
|
533
|
+
`${request.method ?? 'GET'} ${target.path} HTTP/1.1\r\n${headerLines.join('\r\n')}\r\n\r\n`,
|
|
534
|
+
);
|
|
535
|
+
const handshake = await this.readWebSocketHandshake(
|
|
536
|
+
upstream,
|
|
537
|
+
operation.signal,
|
|
538
|
+
expectedAccept,
|
|
539
|
+
);
|
|
540
|
+
clientSocket.write(handshake.header);
|
|
541
|
+
await this.pipeTunnel(
|
|
542
|
+
clientSocket,
|
|
543
|
+
upstream,
|
|
544
|
+
{ toUpstream: head, toClient: handshake.remaining },
|
|
545
|
+
operation.signal,
|
|
546
|
+
);
|
|
547
|
+
} catch {
|
|
548
|
+
this.rejectedRequests += 1;
|
|
549
|
+
upstream?.destroy();
|
|
550
|
+
this.rejectRawSocket(clientSocket, 502, false);
|
|
551
|
+
} finally {
|
|
552
|
+
operation.dispose();
|
|
553
|
+
clientSocket.off('close', abort);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private authorize(request: plugins.http.IncomingMessage): boolean {
|
|
558
|
+
const value = request.headers['proxy-authorization'];
|
|
559
|
+
if (typeof value !== 'string') {
|
|
560
|
+
this.rejectedRequests += 1;
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
const supplied = plugins.Buffer.from(value, 'utf8');
|
|
564
|
+
const accepted = supplied.byteLength === this.expectedAuthorization.byteLength
|
|
565
|
+
&& plugins.crypto.timingSafeEqual(supplied, this.expectedAuthorization);
|
|
566
|
+
if (!accepted) this.rejectedRequests += 1;
|
|
567
|
+
return accepted;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private sendProxyAuthenticationRequired(response: plugins.http.ServerResponse): void {
|
|
571
|
+
response.writeHead(407, {
|
|
572
|
+
connection: 'close',
|
|
573
|
+
'content-length': '0',
|
|
574
|
+
'proxy-authenticate': 'Basic realm="browser-runtime"',
|
|
575
|
+
});
|
|
576
|
+
response.end();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private sendSafeFailure(response: plugins.http.ServerResponse, status: number): void {
|
|
580
|
+
response.writeHead(status, { connection: 'close', 'content-length': '0' });
|
|
581
|
+
response.end();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private rejectRawSocket(
|
|
585
|
+
socket: plugins.stream.Duplex,
|
|
586
|
+
status: 407 | 502,
|
|
587
|
+
authenticate: boolean,
|
|
588
|
+
): void {
|
|
589
|
+
if (!socket.writable) {
|
|
590
|
+
socket.destroy();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const statusText = status === 407 ? 'Proxy Authentication Required' : 'Bad Gateway';
|
|
594
|
+
const challenge = authenticate
|
|
595
|
+
? 'Proxy-Authenticate: Basic realm="browser-runtime"\r\n'
|
|
596
|
+
: '';
|
|
597
|
+
socket.end(
|
|
598
|
+
`HTTP/1.1 ${status} ${statusText}\r\n${challenge}Connection: close\r\nContent-Length: 0\r\n\r\n`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private sanitizeHeaders(
|
|
603
|
+
headers: plugins.http.IncomingHttpHeaders,
|
|
604
|
+
): plugins.http.OutgoingHttpHeaders {
|
|
605
|
+
const connectionTokens = new Set(
|
|
606
|
+
(typeof headers.connection === 'string' ? headers.connection : '')
|
|
607
|
+
.split(',')
|
|
608
|
+
.map((token) => token.trim().toLowerCase())
|
|
609
|
+
.filter(Boolean),
|
|
610
|
+
);
|
|
611
|
+
const result: plugins.http.OutgoingHttpHeaders = {};
|
|
612
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
613
|
+
const normalizedName = name.toLowerCase();
|
|
614
|
+
if (hopByHopHeaders.has(normalizedName) || connectionTokens.has(normalizedName)) continue;
|
|
615
|
+
result[normalizedName] = value;
|
|
616
|
+
}
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private async resolveAbsoluteTarget(
|
|
621
|
+
rawUrl: string | undefined,
|
|
622
|
+
upgrade: boolean,
|
|
623
|
+
signal: AbortSignal,
|
|
624
|
+
): Promise<IResolvedTarget> {
|
|
625
|
+
if (!rawUrl || plugins.Buffer.byteLength(rawUrl, 'utf8') > 8192) {
|
|
626
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
627
|
+
}
|
|
628
|
+
let parsed: URL;
|
|
629
|
+
try {
|
|
630
|
+
parsed = new URL(rawUrl);
|
|
631
|
+
} catch {
|
|
632
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
633
|
+
}
|
|
634
|
+
const allowedProtocols = upgrade ? new Set(['http:', 'ws:']) : new Set(['http:']);
|
|
635
|
+
if (
|
|
636
|
+
!allowedProtocols.has(parsed.protocol)
|
|
637
|
+
|| parsed.username
|
|
638
|
+
|| parsed.password
|
|
639
|
+
|| parsed.hash
|
|
640
|
+
) {
|
|
641
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
642
|
+
}
|
|
643
|
+
const hostname = this.canonicalizeHostname(parsed.hostname);
|
|
644
|
+
const port = parsed.port
|
|
645
|
+
? validateInteger(Number(parsed.port), 'target port', 1, 65535)
|
|
646
|
+
: 80;
|
|
647
|
+
const resolved = await this.resolveHost(hostname, port, signal);
|
|
648
|
+
return {
|
|
649
|
+
...resolved,
|
|
650
|
+
path: `${parsed.pathname || '/'}${parsed.search}`,
|
|
651
|
+
hostHeader: parsed.host,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private async resolveAuthority(
|
|
656
|
+
rawAuthority: string | undefined,
|
|
657
|
+
signal: AbortSignal,
|
|
658
|
+
): Promise<IResolvedTarget> {
|
|
659
|
+
if (
|
|
660
|
+
!rawAuthority
|
|
661
|
+
|| rawAuthority.length > 512
|
|
662
|
+
|| /[\/?#@]/.test(rawAuthority)
|
|
663
|
+
) {
|
|
664
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
665
|
+
}
|
|
666
|
+
let hostnameValue: string;
|
|
667
|
+
let portValue: string;
|
|
668
|
+
if (rawAuthority.startsWith('[')) {
|
|
669
|
+
const closingBracket = rawAuthority.indexOf(']');
|
|
670
|
+
if (closingBracket < 2 || rawAuthority[closingBracket + 1] !== ':') {
|
|
671
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
672
|
+
}
|
|
673
|
+
hostnameValue = rawAuthority.slice(1, closingBracket);
|
|
674
|
+
portValue = rawAuthority.slice(closingBracket + 2);
|
|
675
|
+
} else {
|
|
676
|
+
const separator = rawAuthority.lastIndexOf(':');
|
|
677
|
+
if (separator < 1 || rawAuthority.indexOf(':') !== separator) {
|
|
678
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
679
|
+
}
|
|
680
|
+
hostnameValue = rawAuthority.slice(0, separator);
|
|
681
|
+
portValue = rawAuthority.slice(separator + 1);
|
|
682
|
+
}
|
|
683
|
+
if (!/^\d{1,5}$/.test(portValue)) throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
684
|
+
const hostname = this.canonicalizeHostname(hostnameValue);
|
|
685
|
+
const port = validateInteger(Number(portValue), 'target port', 1, 65535);
|
|
686
|
+
const resolved = await this.resolveHost(hostname, port, signal);
|
|
687
|
+
return { ...resolved, path: '', hostHeader: rawAuthority };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private canonicalizeHostname(hostnameArg: string): string {
|
|
691
|
+
const unbracketed = hostnameArg.startsWith('[') && hostnameArg.endsWith(']')
|
|
692
|
+
? hostnameArg.slice(1, -1)
|
|
693
|
+
: hostnameArg;
|
|
694
|
+
if (!unbracketed || unbracketed.length > 253 || unbracketed.includes('%')) {
|
|
695
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
696
|
+
}
|
|
697
|
+
if (plugins.ipaddr.isValid(unbracketed)) {
|
|
698
|
+
return plugins.ipaddr.parse(unbracketed).toNormalizedString();
|
|
699
|
+
}
|
|
700
|
+
const withoutTrailingDot = unbracketed.endsWith('.') ? unbracketed.slice(0, -1) : unbracketed;
|
|
701
|
+
const ascii = plugins.url.domainToASCII(withoutTrailingDot).toLowerCase();
|
|
702
|
+
if (
|
|
703
|
+
!ascii
|
|
704
|
+
|| ascii.length > 253
|
|
705
|
+
|| !ascii.includes('.')
|
|
706
|
+
|| ascii.split('.').some((label) => (
|
|
707
|
+
label.length < 1
|
|
708
|
+
|| label.length > 63
|
|
709
|
+
|| !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
|
710
|
+
))
|
|
711
|
+
) {
|
|
712
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
713
|
+
}
|
|
714
|
+
return ascii;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private async resolveHost(
|
|
718
|
+
hostname: string,
|
|
719
|
+
port: number,
|
|
720
|
+
signal: AbortSignal,
|
|
721
|
+
): Promise<Omit<IResolvedTarget, 'path' | 'hostHeader'>> {
|
|
722
|
+
if (!this.allowedPorts.has(port)) {
|
|
723
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
724
|
+
}
|
|
725
|
+
const answers = plugins.ipaddr.isValid(hostname)
|
|
726
|
+
? [{ address: hostname, family: plugins.ipaddr.parse(hostname).kind() === 'ipv4' ? 4 : 6 }]
|
|
727
|
+
: await this.raceAbort(
|
|
728
|
+
this.resolver(hostname, { all: true, verbatim: true, signal }),
|
|
729
|
+
signal,
|
|
730
|
+
);
|
|
731
|
+
signal.throwIfAborted();
|
|
732
|
+
if (!Array.isArray(answers) || answers.length < 1 || answers.length > 64) {
|
|
733
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
734
|
+
}
|
|
735
|
+
const validated = answers.map((answer) => {
|
|
736
|
+
if (
|
|
737
|
+
!answer
|
|
738
|
+
|| (answer.family !== 4 && answer.family !== 6)
|
|
739
|
+
|| typeof answer.address !== 'string'
|
|
740
|
+
|| answer.address.includes('%')
|
|
741
|
+
|| !plugins.ipaddr.isValid(answer.address)
|
|
742
|
+
) {
|
|
743
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
744
|
+
}
|
|
745
|
+
const parsed = plugins.ipaddr.parse(answer.address);
|
|
746
|
+
if (
|
|
747
|
+
(
|
|
748
|
+
parsed.kind() === 'ipv6'
|
|
749
|
+
&& (parsed as plugins.ipaddr.IPv6).isIPv4MappedAddress()
|
|
750
|
+
)
|
|
751
|
+
|| parsed.range() !== 'unicast'
|
|
752
|
+
|| (
|
|
753
|
+
parsed.kind() === 'ipv6'
|
|
754
|
+
&& !(parsed as plugins.ipaddr.IPv6).match(plugins.ipaddr.parse('2000::'), 3)
|
|
755
|
+
)
|
|
756
|
+
) {
|
|
757
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
758
|
+
}
|
|
759
|
+
const family = parsed.kind() === 'ipv4' ? 4 : 6;
|
|
760
|
+
if (family !== answer.family) throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
761
|
+
return { address: parsed.toNormalizedString(), family } as const;
|
|
762
|
+
});
|
|
763
|
+
return { ...validated[0]!, hostname, port };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private openUpstream(
|
|
767
|
+
target: Omit<IResolvedTarget, 'path' | 'hostHeader'>,
|
|
768
|
+
signal: AbortSignal,
|
|
769
|
+
): plugins.net.Socket {
|
|
770
|
+
const options: IBrowserEgressConnectionOptions = {
|
|
771
|
+
address: target.address,
|
|
772
|
+
family: target.family,
|
|
773
|
+
port: target.port,
|
|
774
|
+
hostname: target.hostname,
|
|
775
|
+
signal,
|
|
776
|
+
};
|
|
777
|
+
const socket = this.connector(options);
|
|
778
|
+
if (!(socket instanceof plugins.net.Socket)) {
|
|
779
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
780
|
+
}
|
|
781
|
+
this.upstreamSockets.add(socket);
|
|
782
|
+
socket.on('error', () => socket.destroy());
|
|
783
|
+
socket.setTimeout(this.idleTimeoutMs, () => socket.destroy());
|
|
784
|
+
socket.once('close', () => this.upstreamSockets.delete(socket));
|
|
785
|
+
if (signal.aborted) socket.destroy(signal.reason);
|
|
786
|
+
else signal.addEventListener('abort', () => socket.destroy(signal.reason), { once: true });
|
|
787
|
+
return socket;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private async waitForConnect(socket: plugins.net.Socket, signal: AbortSignal): Promise<void> {
|
|
791
|
+
if (
|
|
792
|
+
!socket.connecting
|
|
793
|
+
&& !socket.pending
|
|
794
|
+
&& !socket.destroyed
|
|
795
|
+
&& socket.remoteAddress
|
|
796
|
+
) return;
|
|
797
|
+
if (!socket.connecting || socket.destroyed) throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
798
|
+
await new Promise<void>((resolve, reject) => {
|
|
799
|
+
const timer = setTimeout(() => {
|
|
800
|
+
cleanup();
|
|
801
|
+
socket.destroy();
|
|
802
|
+
reject(new BrowserRuntimeError('TIMEOUT'));
|
|
803
|
+
}, this.connectTimeoutMs);
|
|
804
|
+
timer.unref();
|
|
805
|
+
const cleanup = (): void => {
|
|
806
|
+
clearTimeout(timer);
|
|
807
|
+
socket.off('connect', onConnect);
|
|
808
|
+
socket.off('error', onError);
|
|
809
|
+
socket.off('close', onClose);
|
|
810
|
+
signal.removeEventListener('abort', onAbort);
|
|
811
|
+
};
|
|
812
|
+
const onConnect = (): void => {
|
|
813
|
+
cleanup();
|
|
814
|
+
resolve();
|
|
815
|
+
};
|
|
816
|
+
const onError = (): void => {
|
|
817
|
+
cleanup();
|
|
818
|
+
reject(new BrowserRuntimeError('EGRESS_DENIED'));
|
|
819
|
+
};
|
|
820
|
+
const onClose = (): void => {
|
|
821
|
+
cleanup();
|
|
822
|
+
reject(new BrowserRuntimeError('EGRESS_DENIED'));
|
|
823
|
+
};
|
|
824
|
+
const onAbort = (): void => {
|
|
825
|
+
cleanup();
|
|
826
|
+
socket.destroy(signal.reason);
|
|
827
|
+
reject(signal.reason);
|
|
828
|
+
};
|
|
829
|
+
socket.once('connect', onConnect);
|
|
830
|
+
socket.once('error', onError);
|
|
831
|
+
socket.once('close', onClose);
|
|
832
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private async pipeTunnel(
|
|
837
|
+
clientSocket: plugins.stream.Duplex,
|
|
838
|
+
upstream: plugins.net.Socket,
|
|
839
|
+
initial: { toUpstream: plugins.Buffer; toClient: plugins.Buffer },
|
|
840
|
+
signal: AbortSignal,
|
|
841
|
+
): Promise<void> {
|
|
842
|
+
let transferred = initial.toUpstream.byteLength + initial.toClient.byteLength;
|
|
843
|
+
if (transferred > this.maxTunnelBytes) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
|
|
844
|
+
const lifetimeTimer = setTimeout(() => {
|
|
845
|
+
clientSocket.destroy();
|
|
846
|
+
upstream.destroy();
|
|
847
|
+
}, this.tunnelLifetimeMs);
|
|
848
|
+
lifetimeTimer.unref();
|
|
849
|
+
const count = (chunk: plugins.Buffer): void => {
|
|
850
|
+
transferred += chunk.byteLength;
|
|
851
|
+
if (transferred > this.maxTunnelBytes) {
|
|
852
|
+
clientSocket.destroy();
|
|
853
|
+
upstream.destroy();
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
clientSocket.on('data', count);
|
|
857
|
+
upstream.on('data', count);
|
|
858
|
+
const onAbort = (): void => {
|
|
859
|
+
clientSocket.destroy(signal.reason);
|
|
860
|
+
upstream.destroy(signal.reason);
|
|
861
|
+
};
|
|
862
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
863
|
+
if (initial.toUpstream.byteLength > 0) upstream.write(initial.toUpstream);
|
|
864
|
+
if (initial.toClient.byteLength > 0) clientSocket.write(initial.toClient);
|
|
865
|
+
clientSocket.pipe(upstream);
|
|
866
|
+
upstream.pipe(clientSocket);
|
|
867
|
+
clientSocket.resume();
|
|
868
|
+
await Promise.race([
|
|
869
|
+
new Promise<void>((resolve) => clientSocket.once('close', resolve)),
|
|
870
|
+
new Promise<void>((resolve) => upstream.once('close', resolve)),
|
|
871
|
+
]);
|
|
872
|
+
clearTimeout(lifetimeTimer);
|
|
873
|
+
clientSocket.off('data', count);
|
|
874
|
+
upstream.off('data', count);
|
|
875
|
+
signal.removeEventListener('abort', onAbort);
|
|
876
|
+
clientSocket.destroy();
|
|
877
|
+
upstream.destroy();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private async readWebSocketHandshake(
|
|
881
|
+
upstream: plugins.net.Socket,
|
|
882
|
+
signal: AbortSignal,
|
|
883
|
+
expectedAccept: string,
|
|
884
|
+
): Promise<{ header: plugins.Buffer; remaining: plugins.Buffer }> {
|
|
885
|
+
const data = await new Promise<plugins.Buffer>((resolve, reject) => {
|
|
886
|
+
let buffered = plugins.Buffer.alloc(0);
|
|
887
|
+
const cleanup = (): void => {
|
|
888
|
+
upstream.off('data', onData);
|
|
889
|
+
upstream.off('error', onError);
|
|
890
|
+
upstream.off('close', onClose);
|
|
891
|
+
signal.removeEventListener('abort', onAbort);
|
|
892
|
+
};
|
|
893
|
+
const onData = (chunk: plugins.Buffer): void => {
|
|
894
|
+
buffered = plugins.Buffer.concat([buffered, chunk]);
|
|
895
|
+
const boundary = buffered.indexOf('\r\n\r\n');
|
|
896
|
+
if (boundary < 0 && buffered.byteLength > this.maxHeaderBytes) {
|
|
897
|
+
cleanup();
|
|
898
|
+
reject(new BrowserRuntimeError('QUOTA_EXCEEDED'));
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (boundary < 0) return;
|
|
902
|
+
if (boundary + 4 > this.maxHeaderBytes) {
|
|
903
|
+
cleanup();
|
|
904
|
+
reject(new BrowserRuntimeError('QUOTA_EXCEEDED'));
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
cleanup();
|
|
908
|
+
resolve(buffered);
|
|
909
|
+
};
|
|
910
|
+
const onError = (): void => {
|
|
911
|
+
cleanup();
|
|
912
|
+
reject(new BrowserRuntimeError('EGRESS_DENIED'));
|
|
913
|
+
};
|
|
914
|
+
const onClose = (): void => {
|
|
915
|
+
cleanup();
|
|
916
|
+
reject(new BrowserRuntimeError('EGRESS_DENIED'));
|
|
917
|
+
};
|
|
918
|
+
const onAbort = (): void => {
|
|
919
|
+
cleanup();
|
|
920
|
+
reject(signal.reason);
|
|
921
|
+
};
|
|
922
|
+
upstream.on('data', onData);
|
|
923
|
+
upstream.once('error', onError);
|
|
924
|
+
upstream.once('close', onClose);
|
|
925
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
926
|
+
});
|
|
927
|
+
const boundary = data.indexOf('\r\n\r\n');
|
|
928
|
+
const header = data.subarray(0, boundary + 4);
|
|
929
|
+
const statusLine = header.subarray(0, header.indexOf('\r\n')).toString('latin1');
|
|
930
|
+
if (!/^HTTP\/1\.[01] 101(?: |$)/.test(statusLine)) {
|
|
931
|
+
throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
932
|
+
}
|
|
933
|
+
const headerLines = header.toString('latin1').split('\r\n').slice(1, -2);
|
|
934
|
+
const headers = new Map<string, string[]>();
|
|
935
|
+
for (const headerLine of headerLines) {
|
|
936
|
+
const separator = headerLine.indexOf(':');
|
|
937
|
+
if (separator < 1) throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
938
|
+
const name = headerLine.slice(0, separator).trim().toLowerCase();
|
|
939
|
+
const value = headerLine.slice(separator + 1).trim();
|
|
940
|
+
const values = headers.get(name) ?? [];
|
|
941
|
+
values.push(value);
|
|
942
|
+
headers.set(name, values);
|
|
943
|
+
}
|
|
944
|
+
const connectionTokens = (headers.get('connection') ?? [])
|
|
945
|
+
.flatMap((value) => value.split(','))
|
|
946
|
+
.map((value) => value.trim().toLowerCase());
|
|
947
|
+
if (
|
|
948
|
+
!connectionTokens.includes('upgrade')
|
|
949
|
+
|| headers.get('upgrade')?.length !== 1
|
|
950
|
+
|| headers.get('upgrade')?.[0]?.toLowerCase() !== 'websocket'
|
|
951
|
+
|| headers.get('sec-websocket-accept')?.length !== 1
|
|
952
|
+
|| headers.get('sec-websocket-accept')?.[0] !== expectedAccept
|
|
953
|
+
) throw new BrowserRuntimeError('EGRESS_DENIED');
|
|
954
|
+
return { header, remaining: data.subarray(boundary + 4) };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private createOperationSignal(): {
|
|
958
|
+
controller: AbortController;
|
|
959
|
+
signal: AbortSignal;
|
|
960
|
+
dispose(): void;
|
|
961
|
+
} {
|
|
962
|
+
const controller = new AbortController();
|
|
963
|
+
const timeoutController = new AbortController();
|
|
964
|
+
const timeout = setTimeout(() => {
|
|
965
|
+
timeoutController.abort(new BrowserRuntimeError('TIMEOUT'));
|
|
966
|
+
}, this.requestTimeoutMs);
|
|
967
|
+
timeout.unref();
|
|
968
|
+
return {
|
|
969
|
+
controller,
|
|
970
|
+
signal: AbortSignal.any([
|
|
971
|
+
controller.signal,
|
|
972
|
+
timeoutController.signal,
|
|
973
|
+
this.lifecycleController.signal,
|
|
974
|
+
]),
|
|
975
|
+
dispose: () => clearTimeout(timeout),
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private async raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
980
|
+
signal.throwIfAborted();
|
|
981
|
+
let onAbort!: () => void;
|
|
982
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
983
|
+
onAbort = () => reject(signal.reason);
|
|
984
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
985
|
+
});
|
|
986
|
+
try {
|
|
987
|
+
return await Promise.race([promise, aborted]);
|
|
988
|
+
} finally {
|
|
989
|
+
signal.removeEventListener('abort', onAbort);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
private async waitForResponseCompletion(response: plugins.http.ServerResponse): Promise<void> {
|
|
994
|
+
if (response.writableFinished || response.destroyed) return;
|
|
995
|
+
await new Promise<void>((resolve) => {
|
|
996
|
+
const settle = (): void => {
|
|
997
|
+
response.off('finish', settle);
|
|
998
|
+
response.off('close', settle);
|
|
999
|
+
resolve();
|
|
1000
|
+
};
|
|
1001
|
+
response.once('finish', settle);
|
|
1002
|
+
response.once('close', settle);
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|