Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support SOCKS proxy #540

Merged
merged 3 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

A programmable proxy server (think Squid) with support for SSL/TLS, authentication, upstream proxy chaining,
A programmable proxy server (think Squid) with support for SSL/TLS, authentication, upstream proxy chaining, SOCKS4/5 protocol,
custom HTTP responses, and traffic statistics.
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.

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

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

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

## SOCKS support
SOCKS protocol is supported for versions 4 and 5, specifically: `['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']`, where `socks` will default to version 5.

You can use an `upstreamProxyUrl` like `socks://username:[email protected]:1080`.

## Error status codes

The `502 Bad Gateway` HTTP status code is not comprehensive enough. Therefore, the server may respond with `590-599` instead:
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "proxy-chain",
"version": "2.4.1",
"version": "2.5.0",
"description": "Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, and protocol tunneling.",
"main": "dist/index.js",
"keywords": [
Expand Down Expand Up @@ -62,9 +62,9 @@
"isparta": "^4.1.1",
"mocha": "^10.0.0",
"nyc": "^15.1.0",
"puppeteer": "^19.6.3",
"portastic": "^1.0.1",
"proxy": "^1.0.2",
"puppeteer": "^19.6.3",
"request": "^2.88.2",
"rimraf": "^4.1.2",
"sinon": "^13.0.2",
Expand All @@ -86,6 +86,8 @@
]
},
"dependencies": {
"socks": "^2.8.3",
"socks-proxy-agent": "^8.0.3",
"tslib": "^2.3.1"
}
}
19 changes: 4 additions & 15 deletions src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,7 @@ import { Buffer } from 'buffer';
import { countTargetBytes } from './utils/count_target_bytes';
import { getBasicAuthorizationHeader } from './utils/get_basic';
import { Socket } from './socket';
import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses';

const createHttpResponse = (statusCode: number, statusMessage: string, message = '') => {
return [
`HTTP/1.1 ${statusCode} ${statusMessage || http.STATUS_CODES[statusCode] || 'Unknown Status Code'}`,
'Connection: close',
`Date: ${(new Date()).toUTCString()}`,
`Content-Length: ${Buffer.byteLength(message)}`,
``,
message,
].join('\r\n');
};
import { badGatewayStatusCodes, createCustomStatusHttpResponse, errorCodeToStatusCode } from './statuses';

