Skip to content

Commit a586b3e

Browse files
authored
feat: support SOCKS proxy (#540)
1 parent 94640e5 commit a586b3e

File tree

8 files changed

+290
-27
lines changed

8 files changed

+290
-27
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![npm version](https://badge.fury.io/js/proxy-chain.svg)](http://badge.fury.io/js/proxy-chain)
44

5-
A programmable proxy server (think Squid) with support for SSL/TLS, authentication, upstream proxy chaining,
5+
A programmable proxy server (think Squid) with support for SSL/TLS, authentication, upstream proxy chaining, SOCKS4/5 protocol,
66
custom HTTP responses, and traffic statistics.
77
The authentication and proxy chaining configuration is defined in code and can be fully dynamic, giving you a high level of customization for your use case.
88

@@ -69,11 +69,13 @@ const server = new ProxyChain.Server({
6969
// requiring Basic authentication. Here you can verify user credentials.
7070
requestAuthentication: username !== 'bob' || password !== 'TopSecret',
7171

72-
// Sets up an upstream HTTP proxy to which all the requests are forwarded.
72+
// Sets up an upstream HTTP/SOCKS proxy to which all the requests are forwarded.
7373
// If null, the proxy works in direct mode, i.e. the connection is forwarded directly
7474
// to the target server. This field is ignored if "requestAuthentication" is true.
7575
// The username and password must be URI-encoded.
7676
upstreamProxyUrl: `http://username:[email protected]:3128`,
77+
// Or use SOCKS4/5 proxy, e.g.
78+
// upstreamProxyUrl: `socks://username:[email protected]:1080`,
7779

7880
// If "requestAuthentication" is true, you can use the following property
7981
// to define a custom error message to return to the client instead of the default "Proxy credentials required"
@@ -105,6 +107,11 @@ server.on('requestFailed', ({ request, error }) => {
105107
});
106108
```
107109

110+
## SOCKS support
111+
SOCKS protocol is supported for versions 4 and 5, specifically: `['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']`, where `socks` will default to version 5.
112+
113+
You can use an `upstreamProxyUrl` like `socks://username:[email protected]:1080`.
114+
108115
## Error status codes
109116

110117
The `502 Bad Gateway` HTTP status code is not comprehensive enough. Therefore, the server may respond with `590-599` instead:

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "proxy-chain",
3-
"version": "2.4.1",
3+
"version": "2.5.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": [
@@ -62,9 +62,9 @@
6262
"isparta": "^4.1.1",
6363
"mocha": "^10.0.0",
6464
"nyc": "^15.1.0",
65-
"puppeteer": "^19.6.3",
6665
"portastic": "^1.0.1",
6766
"proxy": "^1.0.2",
67+
"puppeteer": "^19.6.3",
6868
"request": "^2.88.2",
6969
"rimraf": "^4.1.2",
7070
"sinon": "^13.0.2",
@@ -86,6 +86,8 @@
8686
]
8787
},
8888
"dependencies": {
89+
"socks": "^2.8.3",
90+
"socks-proxy-agent": "^8.0.3",
8991
"tslib": "^2.3.1"
9092
}
9193
}

src/chain.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,7 @@ import { Buffer } from 'buffer';
66
import { countTargetBytes } from './utils/count_target_bytes';
77
import { getBasicAuthorizationHeader } from './utils/get_basic';
88
import { Socket } from './socket';
9-
import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses';
10-
11-
const createHttpResponse = (statusCode: number, statusMessage: string, message = '') => {
12-
return [
13-
`HTTP/1.1 ${statusCode} ${statusMessage || http.STATUS_CODES[statusCode] || 'Unknown Status Code'}`,
14-
'Connection: close',
15-
`Date: ${(new Date()).toUTCString()}`,
16-
`Content-Length: ${Buffer.byteLength(message)}`,
17-
``,
18-
message,
19-
].join('\r\n');
20-
};
9+
import { badGatewayStatusCodes, createCustomStatusHttpResponse, errorCodeToStatusCode } from './statuses';
2110

2211
interface Options {
2312
method: string;
@@ -41,7 +30,7 @@ interface ChainOpts {
4130
sourceSocket: Socket;
4231
head?: Buffer;
4332
handlerOpts: HandlerOpts;
44-
server: EventEmitter & { log: (...args: any[]) => void; };
33+
server: EventEmitter & { log: (connectionId: unknown, str: string) => void };
4534
isPlain: boolean;
4635
}
4736

@@ -125,7 +114,7 @@ export const chain = (
125114
? badGatewayStatusCodes.AUTH_FAILED
126115
: badGatewayStatusCodes.NON_200;
127116

128-
sourceSocket.end(createHttpResponse(status, `UPSTREAM${response.statusCode}`));
117+
sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`));
129118
}
130119

131120
server.emit('tunnelConnectFailed', {
@@ -187,7 +176,7 @@ export const chain = (
187176
sourceSocket.end();
188177
} else {
189178
const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;
190-
const response = createHttpResponse(statusCode, error.code ?? 'Upstream Closed Early');
179+
const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early');
191180
sourceSocket.end(response);
192181
}
193182
}

src/chain_socks.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import http from 'http';
2+
import net from 'net';
3+
import { Buffer } from 'buffer';
4+
import { URL } from 'url';
5+
import { EventEmitter } from 'events';
6+
import { SocksClient, SocksClientError, type SocksProxy } from 'socks';
7+
import { countTargetBytes } from './utils/count_target_bytes';
8+
import { Socket } from './socket';
9+
import { createCustomStatusHttpResponse, socksErrorMessageToStatusCode } from './statuses';
10+
11+
export interface HandlerOpts {
12+
upstreamProxyUrlParsed: URL;
13+
customTag?: unknown;
14+
}
15+
16+
interface ChainSocksOpts {
17+
request: http.IncomingMessage,
18+
sourceSocket: Socket;
19+
head: Buffer;
20+
server: EventEmitter & { log: (connectionId: unknown, str: string) => void };
21+
handlerOpts: HandlerOpts;
22+
}
23+
24+
const socksProtocolToVersionNumber = (protocol: string): 4 | 5 => {
25+
switch (protocol) {
26+
case 'socks4:':
27+
case 'socks4a:':
28+
return 4;
29+
default:
30+
return 5;
31+
}
32+
};
33+
34+
/**
35+
* Client -> Apify (CONNECT) -> Upstream (SOCKS) -> Web
36+
* Client <- Apify (CONNECT) <- Upstream (SOCKS) <- Web
37+
*/
38+
export const chainSocks = async ({
39+
request,
40+
sourceSocket,
41+
head,
42+
server,
43+
handlerOpts,
44+
}: ChainSocksOpts): Promise<void> => {
45+
const { proxyChainId } = sourceSocket;
46+
47+
const { hostname, port, username, password } = handlerOpts.upstreamProxyUrlParsed;
48+
49+
const proxy: SocksProxy = {
50+
host: hostname,
51+
port: Number(port),
52+
type: socksProtocolToVersionNumber(handlerOpts.upstreamProxyUrlParsed.protocol),
53+
userId: username,
54+
password,
55+
};
56+
57+
if (head && head.length > 0) {
58+
// HTTP/1.1 has no defined semantics when sending payload along with CONNECT and servers can reject the request.
59+
// HTTP/2 only says that subsequent DATA frames must be transferred after HEADERS has been sent.
60+
// HTTP/3 says that all DATA frames should be transferred (implies pre-HEADERS data).
61+
//
62+
// Let's go with the HTTP/3 behavior.
63+
// There are also clients that send payload along with CONNECT to save milliseconds apparently.
64+
// Beware of upstream proxy servers that send out valid CONNECT responses with diagnostic data such as IPs!
65+
sourceSocket.unshift(head);
66+
}
67+
68+
const url = new URL(`connect://${request.url}`);
69+
const destination = {
70+
port: Number(url.port),
71+
host: url.hostname,
72+
};
73+
74+
let targetSocket: net.Socket;
75+
76+
try {
77+
const client = await SocksClient.createConnection({
78+
proxy,
79+
command: 'connect',
80+
destination,
81+
});
82+
targetSocket = client.socket;
83+
84+
sourceSocket.write(`HTTP/1.1 200 Connection Established\r\n\r\n`);
85+
} catch (error) {
86+
const socksError = error as SocksClientError;
87+
server.log(proxyChainId, `Failed to connect to upstream SOCKS proxy ${socksError.stack}`);
88+
sourceSocket.end(createCustomStatusHttpResponse(socksErrorMessageToStatusCode(socksError.message), socksError.message));
89+
return;
90+
}
91+
92+
countTargetBytes(sourceSocket, targetSocket);
93+
94+
sourceSocket.pipe(targetSocket);
95+
targetSocket.pipe(sourceSocket);
96+
97+
// Once target socket closes forcibly, the source socket gets paused.
98+
// We need to enable flowing, otherwise the socket would remain open indefinitely.
99+
// Nothing would consume the data, we just want to close the socket.
100+
targetSocket.on('close', () => {
101+
sourceSocket.resume();
102+
103+
if (sourceSocket.writable) {
104+
sourceSocket.end();
105+
}
106+
});
107+
108+
// Same here.
109+
sourceSocket.on('close', () => {
110+
targetSocket.resume();
111+
112+
if (targetSocket.writable) {
113+
targetSocket.end();
114+
}
115+
});
116+
117+
targetSocket.on('error', (error) => {
118+
server.log(proxyChainId, `Chain SOCKS Destination Socket Error: ${error.stack}`);
119+
120+
sourceSocket.destroy();
121+
});
122+
123+
sourceSocket.on('error', (error) => {
124+
server.log(proxyChainId, `Chain SOCKS Source Socket Error: ${error.stack}`);
125+
126+
targetSocket.destroy();
127+
});
128+
};

src/direct.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ interface DirectOpts {
1616
request: { url?: string };
1717
sourceSocket: Socket;
1818
head: Buffer;
19-
server: EventEmitter & { log: (...args: any[]) => void; };
19+
server: EventEmitter & { log: (connectionId: unknown, str: string) => void };
2020
handlerOpts: HandlerOpts;
2121
}
2222

src/forward_socks.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import http from 'http';
2+
import stream from 'stream';
3+
import util from 'util';
4+
import { URL } from 'url';
5+
import { SocksProxyAgent } from 'socks-proxy-agent';
6+
import { validHeadersOnly } from './utils/valid_headers_only';
7+
import { countTargetBytes } from './utils/count_target_bytes';
8+
import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses';
9+
10+
const pipeline = util.promisify(stream.pipeline);
11+
12+
interface Options {
13+
method: string;
14+
headers: string[];
15+
insecureHTTPParser: boolean;
16+
path?: string;
17+
localAddress?: string;
18+
agent: http.Agent;
19+
}
20+
21+
export interface HandlerOpts {
22+
upstreamProxyUrlParsed: URL;
23+
localAddress?: string;
24+
}
25+
26+
/**
27+
* ```
28+
* Client -> Apify (HTTP) -> Upstream (SOCKS) -> Web
29+
* Client <- Apify (HTTP) <- Upstream (SOCKS) <- Web
30+
* ```
31+
*/
32+
export const forwardSocks = async (
33+
request: http.IncomingMessage,
34+
response: http.ServerResponse,
35+
handlerOpts: HandlerOpts,
36+
// eslint-disable-next-line no-async-promise-executor
37+
): Promise<void> => new Promise(async (resolve, reject) => {
38+
const agent = new SocksProxyAgent(handlerOpts.upstreamProxyUrlParsed);
39+
40+
const options: Options = {
41+
method: request.method!,
42+
headers: validHeadersOnly(request.rawHeaders),
43+
insecureHTTPParser: true,
44+
localAddress: handlerOpts.localAddress,
45+
agent,
46+
};
47+
48+
// Only handling "http" here - since everything else is handeled by tunnelSocks.
49+
// We have to force cast `options` because @types/node doesn't support an array.
50+
const client = http.request(request.url!, options as unknown as http.ClientRequestArgs, async (clientResponse) => {
51+
try {
52+
// This is necessary to prevent Node.js throwing an error
53+
let statusCode = clientResponse.statusCode!;
54+
if (statusCode < 100 || statusCode > 999) {
55+
statusCode = badGatewayStatusCodes.STATUS_CODE_OUT_OF_RANGE;
56+
}
57+
58+
// 407 is handled separately
59+
if (clientResponse.statusCode === 407) {
60+
reject(new Error('407 Proxy Authentication Required'));
61+
return;
62+
}
63+
64+
response.writeHead(
65+
statusCode,
66+
clientResponse.statusMessage,
67+
validHeadersOnly(clientResponse.rawHeaders),
68+
);
69+
70+
// `pipeline` automatically handles all the events and data
71+
await pipeline(
72+
clientResponse,
73+
response,
74+
);
75+
76+
resolve();
77+
} catch (error) {
78+
// Client error, pipeline already destroys the streams, ignore.
79+
resolve();
80+
}
81+
});
82+
83+
client.once('socket', (socket) => {
84+
countTargetBytes(request.socket, socket);
85+
});
86+
87+
// Can't use pipeline here as it automatically destroys the streams
88+
request.pipe(client);
89+
client.on('error', (error: NodeJS.ErrnoException) => {
90+
if (response.headersSent) {
91+
return;
92+
}
93+
94+
const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;
95+
96+
response.statusCode = statusCode;
97+
response.setHeader('content-type', 'text/plain; charset=utf-8');
98+
response.end(http.STATUS_CODES[response.statusCode]);
99+
100+
resolve();
101+
});
102+
});

0 commit comments

Comments
 (0)