tunnelProxy.ts ×20

Frontier kind: Joint frontier

unlabeled · c_04752ac5c711

23 tests · 11301 LOC · 49 files · introduces 4 tests · 329 LOC · 2 files

Introduces — evidence that enters the hierarchy at this concept

Code
24 ranges329 lines · 2 files
Tests
4 tests

Contains — complete concept membership

All code (extent)
1728 ranges11301 lines · 49 files · Browse complete extent
All tests (intent)
23 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

4 tests introduced at this concept.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

2 files ranked by introduced lines: 329 introduced LOC across 24 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/tunnel/node/tunnelProxy.ts 300 introduced LOC · 20 ranges

Open complete file

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);
142 > }); tunnelProxy.ts
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 }
174 > this._connectSockets.clear(); tunnelProxy.ts
175 > for (const socket of this._remoteSockets) {
176 socket.destroy();
177 }
178 > this._remoteSockets.clear(); tunnelProxy.ts
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;
208 .catch(err => oncreate?.(err, null!));
209 };
210 > return agent; tunnelProxy.ts
211 > }
212 >
213 > /**
214 > * Drop every pooled keep-alive tunnel socket by recreating the
215 > * agent. Called when the upstream tunnel endpoint changes: the pooled
216 > * sockets all dial the now-stale endpoint, so they would be reset en
217 > * masse once it goes away. Recreating the agent closes the idle ones
218 > * gracefully and forces subsequent requests to dial the new endpoint.
219 > */
220 > drainConnectionPool(): void {
221 if (!this._tunnelAgent) {
222 return; // not started yet; nothing pooled
227 this._logService.trace('[TunnelProxy] Upstream endpoint changed; drained pooled tunnel sockets');
228 }
230 > /**
231 > * Handle HTTP CONNECT requests (used for HTTPS tunneling).
232 > * Parses `host:port` from the request URL, establishes a tunnel
233 > * through the remote agent, and pipes the sockets together.
234 > */
235 > private async _onConnect(req: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
236 // Track the socket from the moment the CONNECT event fires so
237 // dispose can tear it down even before the upstream tunnel
283 }
284 }
286 > /**
287 > * Handle plain HTTP requests (GET, POST, etc. with absolute URLs).
288 > *
289 > * Chromium sends proxied HTTP requests with absolute-form URLs
290 > * (e.g. `GET http://example.com/page HTTP/1.1`) and reuses keep-alive
291 > * connections to the proxy for requests to **different** hosts.
292 > *
293 > * Each request is forwarded via `http.request` using a shared
294 > * `http.Agent` that pools tunnel sockets by host:port. The agent
295 > * calls `_createTunnelSocket` only when no pooled socket is available;
296 > * otherwise it reuses an existing tunnel connection.
297 > */
298 > private async _onRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
299 if (!this._checkAuth(req.headers['proxy-authorization'])) {
300 res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="TunnelProxy"' });
392 }
393 }
395 > /**
396 > * Create a `net.Socket`-compatible stream backed by a remote agent
397 > * tunnel. Called by the `http.Agent` when it needs a new connection
398 > * to a given host:port (i.e. no pooled socket is available).
399 > */
400 > private async _createTunnelSocket(host: string, port: number): Promise<Duplex> {
401 // The connect function resolves only once the remote has confirmed the
402 // target is reachable (via the tunnel handshake) and rejects otherwise.
415 return tunnelStream;
416 }
418 > /**
419 > * Take ownership of a freshly-connected tunnel's transport as a Node
420 > * {@link Duplex} stream, together with any bytes the protocol already
421 > * buffered during the handshake (the caller routes that leftover to the
422 > * appropriate side).
423 > *
424 > * Two transports occur in practice:
425 > * - {@link NodeSocket} (classic/websocket server): unwrap the raw
426 > * `net.Socket` so we can rely on Node's native stream backpressure (via
427 > * `pipe()` and the keep-alive `http.Agent`).
428 > * - a generic {@link ISocket} (managed / exec-server connection): there is
429 > * no `net.Socket` underneath, so adapt the message-passing socket to a
430 > * {@link Duplex} ({@link RemoteSocketStream}).
431 > */
432 > private _takeRemoteStream(protocol: { getSocket(): ISocket; readEntireBuffer(): VSBuffer; dispose(): void }): { stream: Duplex; leftover: VSBuffer } {
433 const remoteSocket = protocol.getSocket();
434
458 return { stream: new RemoteSocketStream(remoteSocket), leftover };
459 }
461 > /**
462 > * Parse a `host:port` string. Falls back to `defaultPort` when the
463 > * port component is missing. Returns an empty host when the address
464 > * is empty or the port is outside the valid TCP range (1-65535), per
465 > * RFC 9110 section 9.3.6 ("A server MUST reject a CONNECT request that
466 > * targets an empty or invalid port number").
467 > */
468 > private _parseHostPort(address: string, defaultPort: number): { host: string; port: number } {
469 let host: string;
470 let port: number;
506 return { host, port };
507 }
509 > private _bridgeSockets(localSocket: net.Socket, remoteSocket: Duplex): void {
510 this._trackRemoteSocket(remoteSocket);
511 remoteSocket.on('end', () => localSocket.end());
519 localSocket.pipe(remoteSocket);
520 }
522 > /**
523 > * Track a remote tunnel socket so {@link dispose} can tear it down
524 > * synchronously. The socket auto-removes itself once closed.
525 > */
526 > private _trackRemoteSocket(socket: Duplex): void {
527 this._remoteSockets.add(socket);
528
538 socket.on('close', () => this._remoteSockets.delete(socket));
539 }
540 > } tunnelProxy.ts
541 >
542 > /**
543 > * Adapts a generic {@link ISocket} (such as a managed / exec-server
544 > * connection, which has no underlying `net.Socket`) to a Node {@link Duplex}
545 > * stream, so the {@link TunnelProxy} can pipe and pool it exactly like the raw
546 > * socket it extracts from a {@link NodeSocket}.
547 > */
548 > class RemoteSocketStream extends Duplex {
549 >
550 > private readonly _disposables = new DisposableStore();
551 >
552 > constructor(private readonly _socket: ISocket) {
553 super();
554 this._disposables.add(this._socket.onData(data => this.push(data.buffer)));
562 }));
563 }
565 > // The keep-alive http.Agent pools tunnel sockets and calls net.Socket-only
566 > // transport knobs on them (setKeepAlive/ref/unref, and setTimeout/setNoDelay
567 > // while wiring a request) when parking or reusing a connection. A generic
568 > // ISocket has no such knobs, so expose no-op shims to keep the agent happy;
569 > // otherwise freeing a pooled managed socket throws (e.g.
570 > // "socket.setKeepAlive is not a function").
571 > setKeepAlive(): this { return this; }
572 > setNoDelay(): this { return this; }
573 > setTimeout(): this { return this; }
574 > ref(): this { return this; }
575 > unref(): this { return this; }
576 >
577 > override _read(): void {
578 // Data is delivered through the onData listener; nothing to pull here.
579 }
581 > override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
582 this._socket.write(VSBuffer.wrap(chunk));
583 // Respect backpressure: defer completion until the socket has drained its
586 this._socket.drain().then(() => callback(), err => callback(err));
587 }
589 > override _final(callback: (error?: Error | null) => void): void {
590 this._socket.end();
591 callback();
592 }
594 > override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
595 this._disposables.dispose();
596 this._socket.dispose();
597 callback(error);
598 }
599 > } tunnelProxy.ts
src/vs/base/node/ports.ts 29 introduced LOC · 4 ranges

