Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
313f535
feat: add https server implementation
bliuchak Oct 3, 2025
e6adb19
fix: add onTLSClientError handler
bliuchak Oct 7, 2025
0158e0c
fix: add https edge case tests
bliuchak Oct 7, 2025
44fc388
fix: resolve broken test with raw TCP and TLS
bliuchak Oct 8, 2025
8893eca
fix: skip fixture integrity checks for older node versions
bliuchak Oct 8, 2025
57cb93e
fix: beter dockerfile
bliuchak Oct 8, 2025
9956192
chore: bump minor version
bliuchak Oct 10, 2025
e5a1171
chore: extract https defaults into separate const
bliuchak Oct 10, 2025
8a90e7e
docs: add info about https server and other related stuff
bliuchak Oct 13, 2025
de12161
refactor: rewrite https example to js for consistency
bliuchak Oct 13, 2025
4c036a0
fix: export few user facing interfaces
bliuchak Oct 13, 2025
934693a
fix: add tls overhead bytes into final statistic
bliuchak Oct 21, 2025
597b942
fix: better comment
bliuchak Oct 21, 2025
e6ea53b
fix: log error message on socket only when there are no other handlers
bliuchak Nov 6, 2025
359c959
fix: add tests for tls overhead
bliuchak Nov 13, 2025
63409da
fix: update socks tests
bliuchak Nov 18, 2025
48de780
fix: add dns lookup tests
bliuchak Nov 18, 2025
f3c20ad
fix: make puppeteer great again
bliuchak Nov 19, 2025
e6ad99c
refactor: simplify serverType logic to default on http server
bliuchak Nov 20, 2025
9e3b740
fix: add websocket tls overhead tests
bliuchak Nov 20, 2025
cdaa704
fix: source leak and ports conflict for https edge cases
bliuchak Nov 20, 2025
a887d0c
Merge branch 'master' into feat/https
bliuchak Nov 20, 2025
c7be88a
fix: add tls overhead tests with socks upstream
bliuchak Nov 21, 2025
30079e8
fix: add one more test for websocket + tls overhead
bliuchak Nov 21, 2025
6f1e498
fix: minor cleanup in tests
bliuchak Nov 21, 2025
79fc222
fix: version bump
bliuchak Nov 21, 2025
d4cb296
fix: add POST and HEAD tests for TLS overhead
bliuchak Nov 23, 2025
70606b1
fix: add tests for https targets (nested tls)
bliuchak Nov 23, 2025
d9b2966
fix: add test for concurrent connections in TLS overhead
bliuchak Nov 24, 2025
5dd1dc2
fix: add tls overhead test for custom response
bliuchak Nov 24, 2025
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
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
node_modules
.git
.gitignore
dist
coverage
.nyc_output
*.log
.DS_Store
.idea
.vscode
docs
README.md

2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import apifyTypescriptConfig from '@apify/eslint-config/ts.js';

// eslint-disable-next-line import/no-default-export
export default [
{ ignores: ['**/dist', 'test'] }, // Ignores need to happen first
{ ignores: ['**/dist', 'test', 'examples'] }, // Ignores need to happen first
...apifyTypescriptConfig,
{
languageOptions: {
Expand Down
92 changes: 92 additions & 0 deletions examples/https_proxy_server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Server, generateCertificate } from '../src';

// 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.

(async () => {
// Generate a self-signed certificate for development/testing
// In production, you should use a proper certificate from a Certificate Authority
console.log('Generating self-signed certificate...');
const { key, cert } = generateCertificate({
commonName: 'localhost',
validityDays: 365,
organization: 'Development',
});

console.log('Certificate generated successfully!');

// Create an HTTPS proxy server
const server = new Server({
// Use HTTPS instead of HTTP
serverType: 'https',

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

// 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'}`);

// Example: Require authentication
// if (!username || !password) {
// return {
// requestAuthentication: true,
// failMsg: 'Proxy credentials required',
// };
// }

// Example: Use upstream proxy
// return {
// upstreamProxyUrl: 'http://upstream-proxy.example.com:8000',
// };

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

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

console.log('\n======================================');
console.log(`HTTPS Proxy server is running on port ${server.port}`);
console.log('======================================\n');

console.log('To test the HTTPS proxy server, you can use:');
console.log('\n1. With curl (ignoring self-signed certificate):');
console.log(` curl --proxy-insecure -x https://localhost:${server.port} -k http://example.com\n`);

console.log('2. Configure your browser to use HTTPS proxy:');
console.log(` - Proxy: localhost`);
console.log(` - Port: ${server.port}`);
console.log(` - Type: HTTPS`);
console.log(' - Note: Browser may warn about self-signed certificate\n');

console.log('3. With Node.js https agent:');
console.log(' const agent = new HttpsProxyAgent(');
console.log(` 'https://localhost:${server.port}',`);
console.log(' { rejectUnauthorized: false } // for self-signed cert');
console.log(' );\n');

console.log('Press Ctrl+C to stop the server...\n');

// 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(() => {});
})();
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './request_error';
export * from './server';
export * from './utils/redact_url';
export * from './utils/generate_certificate';
export * from './anonymize_proxy';
export * from './tcp_tunnel_tools';

Expand Down
126 changes: 111 additions & 15 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Buffer } from 'node:buffer';
import type dns from 'node:dns';
import { EventEmitter } from 'node:events';
import http from 'node:http';
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 @@ -18,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 Down Expand Up @@ -91,10 +93,30 @@ 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;
}

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

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 }`.
*/
export class Server extends EventEmitter {
port: number;
Expand All @@ -107,7 +129,9 @@ export class Server extends EventEmitter {

verbose: boolean;

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

serverType: 'http' | 'https';

lastHandlerId: number;

Expand All @@ -119,6 +143,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 @@ -149,13 +176,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 @@ -169,11 +190,55 @@ export class Server extends EventEmitter {
this.authRealm = options.authRealm || DEFAULT_AUTH_REALM;
this.verbose = !!options.verbose;

this.server = http.createServer();
// Create server based on type
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)
// This prevents users from accidentally configuring insecure TLS settings
const secureDefaults: https.ServerOptions = {
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 (strong only)
'ECDHE-RSA-AES128-GCM-SHA256',
'ECDHE-RSA-AES256-GCM-SHA384',
].join(':'),
honorCipherOrder: true, // Server chooses cipher (prevents downgrade attacks)
...options.httpsOptions, // User options override defaults
};

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

// 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
// CRITICAL: 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));
// 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 @@ -195,14 +260,38 @@ export class Server extends EventEmitter {
onClientError(err: NodeJS.ErrnoException, socket: Socket): void {
this.log(socket.proxyChainId, `onClientError: ${err}`);

// HTTP protocol error occurred after TLS handshake succeeded (in case HTTPS server is used)
// https://nodejs.org/api/http.html#http_event_clienterror
if (err.code === 'ECONNRESET' || !socket.writable) {
return;
}

// Can send HTTP response because HTTP protocol layer is active
this.sendSocketResponse(socket, 400, {}, 'Invalid request');
}

/**
* 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}`);

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

