1
>
/*---------------------------------------------------------------------------------------------
tunnelProxy.ts
2
>
* Copyright (c) Microsoft Corporation. All rights reserved.
3
>
* Licensed under the MIT License. See License.txt in the project root for license information.
4
>
*--------------------------------------------------------------------------------------------*/
5
>
6
>
import * as net from 'net';
7
>
import { Duplex } from 'stream';
8
>
import type * as http from 'http';
9
>
import type * as https from 'https';
10
>
11
>
import { findFreePortFaster } from '../../../base/node/ports.js';
12
>
import { NodeSocket } from '../../../base/parts/ipc/node/ipc.net.js';
13
>
import { ISocket, SocketCloseEventType } from '../../../base/parts/ipc/common/ipc.net.js';
14
>
import { VSBuffer } from '../../../base/common/buffer.js';
15
>
import { Limiter } from '../../../base/common/async.js';
16
>
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
17
>
import { ILogService } from '../../log/common/log.js';
18
>
import { ITunnelProxyInfo } from '../common/tunnelProxy.js';
19
>
import { generateSelfSignedCert } from './selfSignedCert.js';
20
>
21
>
/**
22
>
* Maximum number of tunnel connections we establish through the remote
23
>
* agent at the same time. Each new tunnel dials the loopback forwarder,
24
>
* which opens a fresh multiplexed channel to the remote (crypto +
25
>
* round-trips) on a single event loop. An ad-heavy page fans out dozens
26
>
* of simultaneous CONNECTs to distinct hosts; left unbounded, that
27
>
* stampede overflows the forwarder's accept backlog and it starts
28
>
* refusing (ECONNREFUSED) and resetting (ECONNRESET) connections. This
29
>
* cap smooths the burst to a rate the forwarder can absorb; excess
30
>
* requests queue rather than fail.
31
>
*/
32
>
const MAX_CONCURRENT_TUNNEL_CONNECTS = 6;
33
>
34
>
/**
35
>
* A function that opens a TCP tunnel to a given host:port through the
36
>
* remote agent. Resolves only once the remote has confirmed the target is
37
>
* reachable (via the tunnel handshake) and rejects otherwise. Returns an
38
>
* object with `getSocket()`, `readEntireBuffer()`, and `dispose()` — a
39
>
* subset of {@link import('../../base/parts/ipc/common/ipc.net.js').PersistentProtocol}.
40
>
*/
41
>
export interface ITunnelConnectFn {
42
>
(host: string, port: number): Promise<{ getSocket(): ISocket; readEntireBuffer(): VSBuffer; dispose(): void }>;
43
>
}
44
>
45
>
/**
46
>
* An HTTPS proxy server that routes TCP connections through the remote
47
>
* agent tunnel.
48
>
*
49
>
* Handles:
50
>
* - **CONNECT** requests (used by Chromium for HTTPS) — establishes a
51
>
* raw TCP tunnel through the remote agent.
52
>
* - **Plain HTTP** requests (GET, POST, etc. with absolute URLs) —
53
>
* establishes a tunnel and forwards the request.
54
>
*
55
>
* The server binds exclusively to `127.0.0.1` and is never exposed to
56
>
* the network — this is the primary security boundary. The additional
57
>
* layers below are defence-in-depth:
58
>
*
59
>
* - **TLS** with a self-signed certificate (generated in-memory)
60
>
* prevents other local processes from passively sniffing traffic.
61
>
* - **Basic proxy authentication** with randomly generated credentials
62
>
* prevents other local processes from actively using the proxy.
63
>
* - The certificate **fingerprint** is returned from {@link start} so
64
>
* the consumer's Electron session can pin it.
65
>
*
66
>
* If certificate generation or server startup fails the proxy simply
67
>
* does not start — the worst outcome is that the browser view falls
68
>
* back to not having remote network access.
69
>
*/
70
>
export class TunnelProxy extends Disposable {
71
>
72
>
private _server: https.Server | undefined;
73
>
private _http: typeof http | undefined;
74
>
private _tunnelAgent: http.Agent | undefined;
75
>
private _localPort: number = 0;
76
>
private _credentials: { username: string; password: string } | undefined;
77
>
private _expectedAuthHeader: string | undefined;
78
>
private _certFingerprint: string | undefined;
79
>
80
>
/**
81
>
* Sockets we took over from the HTTPS server via CONNECT. Once the
82
>
* CONNECT handler runs the server no longer tracks them, so
83
>
* `server.close()` and `server.closeAllConnections()` won't terminate
84
>
* them — we have to destroy them ourselves on dispose to release the
85
>
* listening port promptly.
86
>
*/
87
>
private readonly _connectSockets = new Set<net.Socket>();
88
>
89
>
/**
90
>
* The remote (tunnel) side of every active bridge — both CONNECT
91
>
* tunnels and pooled plain-HTTP sockets. We destroy these explicitly
92
>
* and synchronously on dispose rather than relying on the local
93
>
* socket's async `'close'` to propagate `end()`; during shared-process
94
>
* teardown the event loop may not get another turn to fire that
95
>
* listener, which would leave the upstream tunnel socket dangling.
96
>
*/
97
>
private readonly _remoteSockets = new Set<Duplex>();
98
>
99
>
/**
100
>
* Bounds how many tunnels we create concurrently through the remote
101
>
* agent. Gates the setup (connect + handshake) only; once a tunnel is
102
>
* established the slot is released and data piping proceeds unthrottled.
103
>
*/
104
>
private readonly _connectLimiter = this._register(new Limiter<Awaited<ReturnType<ITunnelConnectFn>>>(MAX_CONCURRENT_TUNNEL_CONNECTS));
105
>
106
>
get localPort(): number {
107
>
return this._localPort;
108
>
}
109
>
110
>
constructor(
111
>
private readonly _connectTunnel: ITunnelConnectFn,
112
>
private readonly _logService: ILogService,
113
>
) {
114
>
super();
115
>
}
116
>
117
>
async start(): Promise<ITunnelProxyInfo> {
118
>
const crypto = await import('crypto');
119
>
const http = await import('http');
120
>
const https = await import('https');
121
>
122
>
// Generate random credentials
123
>
const username = crypto.randomBytes(16).toString('hex');
124
>
const password = crypto.randomBytes(32).toString('hex');
125
>
this._credentials = { username, password };
126
>
this._expectedAuthHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
127
>
128
>
// Generate a self-signed certificate in memory
129
>
const { key, cert, fingerprint } = await generateSelfSignedCert();
130
>
this._certFingerprint = fingerprint;
131
>
132
>
// Create an agent that pools tunnel sockets by host:port.
133
>
this._http = http;
134
>
this._tunnelAgent = this._createTunnelAgent();
135
>
136
>
// HTTPS server: handles plain HTTP requests (absolute-form URLs from
137
>
// Chromium when configured as a proxy) and CONNECT tunnels for HTTPS.
138
>
const server = https.createServer({ key, cert }, (req, res) => this._onRequest(req, res));
139
>
server.on('connect', (req, socket, head) => this._onConnect(req, socket as net.Socket, head));
140
>
server.on('error', err => {
141
this._logService.error('[TunnelProxy] Server error:', err);
143
>
this._server = server;
144
>
145
>
const port = await findFreePortFaster(0, 2, 1000, '127.0.0.1');
146
>
server.listen(port, '127.0.0.1');
147
>
await new Promise<void>((resolve, reject) => {
148
>
server.once('listening', resolve);
149
>
server.once('error', reject);
150
>
});
151
>
const address = server.address() as net.AddressInfo;
152
>
this._localPort = address.port;
153
>
this._logService.info(`[TunnelProxy] Listening on https://127.0.0.1:${this._localPort}`);
154
>
155
>
return {
156
>
url: `https://127.0.0.1:${this._localPort}`,
157
>
host: '127.0.0.1',
158
>
port: this._localPort,
159
>
credentials: this._credentials,
160
>
certFingerprint: this._certFingerprint,
161
>
};
162
>
}
163
>
164
>
override dispose(): void {
165
>
// Any tunnels still queued behind the limiter are abandoned here:
166
>
// disposing the limiter drops the outstanding queue without settling
167
>
// those promises, so their awaiting `_onConnect`/`_createTunnelSocket`
168
>
// never resumes. That's fine — we destroy every socket below, and the
169
>
// local sockets those handlers would have served are torn down too, so
170
>
// nothing is left waiting on a tunnel that will never arrive.
171
>
for (const socket of this._connectSockets) {
172
socket.destroy();
173
}
175
>
for (const socket of this._remoteSockets) {
176
socket.destroy();
177
}
179
>
this._tunnelAgent?.destroy();
180
>
this._server?.closeAllConnections();
181
>
this._server?.close();
182
>
super.dispose();
183
>
}
184
>
185
>
/**
186
>
* Verify the `Proxy-Authorization` header against our credentials.
187
>
* Returns `true` if the request is authorized.
188
>
*/
189
>
private _checkAuth(authHeader: string | undefined): boolean {
190
return authHeader === this._expectedAuthHeader;
191
}
193
>
/**
194
>
* Create an `http.Agent` that pools tunnel sockets by target
195
>
* host:port. Node calls `createConnection` only when no pooled socket
196
>
* is available for the target; otherwise it reuses an existing one.
197
>
*/
198
>
private _createTunnelAgent(): http.Agent {
199
>
if (!this._http) {
200
throw new Error('HTTP module not initialized');
201
}
202
>
const agent = new this._http.Agent({ keepAlive: true });
tunnelProxy.ts
203
>
agent.createConnection = (options, oncreate) => {
204
const host = options.hostname || options.host || '';
205
const port = Number(options.port) || 80;