Skip to content

Commit 313f535

Browse files
committed
feat: add https server implementation
1 parent 8e3142b commit 313f535

File tree

7 files changed

+437
-75
lines changed

7 files changed

+437
-75
lines changed

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import apifyTypescriptConfig from '@apify/eslint-config/ts.js';
22

33
// eslint-disable-next-line import/no-default-export
44
export default [
5-
{ ignores: ['**/dist', 'test'] }, // Ignores need to happen first
5+
{ ignores: ['**/dist', 'test', 'examples'] }, // Ignores need to happen first
66
...apifyTypescriptConfig,
77
{
88
languageOptions: {

examples/https_proxy_server.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Server, generateCertificate } from '../src';
2+
3+
// This example demonstrates how to create an HTTPS proxy server with a self-signed certificate.
4+
// The HTTPS proxy server works identically to the HTTP version but with TLS encryption.
5+
6+
(async () => {
7+
// Generate a self-signed certificate for development/testing
8+
// In production, you should use a proper certificate from a Certificate Authority
9+
console.log('Generating self-signed certificate...');
10+
const { key, cert } = generateCertificate({
11+
commonName: 'localhost',
12+
validityDays: 365,
13+
organization: 'Development',
14+
});
15+
16+
console.log('Certificate generated successfully!');
17+
18+
// Create an HTTPS proxy server
19+
const server = new Server({
20+
// Use HTTPS instead of HTTP
21+
serverType: 'https',
22+
23+
// Provide the TLS certificate and private key
24+
httpsOptions: {
25+
key,
26+
cert,
27+
},
28+
29+
// Port where the server will listen
30+
port: 8443,
31+
32+
// Enable verbose logging to see what's happening
33+
verbose: true,
34+
35+
// Optional: Add authentication and upstream proxy configuration
36+
prepareRequestFunction: ({ username, hostname, port }) => {
37+
console.log(`Request to ${hostname}:${port} from user: ${username || 'anonymous'}`);
38+
39+
// Example: Require authentication
40+
// if (!username || !password) {
41+
// return {
42+
// requestAuthentication: true,
43+
// failMsg: 'Proxy credentials required',
44+
// };
45+
// }
46+
47+
// Example: Use upstream proxy
48+
// return {
49+
// upstreamProxyUrl: 'http://upstream-proxy.example.com:8000',
50+
// };
51+
52+
// Allow the request
53+
return {};
54+
},
55+
});
56+
57+
// Start the server
58+
await server.listen();
59+
60+
console.log('\n======================================');
61+
console.log(`HTTPS Proxy server is running on port ${server.port}`);
62+
console.log('======================================\n');
63+
64+
console.log('To test the HTTPS proxy server, you can use:');
65+
console.log('\n1. With curl (ignoring self-signed certificate):');
66+
console.log(` curl --proxy-insecure -x https://localhost:${server.port} -k http://example.com\n`);
67+
68+
console.log('2. Configure your browser to use HTTPS proxy:');
69+
console.log(` - Proxy: localhost`);
70+
console.log(` - Port: ${server.port}`);
71+
console.log(` - Type: HTTPS`);
72+
console.log(' - Note: Browser may warn about self-signed certificate\n');
73+
74+
console.log('3. With Node.js https agent:');
75+
console.log(' const agent = new HttpsProxyAgent(');
76+
console.log(` 'https://localhost:${server.port}',`);
77+
console.log(' { rejectUnauthorized: false } // for self-signed cert');
78+
console.log(' );\n');
79+
80+
console.log('Press Ctrl+C to stop the server...\n');
81+
82+
// Handle graceful shutdown
83+
process.on('SIGINT', async () => {
84+
console.log('\nShutting down server...');
85+
await server.close(true);
86+
console.log('Server closed.');
87+
process.exit(0);
88+
});
89+
90+
// Keep the server running
91+
await new Promise(() => {});
92+
})();

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './request_error';
22
export * from './server';
33
export * from './utils/redact_url';
4+
export * from './utils/generate_certificate';
45
export * from './anonymize_proxy';
56
export * from './tcp_tunnel_tools';
67

src/server.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +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 https from 'node:https';
67
import type net from 'node:net';
78
import { URL } from 'node:url';
89
import util from 'node:util';
@@ -91,6 +92,25 @@ export type PrepareRequestFunctionResult = {
9192
type Promisable<T> = T | Promise<T>;
9293
export type PrepareRequestFunction = (opts: PrepareRequestFunctionOpts) => Promisable<undefined | PrepareRequestFunctionResult>;
9394

95+
interface ServerOptionsBase {
96+
port?: number;
97+
host?: string;
98+
prepareRequestFunction?: PrepareRequestFunction;
99+
verbose?: boolean;
100+
authRealm?: unknown;
101+
}
102+
103+
interface HttpServerOptions extends ServerOptionsBase {
104+
serverType?: 'http';
105+
}
106+
107+
interface HttpsServerOptions extends ServerOptionsBase {
108+
serverType: 'https';
109+
httpsOptions: https.ServerOptions;
110+
}
111+
112+
export type ServerOptions = HttpServerOptions | HttpsServerOptions;
113+
94114
/**
95115
* Represents the proxy server.
96116
* It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`.
@@ -107,7 +127,9 @@ export class Server extends EventEmitter {
107127

108128
verbose: boolean;
109129

110-
server: http.Server;
130+
server: http.Server | https.Server;
131+
132+
serverType: 'http' | 'https';
111133

112134
lastHandlerId: number;
113135

@@ -119,6 +141,9 @@ export class Server extends EventEmitter {
119141
* Initializes a new instance of Server class.
120142
* @param options
121143
* @param [options.port] Port where the server will listen. By default 8000.
144+
* @param [options.serverType] Type of server to create: 'http' or 'https'. By default 'http'.
145+
* @param [options.httpsOptions] HTTPS server options (required when serverType is 'https').
146+
* Accepts standard Node.js https.ServerOptions including key, cert, ca, passphrase, etc.
122147
* @param [options.prepareRequestFunction] Custom function to authenticate proxy requests,
123148
* provide URL to upstream proxy or potentially provide a function that generates a custom response to HTTP requests.
124149
* It accepts a single parameter which is an object:
@@ -149,13 +174,7 @@ export class Server extends EventEmitter {
149174
* @param [options.authRealm] Realm used in the Proxy-Authenticate header and also in the 'Server' HTTP header. By default it's `ProxyChain`.
150175
* @param [options.verbose] If true, the server will output logs
151176
*/
152-
constructor(options: {
153-
port?: number,
154-
host?: string,
155-
prepareRequestFunction?: PrepareRequestFunction,
156-
verbose?: boolean,
157-
authRealm?: unknown,
158-
} = {}) {
177+
constructor(options: ServerOptions = {}) {
159178
super();
160179

161180
if (options.port === undefined || options.port === null) {
@@ -169,12 +188,30 @@ export class Server extends EventEmitter {
169188
this.authRealm = options.authRealm || DEFAULT_AUTH_REALM;
170189
this.verbose = !!options.verbose;
171190

172-
this.server = http.createServer();
191+
// Create server based on type
192+
if (options.serverType === 'https') {
193+
if (!options.httpsOptions) {
194+
throw new Error('httpsOptions is required when serverType is "https"');
195+
}
196+
this.server = https.createServer(options.httpsOptions);
197+
this.serverType = 'https';
198+
} else {
199+
this.server = http.createServer();
200+
this.serverType = 'http';
201+
}
202+
203+
// Attach event handlers (same for both HTTP and HTTPS)
173204
this.server.on('clientError', this.onClientError.bind(this));
174205
this.server.on('request', this.onRequest.bind(this));
175206
this.server.on('connect', this.onConnect.bind(this));
176207
this.server.on('connection', this.onConnection.bind(this));
177208

209+
// For HTTPS servers, also listen to secureConnection for proper TLS socket handling
210+
// This ensures connection tracking works correctly with TLS sockets
211+
if (this.serverType === 'https') {
212+
this.server.on('secureConnection', this.onConnection.bind(this));
213+
}
214+
178215
this.lastHandlerId = 0;
179216
this.stats = {
180217
httpRequestCount: 0,
@@ -615,9 +652,11 @@ export class Server extends EventEmitter {
615652

616653
const targetStats = getTargetStats(socket);
617654

655+
// For TLS sockets, bytesRead/bytesWritten might not be immediately available
656+
// Use nullish coalescing to ensure we always have valid numeric values
618657
const result = {
619-
srcTxBytes: socket.bytesWritten,
620-
srcRxBytes: socket.bytesRead,
658+
srcTxBytes: socket.bytesWritten ?? 0,
659+
srcRxBytes: socket.bytesRead ?? 0,
621660
trgTxBytes: targetStats.bytesWritten,
622661
trgRxBytes: targetStats.bytesRead,
623662
};

src/utils/generate_certificate.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { execSync } from 'node:child_process';
2+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
6+
export interface GenerateCertificateOptions {
7+
/**
8+
* Common Name for the certificate (e.g., 'localhost', '*.example.com')
9+
* @default 'localhost'
10+
*/
11+
commonName?: string;
12+
13+
/**
14+
* Number of days the certificate is valid for
15+
* @default 365
16+
*/
17+
validityDays?: number;
18+
19+
/**
20+
* Key size in bits
21+
* @default 2048
22+
*/
23+
keySize?: number;
24+
25+
/**
26+
* Organization name
27+
* @default 'Development'
28+
*/
29+
organization?: string;
30+
31+
/**
32+
* Country code (2 letters)
33+
* @default 'US'
34+
*/
35+
countryCode?: string;
36+
}
37+
38+
export interface GeneratedCertificate {
39+
/**
40+
* Private key in PEM format
41+
*/
42+
key: string;
43+
44+
/**
45+
* Certificate in PEM format
46+
*/
47+
cert: string;
48+
}
49+
50+
/**
51+
* Generates a self-signed certificate for development/testing purposes.
52+
* Requires OpenSSL to be installed on the system.
53+
*
54+
* @param options - Configuration options for certificate generation
55+
* @returns Object containing the private key and certificate in PEM format
56+
* @throws Error if OpenSSL is not available or certificate generation fails
57+
*
58+
* @example
59+
* ```typescript
60+
* import { generateCertificate, Server } from 'proxy-chain';
61+
*
62+
* // Generate a self-signed certificate
63+
* const { key, cert } = generateCertificate({
64+
* commonName: 'localhost',
65+
* validityDays: 365,
66+
* });
67+
*
68+
* // Create HTTPS proxy server
69+
* const server = new Server({
70+
* port: 8443,
71+
* serverType: 'https',
72+
* httpsOptions: { key, cert },
73+
* });
74+
* ```
75+
*/
76+
export function generateCertificate(options: GenerateCertificateOptions = {}): GeneratedCertificate {
77+
const {
78+
commonName = 'localhost',
79+
validityDays = 365,
80+
keySize = 2048,
81+
organization = 'Development',
82+
countryCode = 'US',
83+
} = options;
84+
85+
// Check if OpenSSL is available
86+
try {
87+
execSync('openssl version', { stdio: 'pipe' });
88+
} catch {
89+
throw new Error(
90+
'OpenSSL is not available. Please install OpenSSL to generate certificates.\n'
91+
+ 'macOS: brew install openssl\n'
92+
+ 'Ubuntu/Debian: apt-get install openssl\n'
93+
+ 'Windows: https://slproweb.com/products/Win32OpenSSL.html',
94+
);
95+
}
96+
97+
// Create temporary directory for certificate generation
98+
const tempDir = mkdtempSync(join(tmpdir(), 'proxy-chain-cert-'));
99+
100+
try {
101+
const keyPath = join(tempDir, 'key.pem');
102+
const certPath = join(tempDir, 'cert.pem');
103+
104+
// Build subject string
105+
const subject = `/C=${countryCode}/O=${organization}/CN=${commonName}`;
106+
107+
// Generate private key and certificate in one command
108+
const command = `openssl req -x509 -newkey rsa:${keySize} -nodes -keyout "${keyPath}" -out "${certPath}" -days ${validityDays} -subj "${subject}"`;
109+
110+
execSync(command, { stdio: 'pipe' });
111+
112+
// Read generated files
113+
const key = readFileSync(keyPath, 'utf8');
114+
const cert = readFileSync(certPath, 'utf8');
115+
116+
return { key, cert };
117+
} catch (error) {
118+
throw new Error(`Failed to generate certificate: ${(error as Error).message}`);
119+
} finally {
120+
// Clean up temporary directory
121+
rmSync(tempDir, { recursive: true, force: true });
122+
}
123+
}

0 commit comments

Comments
 (0)