Open complete file

160 */
161 export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number, hostname: string = '127.0.0.1'): Promise<number> {
162 > let resolved: boolean = false; ports.ts
163 > let timeoutHandle: Timeout | undefined = undefined;
164 > let countTried: number = 1;
165 > const server = net.createServer({ pauseOnConnect: true });
166 > function doResolve(port: number, resolve: (port: number) => void) {
167 > if (!resolved) {
168 > resolved = true;
169 > server.removeAllListeners();
170 > server.close();
171 > if (timeoutHandle) {
172 > clearTimeout(timeoutHandle);
173 > }
174 > resolve(port);
175 > }
176 > }
177 > return new Promise<number>(resolve => {
178 > timeoutHandle = setTimeout(() => {
179 doResolve(0, resolve);
180 > }, timeout); ports.ts
181 >
182 > server.on('listening', () => {
183 > doResolve(startPort, resolve);
184 > });
185 > server.on('error', (err: ServerError) => {
186 if (err && (err.code === 'EADDRINUSE' || err.code === 'EACCES') && (countTried < giveUpAfter)) {
187 startPort++;
191 doResolve(0, resolve);
192 }
193 > }); ports.ts
194 > server.on('close', () => {
195 doResolve(0, resolve);
196 > }); ports.ts
197 > server.listen(startPort, hostname);
198 > });
199 > }
200
201 function dispose(socket: net.Socket): void {