Skip to content

Commit 37092f4

Browse files
committed
WIP
1 parent 11ff675 commit 37092f4

File tree

6 files changed

+173
-49
lines changed

6 files changed

+173
-49
lines changed

packages/devtools-connect/src/connect.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,11 @@ export async function connectMongoClient(
469469

470470
let tunnel: Tunnel | undefined;
471471
if (proxyAgent && !hasProxyHostOption(uri, clientOptions)) {
472-
tunnel = createSocks5Tunnel(proxyAgent, 'generate-credentials');
472+
tunnel = createSocks5Tunnel(
473+
proxyAgent,
474+
'generate-credentials',
475+
'mongodb://'
476+
);
473477
cleanupOnClientClose.push(() => tunnel?.close());
474478
}
475479
for (const proxyLogger of new Set([tunnel?.logger, proxyAgent?.logger])) {

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

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { ProxyAgent } from 'proxy-agent';
22
import type { Agent } from 'https';
33
import type { DevtoolsProxyOptions } from './proxy-options';
44
import { proxyForUrl } from './proxy-options';
5-
import type { ClientRequest } from 'http';
5+
import type { ClientRequest, Agent as HTTPAgent } from 'http';
66
import type { TcpNetConnectOpts } from 'net';
77
import type { ConnectionOptions } from 'tls';
88
import type { Duplex } from 'stream';
99
import { SSHAgent } from './ssh';
1010
import type { ProxyLogEmitter } from './logging';
1111
import type { EventEmitter } from 'events';
12+
import type { AgentConnectOpts } from 'agent-base';
1213

1314
// Helper type that represents an https.Agent (= connection factory)
1415
// with some custom properties that TS does not know about and/or
@@ -31,23 +32,71 @@ export type AgentWithInitialize = Agent & {
3132
// http.Agent is an EventEmitter, just missing from @types/node
3233
} & Partial<EventEmitter>;
3334

35+
class DevtoolsProxyAgent extends ProxyAgent implements AgentWithInitialize {
36+
readonly proxyOptions: DevtoolsProxyOptions;
37+
private sshAgent: SSHAgent | undefined;
38+
39+
// Store the current ClientRequest for the time between connect() first
40+
// being called and the corresponding _getProxyForUrl() being called.
41+
// In practice, this is instantaneous, but that is not guaranteed by
42+
// the `ProxyAgent` API contract.
43+
// We use a Promise lock/mutex to avoid concurrent accesses.
44+
private _req: ClientRequest | undefined;
45+
private _reqLock: Promise<void> | undefined;
46+
private _reqLockResolve: (() => void) | undefined;
47+
48+
constructor(proxyOptions: DevtoolsProxyOptions) {
49+
super({
50+
getProxyForUrl: (url: string) => this._getProxyForUrl(url),
51+
...proxyOptions,
52+
});
53+
this.proxyOptions = proxyOptions;
54+
// This could be made a bit more flexible by actually dynamically picking
55+
// ssh vs. other proxy protocols as part of connect(), if we want that at some point.
56+
if (proxyOptions.proxy && new URL(proxyOptions.proxy).protocol === 'ssh:') {
57+
this.sshAgent = new SSHAgent(proxyOptions);
58+
}
59+
}
60+
61+
_getProxyForUrl = (url: string): string => {
62+
if (!this._reqLockResolve || !this._req) {
63+
throw new Error('getProxyForUrl() called without pending request');
64+
}
65+
this._reqLockResolve();
66+
const req = this._req;
67+
this._req = undefined;
68+
this._reqLock = undefined;
69+
this._reqLockResolve = undefined;
70+
return proxyForUrl(this.proxyOptions, url, req);
71+
};
72+
73+
async initialize(): Promise<void> {
74+
await this.sshAgent?.initialize();
75+
}
76+
77+
override async connect(
78+
req: ClientRequest,
79+
opts: AgentConnectOpts
80+
): Promise<HTTPAgent> {
81+
if (this.sshAgent) return this.sshAgent;
82+
while (this._reqLock) {
83+
await this._reqLock;
84+
}
85+
this._req = req;
86+
this._reqLock = new Promise((resolve) => (this._reqLockResolve = resolve));
87+
return await super.connect(req, opts);
88+
}
89+
90+
destroy(): void {
91+
this.sshAgent?.destroy();
92+
super.destroy();
93+
}
94+
}
95+
3496
export function createAgent(
3597
proxyOptions: DevtoolsProxyOptions
3698
): AgentWithInitialize {
37-
// This could be made a bit more flexible by creating an Agent using AgentBase
38-
// that will dynamically choose between SSHAgent and ProxyAgent.
39-
// Right now, this is a bit simpler in terms of lifetime management for SSHAgent.
40-
if (proxyOptions.proxy && new URL(proxyOptions.proxy).protocol === 'ssh:') {
41-
return new SSHAgent(proxyOptions);
42-
}
43-
const getProxyForUrl = proxyForUrl(proxyOptions);
44-
return Object.assign(
45-
new ProxyAgent({
46-
getProxyForUrl,
47-
...proxyOptions,
48-
}),
49-
{ proxyOptions }
50-
);
99+
return new DevtoolsProxyAgent(proxyOptions);
51100
}
52101

53102
export function useOrCreateAgent(
@@ -59,7 +108,7 @@ export function useOrCreateAgent(
59108
} else {
60109
if (
61110
target !== undefined &&
62-
!proxyForUrl(proxyOptions as DevtoolsProxyOptions)(target)
111+
!proxyForUrl(proxyOptions as DevtoolsProxyOptions, target)
63112
)
64113
return undefined;
65114
return createAgent(proxyOptions as DevtoolsProxyOptions);

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,17 @@ describe('proxy options handling', function () {
3737

3838
describe('proxyForUrl', function () {
3939
it('should return a proxy function for a specified proxy URL', function () {
40-
const getProxy = proxyForUrl({ proxy: 'http://proxy.example.com' });
40+
const getProxy = proxyForUrl.bind(null, {
41+
proxy: 'http://proxy.example.com',
42+
});
4143

4244
expect(getProxy('http://target.com')).to.equal(
4345
'http://proxy.example.com'
4446
);
4547
});
4648

4749
it('should respect noProxyHosts', function () {
48-
const getProxy = proxyForUrl({
50+
const getProxy = proxyForUrl.bind(null, {
4951
proxy: 'http://proxy.example.com',
5052
noProxyHosts: 'localhost',
5153
});
@@ -57,11 +59,12 @@ describe('proxy options handling', function () {
5759
});
5860

5961
it('should use environment variables as a fallback', function () {
60-
const getProxy = proxyForUrl({
62+
const getProxy = proxyForUrl.bind(null, {
6163
useEnvironmentVariableProxies: true,
6264
env: {
6365
HTTP_PROXY: 'socks5://env-proxy.example.com',
6466
NO_PROXY: 'localhost',
67+
ALL_PROXY: 'http://fallback.example.com',
6568
},
6669
});
6770

@@ -70,6 +73,9 @@ describe('proxy options handling', function () {
7073
expect(getProxy('http://example.com')).to.equal(
7174
'socks5://env-proxy.example.com'
7275
);
76+
expect(getProxy('mongodb://example.com')).to.equal(
77+
'http://fallback.example.com'
78+
);
7379
});
7480
});
7581

@@ -127,12 +133,14 @@ describe('proxy options handling', function () {
127133
env: {
128134
HTTP_PROXY: 'socks5://env-proxy.example.com',
129135
NO_PROXY: 'zombo.com',
136+
ALL_PROXY: 'http://fallback.example.com',
130137
},
131138
})
132139
).to.deep.equal({
133140
mode: 'fixed_servers',
134141
proxyBypassRules: 'localhost,example.com,zombo.com',
135-
proxyRules: 'http=socks5://env-proxy.example.com',
142+
proxyRules:
143+
'http=socks5://env-proxy.example.com;https=http://fallback.example.com;ftp=http://fallback.example.com',
136144
});
137145
});
138146
it('translates an empty config to an empty config', function () {
@@ -330,6 +338,18 @@ describe('proxy options handling', function () {
330338
expect(await testResolveProxy(config, 'https://example.com')).to.equal(
331339
'DIRECT'
332340
);
341+
expect(
342+
await testResolveProxy(
343+
{
344+
...config,
345+
env: {
346+
...config.env,
347+
ALL_PROXY: 'http://fallback.example.com:1',
348+
},
349+
},
350+
'ftp://mongodb.net'
351+
)
352+
).to.equal('PROXY fallback.example.com:1');
333353
});
334354
});
335355
});

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

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ConnectionOptions } from 'tls';
22
import type { TunnelOptions } from './socks5';
3+
import type { ClientRequest } from 'http';
34

45
// Should be an opaque type, but TS does not support those.
56
export type DevtoolsProxyOptionsSecrets = string;
@@ -78,38 +79,36 @@ function shouldProxy(noProxy: string, url: URL): boolean {
7879
// Create a function which returns the proxy URL for a given target URL,
7980
// based on the proxy config passed to it.
8081
export function proxyForUrl(
81-
proxyOptions: DevtoolsProxyOptions
82-
): (url: string) => string {
82+
proxyOptions: DevtoolsProxyOptions,
83+
target: string,
84+
req?: ClientRequest & { overrideProtocol?: string }
85+
): string {
8386
if (proxyOptions.proxy) {
8487
const proxyUrl = proxyOptions.proxy;
85-
if (new URL(proxyUrl).protocol === 'direct:') return () => '';
86-
return (target: string) => {
87-
if (shouldProxy(proxyOptions.noProxyHosts || '', new URL(target))) {
88-
return proxyUrl;
89-
}
90-
return '';
91-
};
88+
if (new URL(proxyUrl).protocol === 'direct:') return '';
89+
if (shouldProxy(proxyOptions.noProxyHosts || '', new URL(target))) {
90+
return proxyUrl;
91+
}
92+
return '';
9293
}
9394

9495
if (proxyOptions.useEnvironmentVariableProxies) {
9596
const { map, noProxy } = proxyConfForEnvVars(
9697
proxyOptions.env ?? process.env
9798
);
98-
return (target: string) => {
99-
const url = new URL(target);
100-
const protocol = url.protocol.replace(/:$/, '');
101-
const combinedNoProxyRules = [noProxy, proxyOptions.noProxyHosts]
102-
.filter(Boolean)
103-
.join(',');
104-
const proxyForProtocol = map.get(protocol);
105-
if (proxyForProtocol && shouldProxy(combinedNoProxyRules, url)) {
106-
return proxyForProtocol;
107-
}
108-
return '';
109-
};
99+
const url = new URL(target);
100+
const protocol = (req?.overrideProtocol ?? url.protocol).replace(/:$/, '');
101+
const combinedNoProxyRules = [noProxy, proxyOptions.noProxyHosts]
102+
.filter(Boolean)
103+
.join(',');
104+
const proxyForProtocol = map.get(protocol) || map.get('all');
105+
if (proxyForProtocol && shouldProxy(combinedNoProxyRules, url)) {
106+
return proxyForProtocol;
107+
}
108+
return '';
110109
}
111110

112-
return () => '';
111+
return '';
113112
}
114113

115114
function validateElectronProxyURL(url: URL | string): string {
@@ -194,8 +193,12 @@ export function translateToElectronProxyConfig(
194193
const { map, noProxy } = proxyConfForEnvVars(
195194
proxyOptions.env ?? process.env
196195
);
197-
for (const [key, value] of map)
196+
for (const key of ['http', 'https', 'ftp']) {
197+
// supported protocols for Electron proxying
198+
const value = map.get(key) || map.get('all');
199+
if (!value) continue;
198200
proxyRulesList.push(`${key}=${validateElectronProxyURL(value)}`);
201+
}
199202
proxyBypassRulesList.push(noProxy);
200203

201204
const proxyRules = proxyRulesList.join(';');
@@ -225,7 +228,7 @@ export function getSocks5OnlyProxyOptions(
225228
target?: string
226229
): TunnelOptions | undefined {
227230
let proxyUrl: string | undefined;
228-
if (target !== undefined) proxyUrl = proxyForUrl(proxyOptions)(target);
231+
if (target !== undefined) proxyUrl = proxyForUrl(proxyOptions, target);
229232
else if (!proxyOptions.noProxyHosts) proxyUrl = proxyOptions.proxy;
230233
if (!proxyUrl) return undefined;
231234
const url = new URL(proxyUrl);

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,27 @@ describe('createSocks5Tunnel', function () {
178178
expect(err.message).to.include('Socks5 Authentication failed');
179179
}
180180
});
181+
182+
it('picks the proxy specified by the target protocol, if any', async function () {
183+
tunnel = await setupSocks5Tunnel(
184+
{
185+
useEnvironmentVariableProxies: true,
186+
env: {
187+
MONGODB_PROXY: `http://foo:[email protected]:${setup.httpProxyPort}`,
188+
},
189+
},
190+
{},
191+
'mongodb://'
192+
);
193+
if (!tunnel) {
194+
// regular conditional instead of assertion so that TS can follow it
195+
expect.fail('failed to create Socks5 tunnel');
196+
}
197+
198+
const fetch = createFetch({
199+
proxy: `socks5://@127.0.0.1:${tunnel.config.proxyPort}`,
200+
});
201+
const response = await fetch('http://example.com/hello');
202+
expect(await response.text()).to.equal('OK /hello');
203+
});
181204
});

0 commit comments

Comments
 (0)