Skip to content

Commit a0a30c0

Browse files
committed
WIP
1 parent 74404cd commit a0a30c0

File tree

11 files changed

+1261
-45
lines changed

11 files changed

+1261
-45
lines changed

package-lock.json

Lines changed: 556 additions & 25 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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
"test-ci": "npm run test-cov",
4646
"reformat": "npm run prettier -- --write ."
4747
},
48+
"dependencies": {
49+
"proxy-agent": "^6.4.0",
50+
"agent-base": "^7.1.1",
51+
"ssh2": "^1.15.0",
52+
"socksv5": "^0.0.6"
53+
},
4854
"devDependencies": {
4955
"@mongodb-js/eslint-config-devtools": "0.9.10",
5056
"@mongodb-js/mocha-config-devtools": "^1.0.3",
Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
1+
import { ProxyAgent } from 'proxy-agent';
2+
import type { Agent } from 'https';
3+
import type { DevtoolsProxyOptions } from './proxy-options';
4+
import { proxyForUrl } from './proxy-options';
5+
import type { ClientRequest } from 'http';
6+
import type { TcpNetConnectOpts } from 'net';
7+
import type { ConnectionOptions } from 'tls';
8+
import type { Duplex } from 'stream';
9+
import { SSHAgent } from './ssh';
10+
11+
export type AgentWithInitialize = Agent & {
12+
// This is genuinely custom for our usage (to allow establishing an SSH tunnel
13+
// first before starting to push connections through it)
14+
initialize?(): Promise<void>;
15+
16+
// This is just part of the regular Agent interface, used by Node.js itself,
17+
// but missing from @types/node
18+
createSocket(
19+
req: ClientRequest,
20+
options: TcpNetConnectOpts | ConnectionOptions,
21+
cb: (err: Error | null, s?: Duplex) => void
22+
): void;
23+
};
24+
125
export function createAgent(
226
proxyOptions: DevtoolsProxyOptions
3-
): Agent | undefined {}
27+
): AgentWithInitialize {
28+
if (proxyOptions.proxy && new URL(proxyOptions.proxy).protocol === 'ssh:') {
29+
return new SSHAgent(proxyOptions);
30+
}
31+
const getProxyForUrl = proxyForUrl(proxyOptions);
32+
return new ProxyAgent({
33+
getProxyForUrl,
34+
});
35+
}

packages/devtools-proxy-support/src/env-var-proxies.ts

