Skip to content

Commit 4601da3

Browse files
authored
feat: custom connect http server (apify#285)
1 parent 44ba532 commit 4601da3

File tree

5 files changed

+142
-3
lines changed

5 files changed

+142
-3
lines changed

README.md

+55
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,61 @@ server.listen(() => {
244244
});
245245
```
246246

247+
## Routing CONNECT to another HTTP server
248+
249+
While `customResponseFunction` enables custom handling methods such as `GET` and `POST`, many HTTP clients rely on `CONNECT` tunnels.
250+
It's possible to route those requests differently using the `customConnectServer` option. It accepts an instance of Node.js HTTP server.
251+
252+
```javascript
253+
const http = require('http');
254+
const ProxyChain = require('proxy-chain');
255+
256+
const exampleServer = http.createServer((request, response) => {
257+
response.end('Hello from a custom server!');
258+
});
259+
260+
const server = new ProxyChain.Server({
261+
port: 8000,
262+
prepareRequestFunction: ({ request, username, password, hostname, port, isHttp }) => {
263+
if (request.url.toLowerCase() === 'example.com:80') {
264+
return {
265+
customConnectServer: exampleServer,
266+
};
267+
}
268+
269+
return {};
270+
},
271+
});
272+
273+
server.listen(() => {
274+
console.log(`Proxy server is listening on port ${server.port}`);
275+
});
276+
```
277+
278+
In the example above, all CONNECT tunnels to `example.com` are overridden.
279+
This is an unsecure server, so it accepts only `http:` requests.
280+
281+
In order to intercept `https:` requests, `https.createServer` should be used instead, along with a self signed certificate.
282+
283+
```javascript
284+
const https = require('https');
285+
const fs = require('fs');
286+
const key = fs.readFileSync('./test/ssl.key');
287+
const cert = fs.readFileSync('./test/ssl.crt');
288+
289+
const exampleServer = https.createServer({
290+
key,
291+
cert,
292+
}, (request, response) => {
293+
response.end('Hello from a custom server!');
294+
});
295+
```
296+
297+
```diff
298+
-if (request.url.toLowerCase() === 'example.com:80') {
299+
+if (request.url.toLowerCase() === 'example.com:443') {
300+
```
301+
247302
## Closing the server
248303

249304
To shut down the proxy server, call the `close([destroyConnections], [callback])` function. For example:

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "proxy-chain",
3-
"version": "2.1.1",
3+
"version": "2.2.0",
44
"description": "Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, and protocol tunneling.",
55
"main": "dist/index.js",
66
"keywords": [
@@ -47,7 +47,7 @@
4747
"@apify/eslint-config-ts": "^0.2.3",
4848
"@apify/tsconfig": "^0.1.0",
4949
"@types/jest": "^28.1.2",
50-
"@types/node": "^18.0.0",
50+
"@types/node": "^18.8.3",
5151
"@typescript-eslint/eslint-plugin": "5.29.0",
5252
"@typescript-eslint/parser": "5.29.0",
5353
"basic-auth": "^2.0.1",

src/custom_connect.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import net from 'net';
2+
import type http from 'http';
3+
import { promisify } from 'util';
4+
5+
const asyncWrite = promisify(net.Socket.prototype.write);
6+
7+
export const customConnect = async (socket: net.Socket, server: http.Server): Promise<void> => {
8+
// `countTargetBytes(socket, socket)` is incorrect here since `socket` is not a target.
9+
// We would have to create a new stream and pipe traffic through that,
10+
// however this would also increase CPU usage.
11+
// Also, counting bytes here is not correct since we don't know how the response is generated
12+
// (whether any additional sockets are used).
13+
14+
await asyncWrite.call(socket, 'HTTP/1.1 200 Connection Established\r\n\r\n');
15+
server.emit('connection', socket);
16+
17+
return new Promise((resolve) => {
18+
if (socket.destroyed) {
19+
resolve();
20+
return;
21+
}
22+
23+
socket.once('close', () => {
24+
resolve();
25+
});
26+
});
27+
};

src/server.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { handleCustomResponse, HandlerOpts as CustomResponseOpts } from './custo
1717
import { Socket } from './socket';
1818
import { normalizeUrlPort } from './utils/normalize_url_port';
1919
import { badGatewayStatusCodes } from './statuses';
20+
import { customConnect } from './custom_connect';
2021

2122
// TODO:
2223
// - Implement this requirement from rfc7230
@@ -46,7 +47,8 @@ type HandlerOpts = {
4647
trgParsed: URL | null;
4748
upstreamProxyUrlParsed: URL | null;
4849
isHttp: boolean;
49-
customResponseFunction: CustomResponseOpts['customResponseFunction'] | null;
50+
customResponseFunction?: CustomResponseOpts['customResponseFunction'] | null;
51+
customConnectServer?: http.Server | null;
5052
localAddress?: string;
5153
ipFamily?: number;
5254
dnsLookup?: typeof dns['lookup'];
@@ -64,6 +66,7 @@ export type PrepareRequestFunctionOpts = {
6466

6567
export type PrepareRequestFunctionResult = {
6668
customResponseFunction?: CustomResponseOpts['customResponseFunction'];
69+
customConnectServer?: http.Server | null;
6770
requestAuthentication?: boolean;
6871
failMsg?: string;
6972
upstreamProxyUrl?: string | null;
@@ -274,6 +277,11 @@ export class Server extends EventEmitter {
274277

275278
const data = { request, sourceSocket: socket, head, handlerOpts: handlerOpts as ChainOpts, server: this, isPlain: false };
276279

280+
if (handlerOpts.customConnectServer) {
281+
socket.unshift(head); // See chain.ts for why we do this
282+
return await customConnect(socket, handlerOpts.customConnectServer);
283+
}
284+
277285
if (handlerOpts.upstreamProxyUrlParsed) {
278286
this.log(socket.proxyChainId, `Using HandlerTunnelChain => ${request.url}`);
279287
return await chain(data);
@@ -301,6 +309,7 @@ export class Server extends EventEmitter {
301309
isHttp: false,
302310
srcResponse: null,
303311
customResponseFunction: null,
312+
customConnectServer: null,
304313
};
305314

306315
this.log((request.socket as Socket).proxyChainId, `!!! Handling ${request.method} ${request.url} HTTP/${request.httpVersion}`);
@@ -404,6 +413,7 @@ export class Server extends EventEmitter {
404413
handlerOpts.localAddress = funcResult.localAddress;
405414
handlerOpts.ipFamily = funcResult.ipFamily;
406415
handlerOpts.dnsLookup = funcResult.dnsLookup;
416+
handlerOpts.customConnectServer = funcResult.customConnectServer;
407417

408418
// If not authenticated, request client to authenticate
409419
if (funcResult.requestAuthentication) {

test/server.js

+47
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,53 @@ it('supports localAddress', async () => {
13321332
}
13331333
});
13341334

1335+
it('supports custom CONNECT server handler', async () => {
1336+
const server = new Server({
1337+
port: 0,
1338+
prepareRequestFunction: () => {
1339+
const customConnectServer = http.createServer((_request, response) => {
1340+
response.end('Hello, world!');
1341+
});
1342+
1343+
return {
1344+
customConnectServer,
1345+
};
1346+
},
1347+
});
1348+
1349+
await server.listen();
1350+
1351+
try {
1352+
const response = await new Promise((resolve, reject) => {
1353+
http.request(`http://127.0.0.1:${server.port}`, {
1354+
method: 'CONNECT',
1355+
path: 'example.com:80',
1356+
headers: {
1357+
host: 'example.com:80',
1358+
},
1359+
}).on('connect', (connectResponse, socket, head) => {
1360+
http.request('http://example.com', {
1361+
createConnection: () => socket,
1362+
}, (res) => {
1363+
const buffer = [];
1364+
1365+
res.on('data', (chunk) => {
1366+
buffer.push(chunk);
1367+
});
1368+
1369+
res.on('end', () => {
1370+
resolve(Buffer.concat(buffer).toString());
1371+
});
1372+
}).on('error', reject).end();
1373+
}).on('error', reject).end();
1374+
});
1375+
1376+
expect(response).to.be.equal('Hello, world!');
1377+
} finally {
1378+
await server.close();
1379+
}
1380+
});
1381+
13351382
it('supports pre-response CONNECT payload', (done) => {
13361383
const plain = net.createServer((socket) => {
13371384
socket.pipe(socket);

0 commit comments

Comments
 (0)