From 04abab505bfe3fa564e3e39c6235c86be17714ba Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Sat, 19 Apr 2025 18:31:10 +0000 Subject: [PATCH] feat(core): Add backendProtocol option to support HTTP/2 client sessions alongside HTTP/1. This update enhances NetworkProxy's core functionality by integrating HTTP/2 support in server creation and request handling, while updating plugin exports and documentation accordingly. --- changelog.md | 9 + readme.md | 22 ++- ts/00_commitinfo_data.ts | 2 +- ts/networkproxy/classes.np.networkproxy.ts | 44 +++-- ts/networkproxy/classes.np.requesthandler.ts | 181 +++++++++++++++++++ ts/networkproxy/classes.np.types.ts | 2 + ts/plugins.ts | 3 +- 7 files changed, 240 insertions(+), 23 deletions(-) diff --git a/changelog.md b/changelog.md index 1344773..4a91639 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-04-19 - 7.1.0 - feat(core) +Add backendProtocol option to support HTTP/2 client sessions alongside HTTP/1. This update enhances NetworkProxy's core functionality by integrating HTTP/2 support in server creation and request handling, while updating plugin exports and documentation accordingly. + +- Introduced 'backendProtocol' configuration option (http1 | http2) with default 'http1'. +- Updated creation of secure server to use http2.createSecureServer with HTTP/1 fallback. +- Enhanced request handling to establish HTTP/2 client sessions when backendProtocol is set to 'http2'. +- Exported http2 module in plugins. +- Updated readme.md to document backendProtocol usage with example code. + ## 2025-04-05 - 7.0.1 - fix(package.json) Update packageManager field in package.json to specify the pnpm version for improved reproducibility. diff --git a/readme.md b/readme.md index 752f7bd..28bc5e7 100644 --- a/readme.md +++ b/readme.md @@ -197,7 +197,27 @@ sequenceDiagram - **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS - **Let's Encrypt Integration** - Automatic certificate management using ACME protocol - **IP Filtering** - Control access with IP allow/block lists using glob patterns -- **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding + - **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding + +## Configuration Options + +### backendProtocol + +Type: 'http1' | 'http2' (default: 'http1') + +Controls the protocol used when proxying requests to backend services. By default, the proxy uses HTTP/1.x (`http.request`). Setting `backendProtocol: 'http2'` establishes HTTP/2 client sessions (`http2.connect`) to your backends for full end-to-end HTTP/2 support (assuming your backend servers support HTTP/2). + +Example: +```js +import { NetworkProxy } from '@push.rocks/smartproxy'; + +const proxy = new NetworkProxy({ + port: 8443, + backendProtocol: 'http2', + // other options... +}); +proxy.start(); +``` - **Basic Authentication** - Support for basic auth on proxied routes - **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts - **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index eacc082..e73211b 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '7.0.1', + version: '7.1.0', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' } diff --git a/ts/networkproxy/classes.np.networkproxy.ts b/ts/networkproxy/classes.np.networkproxy.ts index a33d0e6..602d78f 100644 --- a/ts/networkproxy/classes.np.networkproxy.ts +++ b/ts/networkproxy/classes.np.networkproxy.ts @@ -16,8 +16,8 @@ export class NetworkProxy implements IMetricsTracker { public options: INetworkProxyOptions; public proxyConfigs: IReverseProxyConfig[] = []; - // Server instances - public httpsServer: plugins.https.Server; + // Server instances (HTTP/2 with HTTP/1 fallback) + public httpsServer: any; // Core components private certificateManager: CertificateManager; @@ -66,6 +66,8 @@ export class NetworkProxy implements IMetricsTracker { connectionPoolSize: optionsArg.connectionPoolSize || 50, portProxyIntegration: optionsArg.portProxyIntegration || false, useExternalPort80Handler: optionsArg.useExternalPort80Handler || false, + // Backend protocol (http1 or http2) + backendProtocol: optionsArg.backendProtocol || 'http1', // Default ACME options acme: { enabled: optionsArg.acme?.enabled || false, @@ -185,33 +187,35 @@ export class NetworkProxy implements IMetricsTracker { await this.certificateManager.initializePort80Handler(); } - // Create the HTTPS server - this.httpsServer = plugins.https.createServer( + // Create HTTP/2 server with HTTP/1 fallback + this.httpsServer = plugins.http2.createSecureServer( { key: this.certificateManager.getDefaultCertificates().key, cert: this.certificateManager.getDefaultCertificates().cert, - SNICallback: (domain, cb) => this.certificateManager.handleSNI(domain, cb) - }, - (req, res) => this.requestHandler.handleRequest(req, res) + allowHTTP1: true, + ALPNProtocols: ['h2', 'http/1.1'] + } ); - // Configure server timeouts - this.httpsServer.keepAliveTimeout = this.options.keepAliveTimeout; - this.httpsServer.headersTimeout = this.options.headersTimeout; - - // Setup connection tracking + // Track raw TCP connections for metrics and limits this.setupConnectionTracking(); - - // Share HTTPS server with certificate manager + + // Handle incoming HTTP/2 streams + this.httpsServer.on('stream', (stream: any, headers: any) => { + this.requestHandler.handleHttp2(stream, headers); + }); + // Handle HTTP/1.x fallback requests + this.httpsServer.on('request', (req: any, res: any) => { + this.requestHandler.handleRequest(req, res); + }); + + // Share server with certificate manager for dynamic contexts this.certificateManager.setHttpsServer(this.httpsServer); - - // Setup WebSocket support + // Setup WebSocket support on HTTP/1 fallback this.webSocketHandler.initialize(this.httpsServer); - - // Start metrics collection + // Start metrics logging this.setupMetricsCollection(); - - // Setup connection pool cleanup interval + // Start periodic connection pool cleanup this.connectionPoolCleanupInterval = this.connectionPool.setupPeriodicCleanup(); // Start the server diff --git a/ts/networkproxy/classes.np.requesthandler.ts b/ts/networkproxy/classes.np.requesthandler.ts index 2f33766..bdf56d7 100644 --- a/ts/networkproxy/classes.np.requesthandler.ts +++ b/ts/networkproxy/classes.np.requesthandler.ts @@ -18,6 +18,8 @@ export class RequestHandler { private defaultHeaders: { [key: string]: string } = {}; private logger: ILogger; private metricsTracker: IMetricsTracker | null = null; + // HTTP/2 client sessions for backend proxying + private h2Sessions: Map = new Map(); constructor( private options: INetworkProxyOptions, @@ -130,6 +132,70 @@ export class RequestHandler { // Apply default headers this.applyDefaultHeaders(res); + // If configured to proxy to backends over HTTP/2, use HTTP/2 client sessions + if (this.options.backendProtocol === 'http2') { + // Route and validate config + const proxyConfig = this.router.routeReq(req); + if (!proxyConfig) { + this.logger.warn(`No proxy configuration for host: ${req.headers.host}`); + res.statusCode = 404; + res.end('Not Found: No proxy configuration for this host'); + if (this.metricsTracker) this.metricsTracker.incrementFailedRequests(); + return; + } + // Determine backend target + const destination = this.connectionPool.getNextTarget( + proxyConfig.destinationIps, + proxyConfig.destinationPorts[0] + ); + // Obtain or create HTTP/2 session + const key = `${destination.host}:${destination.port}`; + let session = this.h2Sessions.get(key); + if (!session || session.closed || (session as any).destroyed) { + session = plugins.http2.connect(`http://${destination.host}:${destination.port}`); + this.h2Sessions.set(key, session); + session.on('error', () => this.h2Sessions.delete(key)); + session.on('close', () => this.h2Sessions.delete(key)); + } + // Build headers for HTTP/2 request + const h2Headers: Record = { + ':method': req.method || 'GET', + ':path': req.url || '/', + ':authority': `${destination.host}:${destination.port}` + }; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string' && !k.startsWith(':')) { + h2Headers[k] = v; + } + } + // Open HTTP/2 stream + const h2Stream = session.request(h2Headers); + // Pipe client request body to backend + req.pipe(h2Stream); + // Handle backend response + h2Stream.on('response', (headers, flags) => { + const status = headers[':status'] as number || 502; + // Map headers + for (const [hk, hv] of Object.entries(headers)) { + if (!hk.startsWith(':') && hv) { + res.setHeader(hk, hv as string); + } + } + res.statusCode = status; + h2Stream.pipe(res); + }); + h2Stream.on('error', (err) => { + this.logger.error(`HTTP/2 proxy error: ${err.message}`); + if (!res.headersSent) { + res.statusCode = 502; + res.end(`Bad Gateway: ${err.message}`); + } else { + res.end(); + } + if (this.metricsTracker) this.metricsTracker.incrementFailedRequests(); + }); + return; + } try { // Find target based on hostname @@ -275,4 +341,119 @@ export class RequestHandler { } } } + + /** + * Handle HTTP/2 stream requests by proxying to HTTP/1 backends + */ + public async handleHttp2(stream: any, headers: any): Promise { + const startTime = Date.now(); + const method = headers[':method'] || 'GET'; + const path = headers[':path'] || '/'; + // If configured to proxy to backends over HTTP/2, use HTTP/2 client sessions + if (this.options.backendProtocol === 'http2') { + const authority = headers[':authority'] as string || ''; + const host = authority.split(':')[0]; + const fakeReq: any = { headers: { host }, method: headers[':method'], url: headers[':path'], socket: (stream.session as any).socket }; + const proxyConfig = this.router.routeReq(fakeReq); + if (!proxyConfig) { + stream.respond({ ':status': 404 }); + stream.end('Not Found'); + if (this.metricsTracker) this.metricsTracker.incrementFailedRequests(); + return; + } + const destination = this.connectionPool.getNextTarget(proxyConfig.destinationIps, proxyConfig.destinationPorts[0]); + const key = `${destination.host}:${destination.port}`; + let session = this.h2Sessions.get(key); + if (!session || session.closed || (session as any).destroyed) { + session = plugins.http2.connect(`http://${destination.host}:${destination.port}`); + this.h2Sessions.set(key, session); + session.on('error', () => this.h2Sessions.delete(key)); + session.on('close', () => this.h2Sessions.delete(key)); + } + // Build headers for backend HTTP/2 request + const h2Headers: Record = { + ':method': headers[':method'], + ':path': headers[':path'], + ':authority': `${destination.host}:${destination.port}` + }; + for (const [k, v] of Object.entries(headers)) { + if (!k.startsWith(':') && typeof v === 'string') { + h2Headers[k] = v; + } + } + const h2Stream2 = session.request(h2Headers); + stream.pipe(h2Stream2); + h2Stream2.on('response', (hdrs: any) => { + // Map status and headers to client + const resp: Record = { ':status': hdrs[':status'] as number }; + for (const [hk, hv] of Object.entries(hdrs)) { + if (!hk.startsWith(':') && hv) resp[hk] = hv; + } + stream.respond(resp); + h2Stream2.pipe(stream); + }); + h2Stream2.on('error', (err) => { + stream.respond({ ':status': 502 }); + stream.end(`Bad Gateway: ${err.message}`); + if (this.metricsTracker) this.metricsTracker.incrementFailedRequests(); + }); + return; + } + try { + // Determine host for routing + const authority = headers[':authority'] as string || ''; + const host = authority.split(':')[0]; + // Fake request object for routing + const fakeReq: any = { headers: { host }, method, url: path, socket: (stream.session as any).socket }; + const proxyConfig = this.router.routeReq(fakeReq as any); + if (!proxyConfig) { + stream.respond({ ':status': 404 }); + stream.end('Not Found'); + if (this.metricsTracker) this.metricsTracker.incrementFailedRequests(); + return; + } + // Select backend target + const destination = this.connectionPool.getNextTarget( + proxyConfig.destinationIps, + proxyConfig.destinationPorts[0] + ); + // Build headers for HTTP/1 proxy + const outboundHeaders: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof key === 'string' && typeof value === 'string' && !key.startsWith(':')) { + outboundHeaders[key] = value; + } + } + if (outboundHeaders.host && (proxyConfig as any).rewriteHostHeader) { + outboundHeaders.host = `${destination.host}:${destination.port}`; + } + // Create HTTP/1 proxy request + const proxyReq = plugins.http.request( + { hostname: destination.host, port: destination.port, path, method, headers: outboundHeaders }, + (proxyRes) => { + // Map status and headers back to HTTP/2 + const responseHeaders: Record = {}; + for (const [k, v] of Object.entries(proxyRes.headers)) { + if (v !== undefined) responseHeaders[k] = v; + } + stream.respond({ ':status': proxyRes.statusCode || 500, ...responseHeaders }); + proxyRes.pipe(stream); + stream.on('close', () => proxyReq.destroy()); + stream.on('error', () => proxyReq.destroy()); + if (this.metricsTracker) stream.on('end', () => this.metricsTracker.incrementRequestsServed()); + } + ); + proxyReq.on('error', (err) => { + stream.respond({ ':status': 502 }); + stream.end(`Bad Gateway: ${err.message}`); + if (this.metricsTracker) this.metricsTracker.incrementFailedRequests(); + }); + // Pipe client stream to backend + stream.pipe(proxyReq); + } catch (err: any) { + stream.respond({ ':status': 500 }); + stream.end('Internal Server Error'); + if (this.metricsTracker) this.metricsTracker.incrementFailedRequests(); + } + } } \ No newline at end of file diff --git a/ts/networkproxy/classes.np.types.ts b/ts/networkproxy/classes.np.types.ts index fbf6387..02fc2bc 100644 --- a/ts/networkproxy/classes.np.types.ts +++ b/ts/networkproxy/classes.np.types.ts @@ -20,6 +20,8 @@ export interface INetworkProxyOptions { connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy useExternalPort80Handler?: boolean; // Flag to indicate using external Port80Handler + // Protocol to use when proxying to backends: HTTP/1.x or HTTP/2 + backendProtocol?: 'http1' | 'http2'; // ACME certificate management options acme?: { diff --git a/ts/plugins.ts b/ts/plugins.ts index e17b5e6..5e5014d 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -5,9 +5,10 @@ import * as https from 'https'; import * as net from 'net'; import * as tls from 'tls'; import * as url from 'url'; +import * as http2 from 'http2'; -export { EventEmitter, http, https, net, tls, url }; +export { EventEmitter, http, https, net, tls, url, http2 }; // tsclass scope import * as tsclass from '@tsclass/tsclass';