Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,99 @@ server.on('requestFailed', ({ request, error }) => {
});
```

## Run a simple HTTPS proxy server

This example demonstrates how to create an HTTPS proxy server with a self-signed certificate. The HTTPS proxy server works identically to the HTTP version but with TLS encryption.

```javascript
const fs = require('fs');
const path = require('path');
const ProxyChain = require('proxy-chain');

(async () => {
// TODO: update these lines to use your own key and cert
const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key'));
const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt'));

const server = new ProxyChain.Server({
// Main difference between 'http' and 'https' is additional event listening:
//
// http
// -> listen for 'connection' events to track raw TCP sockets
//
// https:
// -> listen for 'securedConnection' events (instead of 'connection') to track only post-TLS-handshake sockets
// -> additionally listen for 'tlsError' events to handle TLS handshake errors
//
// Default value is 'http'
serverType: 'https',

// Provide the TLS certificate and private key
httpsOptions: {
key: sslKey,
cert: sslCrt,
},

// Port where the server will listen
port: 8443,

// Enable verbose logging to see what's happening
verbose: true,

// Optional: Add authentication and upstream proxy configuration
prepareRequestFunction: ({ username, hostname, port }) => {
console.log(`Request to ${hostname}:${port} from user: ${username || 'anonymous'}`);

// Allow the request
return {};
},
});

// Handle failed HTTP/HTTPS requests
server.on('requestFailed', ({ request, error }) => {
console.log(`Request ${request.url} failed`);
console.error(error);
});

// Handle TLS handshake errors
server.on('tlsError', ({ error, socket }) => {
console.error(`TLS error from ${socket.remoteAddress}: ${error.message}`);
});

// Emitted when HTTP/HTTPS connection is closed
server.on('connectionClosed', ({ connectionId, stats }) => {
console.log(`Connection ${connectionId} closed`);
console.dir(stats);
});

// Start the server
await server.listen();

// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down server...');
await server.close(true);
console.log('Server closed.');
process.exit(0);
});

