proxy-chain
Version:
Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, and protocol tunneling.
571 lines • 24.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Server = exports.SOCKS_PROTOCOLS = void 0;
const tslib_1 = require("tslib");
/* eslint-disable no-use-before-define */
const node_buffer_1 = require("node:buffer");
const node_events_1 = require("node:events");
const node_http_1 = tslib_1.__importDefault(require("node:http"));
const node_url_1 = require("node:url");
const node_util_1 = tslib_1.__importDefault(require("node:util"));
const chain_1 = require("./chain");
const chain_socks_1 = require("./chain_socks");
const custom_connect_1 = require("./custom_connect");
const custom_response_1 = require("./custom_response");
const direct_1 = require("./direct");
const forward_1 = require("./forward");
const forward_socks_1 = require("./forward_socks");
const request_error_1 = require("./request_error");
const statuses_1 = require("./statuses");
const count_target_bytes_1 = require("./utils/count_target_bytes");
const nodeify_1 = require("./utils/nodeify");
const normalize_url_port_1 = require("./utils/normalize_url_port");
const parse_authorization_header_1 = require("./utils/parse_authorization_header");
const redact_url_1 = require("./utils/redact_url");
exports.SOCKS_PROTOCOLS = ['socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:'];
// TODO:
// - Implement this requirement from rfc7230
// "A proxy MUST forward unrecognized header fields unless the field-name
// is listed in the Connection header field (Section 6.1) or the proxy
// is specifically configured to block, or otherwise transform, such
// fields. Other recipients SHOULD ignore unrecognized header fields.
// These requirements allow HTTP's functionality to be enhanced without
// requiring prior update of deployed intermediaries."
const DEFAULT_AUTH_REALM = 'ProxyChain';
const DEFAULT_PROXY_SERVER_PORT = 8000;
/**
* Represents the proxy server.
* It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`.
* It emits the 'connectionClosed' event when connection to proxy server is closed, with parameter `{ connectionId, stats }`.
*/
class Server extends node_events_1.EventEmitter {
/**
* Initializes a new instance of Server class.
* @param options
* @param [options.port] Port where the server will listen. By default 8000.
* @param [options.prepareRequestFunction] Custom function to authenticate proxy requests,
* provide URL to upstream proxy or potentially provide a function that generates a custom response to HTTP requests.
* It accepts a single parameter which is an object:
* ```
* {
* connectionId: symbol,
* request: http.IncomingMessage,
* username: string,
* password: string,
* hostname: string,
* port: number,
* isHttp: boolean,
* }
* ```
* and returns an object (or promise resolving to the object) with following form:
* ```
* {
* requestAuthentication: boolean,
* upstreamProxyUrl: string,
* customResponseFunction: Function,
* }
* ```
* If `upstreamProxyUrl` is a falsy value, no upstream proxy is used.
* If `prepareRequestFunction` is not set, the proxy server will not require any authentication
* and will not use any upstream proxy.
* If `customResponseFunction` is set, it will be called to generate a custom response to the HTTP request.
* It should not be used together with `upstreamProxyUrl`.
* @param [options.authRealm] Realm used in the Proxy-Authenticate header and also in the 'Server' HTTP header. By default it's `ProxyChain`.
* @param [options.verbose] If true, the server will output logs
*/
constructor(options = {}) {
super();
Object.defineProperty(this, "port", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "host", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "prepareRequestFunction", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "authRealm", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "verbose", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "server", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "lastHandlerId", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "stats", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "connections", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
if (options.port === undefined || options.port === null) {
this.port = DEFAULT_PROXY_SERVER_PORT;
}
else {
this.port = options.port;
}
this.host = options.host;
this.prepareRequestFunction = options.prepareRequestFunction;
this.authRealm = options.authRealm || DEFAULT_AUTH_REALM;
this.verbose = !!options.verbose;
this.server = node_http_1.default.createServer();
this.server.on('clientError', this.onClientError.bind(this));
this.server.on('request', this.onRequest.bind(this));
this.server.on('connect', this.onConnect.bind(this));
this.server.on('connection', this.onConnection.bind(this));
this.lastHandlerId = 0;
this.stats = {
httpRequestCount: 0,
connectRequestCount: 0,
};
this.connections = new Map();
}
log(connectionId, str) {
if (this.verbose) {
const logPrefix = connectionId != null ? `${String(connectionId)} | ` : '';
// eslint-disable-next-line no-console
console.log(`ProxyServer[${this.port}]: ${logPrefix}${str}`);
}
}
onClientError(err, socket) {
this.log(socket.proxyChainId, `onClientError: ${err}`);
// https://nodejs.org/api/http.html#http_event_clienterror
if (err.code === 'ECONNRESET' || !socket.writable) {
return;
}
this.sendSocketResponse(socket, 400, {}, 'Invalid request');
}
/**
* Assigns a unique ID to the socket and keeps the register up to date.
* Needed for abrupt close of the server.
*/
registerConnection(socket) {
const unique = this.lastHandlerId++;
socket.proxyChainId = unique;
this.connections.set(unique, socket);
socket.on('close', () => {
this.emit('connectionClosed', {
connectionId: unique,
stats: this.getConnectionStats(unique),
});
this.connections.delete(unique);
});
// We have to manually destroy the socket if it timeouts.
// This will prevent connections from leaking and close them properly.
socket.on('timeout', () => {
socket.destroy();
});
}
/**
* Handles incoming sockets, useful for error handling
*/
onConnection(socket) {
// https://github.com/nodejs/node/issues/23858
if (!socket.remoteAddress) {
socket.destroy();
return;
}
this.registerConnection(socket);
// We need to consume socket errors, because the handlers are attached asynchronously.
// See https://github.com/apify/proxy-chain/issues/53
socket.on('error', (err) => {
// Handle errors only if there's no other handler
if (this.listenerCount('error') === 1) {
this.log(socket.proxyChainId, `Source socket emitted error: ${err.stack || err}`);
}
});
}
/**
* Converts known errors to be instance of RequestError.
*/
normalizeHandlerError(error) {
if (error.message === 'Username contains an invalid colon') {
return new request_error_1.RequestError('Invalid colon in username in upstream proxy credentials', statuses_1.badGatewayStatusCodes.AUTH_FAILED);
}
if (error.message === '407 Proxy Authentication Required') {
return new request_error_1.RequestError('Invalid upstream proxy credentials', statuses_1.badGatewayStatusCodes.AUTH_FAILED);
}
return error;
}
/**
* Handles normal HTTP request by forwarding it to target host or the upstream proxy.
*/
async onRequest(request, response) {
try {
const handlerOpts = await this.prepareRequestHandling(request);
handlerOpts.srcResponse = response;
const { proxyChainId } = request.socket;
if (handlerOpts.customResponseFunction) {
this.log(proxyChainId, 'Using handleCustomResponse()');
await (0, custom_response_1.handleCustomResponse)(request, response, handlerOpts);
return;
}
if (handlerOpts.upstreamProxyUrlParsed && exports.SOCKS_PROTOCOLS.includes(handlerOpts.upstreamProxyUrlParsed.protocol)) {
this.log(proxyChainId, 'Using forwardSocks()');
await (0, forward_socks_1.forwardSocks)(request, response, handlerOpts);
return;
}
this.log(proxyChainId, 'Using forward()');
await (0, forward_1.forward)(request, response, handlerOpts);
}
catch (error) {
this.failRequest(request, this.normalizeHandlerError(error));
}
}
/**
* Handles HTTP CONNECT request by setting up a tunnel either to target host or to the upstream proxy.
* @param request
* @param socket
* @param head The first packet of the tunneling stream (may be empty)
*/
async onConnect(request, socket, head) {
try {
const handlerOpts = await this.prepareRequestHandling(request);
handlerOpts.srcHead = head;
const data = { request, sourceSocket: socket, head, handlerOpts: handlerOpts, server: this, isPlain: false };
if (handlerOpts.customConnectServer) {
socket.unshift(head); // See chain.ts for why we do this
await (0, custom_connect_1.customConnect)(socket, handlerOpts.customConnectServer);
return;
}
if (handlerOpts.upstreamProxyUrlParsed) {
if (exports.SOCKS_PROTOCOLS.includes(handlerOpts.upstreamProxyUrlParsed.protocol)) {
this.log(socket.proxyChainId, `Using chainSocks() => ${request.url}`);
await (0, chain_socks_1.chainSocks)(data);
return;
}
this.log(socket.proxyChainId, `Using chain() => ${request.url}`);
(0, chain_1.chain)(data);
return;
}
this.log(socket.proxyChainId, `Using direct() => ${request.url}`);
(0, direct_1.direct)(data);
}
catch (error) {
this.failRequest(request, this.normalizeHandlerError(error));
}
}
/**
* Prepares handler options from a request.
* @see {prepareRequestHandling}
*/
getHandlerOpts(request) {
const handlerOpts = {
server: this,
id: request.socket.proxyChainId,
srcRequest: request,
srcHead: null,
trgParsed: null,
upstreamProxyUrlParsed: null,
ignoreUpstreamProxyCertificate: false,
isHttp: false,
srcResponse: null,
customResponseFunction: null,
customConnectServer: null,
};
this.log(request.socket.proxyChainId, `!!! Handling ${request.method} ${request.url} HTTP/${request.httpVersion}`);
if (request.method === 'CONNECT') {
// CONNECT server.example.com:80 HTTP/1.1
try {
handlerOpts.trgParsed = new node_url_1.URL(`connect://${request.url}`);
}
catch {
throw new request_error_1.RequestError(`Target "${request.url}" could not be parsed`, 400);
}
if (!handlerOpts.trgParsed.hostname || !handlerOpts.trgParsed.port) {
throw new request_error_1.RequestError(`Target "${request.url}" could not be parsed`, 400);
}
this.stats.connectRequestCount++;
}
else {
// The request should look like:
// GET http://server.example.com:80/some-path HTTP/1.1
// Note that RFC 7230 says:
// "When making a request to a proxy, other than a CONNECT or server-wide
// OPTIONS request (as detailed below), a client MUST send the target
// URI in absolute-form as the request-target"
let parsed;
try {
parsed = new node_url_1.URL(request.url);
}
catch {
// If URL is invalid, throw HTTP 400 error
throw new request_error_1.RequestError(`Target "${request.url}" could not be parsed`, 400);
}
// Only HTTP is supported, other protocols such as HTTP or FTP must use the CONNECT method
if (parsed.protocol !== 'http:') {
throw new request_error_1.RequestError(`Only HTTP protocol is supported (was ${parsed.protocol})`, 400);
}
handlerOpts.trgParsed = parsed;
handlerOpts.isHttp = true;
this.stats.httpRequestCount++;
}
return handlerOpts;
}
/**
* Calls `this.prepareRequestFunction` with normalized options.
* @param request
* @param handlerOpts
*/
async callPrepareRequestFunction(request, handlerOpts) {
if (this.prepareRequestFunction) {
const funcOpts = {
connectionId: request.socket.proxyChainId,
request,
username: '',
password: '',
hostname: handlerOpts.trgParsed.hostname,
port: (0, normalize_url_port_1.normalizeUrlPort)(handlerOpts.trgParsed),
isHttp: handlerOpts.isHttp,
};
// Authenticate the request using a user function (if provided)
const proxyAuth = request.headers['proxy-authorization'];
if (proxyAuth) {
const auth = (0, parse_authorization_header_1.parseAuthorizationHeader)(proxyAuth);
if (!auth) {
throw new request_error_1.RequestError('Invalid "Proxy-Authorization" header', 400);
}
// https://datatracker.ietf.org/doc/html/rfc7617#page-3
// Note that both scheme and parameter names are matched case-
// insensitively.
if (auth.type.toLowerCase() !== 'basic') {
throw new request_error_1.RequestError('The "Proxy-Authorization" header must have the "Basic" type.', 400);
}
funcOpts.username = auth.username;
funcOpts.password = auth.password;
}
const result = await this.prepareRequestFunction(funcOpts);
return result !== null && result !== void 0 ? result : {};
}
return {};
}
/**
* Authenticates a new request and determines upstream proxy URL using the user function.
* Returns a promise resolving to an object that can be used to run a handler.
* @param request
*/
async prepareRequestHandling(request) {
const handlerOpts = this.getHandlerOpts(request);
const funcResult = await this.callPrepareRequestFunction(request, handlerOpts);
handlerOpts.localAddress = funcResult.localAddress;
handlerOpts.ipFamily = funcResult.ipFamily;
handlerOpts.dnsLookup = funcResult.dnsLookup;
handlerOpts.customConnectServer = funcResult.customConnectServer;
handlerOpts.customTag = funcResult.customTag;
// If not authenticated, request client to authenticate
if (funcResult.requestAuthentication) {
throw new request_error_1.RequestError(funcResult.failMsg || 'Proxy credentials required.', 407);
}
if (funcResult.upstreamProxyUrl) {
try {
handlerOpts.upstreamProxyUrlParsed = new node_url_1.URL(funcResult.upstreamProxyUrl);
}
catch (error) {
throw new Error(`Invalid "upstreamProxyUrl" provided: ${error} (was "${funcResult.upstreamProxyUrl}"`);
}
if (!['http:', 'https:', ...exports.SOCKS_PROTOCOLS].includes(handlerOpts.upstreamProxyUrlParsed.protocol)) {
throw new Error(`Invalid "upstreamProxyUrl" provided: URL must have one of the following protocols: "http", "https", ${exports.SOCKS_PROTOCOLS.map((p) => `"${p.replace(':', '')}"`).join(', ')} (was "${funcResult.upstreamProxyUrl}")`);
}
}
if (funcResult.ignoreUpstreamProxyCertificate !== undefined) {
handlerOpts.ignoreUpstreamProxyCertificate = funcResult.ignoreUpstreamProxyCertificate;
}
const { proxyChainId } = request.socket;
if (funcResult.customResponseFunction) {
this.log(proxyChainId, 'Using custom response function');
handlerOpts.customResponseFunction = funcResult.customResponseFunction;
if (!handlerOpts.isHttp) {
throw new Error('The "customResponseFunction" option can only be used for HTTP requests.');
}
if (typeof (handlerOpts.customResponseFunction) !== 'function') {
throw new Error('The "customResponseFunction" option must be a function.');
}
}
if (handlerOpts.upstreamProxyUrlParsed) {
this.log(proxyChainId, `Using upstream proxy ${(0, redact_url_1.redactUrl)(handlerOpts.upstreamProxyUrlParsed)}`);
}
return handlerOpts;
}
/**
* Sends a HTTP error response to the client.
* @param request
* @param error
*/
failRequest(request, error) {
const { proxyChainId } = request.socket;
if (error.name === 'RequestError') {
const typedError = error;
this.log(proxyChainId, `Request failed (status ${typedError.statusCode}): ${error.message}`);
this.sendSocketResponse(request.socket, typedError.statusCode, typedError.headers, error.message);
}
else {
this.log(proxyChainId, `Request failed with error: ${error.stack || error}`);
this.sendSocketResponse(request.socket, 500, {}, 'Internal error in proxy server');
this.emit('requestFailed', { error, request });
}
this.log(proxyChainId, 'Closing because request failed with error');
}
/**
* Sends a simple HTTP response to the client and forcibly closes the connection.
* This invalidates the ServerResponse instance (if present).
* We don't know the state of the response anyway.
* Writing directly to the socket seems to be the easiest solution.
* @param socket
* @param statusCode
* @param headers
* @param message
*/
sendSocketResponse(socket, statusCode = 500, caseSensitiveHeaders = {}, message = '') {
try {
const headers = Object.fromEntries(Object.entries(caseSensitiveHeaders).map(([name, value]) => [name.toLowerCase(), value]));
headers.connection = 'close';
headers.date = (new Date()).toUTCString();
headers['content-length'] = String(node_buffer_1.Buffer.byteLength(message));
headers.server = headers.server || this.authRealm;
headers['content-type'] = headers['content-type'] || 'text/plain; charset=utf-8';
if (statusCode === 407 && !headers['proxy-authenticate']) {
headers['proxy-authenticate'] = `Basic realm="${this.authRealm}"`;
}
let msg = `HTTP/1.1 ${statusCode} ${node_http_1.default.STATUS_CODES[statusCode] || 'Unknown Status Code'}\r\n`;
for (const [key, value] of Object.entries(headers)) {
msg += `${key}: ${value}\r\n`;
}
msg += `\r\n${message}`;
// Unfortunately it's not possible to send RST in Node.js yet.
// See https://github.com/nodejs/node/issues/27428
socket.setTimeout(1000, () => {
socket.destroy();
});
// This sends FIN, meaning we still can receive data.
socket.end(msg);
}
catch (err) {
this.log(socket.proxyChainId, `Unhandled error in sendResponse(), will be ignored: ${err.stack || err}`);
}
}
/**
* Starts listening at a port specified in the constructor.
*/
async listen(callback) {
const promise = new Promise((resolve, reject) => {
// Unfortunately server.listen() is not a normal function that fails on error,
// so we need this trickery
const onError = (error) => {
this.log(null, `Listen failed: ${error}`);
removeListeners();
reject(error);
};
const onListening = () => {
this.port = this.server.address().port;
this.log(null, 'Listening...');
removeListeners();
resolve();
};
const removeListeners = () => {
this.server.removeListener('error', onError);
this.server.removeListener('listening', onListening);
};
this.server.on('error', onError);
this.server.on('listening', onListening);
this.server.listen(this.port, this.host);
});
return (0, nodeify_1.nodeify)(promise, callback);
}
/**
* Gets array of IDs of all active connections.
*/
getConnectionIds() {
return [...this.connections.keys()];
}
/**
* Gets data transfer statistics of a specific proxy connection.
*/
getConnectionStats(connectionId) {
const socket = this.connections.get(connectionId);
if (!socket)
return undefined;
const targetStats = (0, count_target_bytes_1.getTargetStats)(socket);
const result = {
srcTxBytes: socket.bytesWritten,
srcRxBytes: socket.bytesRead,
trgTxBytes: targetStats.bytesWritten,
trgRxBytes: targetStats.bytesRead,
};
return result;
}
/**
* Forcibly close a specific pending proxy connection.
*/
closeConnection(connectionId) {
this.log(null, 'Closing pending socket');
const socket = this.connections.get(connectionId);
if (!socket)
return;
socket.destroy();
this.log(null, `Destroyed pending socket`);
}
/**
* Forcibly closes pending proxy connections.
*/
closeConnections() {
this.log(null, 'Closing pending sockets');
for (const socket of this.connections.values()) {
socket.destroy();
}
this.log(null, `Destroyed ${this.connections.size} pending sockets`);
}
/**
* Closes the proxy server.
* @param closeConnections If true, pending proxy connections are forcibly closed.
*/
async close(closeConnections, callback) {
if (typeof closeConnections === 'function') {
callback = closeConnections;
closeConnections = false;
}
if (closeConnections) {
this.closeConnections();
}
if (this.server) {
const { server } = this;
// @ts-expect-error Let's make sure we can't access the server anymore.
this.server = null;
const promise = node_util_1.default.promisify(server.close).bind(server)();
return (0, nodeify_1.nodeify)(promise, callback);
}
return (0, nodeify_1.nodeify)(Promise.resolve(), callback);
}
}
exports.Server = Server;
//# sourceMappingURL=server.js.map