Skip to content

Commit 8e29562

Browse files
committed
WIP
1 parent 8eadc41 commit 8e29562

File tree

7 files changed

+297
-42
lines changed

7 files changed

+297
-42
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ describe('createAgent', function () {
223223

224224
it('fails to connect to an ssh proxy with unavailable tunneling', async function () {
225225
setup.authHandler = sinon.stub().returns(true);
226-
setup.canTunnel = false;
226+
setup.canTunnel = sinon.stub().returns(false);
227227

228228
try {
229229
await get(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface DevtoolsProxyOptions {
1818
};
1919

2020
// Not being honored by the translate-to-electron functionality
21+
// TODO(COMPASS-8077): Integrate system CA here
2122
ca?: ConnectionOptions['ca'];
2223

2324
// Mostly intended for testing, defaults to `process.env`.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import sinon from 'sinon';
2+
import { HTTPServerProxyTestSetup } from '../test/helpers';
3+
import type { Tunnel } from './socks5';
4+
import { setupSocks5Tunnel } from './socks5';
5+
import { expect } from 'chai';
6+
import { createFetch } from './fetch';
7+
8+
describe('setupSocks5Tunnel', function () {
9+
let setup: HTTPServerProxyTestSetup;
10+
let tunnel: Tunnel | undefined;
11+
12+
beforeEach(async function () {
13+
setup = new HTTPServerProxyTestSetup();
14+
await setup.listen();
15+
tunnel = undefined;
16+
});
17+
18+
afterEach(async function () {
19+
await setup.teardown();
20+
await tunnel?.close();
21+
});
22+
23+
it('can be used to create a Socks5 server that forwards requests to another proxy', async function () {
24+
setup.authHandler = sinon.stub().returns(true);
25+
26+
tunnel = await setupSocks5Tunnel(
27+
{
28+
proxy: `http://foo:[email protected]:${setup.httpProxyPort}`,
29+
},
30+
{
31+
proxyUsername: 'baz',
32+
proxyPassword: 'quux',
33+
}
34+
);
35+
if (!tunnel) {
36+
// regular conditional instead of assertion so that TS can follow it
37+
expect.fail('failed to create Socks5 tunnel');
38+
}
39+
40+
const fetch = createFetch({
41+
proxy: `socks5://baz:[email protected]:${tunnel.config.proxyPort}`,
42+
});
43+
const response = await fetch('http://example.com/hello');
44+
expect(await response.text()).to.equal('OK /hello');
45+
46+
try {
47+
await fetch('http://localhost:1/hello');
48+
expect.fail('missed exception');
49+
} catch (err) {
50+
expect(err.message).to.include(
51+
'request to http://localhost:1/hello failed'
52+
);
53+
}
54+
});
55+
56+
it('rejects mismatching auth', async function () {
57+
tunnel = await setupSocks5Tunnel(
58+
{
59+
useEnvironmentVariableProxies: true,
60+
env: {},
61+
},
62+
{
63+
proxyUsername: 'baz',
64+
proxyPassword: 'quux',
65+
}
66+
);
67+
if (!tunnel) {
68+
// regular conditional instead of assertion so that TS can follow it
69+
expect.fail('failed to create Socks5 tunnel');
70+
}
71+
72+
const fetch = createFetch({
73+
proxy: `socks5://baz:[email protected]:${tunnel.config.proxyPort}`,
74+
});
75+
76+
try {
77+
await fetch('http://localhost:1234/hello');
78+
expect.fail('missed exception');
79+
} catch (err) {
80+
expect(err.message).to.include('Socks5 Authentication failed');
81+
}
82+
});
83+
84+
it('reports an error when it fails to listen', async function () {
85+
try {
86+
await setupSocks5Tunnel(
87+
{
88+
useEnvironmentVariableProxies: true,
89+
env: {},
90+
},
91+
{
92+
proxyHost: 'example.net',
93+
}
94+
);
95+
expect.fail('missed exception');
96+
} catch (err) {
97+
expect(err.code).to.equal('EADDRNOTAVAIL');
98+
}
99+
});
100+
});

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

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,19 @@ export interface Tunnel {
4646
}
4747

4848
function createFakeHttpClientRequest(dstAddr: string, dstPort: number) {
49-
return {
50-
host: dstAddr,
49+
const headers: Record<string, string> = {
50+
host: `${isIPv6(dstAddr) ? `[${dstAddr}]` : dstAddr}:${dstPort}`,
51+
upgrade: 'websocket', // hack to make proxy-agent prefer CONNECT over HTTP proxying
52+
};
53+
return Object.assign(new EventEmitter() as ClientRequest, {
54+
host: headers.host,
5155
protocol: 'http',
5256
method: 'GET',
5357
path: '/',
54-
getHeader(name) {
55-
return name === 'host'
56-
? `${isIPv6(dstAddr) ? `[${dstAddr}]` : dstAddr}:${dstPort}`
57-
: undefined;
58+
getHeader(name: string) {
59+
return headers[name];
5860
},
59-
} as ClientRequest;
61+
});
6062
}
6163

6264
class Socks5Server extends EventEmitter implements Tunnel {
@@ -126,14 +128,14 @@ class Socks5Server extends EventEmitter implements Tunnel {
126128

127129
const listeningPromise = this.serverListen(proxyPort, proxyHost);
128130
try {
129-
await Promise.all([listeningPromise, this.ensureAgentInitialized()]);
131+
await Promise.all([
132+
listeningPromise,
133+
once(this, 'listening'),
134+
this.ensureAgentInitialized(),
135+
]);
130136
this.agentInitialized = true;
131137
} catch (err: unknown) {
132-
try {
133-
await listeningPromise;
134-
} finally {
135-
await this.close();
136-
}
138+
await this.close();
137139
throw err;
138140
}
139141
}
@@ -192,19 +194,36 @@ class Socks5Server extends EventEmitter implements Tunnel {
192194
this.closeOpenConnections(),
193195
]);
194196

195-
if (maybeError) {
197+
if (
198+
maybeError &&
199+
!('code' in maybeError && maybeError.code === 'ERR_SERVER_NOT_RUNNING')
200+
) {
196201
throw maybeError;
197202
}
198203
}
199204

200205
private async forwardOut(dstAddr: string, dstPort: number): Promise<Duplex> {
201-
const channel = await promisify(this.agent.createSocket.bind(this.agent))(
202-
createFakeHttpClientRequest(dstAddr, dstPort),
203-
{
204-
host: dstAddr,
205-
port: dstPort,
206-
}
207-
);
206+
const channel = await new Promise<Duplex>((resolve, reject) => {
207+
const req = createFakeHttpClientRequest(dstAddr, dstPort);
208+
req.onSocket = (sock) => {
209+
if (sock) resolve(sock);
210+
};
211+
this.agent.createSocket(
212+
req,
213+
{
214+
host: dstAddr,
215+
port: dstPort,
216+
},
217+
(err, sock) => {
218+
// Ideally, we would always be using this callback for retrieving the `sock`
219+
// instance. However, agent-base does not call the callback at all if
220+
// the agent resolved to another agent (as is the case for e.g. `ProxyAgent`).
221+
if (err) reject(err);
222+
else if (sock) resolve(sock);
223+
}
224+
);
225+
});
226+
208227
if (!channel)
209228
throw new Error(`Could not create channel to ${dstAddr}:${dstPort}`);
210229
return channel;
@@ -231,15 +250,19 @@ class Socks5Server extends EventEmitter implements Tunnel {
231250

232251
socket = accept(true);
233252
this.connections.add(socket);
234-
235-
socket.on('error', (err: ErrorWithOrigin) => {
253+
const forwardingErrorHandler = (err: ErrorWithOrigin) => {
254+
if (!socket?.writableEnded) socket?.end();
255+
if (!channel?.writableEnded) channel?.end();
236256
err.origin ??= 'connection';
237257
this.logger.emit('socks5:forwarding-error', {
238258
...logMetadata,
239259
error: String((err as Error).stack),
240260
});
241261
this.emit('forwardingError', err);
242-
});
262+
};
263+
264+
channel.on('error', forwardingErrorHandler);
265+
socket.on('error', forwardingErrorHandler);
243266

244267
socket.once('close', () => {
245268
this.logger.emit('socks5:forwarded-socket-closed', { ...logMetadata });
@@ -265,7 +288,7 @@ class Socks5Server extends EventEmitter implements Tunnel {
265288
export async function setupSocks5Tunnel(
266289
proxyOptions: DevtoolsProxyOptions | AgentWithInitialize,
267290
tunnelOptions?: Partial<TunnelOptions>,
268-
target = 'mongodb://'
291+
target?: string | undefined
269292
): Promise<Tunnel | undefined> {
270293
const agent = useOrCreateAgent(proxyOptions, target);
271294
if (!agent) return undefined;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { HTTPServerProxyTestSetup } from '../test/helpers';
2+
import { SSHAgent } from './ssh';
3+
import { createFetch } from './fetch';
4+
import { expect } from 'chai';
5+
import sinon from 'sinon';
6+
7+
describe('SSHAgent', function () {
8+
let setup: HTTPServerProxyTestSetup;
9+
let agent: SSHAgent | undefined;
10+
11+
beforeEach(async function () {
12+
setup = new HTTPServerProxyTestSetup();
13+
await setup.listen();
14+
agent = undefined;
15+
});
16+
17+
afterEach(async function () {
18+
await setup.teardown();
19+
agent?.destroy();
20+
});
21+
22+
it('allows establishing connections through an SSH server', async function () {
23+
agent = new SSHAgent({
24+
proxy: `ssh://[email protected]:${setup.sshProxyPort}/`,
25+
});
26+
const fetch = createFetch(agent);
27+
const response = await fetch('http://example.com/hello');
28+
expect(await response.text()).to.equal('OK /hello');
29+
});
30+
31+
it('re-uses a single SSH connection if it can', async function () {
32+
setup.authHandler = sinon.stub().returns(true);
33+
agent = new SSHAgent({
34+
proxy: `ssh://foo:[email protected]:${setup.sshProxyPort}/`,
35+
});
36+
const fetch = createFetch(agent);
37+
await Promise.all([
38+
fetch('http://example.com/hello'),
39+
fetch('http://example.com/hello'),
40+
]);
41+
expect(setup.authHandler).to.have.been.calledOnceWith('foo', 'bar');
42+
});
43+
44+
it('allows explicitly initializing the connection', async function () {
45+
setup.authHandler = sinon.stub().returns(true);
46+
agent = new SSHAgent({
47+
proxy: `ssh://foo:[email protected]:${setup.sshProxyPort}/`,
48+
});
49+
await agent.initialize();
50+
await createFetch(agent)('http://example.com/hello');
51+
expect(setup.authHandler).to.have.been.calledOnceWith('foo', 'bar');
52+
});
53+
54+
it('automatically reconnects if a connection was broken', async function () {
55+
setup.authHandler = sinon.stub().returns(true);
56+
agent = new SSHAgent({
57+
proxy: `ssh://foo:[email protected]:${setup.sshProxyPort}/`,
58+
});
59+
await agent.initialize();
60+
const fetch = createFetch(agent);
61+
await fetch('http://example.com/hello');
62+
await agent.interruptForTesting();
63+
await fetch('http://example.com/hello');
64+
expect(setup.authHandler).to.have.been.calledTwice;
65+
});
66+
67+
it('does not reconnect if the agent was intentionally closed', async function () {
68+
setup.authHandler = sinon.stub().returns(true);
69+
agent = new SSHAgent({
70+
proxy: `ssh://foo:[email protected]:${setup.sshProxyPort}/`,
71+
});
72+
await agent.initialize();
73+
const fetch = createFetch(agent);
74+
await fetch('http://example.com/hello');
75+
agent.destroy();
76+
try {
77+
await fetch('http://example.com/hello');
78+
expect.fail('missed exception');
79+
} catch (err) {
80+
expect(err.message).to.include(
81+
'request to http://example.com/hello failed, reason: Disconnected'
82+
);
83+
}
84+
expect(setup.authHandler).to.have.been.calledOnce;
85+
});
86+
87+
it('automatically retries the forwarding operation once (connection lost)', async function () {
88+
setup.authHandler = sinon.stub().returns(true);
89+
agent = new SSHAgent({
90+
proxy: `ssh://foo:[email protected]:${setup.sshProxyPort}/`,
91+
});
92+
await agent.initialize();
93+
await agent.interruptForTesting();
94+
const fetch = createFetch(agent);
95+
await fetch('http://example.com/hello');
96+
expect(setup.authHandler).to.have.been.calledTwice;
97+
});
98+
99+
it('automatically retries the forwarding operation once (tunnel failure)', async function () {
100+
setup.authHandler = sinon.stub().returns(true);
101+
setup.canTunnel = sinon
102+
.stub()
103+
.onFirstCall()
104+
.returns(false)
105+
.onSecondCall()
106+
.returns(true);
107+
agent = new SSHAgent({
108+
proxy: `ssh://foo:[email protected]:${setup.sshProxyPort}/`,
109+
});
110+
const fetch = createFetch(agent);
111+
await fetch('http://example.com/hello');
112+
expect(setup.authHandler).to.have.been.calledTwice;
113+
expect(setup.canTunnel).to.have.been.calledTwice;
114+
});
115+
});

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ export class SSHAgent extends AgentBase implements AgentWithInitialize {
143143
}
144144
return sock;
145145
} catch (err: unknown) {
146-
const retryableError = (err as Error).message === 'Not connected';
146+
const retryableError = /Not connected|Channel open failure/.test(
147+
(err as Error).message
148+
);
147149
this.logger.emit('ssh:failed-forward', {
148150
host,
149151
error: String((err as Error).stack),
@@ -165,4 +167,9 @@ export class SSHAgent extends AgentBase implements AgentWithInitialize {
165167
this.closed = true;
166168
this.sshClient.end();
167169
}
170+
171+
async interruptForTesting(): Promise<void> {
172+
this.sshClient.end();
173+
await once(this.sshClient, 'close');
174+
}
168175
}

0 commit comments

Comments
 (0)