Skip to content

Commit a9449bc

Browse files
authored
feat: add https proxy server implementation (#626)
1 parent b4d2620 commit a9449bc

File tree

8 files changed

+928
-87
lines changed

8 files changed

+928
-87
lines changed

README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,99 @@ server.on('requestFailed', ({ request, error }) => {
110110
});
111111
```
112112

113+
## Run a simple HTTPS proxy server
114+
115+
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.
116+
117+
```javascript
118+
const fs = require('fs');
119+
const path = require('path');
120+
const ProxyChain = require('proxy-chain');
121+
122+
(async () => {
123+
// TODO: update these lines to use your own key and cert
124+
const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key'));
125+
const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt'));
126+
127+
const server = new ProxyChain.Server({
128+
// Main difference between 'http' and 'https' is additional event listening:
129+
//
130+
// http
131+
// -> listen for 'connection' events to track raw TCP sockets
132+
//
133+
// https:
134+
// -> listen for 'securedConnection' events (instead of 'connection') to track only post-TLS-handshake sockets
135+
// -> additionally listen for 'tlsError' events to handle TLS handshake errors
136+
//
137+
// Default value is 'http'
138+
serverType: 'https',
139+
140+
// Provide the TLS certificate and private key
141+
httpsOptions: {
142+
key: sslKey,
143+
cert: sslCrt,
144+
},
145+
146+
// Port where the server will listen
147+
port: 8443,
148+
149+
// Enable verbose logging to see what's happening
150+
verbose: true,
151+
152+
// Optional: Add authentication and upstream proxy configuration
153+
prepareRequestFunction: ({ username, hostname, port }) => {
154+
console.log(`Request to ${hostname}:${port} from user: ${username || 'anonymous'}`);
155+
156+
// Allow the request
157+
return {};
158+
},
159+
});
160+
161+
// Handle failed HTTP/HTTPS requests
162+
server.on('requestFailed', ({ request, error }) => {
163+
console.log(`Request ${request.url} failed`);
164+
console.error(error);
165+
});
166+
167+
// Handle TLS handshake errors
168+
server.on('tlsError', ({ error, socket }) => {
169+
console.error(`TLS error from ${socket.remoteAddress}: ${error.message}`);
170+
});
171+
172+
// Emitted when HTTP/HTTPS connection is closed
173+
server.on('connectionClosed', ({ connectionId, stats }) => {
174+
console.log(`Connection ${connectionId} closed`);
175+
console.dir(stats);
176+
});
177+
178+
// Start the server
179+
await server.listen();
180+
181+
// Handle graceful shutdown
182+
process.on('SIGINT', async () => {
183+
console.log('\nShutting down server...');
184+
await server.close(true);
185+
console.log('Server closed.');
186+
process.exit(0);
187+
});
188+
189+
// Keep the server running
190+
await new Promise(() => { });
191+
})();
192+
```
193+
194+
Run server:
195+
196+
```bash
197+
node https_proxy_server.js
198+
```
199+
200+
Send request via proxy:
201+
202+
```bash
203+
curl --proxy-insecure -x https://localhost:8443 -k https://example.com
204+
```
205+
113206
## Use custom HTTP agents for connection pooling
114207

115208
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:

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "proxy-chain",
3-
"version": "2.6.1",
3+
"version": "2.7.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": [
@@ -37,8 +37,9 @@
3737
"clean": "rimraf dist",
3838
"prepublishOnly": "npm run build",
3939
"local-proxy": "node ./dist/run_locally.js",
40-
"test": "nyc cross-env NODE_OPTIONS=--insecure-http-parser mocha --bail",
41-
"test:docker": "docker build --tag proxy-chain-tests --file test/Dockerfile . && docker run proxy-chain-tests",
40+
"test": "nyc cross-env NODE_OPTIONS=--insecure-http-parser mocha",
41+
"test:docker": "docker build --tag proxy-chain-tests --file test/Dockerfile . && docker run proxy-chain-tests",
42+
"test:docker:all": "bash scripts/test-docker-all.sh",
4243
"lint": "eslint .",
4344
"lint:fix": "eslint . --fix"
4445
},

scripts/test-docker-all.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/bin/bash
2+
3+
echo "Starting parallel Docker tests for Node 14, 16, and 18..."
4+
5+
# Run builds in parallel, capture PIDs.
6+
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 &
7+
pid14=$!
8+
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 &
9+
pid16=$!
10+
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 &
11+
pid18=$!
12+
13+
# Wait for all and capture exit codes.
14+
wait $pid14
15+
ec14=$?
16+
wait $pid16
17+
ec16=$?
18+
wait $pid18
19+
ec18=$?
20+
21+
echo ""
22+
echo "========== Results =========="
23+
echo "Node 14: $([ $ec14 -eq 0 ] && echo 'PASS' || echo 'FAIL')"
24+
echo "Node 16: $([ $ec16 -eq 0 ] && echo 'PASS' || echo 'FAIL')"
25+
echo "Node 18: $([ $ec18 -eq 0 ] && echo 'PASS' || echo 'FAIL')"
26+
echo "============================="
27+
28+
# Exit with non-zero if any failed.
29+
exit $((ec14 + ec16 + ec18))

src/server.ts

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Buffer } from 'node:buffer';
33
import type dns from 'node:dns';
44
import { EventEmitter } from 'node:events';
55
import http from 'node:http';
6-
import type https from 'node:https';
6+
import https from 'node:https';
77
import type net from 'node:net';
88
import { URL } from 'node:url';
99
import util from 'node:util';
@@ -19,7 +19,7 @@ import type { HandlerOpts as ForwardOpts } from './forward';
1919
import { forward } from './forward';
2020
import { forwardSocks } from './forward_socks';
2121
import { RequestError } from './request_error';
22-
import type { Socket } from './socket';
22+
import type { Socket, TLSSocket } from './socket';
2323
import { badGatewayStatusCodes } from './statuses';
2424
import { getTargetStats } from './utils/count_target_bytes';
2525
import { nodeify } from './utils/nodeify';
@@ -41,10 +41,23 @@ export const SOCKS_PROTOCOLS = ['socks:', 'socks4:', 'socks4a:', 'socks5:', 'soc
4141
const DEFAULT_AUTH_REALM = 'ProxyChain';
4242
const DEFAULT_PROXY_SERVER_PORT = 8000;
4343

44+
const HTTPS_DEFAULT_OPTIONS = {
45+
// Disable TLS 1.0 and 1.1 (deprecated, insecure).
46+
// All other TLS settings use Node.js defaults for cipher selection (automatically updated).
47+
minVersion: 'TLSv1.2',
48+
} as const;
49+
50+
/**
51+
* Connection statistics for bandwidth tracking.
52+
*/
4453
export type ConnectionStats = {
54+
// Bytes sent by proxy to client.
4555
srcTxBytes: number;
56+
// Bytes received by proxy from client.
4657
srcRxBytes: number;
58+
// Bytes sent by proxy to target.
4759
trgTxBytes: number | null;
60+
// Bytes received by proxy from target.
4861
trgRxBytes: number | null;
4962
};
5063

@@ -96,10 +109,31 @@ export type PrepareRequestFunctionResult = {
96109
type Promisable<T> = T | Promise<T>;
97110
export type PrepareRequestFunction = (opts: PrepareRequestFunctionOpts) => Promisable<undefined | PrepareRequestFunctionResult>;
98111

112+
type ServerOptionsBase = {
113+
port?: number;
114+
host?: string;
115+
prepareRequestFunction?: PrepareRequestFunction;
116+
verbose?: boolean;
117+
authRealm?: unknown;
118+
};
119+
120+
export type HttpServerOptions = ServerOptionsBase & {
121+
serverType?: 'http';
122+
};
123+
124+
export type HttpsServerOptions = ServerOptionsBase & {
125+
serverType: 'https';
126+
httpsOptions: https.ServerOptions;
127+
};
128+
129+
export type ServerOptions = HttpServerOptions | HttpsServerOptions;
130+
99131
/**
100132
* Represents the proxy server.
101133
* It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`.
102134
* It emits the 'connectionClosed' event when connection to proxy server is closed, with parameter `{ connectionId, stats }`.
135+
* It emits the 'tlsError' event on TLS handshake failures (HTTPS servers only), with parameter `{ error, socket }`.
136+
* with parameter `{ connectionId, reason, hasParent, parentType }`.
103137
*/
104138
export class Server extends EventEmitter {
105139
port: number;
@@ -112,7 +146,9 @@ export class Server extends EventEmitter {
112146

113147
verbose: boolean;
114148

115-
server: http.Server;
149+
server: http.Server | https.Server;
150+
151+
serverType: 'http' | 'https';
116152

117153
lastHandlerId: number;
118154

@@ -124,6 +160,9 @@ export class Server extends EventEmitter {
124160
* Initializes a new instance of Server class.
125161
* @param options
126162
* @param [options.port] Port where the server will listen. By default 8000.
163+
* @param [options.serverType] Type of server to create: 'http' or 'https'. By default 'http'.
164+
* @param [options.httpsOptions] HTTPS server options (required when serverType is 'https').
165+
* Accepts standard Node.js https.ServerOptions including key, cert, ca, passphrase, etc.
127166
* @param [options.prepareRequestFunction] Custom function to authenticate proxy requests,
128167
* provide URL to upstream proxy or potentially provide a function that generates a custom response to HTTP requests.
129168
* It accepts a single parameter which is an object:
@@ -154,13 +193,7 @@ export class Server extends EventEmitter {
154193
* @param [options.authRealm] Realm used in the Proxy-Authenticate header and also in the 'Server' HTTP header. By default it's `ProxyChain`.
155194
* @param [options.verbose] If true, the server will output logs
156195
*/
157-
constructor(options: {
158-
port?: number,
159-
host?: string,
160-
prepareRequestFunction?: PrepareRequestFunction,
161-
verbose?: boolean,
162-
authRealm?: unknown,
163-
} = {}) {
196+
constructor(options: ServerOptions = {}) {
164197
super();
165198

166199
if (options.port === undefined || options.port === null) {
@@ -174,11 +207,43 @@ export class Server extends EventEmitter {
174207
this.authRealm = options.authRealm || DEFAULT_AUTH_REALM;
175208
this.verbose = !!options.verbose;
176209

177-
this.server = http.createServer();
210+
// Keep legacy behavior (http) as default behavior.
211+
this.serverType = options.serverType === 'https' ? 'https' : 'http';
212+
213+
if (options.serverType === 'https') {
214+
if (!options.httpsOptions) {
215+
throw new Error('httpsOptions is required when serverType is "https"');
216+
}
217+
218+
// Apply secure TLS defaults (user options can override).
219+
const effectiveOptions: https.ServerOptions = {
220+
...HTTPS_DEFAULT_OPTIONS,
221+
honorCipherOrder: true,
222+
...options.httpsOptions,
223+
};
224+
225+
this.server = https.createServer(effectiveOptions);
226+
} else {
227+
this.server = http.createServer();
228+
}
229+
230+
// Attach common event handlers (same for both HTTP and HTTPS).
178231
this.server.on('clientError', this.onClientError.bind(this));
179232
this.server.on('request', this.onRequest.bind(this));
180233
this.server.on('connect', this.onConnect.bind(this));
181-
this.server.on('connection', this.onConnection.bind(this));
234+
235+
// Attach connection tracking based on server type.
236+
// Only listen to one connection event to avoid double registration.
237+
if (this.serverType === 'https') {
238+
// For HTTPS: Track only post-TLS-handshake sockets (secureConnection).
239+
// This ensures we track the TLS-wrapped socket with correct bytesRead/bytesWritten.
240+
this.server.on('secureConnection', this.onConnection.bind(this));
241+
// Handle TLS handshake errors to prevent server crashes.
242+
this.server.on('tlsClientError', this.onTLSClientError.bind(this));
243+
} else {
244+
// For HTTP: Track raw TCP sockets (connection).
245+
this.server.on('connection', this.onConnection.bind(this));
246+
}
182247

183248
this.lastHandlerId = 0;
184249
this.stats = {
@@ -189,6 +254,29 @@ export class Server extends EventEmitter {
189254
this.connections = new Map();
190255
}
191256

257+
/**
258+
* Handles TLS handshake errors for HTTPS servers.
259+
* Without this handler, unhandled TLS errors can crash the server.
260+
* Common errors: ECONNRESET, ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN,
261+
* ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION, ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE
262+
*/
263+
onTLSClientError(err: NodeJS.ErrnoException, tlsSocket: TLSSocket): void {
264+
const connectionId = (tlsSocket as TLSSocket).proxyChainId;
265+
this.log(connectionId, `TLS handshake failed: ${err.message}`);
266+
267+
// Emit event in first place before any return statement.
268+
this.emit('tlsError', { error: err, socket: tlsSocket });
269+
270+
// If connection already reset or socket not writable, nothing more to do.
271+
if (err.code === 'ECONNRESET' || !tlsSocket.writable) {
272+
return;
273+
}
274+
275+
// TLS handshake failed before HTTP, cannot send HTTP response.
276+
// Destroy the socket to clean up.
277+
tlsSocket.destroy(err);
278+
}
279+
192280
log(connectionId: unknown, str: string): void {
193281
if (this.verbose) {
194282
const logPrefix = connectionId != null ? `${String(connectionId)} | ` : '';

test/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
FROM node:18.20.8-bookworm@sha256:c6ae79e38498325db67193d391e6ec1d224d96c693a8a4d943498556716d3783
1+
ARG NODE_IMAGE=node:18.20.8-bookworm
2+
FROM ${NODE_IMAGE}
23

34
RUN apt-get update && apt-get install -y --no-install-recommends chromium \
45
&& rm -rf /var/lib/apt/lists/*

0 commit comments

Comments
 (0)