Skip to content

Commit efc31b2

Browse files
authored
fix(devtools-proxy-support): work around more issues with proxy-agent (#451)
Work around more issues identified while adding testing for proxy support to mongosh. Admittedly, at this point the original choice to use `proxy-agent` might not seem like the wisest anymore :) We’ll see how receptive their maintainers are to our patches – so far, none of the issues discovered should be fundamentally in conflict with the packages’ design philosophy.
1 parent b560ad9 commit efc31b2

File tree

10 files changed

+375
-299
lines changed

10 files changed

+375
-299
lines changed

package-lock.json

Lines changed: 156 additions & 249 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/devtools-proxy-support/package.json

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,22 @@
2424
"license": "Apache-2.0",
2525
"main": "dist/index.js",
2626
"exports": {
27-
"require": "./dist/index.js",
28-
"import": "./dist/.esm-wrapper.mjs"
27+
".": {
28+
"require": "./dist/index.js",
29+
"import": "./dist/.esm-wrapper.mjs",
30+
"types": "./dist/index.d.ts"
31+
},
32+
"./proxy-options": {
33+
"require": "./dist/proxy-options-public.js",
34+
"import": "./dist/.esm-wrapper-po.mjs",
35+
"types": "./dist/proxy-options-public.d.ts"
36+
}
2937
},
3038
"types": "./dist/index.d.ts",
3139
"scripts": {
3240
"bootstrap": "npm run compile",
3341
"prepublishOnly": "npm run compile",
34-
"compile": "tsc -p tsconfig.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs",
42+
"compile": "tsc -p tsconfig.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs && gen-esm-wrapper ./dist/proxy-options-public ./dist/.esm-wrapper-po.mjs",
3543
"typecheck": "tsc --noEmit",
3644
"eslint": "eslint",
3745
"prettier": "prettier",
@@ -48,9 +56,10 @@
4856
"dependencies": {
4957
"@mongodb-js/socksv5": "^0.0.10",
5058
"agent-base": "^7.1.1",
59+
"debug": "^4.3.6",
5160
"lru-cache": "^11.0.0",
5261
"node-fetch": "^3.3.2",
53-
"pac-proxy-agent": "7.0.2",
62+
"pac-proxy-agent": "^7.0.2",
5463
"http-proxy-agent": "^7.0.2",
5564
"https-proxy-agent": "^7.0.5",
5665
"socks-proxy-agent": "^8.0.4",

packages/devtools-proxy-support/src/agent.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,20 @@ export function createAgent(
145145

146146
export function useOrCreateAgent(
147147
proxyOptions: DevtoolsProxyOptions | AgentWithInitialize,
148-
target?: string
148+
target?: string,
149+
useTargetRegardlessOfExistingAgent = false
149150
): AgentWithInitialize | undefined {
150151
if ('createConnection' in proxyOptions) {
151-
return proxyOptions as AgentWithInitialize;
152+
const agent = proxyOptions as AgentWithInitialize;
153+
if (
154+
useTargetRegardlessOfExistingAgent &&
155+
target !== undefined &&
156+
agent.proxyOptions &&
157+
!proxyForUrl(agent.proxyOptions, target)
158+
) {
159+
return undefined;
160+
}
161+
return agent;
152162
} else {
153163
if (
154164
target !== undefined &&

packages/devtools-proxy-support/src/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
export {
2-
DevtoolsProxyOptions,
3-
DevtoolsProxyOptionsSecrets,
4-
translateToElectronProxyConfig,
5-
extractProxySecrets,
6-
mergeProxySecrets,
7-
} from './proxy-options';
1+
export * from './proxy-options-public';
82
export { Tunnel, TunnelOptions, createSocks5Tunnel } from './socks5';
93
export { createAgent, useOrCreateAgent, AgentWithInitialize } from './agent';
104
export {

packages/devtools-proxy-support/src/proxy-agent.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import type { HttpsProxyAgentOptions } from 'https-proxy-agent';
4141
import type { HttpsProxyAgent } from 'https-proxy-agent';
4242
import type { SocksProxyAgentOptions } from 'socks-proxy-agent';
4343
import type { SocksProxyAgent } from 'socks-proxy-agent';
44+
import { createRequire } from 'module';
4445

4546
const debug = createDebug('proxy-agent');
4647

@@ -180,6 +181,10 @@ export class ProxyAgent extends Agent {
180181
debug('Request URL: %o', url);
181182
debug('Proxy URL: %o', proxy);
182183

184+
if (proxy.startsWith('pac+')) {
185+
installPacHttpsHack();
186+
}
187+
183188
// attempt to get a cached `http.Agent` instance first
184189
const cacheKey = `${protocol}+${proxy}`;
185190
let agent = this.cache.get(cacheKey);
@@ -208,3 +213,51 @@ export class ProxyAgent extends Agent {
208213
super.destroy();
209214
}
210215
}
216+
217+
declare const __webpack_require__: unknown;
218+
219+
// Work around https://github.com/TooTallNate/proxy-agents/pull/329
220+
// While the proxy-agent package implementation in this file,
221+
// and in the original, properly check whether an 'upgrade' header
222+
// is present and set to 'websocket', the pac-proxy-agent performs
223+
// a similar 'CONNECT vs regular HTTP proxy' selection and doesn't
224+
// account for this. We monkey-patch in this behavior ourselves.
225+
function installPacHttpsHack() {
226+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
227+
let HttpProxyAgent: typeof import('http-proxy-agent').HttpProxyAgent;
228+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
229+
let HttpsProxyAgent: typeof import('https-proxy-agent').HttpsProxyAgent;
230+
if (typeof __webpack_require__ === 'undefined') {
231+
const pacProxyAgentPath = require.resolve('pac-proxy-agent');
232+
const pacRequire = createRequire(pacProxyAgentPath);
233+
HttpProxyAgent = pacRequire('http-proxy-agent').HttpProxyAgent;
234+
HttpsProxyAgent = pacRequire('https-proxy-agent').HttpsProxyAgent;
235+
} else {
236+
// No such thing as require.resolve() in webpack, just need to assume
237+
// that everything is hoisted :(
238+
// eslint-disable-next-line @typescript-eslint/no-var-requires
239+
HttpProxyAgent = require('http-proxy-agent').HttpProxyAgent;
240+
// eslint-disable-next-line @typescript-eslint/no-var-requires
241+
HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
242+
}
243+
244+
const kCompanionHttpsProxyAgent = Symbol('kCompanionHttpsProxyAgent');
245+
// eslint-disable-next-line @typescript-eslint/unbound-method
246+
const originalConnect = HttpProxyAgent.prototype.connect;
247+
HttpProxyAgent.prototype.connect = function (req, ...args) {
248+
if (req.getHeader('upgrade') === 'websocket') {
249+
let companionHttpsAgent: HttpsProxyAgent<string> = (this as any)[
250+
kCompanionHttpsProxyAgent
251+
];
252+
if (!companionHttpsAgent) {
253+
companionHttpsAgent = new HttpsProxyAgent(
254+
this.proxy.href,
255+
this.options
256+
);
257+
(this as any)[kCompanionHttpsProxyAgent] = companionHttpsAgent;
258+
}
259+
return companionHttpsAgent.connect(req, ...args);
260+
}
261+
return originalConnect.call(this, req, ...args);
262+
};
263+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Seperate exports that can be loaded in a browser-only
2+
// environment (e.g. compass-web).
3+
export {
4+
DevtoolsProxyOptions,
5+
DevtoolsProxyOptionsSecrets,
6+
translateToElectronProxyConfig,
7+
extractProxySecrets,
8+
mergeProxySecrets,
9+
} from './proxy-options';

packages/devtools-proxy-support/src/proxy-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Only use browser-compatible imports or type imports here
12
import type { ConnectionOptions } from 'tls';
23
import type { TunnelOptions } from './socks5';
34
import type { ClientRequest } from 'http';

packages/devtools-proxy-support/src/socks5.spec.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import sinon from 'sinon';
22
import { HTTPServerProxyTestSetup } from '../test/helpers';
33
import type { Tunnel, TunnelOptions } from './socks5';
4-
import { createSocks5Tunnel } from './socks5';
4+
import { connectThroughAgent, createSocks5Tunnel } from './socks5';
55
import { expect } from 'chai';
66
import { createFetch } from './fetch';
77
import type { DevtoolsProxyOptions } from './proxy-options';
8+
import { createAgent } from './agent';
9+
import type { AddressInfo, Server } from 'net';
10+
import { createConnection, createServer } from 'net';
11+
import { once } from 'events';
12+
import type { IncomingMessage } from 'http';
13+
import type { Duplex } from 'stream';
814

915
describe('createSocks5Tunnel', function () {
1016
let setup: HTTPServerProxyTestSetup;
@@ -201,4 +207,65 @@ describe('createSocks5Tunnel', function () {
201207
const response = await fetch('http://example.com/hello');
202208
expect(await response.text()).to.equal('OK /hello');
203209
});
210+
211+
context('with a non-HTTP target', function () {
212+
let netServer: Server;
213+
beforeEach(async function () {
214+
netServer = createServer((sock) =>
215+
sock.once('data', (chk) => sock.end('hello, ' + chk.toString() + '!'))
216+
);
217+
netServer.listen(0);
218+
await once(netServer, 'listening');
219+
});
220+
221+
afterEach(async function () {
222+
netServer.close();
223+
await once(netServer, 'close');
224+
});
225+
226+
// This simulates a number of aspects of using a PAC proxy with an actual MongoDB server
227+
it('can be used with a PAC proxy and a non-HTTP target', async function () {
228+
setup.pacFile = () => {
229+
return `function FindProxyForURL() { return 'HTTP 127.0.0.1:${setup.httpProxyPort}'; }`;
230+
};
231+
setup.httpProxyServer.removeAllListeners('connect');
232+
setup.httpProxyServer.on(
233+
'connect',
234+
(req: IncomingMessage, socket: Duplex, head: Buffer) => {
235+
socket.unshift(head);
236+
const [host, port] = req.url!.split(':');
237+
const outgoing = createConnection(+port, host);
238+
socket.write('HTTP/1.0 200 OK\r\n\r\n');
239+
socket.pipe(outgoing).pipe(socket);
240+
}
241+
);
242+
tunnel = await setupSocks5Tunnel(
243+
{
244+
useEnvironmentVariableProxies: true,
245+
env: {
246+
MONGODB_PROXY: `pac+http://foo:[email protected]:${setup.httpServerPort}/pac`,
247+
},
248+
},
249+
{},
250+
'mongodb://'
251+
);
252+
if (!tunnel) {
253+
// regular conditional instead of assertion so that TS can follow it
254+
expect.fail('failed to create Socks5 tunnel');
255+
}
256+
257+
const agent = createAgent({
258+
proxy: `socks5://127.0.0.1:${tunnel.config.proxyPort}`,
259+
});
260+
const socket = await connectThroughAgent({
261+
dstAddr: 'localhost',
262+
dstPort: (netServer.address() as AddressInfo).port,
263+
agent,
264+
});
265+
socket.write('world');
266+
let received = '';
267+
for await (const chunk of socket.setEncoding('utf8')) received += chunk;
268+
expect(received).to.equal('hello, world!');
269+
});
270+
});
204271
});

packages/devtools-proxy-support/src/socks5.ts

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,61 @@ function createFakeHttpClientRequest(
6969
getHeader(name: string) {
7070
return headers[name];
7171
},
72+
setHeader(name: string, value: string) {
73+
headers[name] = value;
74+
},
75+
_implicitHeader() {
76+
// Even some internal/non-public properties like this are required by http-proxy-agent:
77+
// https://github.com/TooTallNate/proxy-agents/blob/5555794b6d9e4b0a36fac80a2d3acea876a8f7dc/packages/http-proxy-agent/src/index.ts#L36
78+
},
7279
overrideProtocol,
7380
}
7481
);
7582
}
7683

84+
export async function connectThroughAgent({
85+
dstAddr,
86+
dstPort,
87+
agent,
88+
overrideProtocol,
89+
}: {
90+
dstAddr: string;
91+
dstPort: number;
92+
agent: AgentWithInitialize;
93+
overrideProtocol?: string | undefined;
94+
}): Promise<Duplex> {
95+
const channel = await new Promise<Duplex | undefined>((resolve, reject) => {
96+
const req = createFakeHttpClientRequest(dstAddr, dstPort, overrideProtocol);
97+
req.onSocket = (sock) => {
98+
if (sock) resolve(sock);
99+
};
100+
agent.createSocket(
101+
req,
102+
{
103+
host: dstAddr,
104+
port: dstPort,
105+
},
106+
(err, sock) => {
107+
// Ideally, we would always be using this callback for retrieving the `sock`
108+
// instance. However, agent-base does not call the callback at all if
109+
// the agent resolved to another agent (as is the case for e.g. `ProxyAgent`).
110+
if (err) reject(err);
111+
else if (sock) resolve(sock);
112+
else
113+
reject(
114+
new Error(
115+
'Received neither error object nor socket from agent.createSocket()'
116+
)
117+
);
118+
}
119+
);
120+
});
121+
122+
if (!channel)
123+
throw new Error(`Could not create channel to ${dstAddr}:${dstPort}`);
124+
return channel;
125+
}
126+
77127
// The original version of this code was largely taken from
78128
// https://github.com/mongodb-js/compass/tree/55a5a608713d7316d158dc66febeb6b114d8b40d/packages/ssh-tunnel/src
79129
class Socks5Server extends EventEmitter implements Tunnel {
@@ -237,40 +287,12 @@ class Socks5Server extends EventEmitter implements Tunnel {
237287
}
238288

239289
private async forwardOut(dstAddr: string, dstPort: number): Promise<Duplex> {
240-
const channel = await new Promise<Duplex>((resolve, reject) => {
241-
const req = createFakeHttpClientRequest(
242-
dstAddr,
243-
dstPort,
244-
this.overrideProtocol
245-
);
246-
req.onSocket = (sock) => {
247-
if (sock) resolve(sock);
248-
};
249-
this.agent.createSocket(
250-
req,
251-
{
252-
host: dstAddr,
253-
port: dstPort,
254-
},
255-
(err, sock) => {
256-
// Ideally, we would always be using this callback for retrieving the `sock`
257-
// instance. However, agent-base does not call the callback at all if
258-
// the agent resolved to another agent (as is the case for e.g. `ProxyAgent`).
259-
if (err) reject(err);
260-
else if (sock) resolve(sock);
261-
else
262-
reject(
263-
new Error(
264-
'Received neither error object nor socket from agent.createSocket()'
265-
)
266-
);
267-
}
268-
);
290+
return await connectThroughAgent({
291+
dstAddr,
292+
dstPort,
293+
agent: this.agent,
294+
overrideProtocol: this.overrideProtocol,
269295
});
270-
271-
if (!channel)
272-
throw new Error(`Could not create channel to ${dstAddr}:${dstPort}`);
273-
return channel;
274296
}
275297

276298
private async socks5Request(
@@ -309,9 +331,13 @@ class Socks5Server extends EventEmitter implements Tunnel {
309331
socket.on('error', forwardingErrorHandler);
310332

311333
socket.once('close', () => {
334+
if (!channel?.destroyed) channel.destroy();
312335
this.logger.emit('socks5:forwarded-socket-closed', { ...logMetadata });
313336
this.connections.delete(socket as Socket);
314337
});
338+
channel.once('close', () => {
339+
if (!socket?.destroyed) socket?.destroy();
340+
});
315341

316342
socket.pipe(channel).pipe(socket);
317343
} catch (err) {
@@ -370,7 +396,7 @@ export function createSocks5Tunnel(
370396
return new ExistingTunnel(socks5OnlyProxyOptions);
371397
}
372398

373-
const agent = useOrCreateAgent(proxyOptions, target);
399+
const agent = useOrCreateAgent(proxyOptions, target, true);
374400
if (!agent) return undefined;
375401

376402
let generateCredentials = false;

packages/devtools-proxy-support/test/helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,12 @@ export class HTTPServerProxyTestSetup {
223223
await Promise.all(closePromises);
224224
}
225225

226-
pacFile() {
226+
pacFile = () => {
227227
return `function FindProxyForURL(url, host) {
228228
if (host === 'pac-invalidproxy') {
229229
return 'SOCKS5 127.0.0.1:1';
230230
}
231231
return 'SOCKS5 127.0.0.1:${this.socks5ProxyPort}';
232232
}`;
233-
}
233+
};
234234
}

0 commit comments

Comments
 (0)