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.

This commit is contained in:
Philipp Kunz 2025-04-19 18:31:10 +00:00
parent e69c55de3b
commit 04abab505b
7 changed files with 240 additions and 23 deletions

View File

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

View File

@ -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

View File

@ -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.'
}

View File

@ -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

View File

@ -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<string, plugins.http2.ClientHttp2Session> = 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<string, string> = {
':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<void> {
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<string, any> = {
':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<string, any> = { ':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<string,string> = {};
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<string, number|string|string[]> = {};
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();
}
}
}

View File

@ -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?: {

View File

@ -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';