// TLS handshake failed before HTTP, cannot send HTTP response
tlsSocket.destroy(err);

// Emit event for user monitoring/metrics
this.emit('tlsError', { error: err, socket: tlsSocket });
}

/**
* Assigns a unique ID to the socket and keeps the register up to date.
* Needed for abrupt close of the server.
Expand Down Expand Up @@ -243,8 +332,12 @@ export class Server extends EventEmitter {
// 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) {
// Prevent duplicate error handling for the same socket
if (socket.proxyChainErrorHandled) return;
socket.proxyChainErrorHandled = true;

// Log errors only if there are no user-provided error handlers
if (this.listenerCount('error') === 0) {
Copy link
Member

Choose a reason for hiding this comment

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

There was === 1 before, why is it === 0 now?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I understand it correctly for previous condition this.listenerCount('error') === 1 (which count server-listeners, not socket) app log error only when there is one server handler. Right?

If I'm correct, then in this case the error will be handled by that one server-handler. For us it might be useful to log here when there are no other server-handlers this.listenerCount('error') === 0.

What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

So it was broken before, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it was broken before. But I was wrong in my assumptions.

Original code should check socket.listenerCount('error') === 1 instead for this (server). Because socker and server error events aren't interconnected.

Also, we're attaching additional error listeners in our handlers (e.g. direct).

So, basically the flow should be like that:

  1. onConnection() - attach out early handler - socket.listenerCount('error') === 1
  2. onReqeust() / onConnect() - prepareRequestHandling
  3. direct() function called - attaches sourceSocket error handler
  4. Now socket.listenerCount('error') === 2

If error occurs after step 3: then the direct() handler will log instead our early handler.

If error occurs between steps 1-2: then our early handler will log it.

I'm gonna prepare fix for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jirimoravcik fixed here e6ea53b

this.log(socket.proxyChainId, `Source socket emitted error: ${err.stack || err}`);
}
});
Expand Down Expand Up @@ -615,9 +708,12 @@ export class Server extends EventEmitter {

const targetStats = getTargetStats(socket);

// For TLS sockets, bytesRead/bytesWritten might not be immediately available
// Use nullish coalescing to ensure we always have valid numeric values
// Note: Even destroyed sockets retain their byte count properties
const result = {
srcTxBytes: socket.bytesWritten,
srcRxBytes: socket.bytesRead,
srcTxBytes: socket.bytesWritten ?? 0,
srcRxBytes: socket.bytesRead ?? 0,
trgTxBytes: targetStats.bytesWritten,
trgRxBytes: targetStats.bytesRead,
};
Expand Down
5 changes: 4 additions & 1 deletion src/socket.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type net from 'node:net';
import type tls from 'node:tls';

type AdditionalProps = { proxyChainId?: number };
type AdditionalProps = {
proxyChainId?: number;
proxyChainErrorHandled?: boolean;
};

export type Socket = net.Socket & AdditionalProps;
export type TLSSocket = tls.TLSSocket & AdditionalProps;
Loading