-
Notifications
You must be signed in to change notification settings - Fork 161
feat: implement https server support #602
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
Changes from 6 commits
313f535
e6adb19
0158e0c
44fc388
8893eca
57cb93e
9956192
e5a1171
8a90e7e
de12161
4c036a0
934693a
597b942
e6ea53b
359c959
63409da
48de780
f3c20ad
e6ad99c
9e3b740
cdaa704
a887d0c
c7be88a
30079e8
6f1e498
79fc222
d4cb296
70606b1
d9b2966
5dd1dc2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
| 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(() => {}); | ||
| })(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
||
|
|
@@ -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'; | ||
|
|
@@ -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; | ||
|
|
@@ -107,7 +129,9 @@ export class Server extends EventEmitter { | |
|
|
||
| verbose: boolean; | ||
|
|
||
| server: http.Server; | ||
| server: http.Server | https.Server; | ||
|
|
||
| serverType: 'http' | 'https'; | ||
|
|
||
| lastHandlerId: number; | ||
|
|
||
|
|
@@ -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: | ||
|
|
@@ -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) { | ||
|
|
@@ -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(':'), | ||
bliuchak marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 = { | ||
|
|
@@ -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 }); | ||
bliuchak marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /** | ||
| * Assigns a unique ID to the socket and keeps the register up to date. | ||
| * Needed for abrupt close of the server. | ||
|
|
@@ -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) { | ||
|
||
| this.log(socket.proxyChainId, `Source socket emitted error: ${err.stack || err}`); | ||
| } | ||
| }); | ||
|
|
@@ -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, | ||
| }; | ||
|
|
||
| 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; |
Uh oh!
There was an error while loading. Please reload this page.