interface Options {
method: string;
Expand All @@ -41,7 +30,7 @@ interface ChainOpts {
sourceSocket: Socket;
head?: Buffer;
handlerOpts: HandlerOpts;
server: EventEmitter & { log: (...args: any[]) => void; };
server: EventEmitter & { log: (connectionId: unknown, str: string) => void };
isPlain: boolean;
}

Expand Down Expand Up @@ -125,7 +114,7 @@ export const chain = (
? badGatewayStatusCodes.AUTH_FAILED
: badGatewayStatusCodes.NON_200;

sourceSocket.end(createHttpResponse(status, `UPSTREAM${response.statusCode}`));
sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`));
}

server.emit('tunnelConnectFailed', {
Expand Down Expand Up @@ -187,7 +176,7 @@ export const chain = (
sourceSocket.end();
} else {
const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;
const response = createHttpResponse(statusCode, error.code ?? 'Upstream Closed Early');
const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early');
sourceSocket.end(response);
}
}
Expand Down
128 changes: 128 additions & 0 deletions src/chain_socks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import http from 'http';
import net from 'net';
import { Buffer } from 'buffer';
import { URL } from 'url';
import { EventEmitter } from 'events';
import { SocksClient, SocksClientError, type SocksProxy } from 'socks';
import { countTargetBytes } from './utils/count_target_bytes';
import { Socket } from './socket';
import { createCustomStatusHttpResponse, socksErrorMessageToStatusCode } from './statuses';

export interface HandlerOpts {
upstreamProxyUrlParsed: URL;
customTag?: unknown;
}

interface ChainSocksOpts {
request: http.IncomingMessage,
sourceSocket: Socket;
head: Buffer;
server: EventEmitter & { log: (connectionId: unknown, str: string) => void };
handlerOpts: HandlerOpts;
}

const socksProtocolToVersionNumber = (protocol: string): 4 | 5 => {
switch (protocol) {
case 'socks4:':
case 'socks4a:':
return 4;
default:
return 5;
}
};

/**
* Client -> Apify (CONNECT) -> Upstream (SOCKS) -> Web
* Client <- Apify (CONNECT) <- Upstream (SOCKS) <- Web
*/
export const chainSocks = async ({
request,
sourceSocket,
head,
server,
handlerOpts,
}: ChainSocksOpts): Promise<void> => {
const { proxyChainId } = sourceSocket;

const { hostname, port, username, password } = handlerOpts.upstreamProxyUrlParsed;

const proxy: SocksProxy = {
host: hostname,
port: Number(port),
type: socksProtocolToVersionNumber(handlerOpts.upstreamProxyUrlParsed.protocol),
userId: username,
password,
};

if (head && head.length > 0) {
// HTTP/1.1 has no defined semantics when sending payload along with CONNECT and servers can reject the request.
// HTTP/2 only says that subsequent DATA frames must be transferred after HEADERS has been sent.
// HTTP/3 says that all DATA frames should be transferred (implies pre-HEADERS data).
//
// Let's go with the HTTP/3 behavior.
// There are also clients that send payload along with CONNECT to save milliseconds apparently.
// Beware of upstream proxy servers that send out valid CONNECT responses with diagnostic data such as IPs!
sourceSocket.unshift(head);
}

const url = new URL(`connect://${request.url}`);
const destination = {
port: Number(url.port),
host: url.hostname,
};

let targetSocket: net.Socket;

try {
const client = await SocksClient.createConnection({
proxy,
command: 'connect',
destination,
});
targetSocket = client.socket;

sourceSocket.write(`HTTP/1.1 200 Connection Established\r\n\r\n`);
} catch (error) {
const socksError = error as SocksClientError;
server.log(proxyChainId, `Failed to connect to upstream SOCKS proxy ${socksError.stack}`);
sourceSocket.end(createCustomStatusHttpResponse(socksErrorMessageToStatusCode(socksError.message), socksError.message));
return;
}

countTargetBytes(sourceSocket, targetSocket);

sourceSocket.pipe(targetSocket);
targetSocket.pipe(sourceSocket);

// Once target socket closes forcibly, the source socket gets paused.
// We need to enable flowing, otherwise the socket would remain open indefinitely.
// Nothing would consume the data, we just want to close the socket.
targetSocket.on('close', () => {
sourceSocket.resume();

if (sourceSocket.writable) {
sourceSocket.end();
}
});

// Same here.
sourceSocket.on('close', () => {
targetSocket.resume();

if (targetSocket.writable) {
targetSocket.end();
}
});

targetSocket.on('error', (error) => {
server.log(proxyChainId, `Chain SOCKS Destination Socket Error: ${error.stack}`);

sourceSocket.destroy();
});

sourceSocket.on('error', (error) => {
server.log(proxyChainId, `Chain SOCKS Source Socket Error: ${error.stack}`);

targetSocket.destroy();
});
};
2 changes: 1 addition & 1 deletion src/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface DirectOpts {
request: { url?: string };
sourceSocket: Socket;
head: Buffer;
server: EventEmitter & { log: (...args: any[]) => void; };
server: EventEmitter & { log: (connectionId: unknown, str: string) => void };
handlerOpts: HandlerOpts;
}

Expand Down
102 changes: 102 additions & 0 deletions src/forward_socks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import http from 'http';
import stream from 'stream';
import util from 'util';
import { URL } from 'url';
import { SocksProxyAgent } from 'socks-proxy-agent';
import { validHeadersOnly } from './utils/valid_headers_only';
import { countTargetBytes } from './utils/count_target_bytes';
import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses';

const pipeline = util.promisify(stream.pipeline);

interface Options {
method: string;
headers: string[];
insecureHTTPParser: boolean;
path?: string;
localAddress?: string;
agent: http.Agent;
}

export interface HandlerOpts {
upstreamProxyUrlParsed: URL;
localAddress?: string;
}

/**
* ```
* Client -> Apify (HTTP) -> Upstream (SOCKS) -> Web
* Client <- Apify (HTTP) <- Upstream (SOCKS) <- Web
* ```
*/
export const forwardSocks = async (
request: http.IncomingMessage,
response: http.ServerResponse,
handlerOpts: HandlerOpts,
// eslint-disable-next-line no-async-promise-executor
): Promise<void> => new Promise(async (resolve, reject) => {
const agent = new SocksProxyAgent(handlerOpts.upstreamProxyUrlParsed);

const options: Options = {
method: request.method!,
headers: validHeadersOnly(request.rawHeaders),
insecureHTTPParser: true,
localAddress: handlerOpts.localAddress,
agent,
};

// Only handling "http" here - since everything else is handeled by tunnelSocks.
// We have to force cast `options` because @types/node doesn't support an array.
const client = http.request(request.url!, options as unknown as http.ClientRequestArgs, async (clientResponse) => {
try {
// This is necessary to prevent Node.js throwing an error
let statusCode = clientResponse.statusCode!;
if (statusCode < 100 || statusCode > 999) {
statusCode = badGatewayStatusCodes.STATUS_CODE_OUT_OF_RANGE;
}

// 407 is handled separately
if (clientResponse.statusCode === 407) {
reject(new Error('407 Proxy Authentication Required'));
return;
}

response.writeHead(
statusCode,
clientResponse.statusMessage,
validHeadersOnly(clientResponse.rawHeaders),
);

// `pipeline` automatically handles all the events and data
await pipeline(
clientResponse,
response,
);

resolve();
} catch (error) {
// Client error, pipeline already destroys the streams, ignore.
resolve();
}
});

client.once('socket', (socket) => {
countTargetBytes(request.socket, socket);
});

// Can't use pipeline here as it automatically destroys the streams
request.pipe(client);
client.on('error', (error: NodeJS.ErrnoException) => {
if (response.headersSent) {
return;
}

const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;

response.statusCode = statusCode;
response.setHeader('content-type', 'text/plain; charset=utf-8');
response.end(http.STATUS_CODES[response.statusCode]);

resolve();
});
});
Loading
Loading