// Keep the server running
await new Promise(() => { });
})();
```

Run server:

```bash
node https_proxy_server.js
```

Send request via proxy:

```bash
curl --proxy-insecure -x https://localhost:8443 -k https://example.com
```

## Use custom HTTP agents for connection pooling

You can provide custom HTTP/HTTPS agents to enable connection pooling and reuse with upstream proxies. This is particularly useful for maintaining sticky IP addresses or reducing connection overhead:
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "proxy-chain",
"version": "2.6.1",
"version": "2.7.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 @@ -37,8 +37,11 @@
"clean": "rimraf dist",
"prepublishOnly": "npm run build",
"local-proxy": "node ./dist/run_locally.js",
"test": "nyc cross-env NODE_OPTIONS=--insecure-http-parser mocha --bail",
"test:docker": "docker build --tag proxy-chain-tests --file test/Dockerfile . && docker run proxy-chain-tests",
"test": "nyc cross-env NODE_OPTIONS=--insecure-http-parser mocha",
"test:docker": "docker build --tag proxy-chain-tests --file test/Dockerfile . && docker run proxy-chain-tests",
"test:docker:node18": "docker build --build-arg NODE_IMAGE=node:18.20.8-bookworm --tag proxy-chain-tests:node18 --file test/Dockerfile . && docker run proxy-chain-tests:node18",
"test:docker:node16": "docker build --build-arg NODE_IMAGE=node:16.20.2-bookworm --tag proxy-chain-tests:node16 --file test/Dockerfile . && docker run proxy-chain-tests:node16",
"test:docker:node14": "docker build --build-arg NODE_IMAGE=node:14.21.3-bullseye --tag proxy-chain-tests:node14 --file test/Dockerfile . && docker run proxy-chain-tests:node14",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
Expand Down
16 changes: 15 additions & 1 deletion src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,16 @@ export const chain = (
targetSocket.on('error', (error) => {
server.log(proxyChainId, `Chain Destination Socket Error: ${error.stack}`);

sourceSocket.unpipe(targetSocket);
targetSocket.unpipe(sourceSocket);
sourceSocket.destroy();
});

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

sourceSocket.unpipe(targetSocket);
targetSocket.unpipe(sourceSocket);
targetSocket.destroy();
});

Expand Down Expand Up @@ -163,7 +167,13 @@ export const chain = (
head: clientHead,
});

sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`);
try {
sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`);
} catch (error) {
targetSocket.destroy();
sourceSocket.destroy(error as Error);
return;
}

sourceSocket.pipe(targetSocket);
targetSocket.pipe(sourceSocket);
Expand All @@ -172,6 +182,8 @@ export const chain = (
// 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.unpipe(targetSocket);
targetSocket.unpipe(sourceSocket);
sourceSocket.resume();

if (sourceSocket.writable) {
Expand All @@ -181,6 +193,8 @@ export const chain = (

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

if (targetSocket.writable) {
Expand Down
124 changes: 112 additions & 12 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { Buffer } from 'node:buffer';
import type dns from 'node:dns';
import { EventEmitter } from 'node:events';
import http from 'node:http';
import type https from 'node:https';
import https from 'node:https';
import type net from 'node:net';
import type tls from 'node:tls';
import { URL } from 'node:url';
import util from 'node:util';

Expand All @@ -19,7 +20,7 @@ import type { HandlerOpts as ForwardOpts } from './forward';
import { forward } from './forward';
import { forwardSocks } from './forward_socks';
import { RequestError } from './request_error';
import type { Socket } from './socket';
import type { Socket, TLSSocket } from './socket';
import { badGatewayStatusCodes } from './statuses';
import { getTargetStats } from './utils/count_target_bytes';
import { nodeify } from './utils/nodeify';
Expand All @@ -41,10 +42,34 @@ export const SOCKS_PROTOCOLS = ['socks:', 'socks4:', 'socks4a:', 'socks5:', 'soc
const DEFAULT_AUTH_REALM = 'ProxyChain';
const DEFAULT_PROXY_SERVER_PORT = 8000;

const HTTPS_DEFAULTS = {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const HTTPS_DEFAULTS = {
const HTTPS_DEFAULT_OPTIONS = {

(opt) Just more consistent naming across the code...

minVersion: 'TLSv1.2', // Disable TLS 1.0 and 1.1 (deprecated, insecure)
maxVersion: 'TLSv1.3', // Enable modern TLS 1.3
// Strong cipher suites (TLS 1.3 and TLS 1.2)
ciphers: [
// TLS 1.3 ciphers (always enabled with TLS 1.3)
'TLS_AES_128_GCM_SHA256',
'TLS_AES_256_GCM_SHA384',
'TLS_CHACHA20_POLY1305_SHA256',
// TLS 1.2 ciphers - ECDSA first (more efficient), then RSA for compatibility
'ECDHE-ECDSA-AES128-GCM-SHA256',
'ECDHE-ECDSA-AES256-GCM-SHA384',
'ECDHE-RSA-AES128-GCM-SHA256',
'ECDHE-RSA-AES256-GCM-SHA384',
].join(':'),
} as const;

/**
* Connection statistics for bandwidth tracking.
*/
export type ConnectionStats = {
// Bytes sent from proxy to client.
srcTxBytes: number;
// Bytes received from client to proxy.
srcRxBytes: number;
// Bytes sent from proxy to target.
trgTxBytes: number | null;
// Bytes received from target to proxy.
trgRxBytes: number | null;
};

Expand Down Expand Up @@ -96,10 +121,31 @@ export type PrepareRequestFunctionResult = {
type Promisable<T> = T | Promise<T>;
export type PrepareRequestFunction = (opts: PrepareRequestFunctionOpts) => Promisable<undefined | PrepareRequestFunctionResult>;

interface ServerOptionsBase {
port?: number;
host?: string;
prepareRequestFunction?: PrepareRequestFunction;
verbose?: boolean;
authRealm?: unknown;
}

export interface HttpServerOptions extends ServerOptionsBase {
serverType?: 'http';
}

export interface HttpsServerOptions extends ServerOptionsBase {
serverType: 'https';
httpsOptions: https.ServerOptions;
}

export type ServerOptions = HttpServerOptions | HttpsServerOptions;

/**
* 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 }`.
* It emits the 'tlsError' event on TLS handshake failures (HTTPS servers only), with parameter `{ error, socket }`.
* with parameter `{ connectionId, reason, hasParent, parentType }`.
*/
export class Server extends EventEmitter {
port: number;
Expand All @@ -112,7 +158,9 @@ export class Server extends EventEmitter {

verbose: boolean;

server: http.Server;
server: http.Server | https.Server;

serverType: 'http' | 'https';

lastHandlerId: number;

Expand All @@ -124,6 +172,9 @@ export class Server extends EventEmitter {
* Initializes a new instance of Server class.
* @param options
* @param [options.port] Port where the server will listen. By default 8000.
* @param [options.serverType] Type of server to create: 'http' or 'https'. By default 'http'.
* @param [options.httpsOptions] HTTPS server options (required when serverType is 'https').
* Accepts standard Node.js https.ServerOptions including key, cert, ca, passphrase, etc.
* @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:
Expand Down Expand Up @@ -154,13 +205,7 @@ export class Server extends EventEmitter {
* @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: {
port?: number,
host?: string,
prepareRequestFunction?: PrepareRequestFunction,
verbose?: boolean,
authRealm?: unknown,
} = {}) {
constructor(options: ServerOptions = {}) {
super();

if (options.port === undefined || options.port === null) {
Expand All @@ -174,11 +219,43 @@ export class Server extends EventEmitter {
this.authRealm = options.authRealm || DEFAULT_AUTH_REALM;
this.verbose = !!options.verbose;

this.server = http.createServer();
// Keep legacy behavior (http) as default behavior.
this.serverType = options.serverType === 'https' ? 'https' : 'http';

if (options.serverType === 'https') {
if (!options.httpsOptions) {
throw new Error('httpsOptions is required when serverType is "https"');
}

// Apply secure TLS defaults (user options can override).
const secureDefaults: https.ServerOptions = {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const secureDefaults: https.ServerOptions = {
const effectiveOptions: https.ServerOptions = {

(nit) IMHO these are no longer defaults.

...HTTPS_DEFAULTS,
honorCipherOrder: true,
...options.httpsOptions,
};

this.server = https.createServer(secureDefaults);
} else {
this.server = http.createServer();
}

// Attach common event handlers (same for both HTTP and HTTPS).
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));

// Attach connection tracking based on server type.
// Only listen to one connection event to avoid double registration.
if (this.serverType === 'https') {
// For HTTPS: Track only post-TLS-handshake sockets (secureConnection).
// This ensures we track the TLS-wrapped socket with correct bytesRead/bytesWritten.
this.server.on('secureConnection', this.onConnection.bind(this));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the server is HTTPs but somebody tries to establish an insecure connection? Is that even possible? 🤔

// Handle TLS handshake errors to prevent server crashes.
this.server.on('tlsClientError', this.onTLSClientError.bind(this));
} else {
// For HTTP: Track raw TCP sockets (connection).
this.server.on('connection', this.onConnection.bind(this));
}

this.lastHandlerId = 0;
this.stats = {
Expand All @@ -189,6 +266,29 @@ export class Server extends EventEmitter {
this.connections = new Map();
}

/**
* Handles TLS handshake errors for HTTPS servers.
* Without this handler, unhandled TLS errors can crash the server.
* Common errors: ECONNRESET, ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN,
* ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION, ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE
*/
onTLSClientError(err: NodeJS.ErrnoException, tlsSocket: tls.TLSSocket): void {
const connectionId = (tlsSocket as TLSSocket).proxyChainId;
this.log(connectionId, `TLS handshake failed: ${err.message}`);

// Emit event in first place before any return statement.
this.emit('tlsError', { error: err, socket: tlsSocket });

// If connection already reset or socket not writable, nothing more to do.
if (err.code === 'ECONNRESET' || !tlsSocket.writable) {
return;
}

// TLS handshake failed before HTTP, cannot send HTTP response.
// Destroy the socket to clean up.
tlsSocket.destroy(err);
}

log(connectionId: unknown, str: string): void {
if (this.verbose) {
const logPrefix = connectionId != null ? `${String(connectionId)} | ` : '';
Expand Down
3 changes: 2 additions & 1 deletion test/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM node:18.20.8-bookworm@sha256:c6ae79e38498325db67193d391e6ec1d224d96c693a8a4d943498556716d3783
ARG NODE_IMAGE=node:18.20.8-bookworm
FROM ${NODE_IMAGE}

RUN apt-get update && apt-get install -y --no-install-recommends chromium \
&& rm -rf /var/lib/apt/lists/*
Expand Down
Loading