Whitespace-only changes.
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
import type { RequestInit, Response } from 'node-fetch';
2+
import fetch from 'node-fetch';
3+
import { createAgent } from './agent';
4+
import type { DevtoolsProxyOptions } from './proxy-options';
5+
16
export function createFetch(
27
proxyOptions: DevtoolsProxyOptions
3-
): (url: string, fetchOptions: FetchOptions) => Promise<FetchResponse> {}
8+
): (url: string, fetchOptions?: RequestInit) => Promise<Response> {
9+
const agent = createAgent(proxyOptions);
10+
return async (url, fetchOptions) => {
11+
return await fetch(url, { agent, ...fetchOptions });
12+
};
13+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ export {
55
extractProxySecrets,
66
mergeProxySecrets,
77
} from './proxy-options';
8-
export { Tunnel, setupSocks5Tunnel } from './tunnel';
8+
export { Tunnel, TunnelOptions, setupSocks5Tunnel } from './tunnel';
99
export { createAgent } from './agent';
1010
export { createFetch } from './fetch';

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

Lines changed: 187 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,210 @@ export interface DevtoolsProxyOptions {
1818

1919
// https://www.electronjs.org/docs/latest/api/structures/proxy-config
2020
interface ElectronProxyConfig {
21-
mode: 'direct' | 'auto_detect' | 'pac_script' | 'fixed_servers' | 'system';
21+
mode?: 'direct' | 'auto_detect' | 'pac_script' | 'fixed_servers' | 'system';
2222
pacScript?: string;
2323
proxyRules?: string;
2424
proxyBypassRules?: string;
2525
}
2626

27+
function proxyConfForEnvVars(
28+
env: Record<string, string | undefined> = process.env
29+
): { map: Map<string, string>; noProxy: string } {
30+
const map = new Map<string, string>();
31+
let noProxy = '';
32+
for (const [_key, value] of Object.entries(env)) {
33+
if (value === undefined) continue;
34+
const key = _key.toUpperCase();
35+
if (key.endsWith('_PROXY') && key !== 'NO_PROXY') {
36+
map.set(key.replace(/_PROXY$/, '').toLowerCase(), value || 'direct://');
37+
}
38+
if (key === 'NO_PROXY') noProxy = value;
39+
}
40+
return { map, noProxy };
41+
}
42+
43+
function shouldProxy(noProxy: string, url: URL): boolean {
44+
if (!noProxy) return true;
45+
if (noProxy === '*') return false;
46+
for (const noProxyItem of noProxy.split(/[\s,]/)) {
47+
let { host, port } =
48+
noProxyItem.match(/(?<host>.+)(:(?<port>\d+)$)?/)?.groups ?? {};
49+
if (!host) {
50+
host = noProxyItem;
51+
port = '';
52+
}
53+
if (port && url.port !== port) continue;
54+
if (
55+
host === url.hostname ||
56+
(host.startsWith('*') && url.hostname.endsWith(host.substring(1)))
57+
)
58+
return false;
59+
}
60+
return true;
61+
}
62+
63+
export function proxyForUrl(
64+
proxyOptions: DevtoolsProxyOptions
65+
): (url: string) => string {
66+
if (proxyOptions.proxy) {
67+
const proxyUrl = proxyOptions.proxy;
68+
if (new URL(proxyUrl).protocol === 'direct:') return () => '';
69+
return (target: string) => {
70+
if (shouldProxy(proxyOptions.noProxyHosts || '', new URL(target))) {
71+
return proxyUrl;
72+
}
73+
return '';
74+
};
75+
}
76+
77+
if (proxyOptions.useEnvironmentVariableProxies) {
78+
const { map, noProxy } = proxyConfForEnvVars();
79+
return (target: string) => {
80+
const url = new URL(target);
81+
const protocol = url.protocol.replace(/:$/, '');
82+
const combinedNoProxyRules = [noProxy, proxyOptions.noProxyHosts]
83+
.filter(Boolean)
84+
.join(',');
85+
const proxyForProtocol = map.get(protocol);
86+
if (proxyForProtocol && shouldProxy(combinedNoProxyRules, url)) {
87+
return proxyForProtocol;
88+
}
89+
return '';
90+
};
91+
}
92+
93+
return () => '';
94+
}
95+
2796
export function translateToElectronProxyConfig(
2897
proxyOptions: DevtoolsProxyOptions
2998
): ElectronProxyConfig {
3099
if (proxyOptions.proxy) {
100+
const url = new URL(proxyOptions.proxy);
101+
if (url.protocol === 'ssh:') {
102+
throw new Error(
103+
`Using ssh:// proxies for generic browser proxy usage is not supported (translating '${redactUrl(
104+
url
105+
)}')`
106+
);
107+
}
108+
if (url.username || url.password) {
109+
throw new Error(
110+
`Using authenticated proxies for generic browser proxy usage is not supported (translating '${redactUrl(
111+
url
112+
)}')`
113+
);
114+
}
115+
if (url.protocol.startsWith('pac+')) {
116+
url.protocol = url.protocol.replace('pac+', '');
117+
return {
118+
mode: 'pac_script',
119+
pacScript: url.toString(),
120+
proxyBypassRules: proxyOptions.noProxyHosts,
121+
};
122+
}
123+
if (
124+
url.protocol !== 'http:' &&
125+
url.protocol !== 'https:' &&
126+
url.protocol !== 'socks5:'
127+
) {
128+
throw new Error(
129+
`Unsupported proxy protocol (translating '${redactUrl(url)}')`
130+
);
131+
}
132+
return {
133+
mode: 'fixed_servers',
134+
proxyRules: url.toString(),
135+
proxyBypassRules: proxyOptions.noProxyHosts,
136+
};
137+
}
138+
139+
if (proxyOptions.useEnvironmentVariableProxies) {
140+
const proxyRules: string[] = [];
141+
const proxyBypassRules = [proxyOptions.noProxyHosts];
142+
const { map, noProxy } = proxyConfForEnvVars();
143+
for (const [key, value] of map) proxyBypassRules.push(`${key}=${value}`);
144+
proxyBypassRules.push(noProxy);
145+
146+
return {
147+
mode: 'fixed_servers',
148+
proxyBypassRules: proxyBypassRules.filter(Boolean).join(',') || undefined,
149+
proxyRules: proxyRules.join(';'),
150+
};
31151
}
152+
153+
return {};
154+
}
155+
156+
interface DevtoolsProxyOptionsSecretsInternal {
157+
username?: string;
158+
password?: string;
159+
sshIdentityKeyPassphrase?: string;
32160
}
33161

34162
// These mirror our secrets extraction/merging logic in Compass
35-
export function extractProxySecrets(proxyOptions: DevtoolsProxyOptions): {
36-
proxyOptions: Partial<DevtoolsProxyOptions>;
163+
export function extractProxySecrets(
164+
proxyOptions: Readonly<DevtoolsProxyOptions>
165+
): {
166+
proxyOptions: DevtoolsProxyOptions;
37167
secrets: DevtoolsProxyOptionsSecrets;
38-
} {}
168+
} {
169+
const secrets: DevtoolsProxyOptionsSecretsInternal = {};
170+
if (proxyOptions.proxy) {
171+
const proxyUrl = new URL(proxyOptions.proxy);
172+
({ username: secrets.username, password: secrets.password } = proxyUrl);
173+
proxyUrl.username = proxyUrl.password = '';
174+
proxyOptions = { ...proxyOptions, proxy: proxyUrl.toString() };
175+
}
176+
if (proxyOptions.sshOptions) {
177+
secrets.sshIdentityKeyPassphrase =
178+
proxyOptions.sshOptions.identityKeyPassphrase;
179+
proxyOptions = {
180+
...proxyOptions,
181+
sshOptions: {
182+
...proxyOptions.sshOptions,
183+
identityKeyPassphrase: undefined,
184+
},
185+
};
186+
}
187+
return {
188+
secrets: JSON.stringify(secrets),
189+
proxyOptions: proxyOptions,
190+
};
191+
}
39192

40193
export function mergeProxySecrets({
41194
proxyOptions,
42195
secrets,
43196
}: {
44-
proxyOptions: Partial<DevtoolsProxyOptions>;
197+
proxyOptions: Readonly<DevtoolsProxyOptions>;
45198
secrets: DevtoolsProxyOptionsSecrets;
46-
}): DevtoolsProxyOptions {}
199+
}): DevtoolsProxyOptions {
200+
const parsedSecrets: DevtoolsProxyOptionsSecretsInternal =
201+
JSON.parse(secrets);
202+
if (
203+
(parsedSecrets.username || parsedSecrets.password) &&
204+
proxyOptions.proxy
205+
) {
206+
const proxyUrl = new URL(proxyOptions.proxy);
207+
proxyUrl.username = parsedSecrets.username || '';
208+
proxyUrl.password = parsedSecrets.password || '';
209+
proxyOptions = { ...proxyOptions, proxy: proxyUrl.toString() };
210+
}
211+
if (parsedSecrets.sshIdentityKeyPassphrase) {
212+
proxyOptions = {
213+
...proxyOptions,
214+
sshOptions: {
215+
...proxyOptions.sshOptions,
216+
identityKeyPassphrase: parsedSecrets.sshIdentityKeyPassphrase,
217+
},
218+
};
219+
}
220+
return proxyOptions;
221+
}
222+
223+
function redactUrl(urlOrString: URL | string): string {
224+
const url = new URL(urlOrString.toString());
225+
url.username = url.password = '(credential)';
226+
return url.toString();
227+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
declare module 'socksv5/lib/server' {
2+
const mod: any;
3+
export = mod;
4+
}
5+
declare module 'socksv5/lib/auth/None' {
6+
const mod: any;
7+
export = mod;
8+
}
9+
declare module 'socksv5/lib/auth/UserPassword' {
10+
const mod: any;
11+
export = mod;
12+
}

0 commit comments

Comments
 (0)