feat(rustproxy): introduce a Rust-powered proxy engine and workspace with core crates for proxy functionality, ACME/TLS support, passthrough and HTTP proxies, metrics, nftables integration, routing/security, management IPC, tests, and README updates
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '22.4.2',
|
||||
version: '22.5.0',
|
||||
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
||||
}
|
||||
|
||||
16
ts/index.ts
16
ts/index.ts
@@ -5,15 +5,10 @@
|
||||
// NFTables proxy exports
|
||||
export * from './proxies/nftables-proxy/index.js';
|
||||
|
||||
// Export HttpProxy elements
|
||||
export { HttpProxy, CertificateManager, ConnectionPool, RequestHandler, WebSocketHandler } from './proxies/http-proxy/index.js';
|
||||
export type { IMetricsTracker, MetricsTracker } from './proxies/http-proxy/index.js';
|
||||
export type { IHttpProxyOptions, ICertificateEntry, ILogger } from './proxies/http-proxy/models/types.js';
|
||||
export { SharedRouteManager as HttpProxyRouteManager } from './core/routing/route-manager.js';
|
||||
|
||||
// Export SmartProxy elements selectively to avoid RouteManager ambiguity
|
||||
export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, HttpProxyBridge, RouteConnectionHandler, SmartCertManager } from './proxies/smart-proxy/index.js';
|
||||
// Export SmartProxy elements
|
||||
export { SmartProxy } from './proxies/smart-proxy/index.js';
|
||||
export { SharedRouteManager as RouteManager } from './core/routing/route-manager.js';
|
||||
|
||||
// Export smart-proxy models
|
||||
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js';
|
||||
export type { TSmartProxyCertProvisionObject } from './proxies/smart-proxy/models/interfaces.js';
|
||||
@@ -22,8 +17,6 @@ export * from './proxies/smart-proxy/utils/index.js';
|
||||
// Original: export * from './smartproxy/classes.pp.snihandler.js'
|
||||
// Now we export from the new module
|
||||
export { SniHandler } from './tls/sni/sni-handler.js';
|
||||
// Original: export * from './smartproxy/classes.pp.interfaces.js'
|
||||
// Now we export from the new module (selectively to avoid conflicts)
|
||||
|
||||
// Core types and utilities
|
||||
export * from './core/models/common-types.js';
|
||||
@@ -32,8 +25,7 @@ export * from './core/models/common-types.js';
|
||||
export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js';
|
||||
|
||||
// Modular exports for new architecture
|
||||
// Certificate module has been removed - use SmartCertManager instead
|
||||
export * as tls from './tls/index.js';
|
||||
export * as routing from './routing/index.js';
|
||||
export * as detection from './detection/index.js';
|
||||
export * as protocols from './protocols/index.js';
|
||||
export * as protocols from './protocols/index.js';
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { type IHttpProxyOptions, type IConnectionEntry, type ILogger, createLogger } from './models/types.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Manages a pool of backend connections for efficient reuse
|
||||
*/
|
||||
export class ConnectionPool {
|
||||
private connectionPool: Map<string, Array<IConnectionEntry>> = new Map();
|
||||
private roundRobinPositions: Map<string, number> = new Map();
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(private options: IHttpProxyOptions) {
|
||||
this.logger = createLogger(options.logLevel || 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a connection from the pool or create a new one
|
||||
*/
|
||||
public getConnection(host: string, port: number): Promise<plugins.net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poolKey = `${host}:${port}`;
|
||||
const connectionList = this.connectionPool.get(poolKey) || [];
|
||||
|
||||
// Look for an idle connection
|
||||
const idleConnectionIndex = connectionList.findIndex(c => c.isIdle);
|
||||
|
||||
if (idleConnectionIndex >= 0) {
|
||||
// Get existing connection from pool
|
||||
const connection = connectionList[idleConnectionIndex];
|
||||
connection.isIdle = false;
|
||||
connection.lastUsed = Date.now();
|
||||
this.logger.debug(`Reusing connection from pool for ${poolKey}`);
|
||||
|
||||
// Update the pool
|
||||
this.connectionPool.set(poolKey, connectionList);
|
||||
|
||||
resolve(connection.socket);
|
||||
return;
|
||||
}
|
||||
|
||||
// No idle connection available, create a new one if pool isn't full
|
||||
const poolSize = this.options.connectionPoolSize || 50;
|
||||
if (connectionList.length < poolSize) {
|
||||
this.logger.debug(`Creating new connection to ${host}:${port}`);
|
||||
|
||||
try {
|
||||
const socket = plugins.net.connect({
|
||||
host,
|
||||
port,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelay: 30000 // 30 seconds
|
||||
});
|
||||
|
||||
socket.once('connect', () => {
|
||||
// Add to connection pool
|
||||
const connection = {
|
||||
socket,
|
||||
lastUsed: Date.now(),
|
||||
isIdle: false
|
||||
};
|
||||
|
||||
connectionList.push(connection);
|
||||
this.connectionPool.set(poolKey, connectionList);
|
||||
|
||||
// Setup cleanup when the connection is closed
|
||||
socket.once('close', () => {
|
||||
const idx = connectionList.findIndex(c => c.socket === socket);
|
||||
if (idx >= 0) {
|
||||
connectionList.splice(idx, 1);
|
||||
this.connectionPool.set(poolKey, connectionList);
|
||||
this.logger.debug(`Removed closed connection from pool for ${poolKey}`);
|
||||
}
|
||||
});
|
||||
|
||||
resolve(socket);
|
||||
});
|
||||
|
||||
socket.once('error', (err) => {
|
||||
this.logger.error(`Error creating connection to ${host}:${port}`, err);
|
||||
reject(err);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create connection to ${host}:${port}`, err);
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
// Pool is full, wait for an idle connection or reject
|
||||
this.logger.warn(`Connection pool for ${poolKey} is full (${connectionList.length})`);
|
||||
reject(new Error(`Connection pool for ${poolKey} is full`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a connection to the pool for reuse
|
||||
*/
|
||||
public returnConnection(socket: plugins.net.Socket, host: string, port: number): void {
|
||||
const poolKey = `${host}:${port}`;
|
||||
const connectionList = this.connectionPool.get(poolKey) || [];
|
||||
|
||||
// Find this connection in the pool
|
||||
const connectionIndex = connectionList.findIndex(c => c.socket === socket);
|
||||
|
||||
if (connectionIndex >= 0) {
|
||||
// Mark as idle and update last used time
|
||||
connectionList[connectionIndex].isIdle = true;
|
||||
connectionList[connectionIndex].lastUsed = Date.now();
|
||||
|
||||
this.logger.debug(`Returned connection to pool for ${poolKey}`);
|
||||
} else {
|
||||
this.logger.warn(`Attempted to return unknown connection to pool for ${poolKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup the connection pool by removing idle connections
|
||||
* or reducing pool size if it exceeds the configured maximum
|
||||
*/
|
||||
public cleanupConnectionPool(): void {
|
||||
const now = Date.now();
|
||||
const idleTimeout = this.options.keepAliveTimeout || 120000; // 2 minutes default
|
||||
|
||||
for (const [host, connections] of this.connectionPool.entries()) {
|
||||
// Sort by last used time (oldest first)
|
||||
connections.sort((a, b) => a.lastUsed - b.lastUsed);
|
||||
|
||||
// Remove idle connections older than the idle timeout
|
||||
let removed = 0;
|
||||
while (connections.length > 0) {
|
||||
const connection = connections[0];
|
||||
|
||||
// Remove if idle and exceeds timeout, or if pool is too large
|
||||
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
|
||||
connections.length > (this.options.connectionPoolSize || 50)) {
|
||||
|
||||
cleanupSocket(connection.socket, `pool-${host}-idle`, { immediate: true }).catch(() => {});
|
||||
|
||||
connections.shift(); // Remove from pool
|
||||
removed++;
|
||||
} else {
|
||||
break; // Stop removing if we've reached active or recent connections
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`Removed ${removed} idle connections from pool for ${host}, ${connections.length} remaining`);
|
||||
}
|
||||
|
||||
// Update the pool with the remaining connections
|
||||
if (connections.length === 0) {
|
||||
this.connectionPool.delete(host);
|
||||
} else {
|
||||
this.connectionPool.set(host, connections);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections in the pool
|
||||
*/
|
||||
public closeAllConnections(): void {
|
||||
for (const [host, connections] of this.connectionPool.entries()) {
|
||||
this.logger.debug(`Closing ${connections.length} connections to ${host}`);
|
||||
|
||||
for (const connection of connections) {
|
||||
cleanupSocket(connection.socket, `pool-${host}-close`, { immediate: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
this.connectionPool.clear();
|
||||
this.roundRobinPositions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get load balancing target using round-robin
|
||||
*/
|
||||
public getNextTarget(targets: string[], port: number): { host: string, port: number } {
|
||||
const targetKey = targets.join(',');
|
||||
|
||||
// Initialize position if not exists
|
||||
if (!this.roundRobinPositions.has(targetKey)) {
|
||||
this.roundRobinPositions.set(targetKey, 0);
|
||||
}
|
||||
|
||||
// Get current position and increment for next time
|
||||
const currentPosition = this.roundRobinPositions.get(targetKey)!;
|
||||
const nextPosition = (currentPosition + 1) % targets.length;
|
||||
this.roundRobinPositions.set(targetKey, nextPosition);
|
||||
|
||||
// Return the selected target
|
||||
return {
|
||||
host: targets[currentPosition],
|
||||
port
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the connection pool status
|
||||
*/
|
||||
public getPoolStatus(): Record<string, { total: number, idle: number }> {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.connectionPool.entries()).map(([host, connections]) => [
|
||||
host,
|
||||
{
|
||||
total: connections.length,
|
||||
idle: connections.filter(c => c.isIdle).length
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a periodic cleanup task
|
||||
*/
|
||||
public setupPeriodicCleanup(interval: number = 60000): NodeJS.Timeout {
|
||||
const timer = setInterval(() => {
|
||||
this.cleanupConnectionPool();
|
||||
}, interval);
|
||||
|
||||
// Don't prevent process exit
|
||||
if (timer.unref) {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import type { IRouteContext, IHttpRouteContext, IHttp2RouteContext } from '../../core/models/route-context.js';
|
||||
|
||||
/**
|
||||
* Context creator for NetworkProxy
|
||||
* Creates route contexts for matching and function evaluation
|
||||
*/
|
||||
export class ContextCreator {
|
||||
/**
|
||||
* Create a route context from HTTP request information
|
||||
*/
|
||||
public createHttpRouteContext(req: any, options: {
|
||||
tlsVersion?: string;
|
||||
connectionId: string;
|
||||
clientIp: string;
|
||||
serverIp: string;
|
||||
}): IHttpRouteContext {
|
||||
// Parse headers
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (typeof value === 'string') {
|
||||
headers[key.toLowerCase()] = value;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
headers[key.toLowerCase()] = value[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse domain from Host header
|
||||
const domain = headers['host']?.split(':')[0] || '';
|
||||
|
||||
// Parse URL
|
||||
const url = new URL(`http://${domain}${req.url || '/'}`);
|
||||
|
||||
return {
|
||||
// Connection basics
|
||||
port: req.socket.localPort || 0,
|
||||
domain,
|
||||
clientIp: options.clientIp,
|
||||
serverIp: options.serverIp,
|
||||
|
||||
// HTTP specifics
|
||||
path: url.pathname,
|
||||
query: url.search ? url.search.substring(1) : '',
|
||||
headers,
|
||||
|
||||
// TLS information
|
||||
isTls: !!req.socket.encrypted,
|
||||
tlsVersion: options.tlsVersion,
|
||||
|
||||
// Request objects
|
||||
req,
|
||||
|
||||
// Metadata
|
||||
timestamp: Date.now(),
|
||||
connectionId: options.connectionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route context from HTTP/2 stream and headers
|
||||
*/
|
||||
public createHttp2RouteContext(
|
||||
stream: plugins.http2.ServerHttp2Stream,
|
||||
headers: plugins.http2.IncomingHttpHeaders,
|
||||
options: {
|
||||
connectionId: string;
|
||||
clientIp: string;
|
||||
serverIp: string;
|
||||
}
|
||||
): IHttp2RouteContext {
|
||||
// Parse headers, excluding HTTP/2 pseudo-headers
|
||||
const processedHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!key.startsWith(':') && typeof value === 'string') {
|
||||
processedHeaders[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Get domain from :authority pseudo-header
|
||||
const authority = headers[':authority'] as string || '';
|
||||
const domain = authority.split(':')[0];
|
||||
|
||||
// Get path from :path pseudo-header
|
||||
const path = headers[':path'] as string || '/';
|
||||
|
||||
// Parse the path to extract query string
|
||||
const pathParts = path.split('?');
|
||||
const pathname = pathParts[0];
|
||||
const query = pathParts.length > 1 ? pathParts[1] : '';
|
||||
|
||||
// Get the socket from the session
|
||||
const socket = (stream.session as any)?.socket;
|
||||
|
||||
return {
|
||||
// Connection basics
|
||||
port: socket?.localPort || 0,
|
||||
domain,
|
||||
clientIp: options.clientIp,
|
||||
serverIp: options.serverIp,
|
||||
|
||||
// HTTP specifics
|
||||
path: pathname,
|
||||
query,
|
||||
headers: processedHeaders,
|
||||
|
||||
// HTTP/2 specific properties
|
||||
method: headers[':method'] as string,
|
||||
stream,
|
||||
|
||||
// TLS information - HTTP/2 is always on TLS in browsers
|
||||
isTls: true,
|
||||
tlsVersion: socket?.getTLSVersion?.() || 'TLSv1.3',
|
||||
|
||||
// Metadata
|
||||
timestamp: Date.now(),
|
||||
connectionId: options.connectionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic route context from socket information
|
||||
*/
|
||||
public createSocketRouteContext(socket: plugins.net.Socket, options: {
|
||||
domain?: string;
|
||||
tlsVersion?: string;
|
||||
connectionId: string;
|
||||
}): IRouteContext {
|
||||
return {
|
||||
// Connection basics
|
||||
port: socket.localPort || 0,
|
||||
domain: options.domain,
|
||||
clientIp: socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
serverIp: socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
|
||||
// TLS information
|
||||
isTls: options.tlsVersion !== undefined,
|
||||
tlsVersion: options.tlsVersion,
|
||||
|
||||
// Metadata
|
||||
timestamp: Date.now(),
|
||||
connectionId: options.connectionId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import type { ILogger, ICertificateEntry } from './models/types.js';
|
||||
|
||||
/**
|
||||
* Interface for default certificate data
|
||||
*/
|
||||
export interface IDefaultCertificates {
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides default SSL certificates for HttpProxy.
|
||||
* This is a minimal replacement for the deprecated CertificateManager.
|
||||
*
|
||||
* For production certificate management, use SmartCertManager instead.
|
||||
*/
|
||||
export class DefaultCertificateProvider {
|
||||
private defaultCertificates: IDefaultCertificates | null = null;
|
||||
private certificateCache: Map<string, ICertificateEntry> = new Map();
|
||||
private initialized = false;
|
||||
|
||||
constructor(private logger?: ILogger) {}
|
||||
|
||||
/**
|
||||
* Load default certificates asynchronously (preferred)
|
||||
*/
|
||||
public async loadDefaultCertificatesAsync(): Promise<IDefaultCertificates> {
|
||||
if (this.defaultCertificates) {
|
||||
return this.defaultCertificates;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
||||
|
||||
try {
|
||||
const [key, cert] = await Promise.all([
|
||||
AsyncFileSystem.readFile(path.join(certPath, 'key.pem')),
|
||||
AsyncFileSystem.readFile(path.join(certPath, 'cert.pem'))
|
||||
]);
|
||||
|
||||
this.defaultCertificates = { key, cert };
|
||||
this.logger?.info?.('Loaded default certificates from filesystem');
|
||||
this.initialized = true;
|
||||
return this.defaultCertificates;
|
||||
} catch (error) {
|
||||
this.logger?.warn?.(`Failed to load default certificates: ${error}`);
|
||||
this.defaultCertificates = this.generateFallbackCertificate();
|
||||
this.initialized = true;
|
||||
return this.defaultCertificates;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load default certificates synchronously (for backward compatibility)
|
||||
* @deprecated Use loadDefaultCertificatesAsync instead
|
||||
*/
|
||||
public loadDefaultCertificatesSync(): IDefaultCertificates {
|
||||
if (this.defaultCertificates) {
|
||||
return this.defaultCertificates;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
||||
|
||||
try {
|
||||
this.defaultCertificates = {
|
||||
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
||||
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
||||
};
|
||||
this.logger?.info?.('Loaded default certificates from filesystem (sync)');
|
||||
} catch (error) {
|
||||
this.logger?.warn?.(`Failed to load default certificates: ${error}`);
|
||||
this.defaultCertificates = this.generateFallbackCertificate();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
return this.defaultCertificates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default certificates (loads synchronously if not already loaded)
|
||||
*/
|
||||
public getDefaultCertificates(): IDefaultCertificates {
|
||||
if (!this.defaultCertificates) {
|
||||
return this.loadDefaultCertificatesSync();
|
||||
}
|
||||
return this.defaultCertificates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a certificate in the cache
|
||||
*/
|
||||
public updateCertificate(domain: string, cert: string, key: string): void {
|
||||
this.certificateCache.set(domain, {
|
||||
cert,
|
||||
key,
|
||||
expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days
|
||||
});
|
||||
|
||||
this.logger?.info?.(`Certificate updated for ${domain}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a cached certificate
|
||||
*/
|
||||
public getCachedCertificate(domain: string): ICertificateEntry | null {
|
||||
return this.certificateCache.get(domain) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets statistics for metrics
|
||||
*/
|
||||
public getStats(): { cachedCertificates: number; defaultCertEnabled: boolean } {
|
||||
return {
|
||||
cachedCertificates: this.certificateCache.size,
|
||||
defaultCertEnabled: this.defaultCertificates !== null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fallback self-signed certificate placeholder
|
||||
* Note: This is just a placeholder - real apps should provide proper certificates
|
||||
*/
|
||||
private generateFallbackCertificate(): IDefaultCertificates {
|
||||
this.logger?.warn?.('Using fallback self-signed certificate placeholder');
|
||||
|
||||
// Minimal self-signed certificate for fallback only
|
||||
// In production, proper certificates should be provided via SmartCertManager
|
||||
const selfSignedCert = `-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHHIgIIA0/cMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT
|
||||
MB4XDTE0MDEwMTAwMDAwMFoXDTI0MDEwMTAwMDAwMFowDTELMAkGA1UEBhMCVVMw
|
||||
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMRiH0VwnOH3jCV7c6JFZWYrvuqy
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
const selfSignedKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMRiH0VwnOH3jCV7
|
||||
c6JFZWYrvuqyALCLXj0pcr1iqNdHjegNXnkl5zjdaUjq4edNOKl7M1AlFiYjG2xk
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
return {
|
||||
key: selfSignedKey,
|
||||
cert: selfSignedCert
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import type { ILogger } from './models/types.js';
|
||||
|
||||
/**
|
||||
* Interface for cached function result
|
||||
*/
|
||||
interface ICachedResult<T> {
|
||||
value: T;
|
||||
expiry: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function cache for NetworkProxy function-based targets
|
||||
*
|
||||
* This cache improves performance for function-based targets by storing
|
||||
* the results of function evaluations and reusing them for similar contexts.
|
||||
*/
|
||||
export class FunctionCache {
|
||||
// Cache storage
|
||||
private hostCache: Map<string, ICachedResult<string | string[]>> = new Map();
|
||||
private portCache: Map<string, ICachedResult<number>> = new Map();
|
||||
|
||||
// Maximum number of entries to store in each cache
|
||||
private maxCacheSize: number;
|
||||
|
||||
// Default TTL for cache entries in milliseconds (default: 5 seconds)
|
||||
private defaultTtl: number;
|
||||
|
||||
// Logger
|
||||
private logger: ILogger;
|
||||
|
||||
// Cleanup interval timer
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new function cache
|
||||
*
|
||||
* @param logger Logger for debug output
|
||||
* @param options Cache options
|
||||
*/
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
options: {
|
||||
maxCacheSize?: number;
|
||||
defaultTtl?: number;
|
||||
} = {}
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.maxCacheSize = options.maxCacheSize || 1000;
|
||||
this.defaultTtl = options.defaultTtl || 5000; // 5 seconds default
|
||||
|
||||
// Start the cache cleanup timer
|
||||
this.cleanupInterval = setInterval(() => this.cleanupCache(), 30000); // Cleanup every 30 seconds
|
||||
|
||||
// Make sure the interval doesn't keep the process alive
|
||||
if (this.cleanupInterval.unref) {
|
||||
this.cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a hash for a context object
|
||||
* This is used to identify similar contexts for caching
|
||||
*
|
||||
* @param context The route context to hash
|
||||
* @param functionId Identifier for the function (usually route name or ID)
|
||||
* @returns A string hash
|
||||
*/
|
||||
private computeContextHash(context: IRouteContext, functionId: string): string {
|
||||
// Extract relevant properties for the hash
|
||||
const hashBase = {
|
||||
functionId,
|
||||
port: context.port,
|
||||
domain: context.domain,
|
||||
clientIp: context.clientIp,
|
||||
path: context.path,
|
||||
query: context.query,
|
||||
isTls: context.isTls,
|
||||
tlsVersion: context.tlsVersion
|
||||
};
|
||||
|
||||
// Generate a hash string
|
||||
return JSON.stringify(hashBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached host result for a function and context
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @returns Cached host value or undefined if not found
|
||||
*/
|
||||
public getCachedHost(context: IRouteContext, functionId: string): string | string[] | undefined {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const cached = this.hostCache.get(hash);
|
||||
|
||||
// Return if no cached value or expired
|
||||
if (!cached || cached.expiry < Date.now()) {
|
||||
if (cached) {
|
||||
// If expired, remove from cache
|
||||
this.hostCache.delete(hash);
|
||||
this.logger.debug(`Cache miss (expired) for host function: ${functionId}`);
|
||||
} else {
|
||||
this.logger.debug(`Cache miss for host function: ${functionId}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.logger.debug(`Cache hit for host function: ${functionId}`);
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached port result for a function and context
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @returns Cached port value or undefined if not found
|
||||
*/
|
||||
public getCachedPort(context: IRouteContext, functionId: string): number | undefined {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const cached = this.portCache.get(hash);
|
||||
|
||||
// Return if no cached value or expired
|
||||
if (!cached || cached.expiry < Date.now()) {
|
||||
if (cached) {
|
||||
// If expired, remove from cache
|
||||
this.portCache.delete(hash);
|
||||
this.logger.debug(`Cache miss (expired) for port function: ${functionId}`);
|
||||
} else {
|
||||
this.logger.debug(`Cache miss for port function: ${functionId}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.logger.debug(`Cache hit for port function: ${functionId}`);
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a host function result in the cache
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @param value Host value to cache
|
||||
* @param ttl Optional TTL in milliseconds
|
||||
*/
|
||||
public cacheHost(
|
||||
context: IRouteContext,
|
||||
functionId: string,
|
||||
value: string | string[],
|
||||
ttl?: number
|
||||
): void {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const expiry = Date.now() + (ttl || this.defaultTtl);
|
||||
|
||||
// Check if we need to prune the cache before adding
|
||||
if (this.hostCache.size >= this.maxCacheSize) {
|
||||
this.pruneOldestEntries(this.hostCache);
|
||||
}
|
||||
|
||||
// Store the result
|
||||
this.hostCache.set(hash, { value, expiry, hash });
|
||||
this.logger.debug(`Cached host function result for: ${functionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a port function result in the cache
|
||||
*
|
||||
* @param context Route context
|
||||
* @param functionId Identifier for the function
|
||||
* @param value Port value to cache
|
||||
* @param ttl Optional TTL in milliseconds
|
||||
*/
|
||||
public cachePort(
|
||||
context: IRouteContext,
|
||||
functionId: string,
|
||||
value: number,
|
||||
ttl?: number
|
||||
): void {
|
||||
const hash = this.computeContextHash(context, functionId);
|
||||
const expiry = Date.now() + (ttl || this.defaultTtl);
|
||||
|
||||
// Check if we need to prune the cache before adding
|
||||
if (this.portCache.size >= this.maxCacheSize) {
|
||||
this.pruneOldestEntries(this.portCache);
|
||||
}
|
||||
|
||||
// Store the result
|
||||
this.portCache.set(hash, { value, expiry, hash });
|
||||
this.logger.debug(`Cached port function result for: ${functionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired entries from the cache
|
||||
*/
|
||||
private cleanupCache(): void {
|
||||
const now = Date.now();
|
||||
let expiredCount = 0;
|
||||
|
||||
// Clean up host cache
|
||||
for (const [hash, cached] of this.hostCache.entries()) {
|
||||
if (cached.expiry < now) {
|
||||
this.hostCache.delete(hash);
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up port cache
|
||||
for (const [hash, cached] of this.portCache.entries()) {
|
||||
if (cached.expiry < now) {
|
||||
this.portCache.delete(hash);
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredCount > 0) {
|
||||
this.logger.debug(`Cleaned up ${expiredCount} expired cache entries`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune oldest entries from a cache map
|
||||
* Used when the cache exceeds the maximum size
|
||||
*
|
||||
* @param cache The cache map to prune
|
||||
*/
|
||||
private pruneOldestEntries<T>(cache: Map<string, ICachedResult<T>>): void {
|
||||
// Find the oldest entries
|
||||
const now = Date.now();
|
||||
const itemsToRemove = Math.floor(this.maxCacheSize * 0.2); // Remove 20% of the cache
|
||||
|
||||
// Convert to array for sorting
|
||||
const entries = Array.from(cache.entries());
|
||||
|
||||
// Sort by expiry (oldest first)
|
||||
entries.sort((a, b) => a[1].expiry - b[1].expiry);
|
||||
|
||||
// Remove oldest entries
|
||||
const toRemove = entries.slice(0, itemsToRemove);
|
||||
for (const [hash] of toRemove) {
|
||||
cache.delete(hash);
|
||||
}
|
||||
|
||||
this.logger.debug(`Pruned ${toRemove.length} oldest cache entries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache stats
|
||||
*/
|
||||
public getStats(): { hostCacheSize: number; portCacheSize: number } {
|
||||
return {
|
||||
hostCacheSize: this.hostCache.size,
|
||||
portCacheSize: this.portCache.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached entries
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.hostCache.clear();
|
||||
this.portCache.clear();
|
||||
this.logger.info('Function cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the cache and cleanup resources
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
this.clearCache();
|
||||
this.logger.debug('Function cache destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* HTTP handlers for various route types
|
||||
*/
|
||||
|
||||
// Empty - all handlers have been removed
|
||||
@@ -1,669 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
createLogger,
|
||||
} from './models/types.js';
|
||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import type {
|
||||
IHttpProxyOptions,
|
||||
ILogger
|
||||
} from './models/types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
||||
import { createBaseRouteContext } from '../../core/models/route-context.js';
|
||||
import { DefaultCertificateProvider } from './default-certificates.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { RequestHandler, type IMetricsTracker } from './request-handler.js';
|
||||
import { WebSocketHandler } from './websocket-handler.js';
|
||||
import { HttpRouter } from '../../routing/router/index.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
import { FunctionCache } from './function-cache.js';
|
||||
import { SecurityManager } from './security-manager.js';
|
||||
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
||||
|
||||
/**
|
||||
* HttpProxy provides a reverse proxy with TLS termination, WebSocket support,
|
||||
* automatic certificate management, and high-performance connection pooling.
|
||||
* Handles all HTTP/HTTPS traffic including redirects, ACME challenges, and static routes.
|
||||
*/
|
||||
export class HttpProxy implements IMetricsTracker {
|
||||
// Provide a minimal JSON representation to avoid circular references during deep equality checks
|
||||
public toJSON(): any {
|
||||
return {};
|
||||
}
|
||||
// Configuration
|
||||
public options: IHttpProxyOptions;
|
||||
public routes: IRouteConfig[] = [];
|
||||
|
||||
// Server instances (HTTP/2 with HTTP/1 fallback)
|
||||
public httpsServer: plugins.http2.Http2SecureServer;
|
||||
|
||||
// Core components
|
||||
private defaultCertProvider: DefaultCertificateProvider;
|
||||
private connectionPool: ConnectionPool;
|
||||
private requestHandler: RequestHandler;
|
||||
private webSocketHandler: WebSocketHandler;
|
||||
private router = new HttpRouter(); // Unified HTTP router
|
||||
private routeManager: RouteManager;
|
||||
private functionCache: FunctionCache;
|
||||
private securityManager: SecurityManager;
|
||||
|
||||
// State tracking
|
||||
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
||||
public activeContexts: Set<string> = new Set();
|
||||
public connectedClients: number = 0;
|
||||
public startTime: number = 0;
|
||||
public requestsServed: number = 0;
|
||||
public failedRequests: number = 0;
|
||||
|
||||
// Tracking for SmartProxy integration
|
||||
private portProxyConnections: number = 0;
|
||||
private tlsTerminatedConnections: number = 0;
|
||||
|
||||
// Timers
|
||||
private metricsInterval: NodeJS.Timeout;
|
||||
private connectionPoolCleanupInterval: NodeJS.Timeout;
|
||||
|
||||
// Logger
|
||||
private logger: ILogger;
|
||||
|
||||
/**
|
||||
* Creates a new HttpProxy instance
|
||||
*/
|
||||
constructor(optionsArg: IHttpProxyOptions) {
|
||||
// Set default options
|
||||
this.options = {
|
||||
port: optionsArg.port,
|
||||
maxConnections: optionsArg.maxConnections || 10000,
|
||||
keepAliveTimeout: optionsArg.keepAliveTimeout || 120000, // 2 minutes
|
||||
headersTimeout: optionsArg.headersTimeout || 60000, // 1 minute
|
||||
logLevel: optionsArg.logLevel || 'info',
|
||||
cors: optionsArg.cors || {
|
||||
allowOrigin: '*',
|
||||
allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
allowHeaders: 'Content-Type, Authorization',
|
||||
maxAge: 86400
|
||||
},
|
||||
// Defaults for SmartProxy integration
|
||||
connectionPoolSize: optionsArg.connectionPoolSize || 50,
|
||||
portProxyIntegration: optionsArg.portProxyIntegration || false,
|
||||
// Backend protocol (http1 or http2)
|
||||
backendProtocol: optionsArg.backendProtocol || 'http1',
|
||||
// Default ACME options
|
||||
acme: {
|
||||
enabled: optionsArg.acme?.enabled || false,
|
||||
port: optionsArg.acme?.port || 80,
|
||||
accountEmail: optionsArg.acme?.accountEmail || 'admin@example.com',
|
||||
useProduction: optionsArg.acme?.useProduction || false, // Default to staging for safety
|
||||
renewThresholdDays: optionsArg.acme?.renewThresholdDays || 30,
|
||||
autoRenew: optionsArg.acme?.autoRenew !== false, // Default to true
|
||||
certificateStore: optionsArg.acme?.certificateStore || './certs',
|
||||
skipConfiguredCerts: optionsArg.acme?.skipConfiguredCerts || false
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize logger
|
||||
this.logger = createLogger(this.options.logLevel);
|
||||
|
||||
// Initialize route manager
|
||||
this.routeManager = new RouteManager({
|
||||
logger: this.logger,
|
||||
enableDetailedLogging: this.options.logLevel === 'debug',
|
||||
routes: []
|
||||
});
|
||||
|
||||
// Initialize function cache
|
||||
this.functionCache = new FunctionCache(this.logger, {
|
||||
maxCacheSize: this.options.functionCacheSize || 1000,
|
||||
defaultTtl: this.options.functionCacheTtl || 5000
|
||||
});
|
||||
|
||||
// Initialize security manager
|
||||
this.securityManager = new SecurityManager(
|
||||
this.logger,
|
||||
[],
|
||||
this.options.maxConnectionsPerIP || 100,
|
||||
this.options.connectionRateLimitPerMinute || 300
|
||||
);
|
||||
|
||||
// Initialize other components
|
||||
this.defaultCertProvider = new DefaultCertificateProvider(this.logger);
|
||||
this.connectionPool = new ConnectionPool(this.options);
|
||||
this.requestHandler = new RequestHandler(
|
||||
this.options,
|
||||
this.connectionPool,
|
||||
this.routeManager,
|
||||
this.functionCache,
|
||||
this.router
|
||||
);
|
||||
this.webSocketHandler = new WebSocketHandler(
|
||||
this.options,
|
||||
this.connectionPool,
|
||||
this.routes // Pass current routes to WebSocketHandler
|
||||
);
|
||||
|
||||
// Connect request handler to this metrics tracker
|
||||
this.requestHandler.setMetricsTracker(this);
|
||||
|
||||
// Initialize with any provided routes
|
||||
if (this.options.routes && this.options.routes.length > 0) {
|
||||
this.updateRouteConfigs(this.options.routes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements IMetricsTracker interface to increment request counters
|
||||
*/
|
||||
public incrementRequestsServed(): void {
|
||||
this.requestsServed++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements IMetricsTracker interface to increment failed request counters
|
||||
*/
|
||||
public incrementFailedRequests(): void {
|
||||
this.failedRequests++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the port number this HttpProxy is listening on
|
||||
* Useful for SmartProxy to determine where to forward connections
|
||||
*/
|
||||
public getListeningPort(): number {
|
||||
// If the server is running, get the actual listening port
|
||||
if (this.httpsServer && this.httpsServer.address()) {
|
||||
const address = this.httpsServer.address();
|
||||
if (address && typeof address === 'object' && 'port' in address) {
|
||||
return address.port;
|
||||
}
|
||||
}
|
||||
// Fallback to configured port
|
||||
return this.options.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the server capacity settings
|
||||
* @param maxConnections Maximum number of simultaneous connections
|
||||
* @param keepAliveTimeout Keep-alive timeout in milliseconds
|
||||
* @param connectionPoolSize Size of the connection pool per backend
|
||||
*/
|
||||
public updateCapacity(maxConnections?: number, keepAliveTimeout?: number, connectionPoolSize?: number): void {
|
||||
if (maxConnections !== undefined) {
|
||||
this.options.maxConnections = maxConnections;
|
||||
this.logger.info(`Updated max connections to ${maxConnections}`);
|
||||
}
|
||||
|
||||
if (keepAliveTimeout !== undefined) {
|
||||
this.options.keepAliveTimeout = keepAliveTimeout;
|
||||
|
||||
if (this.httpsServer) {
|
||||
// HTTP/2 servers have setTimeout method for timeout management
|
||||
this.httpsServer.setTimeout(keepAliveTimeout);
|
||||
this.logger.info(`Updated server timeout to ${keepAliveTimeout}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionPoolSize !== undefined) {
|
||||
this.options.connectionPoolSize = connectionPoolSize;
|
||||
this.logger.info(`Updated connection pool size to ${connectionPoolSize}`);
|
||||
|
||||
// Clean up excess connections in the pool
|
||||
this.connectionPool.cleanupConnectionPool();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current server metrics
|
||||
* Useful for SmartProxy to determine which HttpProxy to use for load balancing
|
||||
*/
|
||||
public getMetrics(): any {
|
||||
return {
|
||||
activeConnections: this.connectedClients,
|
||||
totalRequests: this.requestsServed,
|
||||
failedRequests: this.failedRequests,
|
||||
portProxyConnections: this.portProxyConnections,
|
||||
tlsTerminatedConnections: this.tlsTerminatedConnections,
|
||||
connectionPoolSize: this.connectionPool.getPoolStatus(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
activeWebSockets: this.webSocketHandler.getConnectionInfo().activeConnections,
|
||||
functionCache: this.functionCache.getStats()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the proxy server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Create HTTP/2 server with HTTP/1 fallback
|
||||
const defaultCerts = this.defaultCertProvider.getDefaultCertificates();
|
||||
this.httpsServer = plugins.http2.createSecureServer(
|
||||
{
|
||||
key: defaultCerts.key,
|
||||
cert: defaultCerts.cert,
|
||||
allowHTTP1: true,
|
||||
ALPNProtocols: ['h2', 'http/1.1']
|
||||
}
|
||||
);
|
||||
|
||||
// Track raw TCP connections for metrics and limits
|
||||
this.setupConnectionTracking();
|
||||
|
||||
// Handle incoming HTTP/2 streams
|
||||
this.httpsServer.on('stream', (stream: plugins.http2.ServerHttp2Stream, headers: plugins.http2.IncomingHttpHeaders) => {
|
||||
this.requestHandler.handleHttp2(stream, headers);
|
||||
});
|
||||
// Handle HTTP/1.x fallback requests
|
||||
this.httpsServer.on('request', (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => {
|
||||
this.requestHandler.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Setup WebSocket support on HTTP/1 fallback
|
||||
this.webSocketHandler.initialize(this.httpsServer as any);
|
||||
// Start metrics logging
|
||||
this.setupMetricsCollection();
|
||||
// Start periodic connection pool cleanup
|
||||
this.connectionPoolCleanupInterval = this.connectionPool.setupPeriodicCleanup();
|
||||
|
||||
// Start the server
|
||||
return new Promise((resolve) => {
|
||||
this.httpsServer.listen(this.options.port, () => {
|
||||
this.logger.info(`HttpProxy started on port ${this.options.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an address is a loopback address (IPv4 or IPv6)
|
||||
*/
|
||||
private isLoopback(addr?: string): boolean {
|
||||
if (!addr) return false;
|
||||
// Check for IPv6 loopback
|
||||
if (addr === '::1') return true;
|
||||
// Handle IPv6-mapped IPv4 addresses
|
||||
if (addr.startsWith('::ffff:')) {
|
||||
addr = addr.substring(7);
|
||||
}
|
||||
// Check for IPv4 loopback range (127.0.0.0/8)
|
||||
return addr.startsWith('127.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up tracking of TCP connections
|
||||
*/
|
||||
private setupConnectionTracking(): void {
|
||||
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
||||
let remoteIP = connection.remoteAddress || '';
|
||||
const connectionId = Math.random().toString(36).substring(2, 15);
|
||||
const isFromSmartProxy = this.options.portProxyIntegration && this.isLoopback(connection.remoteAddress);
|
||||
|
||||
// For SmartProxy connections, wait for CLIENT_IP header
|
||||
if (isFromSmartProxy) {
|
||||
const MAX_PREFACE = 256; // bytes - prevent DoS
|
||||
const HEADER_TIMEOUT_MS = 2000; // timeout for header parsing (increased for slow networks)
|
||||
let headerTimer: NodeJS.Timeout | undefined;
|
||||
let buffered = Buffer.alloc(0);
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
|
||||
// Prevent unbounded growth
|
||||
if (buffered.length > MAX_PREFACE) {
|
||||
connection.removeListener('data', onData);
|
||||
if (headerTimer) clearTimeout(headerTimer);
|
||||
this.logger.warn('Header preface too large, closing connection');
|
||||
connection.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = buffered.indexOf('\r\n');
|
||||
if (idx !== -1) {
|
||||
const headerLine = buffered.slice(0, idx).toString('utf8');
|
||||
if (headerLine.startsWith('CLIENT_IP:')) {
|
||||
remoteIP = headerLine.substring(10).trim();
|
||||
this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`);
|
||||
}
|
||||
|
||||
// Clean up listener and timer
|
||||
connection.removeListener('data', onData);
|
||||
if (headerTimer) clearTimeout(headerTimer);
|
||||
|
||||
// Put remaining data back onto the stream
|
||||
const remaining = buffered.slice(idx + 2);
|
||||
if (remaining.length > 0) {
|
||||
connection.unshift(remaining);
|
||||
}
|
||||
|
||||
// Store the real IP on the connection
|
||||
connection._realRemoteIP = remoteIP;
|
||||
|
||||
// Validate the real IP
|
||||
const ipValidation = this.securityManager.validateIP(remoteIP);
|
||||
if (!ipValidation.allowed) {
|
||||
connectionLogDeduplicator.log(
|
||||
'ip-rejected',
|
||||
'warn',
|
||||
`HttpProxy connection rejected (via SmartProxy)`,
|
||||
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
|
||||
remoteIP
|
||||
);
|
||||
connection.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track connection by real IP
|
||||
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
|
||||
}
|
||||
};
|
||||
|
||||
// Set timeout for header parsing
|
||||
headerTimer = setTimeout(() => {
|
||||
connection.removeListener('data', onData);
|
||||
this.logger.warn('Header parsing timeout, closing connection');
|
||||
connection.destroy();
|
||||
}, HEADER_TIMEOUT_MS);
|
||||
|
||||
// Unref the timer so it doesn't keep the process alive
|
||||
if (headerTimer.unref) headerTimer.unref();
|
||||
|
||||
// Use prependListener to get data first
|
||||
connection.prependListener('data', onData);
|
||||
} else {
|
||||
// Direct connection - validate immediately
|
||||
const ipValidation = this.securityManager.validateIP(remoteIP);
|
||||
if (!ipValidation.allowed) {
|
||||
connectionLogDeduplicator.log(
|
||||
'ip-rejected',
|
||||
'warn',
|
||||
`HttpProxy connection rejected`,
|
||||
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
|
||||
remoteIP
|
||||
);
|
||||
connection.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track connection by IP
|
||||
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
|
||||
}
|
||||
|
||||
// Then check global max connections
|
||||
if (this.socketMap.getArray().length >= this.options.maxConnections) {
|
||||
connectionLogDeduplicator.log(
|
||||
'connection-rejected',
|
||||
'warn',
|
||||
'HttpProxy max connections reached',
|
||||
{
|
||||
reason: 'global-limit',
|
||||
currentConnections: this.socketMap.getArray().length,
|
||||
maxConnections: this.options.maxConnections,
|
||||
component: 'http-proxy'
|
||||
},
|
||||
'http-proxy-global-limit'
|
||||
);
|
||||
connection.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add connection to tracking with metadata
|
||||
connection._connectionId = connectionId;
|
||||
connection._remoteIP = remoteIP;
|
||||
this.socketMap.add(connection);
|
||||
this.connectedClients = this.socketMap.getArray().length;
|
||||
|
||||
// Check for connection from SmartProxy by inspecting the source port
|
||||
const localPort = connection.localPort || 0;
|
||||
const remotePort = connection.remotePort || 0;
|
||||
|
||||
// If this connection is from a SmartProxy
|
||||
if (isFromSmartProxy) {
|
||||
this.portProxyConnections++;
|
||||
this.logger.debug(`New connection from SmartProxy for client ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
|
||||
} else {
|
||||
this.logger.debug(`New direct connection from ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
|
||||
}
|
||||
|
||||
// Setup connection cleanup handlers
|
||||
const cleanupConnection = () => {
|
||||
if (this.socketMap.checkForObject(connection)) {
|
||||
this.socketMap.remove(connection);
|
||||
this.connectedClients = this.socketMap.getArray().length;
|
||||
|
||||
// Remove IP tracking
|
||||
const connId = connection._connectionId;
|
||||
const connIP = connection._realRemoteIP || connection._remoteIP;
|
||||
if (connId && connIP) {
|
||||
this.securityManager.removeConnectionByIP(connIP, connId);
|
||||
}
|
||||
|
||||
// If this was a SmartProxy connection, decrement the counter
|
||||
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
|
||||
this.portProxyConnections--;
|
||||
}
|
||||
|
||||
this.logger.debug(`Connection closed from ${connIP || 'unknown'}. ${this.connectedClients} connections remaining`);
|
||||
}
|
||||
};
|
||||
|
||||
connection.on('close', cleanupConnection);
|
||||
connection.on('error', (err) => {
|
||||
this.logger.debug('Connection error', err);
|
||||
cleanupConnection();
|
||||
});
|
||||
connection.on('end', cleanupConnection);
|
||||
});
|
||||
|
||||
// Track TLS handshake completions
|
||||
this.httpsServer.on('secureConnection', (tlsSocket) => {
|
||||
this.tlsTerminatedConnections++;
|
||||
this.logger.debug('TLS handshake completed, connection secured');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up metrics collection
|
||||
*/
|
||||
private setupMetricsCollection(): void {
|
||||
this.metricsInterval = setInterval(() => {
|
||||
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
|
||||
const metrics = {
|
||||
uptime,
|
||||
activeConnections: this.connectedClients,
|
||||
totalRequests: this.requestsServed,
|
||||
failedRequests: this.failedRequests,
|
||||
portProxyConnections: this.portProxyConnections,
|
||||
tlsTerminatedConnections: this.tlsTerminatedConnections,
|
||||
activeWebSockets: this.webSocketHandler.getConnectionInfo().activeConnections,
|
||||
memoryUsage: process.memoryUsage(),
|
||||
activeContexts: Array.from(this.activeContexts),
|
||||
connectionPool: this.connectionPool.getPoolStatus()
|
||||
};
|
||||
|
||||
this.logger.debug('Proxy metrics', metrics);
|
||||
}, 60000); // Log metrics every minute
|
||||
|
||||
// Don't keep process alive just for metrics
|
||||
if (this.metricsInterval.unref) {
|
||||
this.metricsInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the route configurations - this is the primary method for configuring HttpProxy
|
||||
* @param routes The new route configurations to use
|
||||
*/
|
||||
public async updateRouteConfigs(routes: IRouteConfig[]): Promise<void> {
|
||||
this.logger.info(`Updating route configurations (${routes.length} routes)`);
|
||||
|
||||
// Update routes in RouteManager, modern router, WebSocketHandler, and SecurityManager
|
||||
this.routeManager.updateRoutes(routes);
|
||||
this.router.setRoutes(routes);
|
||||
this.webSocketHandler.setRoutes(routes);
|
||||
this.requestHandler.securityManager.setRoutes(routes);
|
||||
this.routes = routes;
|
||||
|
||||
// Collect all domains and certificates for configuration
|
||||
const currentHostnames = new Set<string>();
|
||||
const certificateUpdates = new Map<string, { cert: string, key: string }>();
|
||||
|
||||
// Process each route to extract domain and certificate information
|
||||
for (const route of routes) {
|
||||
// Skip non-forward routes or routes without domains
|
||||
if (route.action.type !== 'forward' || !route.match.domains) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get domains from route
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
// Process each domain
|
||||
for (const domain of domains) {
|
||||
// Skip wildcard domains for direct host configuration
|
||||
if (domain.includes('*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentHostnames.add(domain);
|
||||
|
||||
// Check if we have a static certificate for this domain
|
||||
if (route.action.tls?.certificate && route.action.tls.certificate !== 'auto') {
|
||||
certificateUpdates.set(domain, {
|
||||
cert: route.action.tls.certificate.cert,
|
||||
key: route.action.tls.certificate.key
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update certificate cache with any static certificates
|
||||
for (const [domain, certData] of certificateUpdates.entries()) {
|
||||
try {
|
||||
this.defaultCertProvider.updateCertificate(
|
||||
domain,
|
||||
certData.cert,
|
||||
certData.key
|
||||
);
|
||||
|
||||
this.activeContexts.add(domain);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to add SSL context for ${domain}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up removed contexts
|
||||
for (const hostname of this.activeContexts) {
|
||||
if (!currentHostnames.has(hostname)) {
|
||||
this.logger.info(`Hostname ${hostname} removed from configuration`);
|
||||
this.activeContexts.delete(hostname);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the router with new routes
|
||||
this.router.setRoutes(routes);
|
||||
|
||||
// Update WebSocket handler with new routes
|
||||
this.webSocketHandler.setRoutes(routes);
|
||||
|
||||
this.logger.info(`Route configuration updated with ${routes.length} routes`);
|
||||
}
|
||||
|
||||
// Legacy methods have been removed.
|
||||
// Please use updateRouteConfigs() directly with modern route-based configuration.
|
||||
|
||||
/**
|
||||
* Adds default headers to be included in all responses
|
||||
*/
|
||||
public async addDefaultHeaders(headersArg: { [key: string]: string }): Promise<void> {
|
||||
this.logger.info('Adding default headers', headersArg);
|
||||
this.requestHandler.setDefaultHeaders(headersArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the proxy server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.logger.info('Stopping HttpProxy server');
|
||||
|
||||
// Clear intervals
|
||||
if (this.metricsInterval) {
|
||||
clearInterval(this.metricsInterval);
|
||||
}
|
||||
|
||||
if (this.connectionPoolCleanupInterval) {
|
||||
clearInterval(this.connectionPoolCleanupInterval);
|
||||
}
|
||||
|
||||
// Stop WebSocket handler
|
||||
this.webSocketHandler.shutdown();
|
||||
|
||||
// Destroy request handler (cleans up intervals and caches)
|
||||
if (this.requestHandler && typeof this.requestHandler.destroy === 'function') {
|
||||
this.requestHandler.destroy();
|
||||
}
|
||||
|
||||
// Close all tracked sockets
|
||||
const socketCleanupPromises = this.socketMap.getArray().map(socket =>
|
||||
cleanupSocket(socket, 'http-proxy-stop', { immediate: true })
|
||||
);
|
||||
await Promise.all(socketCleanupPromises);
|
||||
|
||||
// Close all connection pool connections
|
||||
this.connectionPool.closeAllConnections();
|
||||
|
||||
// Certificate management cleanup is handled by SmartCertManager
|
||||
|
||||
// Flush any pending deduplicated logs
|
||||
connectionLogDeduplicator.flushAll();
|
||||
|
||||
// Close the HTTPS server
|
||||
return new Promise((resolve) => {
|
||||
this.httpsServer.close(() => {
|
||||
this.logger.info('HttpProxy server stopped successfully');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a new certificate for a domain
|
||||
* This can be used to manually trigger certificate issuance
|
||||
* @param domain The domain to request a certificate for
|
||||
* @returns A promise that resolves when the request is submitted (not when the certificate is issued)
|
||||
*/
|
||||
public async requestCertificate(domain: string): Promise<boolean> {
|
||||
this.logger.warn('requestCertificate is deprecated - use SmartCertManager instead');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update certificate for a domain
|
||||
*
|
||||
* This method allows direct updates of certificates from external sources
|
||||
* like Port80Handler or custom certificate providers.
|
||||
*
|
||||
* @param domain The domain to update certificate for
|
||||
* @param certificate The new certificate (public key)
|
||||
* @param privateKey The new private key
|
||||
* @param expiryDate Optional expiry date
|
||||
*/
|
||||
public updateCertificate(
|
||||
domain: string,
|
||||
certificate: string,
|
||||
privateKey: string,
|
||||
expiryDate?: Date
|
||||
): void {
|
||||
this.logger.info(`Updating certificate for ${domain}`);
|
||||
this.defaultCertProvider.updateCertificate(domain, certificate, privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all route configurations currently in use
|
||||
*/
|
||||
public getRouteConfigs(): IRouteConfig[] {
|
||||
return this.routeManager.getRoutes();
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import type { IHttpRouteContext, IRouteContext } from '../../core/models/route-context.js';
|
||||
import type { ILogger } from './models/types.js';
|
||||
import type { IMetricsTracker } from './request-handler.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
||||
|
||||
/**
|
||||
* HTTP Request Handler Helper - handles requests with specific destinations
|
||||
* This is a helper class for the main RequestHandler
|
||||
*/
|
||||
export class HttpRequestHandler {
|
||||
/**
|
||||
* Handle HTTP request with a specific destination
|
||||
*/
|
||||
public static async handleHttpRequestWithDestination(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
destination: { host: string, port: number },
|
||||
routeContext: IHttpRouteContext,
|
||||
startTime: number,
|
||||
logger: ILogger,
|
||||
metricsTracker?: IMetricsTracker | null,
|
||||
route?: IRouteConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Apply URL rewriting if route config is provided
|
||||
if (route) {
|
||||
HttpRequestHandler.applyUrlRewriting(req, route, routeContext, logger);
|
||||
HttpRequestHandler.applyRouteHeaderModifications(route, req, res, logger);
|
||||
}
|
||||
|
||||
// Create options for the proxy request
|
||||
const options: plugins.http.RequestOptions = {
|
||||
hostname: destination.host,
|
||||
port: destination.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers }
|
||||
};
|
||||
|
||||
// Optionally rewrite host header to match target
|
||||
if (options.headers && 'host' in options.headers) {
|
||||
// Only apply if host header rewrite is enabled or not explicitly disabled
|
||||
const shouldRewriteHost = route?.action.options?.rewriteHostHeader !== false;
|
||||
if (shouldRewriteHost) {
|
||||
// Safely cast to OutgoingHttpHeaders to access host property
|
||||
(options.headers as plugins.http.OutgoingHttpHeaders).host = `${destination.host}:${destination.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Proxying request to ${destination.host}:${destination.port}${req.url}`,
|
||||
{ method: req.method }
|
||||
);
|
||||
|
||||
// Create proxy request
|
||||
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
||||
// Copy status code
|
||||
res.statusCode = proxyRes.statusCode || 500;
|
||||
|
||||
// Copy headers from proxy response to client response
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value !== undefined) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply response header modifications if route config is provided
|
||||
if (route && route.headers?.response) {
|
||||
HttpRequestHandler.applyResponseHeaderModifications(route, res, logger, routeContext);
|
||||
}
|
||||
|
||||
// Pipe proxy response to client response
|
||||
proxyRes.pipe(res);
|
||||
|
||||
// Increment served requests counter when the response finishes
|
||||
res.on('finish', () => {
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementRequestsServed();
|
||||
}
|
||||
|
||||
// Log the completed request
|
||||
const duration = Date.now() - startTime;
|
||||
logger.debug(
|
||||
`Request completed in ${duration}ms: ${req.method} ${req.url} ${res.statusCode}`,
|
||||
{ duration, statusCode: res.statusCode }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle proxy request errors
|
||||
proxyReq.on('error', (error) => {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(
|
||||
`Proxy error for ${req.method} ${req.url}: ${error.message}`,
|
||||
{ duration, error: error.message }
|
||||
);
|
||||
|
||||
// Increment failed requests counter
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
|
||||
// Check if headers have already been sent
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 502;
|
||||
res.end(`Bad Gateway: ${error.message}`);
|
||||
} else {
|
||||
// If headers already sent, just close the connection
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe request body to proxy request and handle client-side errors
|
||||
req.pipe(proxyReq);
|
||||
|
||||
// Handle client disconnection
|
||||
req.on('error', (error) => {
|
||||
logger.debug(`Client connection error: ${error.message}`);
|
||||
proxyReq.destroy();
|
||||
|
||||
// Increment failed requests counter on client errors
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle response errors
|
||||
res.on('error', (error) => {
|
||||
logger.debug(`Response error: ${error.message}`);
|
||||
proxyReq.destroy();
|
||||
|
||||
// Increment failed requests counter on response errors
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle any unexpected errors
|
||||
logger.error(
|
||||
`Unexpected error handling request: ${error.message}`,
|
||||
{ error: error.stack }
|
||||
);
|
||||
|
||||
// Increment failed requests counter
|
||||
if (metricsTracker) {
|
||||
metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end('Internal Server Error');
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply URL rewriting based on route configuration
|
||||
* Implements Phase 5.2: URL rewriting using route context
|
||||
*
|
||||
* @param req The request with the URL to rewrite
|
||||
* @param route The route configuration containing rewrite rules
|
||||
* @param routeContext Context for template variable resolution
|
||||
* @param logger Logger for debugging information
|
||||
* @returns True if URL was rewritten, false otherwise
|
||||
*/
|
||||
private static applyUrlRewriting(
|
||||
req: plugins.http.IncomingMessage,
|
||||
route: IRouteConfig,
|
||||
routeContext: IHttpRouteContext,
|
||||
logger: ILogger
|
||||
): boolean {
|
||||
// Check if route has URL rewriting configuration
|
||||
if (!route.action.advanced?.urlRewrite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rewriteConfig = route.action.advanced.urlRewrite;
|
||||
|
||||
// Store original URL for logging
|
||||
const originalUrl = req.url;
|
||||
|
||||
if (rewriteConfig.pattern && rewriteConfig.target) {
|
||||
try {
|
||||
// Create a RegExp from the pattern with optional flags
|
||||
const regex = new RegExp(rewriteConfig.pattern, rewriteConfig.flags || '');
|
||||
|
||||
// Apply rewriting with template variable resolution
|
||||
let target = rewriteConfig.target;
|
||||
|
||||
// Replace template variables in target with values from context
|
||||
target = TemplateUtils.resolveTemplateVariables(target, routeContext);
|
||||
|
||||
// If onlyRewritePath is set, split URL into path and query parts
|
||||
if (rewriteConfig.onlyRewritePath && req.url) {
|
||||
const [path, query] = req.url.split('?');
|
||||
const rewrittenPath = path.replace(regex, target);
|
||||
req.url = query ? `${rewrittenPath}?${query}` : rewrittenPath;
|
||||
} else {
|
||||
// Perform the replacement on the entire URL
|
||||
req.url = req.url?.replace(regex, target);
|
||||
}
|
||||
|
||||
logger.debug(`URL rewritten: ${originalUrl} -> ${req.url}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Error in URL rewriting: ${err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply header modifications from route configuration to request headers
|
||||
* Implements Phase 5.1: Route-based header manipulation for requests
|
||||
*/
|
||||
private static applyRouteHeaderModifications(
|
||||
route: IRouteConfig,
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
logger: ILogger
|
||||
): void {
|
||||
// Check if route has header modifications
|
||||
if (!route.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply request header modifications (these will be sent to the backend)
|
||||
if (route.headers.request && req.headers) {
|
||||
// Create routing context for template resolution
|
||||
const routeContext: IRouteContext = {
|
||||
domain: req.headers.host as string || '',
|
||||
path: req.url || '',
|
||||
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '',
|
||||
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '',
|
||||
port: parseInt(req.socket.localPort?.toString() || '0', 10),
|
||||
isTls: !!req.socket.encrypted,
|
||||
headers: req.headers as Record<string, string>,
|
||||
timestamp: Date.now(),
|
||||
connectionId: `${Date.now()}-${Math.floor(Math.random() * 10000)}`,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(route.headers.request)) {
|
||||
// Skip if header already exists and we're not overriding
|
||||
if (req.headers[key.toLowerCase()] && !value.startsWith('!')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special delete directive (!delete)
|
||||
if (value === '!delete') {
|
||||
delete req.headers[key.toLowerCase()];
|
||||
logger.debug(`Deleted request header: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle forced override (!value)
|
||||
let finalValue: string;
|
||||
if (value.startsWith('!')) {
|
||||
// Keep the ! but resolve any templates in the rest
|
||||
const templateValue = value.substring(1);
|
||||
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, routeContext);
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = TemplateUtils.resolveTemplateVariables(value, routeContext);
|
||||
}
|
||||
|
||||
// Set the header
|
||||
req.headers[key.toLowerCase()] = finalValue;
|
||||
logger.debug(`Modified request header: ${key}=${finalValue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply header modifications from route configuration to response headers
|
||||
* Implements Phase 5.1: Route-based header manipulation for responses
|
||||
*/
|
||||
private static applyResponseHeaderModifications(
|
||||
route: IRouteConfig,
|
||||
res: plugins.http.ServerResponse,
|
||||
logger: ILogger,
|
||||
routeContext?: IRouteContext
|
||||
): void {
|
||||
// Check if route has response header modifications
|
||||
if (!route.headers?.response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply response header modifications
|
||||
for (const [key, value] of Object.entries(route.headers.response)) {
|
||||
// Skip if header already exists and we're not overriding
|
||||
if (res.hasHeader(key) && !value.startsWith('!')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special delete directive (!delete)
|
||||
if (value === '!delete') {
|
||||
res.removeHeader(key);
|
||||
logger.debug(`Deleted response header: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle forced override (!value)
|
||||
let finalValue: string;
|
||||
if (value.startsWith('!') && value !== '!delete') {
|
||||
// Keep the ! but resolve any templates in the rest
|
||||
const templateValue = value.substring(1);
|
||||
finalValue = routeContext
|
||||
? '!' + TemplateUtils.resolveTemplateVariables(templateValue, routeContext)
|
||||
: '!' + templateValue;
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = routeContext
|
||||
? TemplateUtils.resolveTemplateVariables(value, routeContext)
|
||||
: value;
|
||||
}
|
||||
|
||||
// Set the header
|
||||
res.setHeader(key, finalValue);
|
||||
logger.debug(`Modified response header: ${key}=${finalValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Template resolution is now handled by the TemplateUtils class
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IHttpRouteContext } from '../../core/models/route-context.js';
|
||||
import type { ILogger } from './models/types.js';
|
||||
import type { IMetricsTracker } from './request-handler.js';
|
||||
|
||||
/**
|
||||
* HTTP/2 Request Handler Helper - handles HTTP/2 streams with specific destinations
|
||||
* This is a helper class for the main RequestHandler
|
||||
*/
|
||||
export class Http2RequestHandler {
|
||||
/**
|
||||
* Handle HTTP/2 stream with direct HTTP/2 backend
|
||||
*/
|
||||
public static async handleHttp2WithHttp2Destination(
|
||||
stream: plugins.http2.ServerHttp2Stream,
|
||||
headers: plugins.http2.IncomingHttpHeaders,
|
||||
destination: { host: string, port: number },
|
||||
routeContext: IHttpRouteContext,
|
||||
sessions: Map<string, plugins.http2.ClientHttp2Session>,
|
||||
logger: ILogger,
|
||||
metricsTracker?: IMetricsTracker | null
|
||||
): Promise<void> {
|
||||
const key = `${destination.host}:${destination.port}`;
|
||||
|
||||
// Get or create a client HTTP/2 session
|
||||
let session = sessions.get(key);
|
||||
if (!session || session.closed || (session as any).destroyed) {
|
||||
try {
|
||||
// Connect to the backend HTTP/2 server
|
||||
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
|
||||
sessions.set(key, session);
|
||||
|
||||
// Handle session errors and cleanup
|
||||
session.on('error', (err) => {
|
||||
logger.error(`HTTP/2 session error to ${key}: ${err.message}`);
|
||||
sessions.delete(key);
|
||||
});
|
||||
|
||||
session.on('close', () => {
|
||||
logger.debug(`HTTP/2 session closed to ${key}`);
|
||||
sessions.delete(key);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to establish HTTP/2 session to ${key}: ${err.message}`);
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end('Bad Gateway: Failed to establish connection to backend');
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Build headers for backend HTTP/2 request
|
||||
const h2Headers: Record<string, any> = {
|
||||
':method': headers[':method'],
|
||||
':path': headers[':path'],
|
||||
':authority': `${destination.host}:${destination.port}`
|
||||
};
|
||||
|
||||
// Copy other headers, excluding pseudo-headers
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!key.startsWith(':') && typeof value === 'string') {
|
||||
h2Headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Proxying HTTP/2 request to ${destination.host}:${destination.port}${headers[':path']}`,
|
||||
{ method: headers[':method'] }
|
||||
);
|
||||
|
||||
// Create HTTP/2 request stream to the backend
|
||||
const h2Stream = session.request(h2Headers);
|
||||
|
||||
// Pipe client stream to backend stream
|
||||
stream.pipe(h2Stream);
|
||||
|
||||
// Handle responses from the backend
|
||||
h2Stream.on('response', (responseHeaders) => {
|
||||
// Map status and headers to client response
|
||||
const resp: Record<string, any> = {
|
||||
':status': responseHeaders[':status'] as number
|
||||
};
|
||||
|
||||
// Copy non-pseudo headers
|
||||
for (const [key, value] of Object.entries(responseHeaders)) {
|
||||
if (!key.startsWith(':') && value !== undefined) {
|
||||
resp[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Send headers to client
|
||||
stream.respond(resp);
|
||||
|
||||
// Pipe backend response to client
|
||||
h2Stream.pipe(stream);
|
||||
|
||||
// Track successful requests
|
||||
stream.on('end', () => {
|
||||
if (metricsTracker) metricsTracker.incrementRequestsServed();
|
||||
logger.debug(
|
||||
`HTTP/2 request completed: ${headers[':method']} ${headers[':path']} ${responseHeaders[':status']}`,
|
||||
{ method: headers[':method'], status: responseHeaders[':status'] }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle backend errors
|
||||
h2Stream.on('error', (err) => {
|
||||
logger.error(`HTTP/2 stream error: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end(`Bad Gateway: ${err.message}`);
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
// Handle client stream errors
|
||||
stream.on('error', (err) => {
|
||||
logger.debug(`Client HTTP/2 stream error: ${err.message}`);
|
||||
h2Stream.destroy();
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Error handling HTTP/2 request: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 500 });
|
||||
stream.end('Internal Server Error');
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP/2 stream with HTTP/1 backend
|
||||
*/
|
||||
public static async handleHttp2WithHttp1Destination(
|
||||
stream: plugins.http2.ServerHttp2Stream,
|
||||
headers: plugins.http2.IncomingHttpHeaders,
|
||||
destination: { host: string, port: number },
|
||||
routeContext: IHttpRouteContext,
|
||||
logger: ILogger,
|
||||
metricsTracker?: IMetricsTracker | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Build headers for HTTP/1 proxy request, excluding HTTP/2 pseudo-headers
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Always rewrite host header to match target
|
||||
outboundHeaders.host = `${destination.host}:${destination.port}`;
|
||||
|
||||
logger.debug(
|
||||
`Proxying HTTP/2 request to HTTP/1 backend ${destination.host}:${destination.port}${headers[':path']}`,
|
||||
{ method: headers[':method'] }
|
||||
);
|
||||
|
||||
// Create HTTP/1 proxy request
|
||||
const proxyReq = plugins.http.request(
|
||||
{
|
||||
hostname: destination.host,
|
||||
port: destination.port,
|
||||
path: headers[':path'] as string,
|
||||
method: headers[':method'] as string,
|
||||
headers: outboundHeaders
|
||||
},
|
||||
(proxyRes) => {
|
||||
// Map status and headers back to HTTP/2
|
||||
const responseHeaders: Record<string, number | string | string[]> = {
|
||||
':status': proxyRes.statusCode || 500
|
||||
};
|
||||
|
||||
// Copy headers from HTTP/1 response to HTTP/2 response
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value !== undefined) {
|
||||
responseHeaders[key] = value as string | string[];
|
||||
}
|
||||
}
|
||||
|
||||
// Send headers to client
|
||||
stream.respond(responseHeaders);
|
||||
|
||||
// Pipe HTTP/1 response to HTTP/2 stream
|
||||
proxyRes.pipe(stream);
|
||||
|
||||
// Clean up when client disconnects
|
||||
stream.on('close', () => proxyReq.destroy());
|
||||
stream.on('error', () => proxyReq.destroy());
|
||||
|
||||
// Track successful requests
|
||||
stream.on('end', () => {
|
||||
if (metricsTracker) metricsTracker.incrementRequestsServed();
|
||||
logger.debug(
|
||||
`HTTP/2 to HTTP/1 request completed: ${headers[':method']} ${headers[':path']} ${proxyRes.statusCode}`,
|
||||
{ method: headers[':method'], status: proxyRes.statusCode }
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Handle proxy request errors
|
||||
proxyReq.on('error', (err) => {
|
||||
logger.error(`HTTP/1 proxy error: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end(`Bad Gateway: ${err.message}`);
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
// Pipe client stream to proxy request
|
||||
stream.pipe(proxyReq);
|
||||
|
||||
// Handle client stream errors
|
||||
stream.on('error', (err) => {
|
||||
logger.debug(`Client HTTP/2 stream error: ${err.message}`);
|
||||
proxyReq.destroy();
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Error handling HTTP/2 to HTTP/1 request: ${err.message}`);
|
||||
|
||||
// Only send error response if headers haven't been sent
|
||||
if (!stream.headersSent) {
|
||||
stream.respond({ ':status': 500 });
|
||||
stream.end('Internal Server Error');
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
if (metricsTracker) metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* HttpProxy implementation
|
||||
*/
|
||||
// Re-export models
|
||||
export * from './models/index.js';
|
||||
|
||||
// Export HttpProxy and supporting classes
|
||||
export { HttpProxy } from './http-proxy.js';
|
||||
export { DefaultCertificateProvider } from './default-certificates.js';
|
||||
export { ConnectionPool } from './connection-pool.js';
|
||||
export { RequestHandler } from './request-handler.js';
|
||||
export type { IMetricsTracker, MetricsTracker } from './request-handler.js';
|
||||
export { WebSocketHandler } from './websocket-handler.js';
|
||||
|
||||
/**
|
||||
* @deprecated Use DefaultCertificateProvider instead. This alias is for backward compatibility.
|
||||
*/
|
||||
export { DefaultCertificateProvider as CertificateManager } from './default-certificates.js';
|
||||
@@ -1,148 +0,0 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
// Import from protocols for consistent status codes
|
||||
import { HttpStatus as ProtocolHttpStatus, getStatusText as getProtocolStatusText } from '../../../protocols/http/index.js';
|
||||
|
||||
/**
|
||||
* HTTP-specific event types
|
||||
*/
|
||||
export enum HttpEvents {
|
||||
REQUEST_RECEIVED = 'request-received',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
REQUEST_HANDLED = 'request-handled',
|
||||
REQUEST_ERROR = 'request-error',
|
||||
}
|
||||
|
||||
|
||||
// Re-export for backward compatibility with subset of commonly used codes
|
||||
export const HttpStatus = {
|
||||
OK: ProtocolHttpStatus.OK,
|
||||
MOVED_PERMANENTLY: ProtocolHttpStatus.MOVED_PERMANENTLY,
|
||||
FOUND: ProtocolHttpStatus.FOUND,
|
||||
TEMPORARY_REDIRECT: ProtocolHttpStatus.TEMPORARY_REDIRECT,
|
||||
PERMANENT_REDIRECT: ProtocolHttpStatus.PERMANENT_REDIRECT,
|
||||
BAD_REQUEST: ProtocolHttpStatus.BAD_REQUEST,
|
||||
UNAUTHORIZED: ProtocolHttpStatus.UNAUTHORIZED,
|
||||
FORBIDDEN: ProtocolHttpStatus.FORBIDDEN,
|
||||
NOT_FOUND: ProtocolHttpStatus.NOT_FOUND,
|
||||
METHOD_NOT_ALLOWED: ProtocolHttpStatus.METHOD_NOT_ALLOWED,
|
||||
REQUEST_TIMEOUT: ProtocolHttpStatus.REQUEST_TIMEOUT,
|
||||
TOO_MANY_REQUESTS: ProtocolHttpStatus.TOO_MANY_REQUESTS,
|
||||
INTERNAL_SERVER_ERROR: ProtocolHttpStatus.INTERNAL_SERVER_ERROR,
|
||||
NOT_IMPLEMENTED: ProtocolHttpStatus.NOT_IMPLEMENTED,
|
||||
BAD_GATEWAY: ProtocolHttpStatus.BAD_GATEWAY,
|
||||
SERVICE_UNAVAILABLE: ProtocolHttpStatus.SERVICE_UNAVAILABLE,
|
||||
GATEWAY_TIMEOUT: ProtocolHttpStatus.GATEWAY_TIMEOUT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Base error class for HTTP-related errors
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(message: string, public readonly statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to certificate operations
|
||||
*/
|
||||
export class CertificateError extends HttpError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly domain: string,
|
||||
public readonly isRenewal: boolean = false
|
||||
) {
|
||||
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
this.name = 'CertificateError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to server operations
|
||||
*/
|
||||
export class ServerError extends HttpError {
|
||||
constructor(message: string, public readonly code?: string, statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
super(message, statusCode);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for bad requests
|
||||
*/
|
||||
export class BadRequestError extends HttpError {
|
||||
constructor(message: string) {
|
||||
super(message, HttpStatus.BAD_REQUEST);
|
||||
this.name = 'BadRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for not found resources
|
||||
*/
|
||||
export class NotFoundError extends HttpError {
|
||||
constructor(message: string = 'Resource not found') {
|
||||
super(message, HttpStatus.NOT_FOUND);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect configuration for HTTP requests
|
||||
*/
|
||||
export interface IRedirectConfig {
|
||||
source: string; // Source path or pattern
|
||||
destination: string; // Destination URL
|
||||
type: number; // Redirect status code
|
||||
preserveQuery?: boolean; // Whether to preserve query parameters
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP router configuration
|
||||
*/
|
||||
export interface IRouterConfig {
|
||||
routes: Array<{
|
||||
path: string;
|
||||
method?: string;
|
||||
handler: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void | Promise<void>;
|
||||
}>;
|
||||
notFoundHandler?: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
errorHandler?: (error: Error, req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request method types
|
||||
*/
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to get HTTP status text
|
||||
*/
|
||||
export function getStatusText(status: number): string {
|
||||
return getProtocolStatusText(status as ProtocolHttpStatus);
|
||||
}
|
||||
|
||||
// Legacy interfaces for backward compatibility
|
||||
export interface IDomainOptions {
|
||||
domainName: string;
|
||||
sslRedirect: boolean;
|
||||
acmeMaintenance: boolean;
|
||||
forward?: { ip: string; port: number };
|
||||
acmeForward?: { ip: string; port: number };
|
||||
}
|
||||
|
||||
export interface IDomainCertificate {
|
||||
options: IDomainOptions;
|
||||
certObtained: boolean;
|
||||
obtainingInProgress: boolean;
|
||||
certificate?: string;
|
||||
privateKey?: string;
|
||||
expiryDate?: Date;
|
||||
lastRenewalAttempt?: Date;
|
||||
}
|
||||
|
||||
// Backward compatibility exports
|
||||
export { HttpError as Port80HandlerError };
|
||||
export { CertificateError as CertError };
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* HttpProxy models
|
||||
*/
|
||||
export * from './types.js';
|
||||
export * from './http-types.js';
|
||||
@@ -1,125 +0,0 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
// Certificate types removed - define IAcmeOptions locally
|
||||
export interface IAcmeOptions {
|
||||
enabled: boolean;
|
||||
email?: string;
|
||||
accountEmail?: string;
|
||||
port?: number;
|
||||
certificateStore?: string;
|
||||
environment?: 'production' | 'staging';
|
||||
useProduction?: boolean;
|
||||
renewThresholdDays?: number;
|
||||
autoRenew?: boolean;
|
||||
skipConfiguredCerts?: boolean;
|
||||
}
|
||||
import type { IRouteConfig } from '../../smart-proxy/models/route-types.js';
|
||||
|
||||
/**
|
||||
* Configuration options for HttpProxy
|
||||
*/
|
||||
export interface IHttpProxyOptions {
|
||||
port: number;
|
||||
maxConnections?: number;
|
||||
keepAliveTimeout?: number;
|
||||
headersTimeout?: number;
|
||||
logLevel?: 'error' | 'warn' | 'info' | 'debug';
|
||||
cors?: {
|
||||
allowOrigin?: string;
|
||||
allowMethods?: string;
|
||||
allowHeaders?: string;
|
||||
maxAge?: number;
|
||||
};
|
||||
|
||||
// Settings for SmartProxy integration
|
||||
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
|
||||
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by SmartProxy
|
||||
// Protocol to use when proxying to backends: HTTP/1.x or HTTP/2
|
||||
backendProtocol?: 'http1' | 'http2';
|
||||
|
||||
// Function cache options
|
||||
functionCacheSize?: number; // Maximum number of cached function results (default: 1000)
|
||||
functionCacheTtl?: number; // Time to live for cached function results in ms (default: 5000)
|
||||
|
||||
// ACME certificate management options
|
||||
acme?: IAcmeOptions;
|
||||
|
||||
// Direct route configurations
|
||||
routes?: IRouteConfig[];
|
||||
|
||||
// Rate limiting and security
|
||||
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
|
||||
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a certificate entry in the cache
|
||||
*/
|
||||
export interface ICertificateEntry {
|
||||
key: string;
|
||||
cert: string;
|
||||
expires?: Date;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Interface for connection tracking in the pool
|
||||
*/
|
||||
export interface IConnectionEntry {
|
||||
socket: plugins.net.Socket;
|
||||
lastUsed: number;
|
||||
isIdle: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket with heartbeat interface
|
||||
*/
|
||||
export interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
||||
lastPong: number;
|
||||
isAlive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for consistent logging across components
|
||||
*/
|
||||
export interface ILogger {
|
||||
debug(message: string, data?: any): void;
|
||||
info(message: string, data?: any): void;
|
||||
warn(message: string, data?: any): void;
|
||||
error(message: string, data?: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a logger based on the specified log level
|
||||
*/
|
||||
export function createLogger(logLevel: string = 'info'): ILogger {
|
||||
const logLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3
|
||||
};
|
||||
|
||||
return {
|
||||
debug: (message: string, data?: any) => {
|
||||
if (logLevels[logLevel] >= logLevels.debug) {
|
||||
console.log(`[DEBUG] ${message}`, data || '');
|
||||
}
|
||||
},
|
||||
info: (message: string, data?: any) => {
|
||||
if (logLevels[logLevel] >= logLevels.info) {
|
||||
console.log(`[INFO] ${message}`, data || '');
|
||||
}
|
||||
},
|
||||
warn: (message: string, data?: any) => {
|
||||
if (logLevels[logLevel] >= logLevels.warn) {
|
||||
console.warn(`[WARN] ${message}`, data || '');
|
||||
}
|
||||
},
|
||||
error: (message: string, data?: any) => {
|
||||
if (logLevels[logLevel] >= logLevels.error) {
|
||||
console.error(`[ERROR] ${message}`, data || '');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,878 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import {
|
||||
type IHttpProxyOptions,
|
||||
type ILogger,
|
||||
createLogger,
|
||||
} from './models/types.js';
|
||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { ContextCreator } from './context-creator.js';
|
||||
import { HttpRequestHandler } from './http-request-handler.js';
|
||||
import { Http2RequestHandler } from './http2-request-handler.js';
|
||||
import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
||||
import { toBaseContext } from '../../core/models/route-context.js';
|
||||
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
||||
import { SecurityManager } from './security-manager.js';
|
||||
|
||||
/**
|
||||
* Interface for tracking metrics
|
||||
*/
|
||||
export interface IMetricsTracker {
|
||||
incrementRequestsServed(): void;
|
||||
incrementFailedRequests(): void;
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
export type MetricsTracker = IMetricsTracker;
|
||||
|
||||
/**
|
||||
* Handles HTTP request processing and proxying
|
||||
*/
|
||||
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();
|
||||
|
||||
// Context creator for route contexts
|
||||
private contextCreator: ContextCreator = new ContextCreator();
|
||||
|
||||
// Security manager for IP filtering, rate limiting, etc.
|
||||
public securityManager: SecurityManager;
|
||||
|
||||
// Rate limit cleanup interval
|
||||
private rateLimitCleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private options: IHttpProxyOptions,
|
||||
private connectionPool: ConnectionPool,
|
||||
private routeManager?: RouteManager,
|
||||
private functionCache?: any, // FunctionCache - using any to avoid circular dependency
|
||||
private router?: any // HttpRouter - using any to avoid circular dependency
|
||||
) {
|
||||
this.logger = createLogger(options.logLevel || 'info');
|
||||
this.securityManager = new SecurityManager(this.logger);
|
||||
|
||||
// Schedule rate limit cleanup every minute
|
||||
this.rateLimitCleanupInterval = setInterval(() => {
|
||||
this.securityManager.cleanupExpiredRateLimits();
|
||||
}, 60000);
|
||||
|
||||
// Make sure the interval doesn't keep the process alive
|
||||
if (this.rateLimitCleanupInterval.unref) {
|
||||
this.rateLimitCleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the route manager instance
|
||||
*/
|
||||
public setRouteManager(routeManager: RouteManager): void {
|
||||
this.routeManager = routeManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the metrics tracker instance
|
||||
*/
|
||||
public setMetricsTracker(tracker: IMetricsTracker): void {
|
||||
this.metricsTracker = tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default headers to be included in all responses
|
||||
*/
|
||||
public setDefaultHeaders(headers: { [key: string]: string }): void {
|
||||
this.defaultHeaders = {
|
||||
...this.defaultHeaders,
|
||||
...headers
|
||||
};
|
||||
this.logger.info('Updated default response headers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all default headers
|
||||
*/
|
||||
public getDefaultHeaders(): { [key: string]: string } {
|
||||
return { ...this.defaultHeaders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the appropriate target from the targets array based on sub-matching criteria
|
||||
*/
|
||||
private selectTarget(
|
||||
targets: IRouteTarget[],
|
||||
context: {
|
||||
port: number;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
}
|
||||
): IRouteTarget | null {
|
||||
// Sort targets by priority (higher first)
|
||||
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
|
||||
// Find the first matching target
|
||||
for (const target of sortedTargets) {
|
||||
if (!target.match) {
|
||||
// No match criteria means this is a default/fallback target
|
||||
return target;
|
||||
}
|
||||
|
||||
// Check port match
|
||||
if (target.match.ports && !target.match.ports.includes(context.port)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check path match (supports wildcards)
|
||||
if (target.match.path && context.path) {
|
||||
const pathPattern = target.match.path.replace(/\*/g, '.*');
|
||||
const pathRegex = new RegExp(`^${pathPattern}$`);
|
||||
if (!pathRegex.test(context.path)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check method match
|
||||
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check headers match
|
||||
if (target.match.headers && context.headers) {
|
||||
let headersMatch = true;
|
||||
for (const [key, pattern] of Object.entries(target.match.headers)) {
|
||||
const headerValue = context.headers[key.toLowerCase()];
|
||||
if (!headerValue) {
|
||||
headersMatch = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (pattern instanceof RegExp) {
|
||||
if (!pattern.test(headerValue)) {
|
||||
headersMatch = false;
|
||||
break;
|
||||
}
|
||||
} else if (headerValue !== pattern) {
|
||||
headersMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!headersMatch) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// All criteria matched
|
||||
return target;
|
||||
}
|
||||
|
||||
// No matching target found, return the first target without match criteria (default)
|
||||
return sortedTargets.find(t => !t.match) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CORS headers to response if configured
|
||||
* Implements Phase 5.5: Context-aware CORS handling
|
||||
*
|
||||
* @param res The server response to apply headers to
|
||||
* @param req The incoming request
|
||||
* @param route Optional route config with CORS settings
|
||||
*/
|
||||
private applyCorsHeaders(
|
||||
res: plugins.http.ServerResponse,
|
||||
req: plugins.http.IncomingMessage,
|
||||
route?: IRouteConfig
|
||||
): void {
|
||||
// Use route-specific CORS config if available, otherwise use global config
|
||||
let corsConfig: any = null;
|
||||
|
||||
// Route CORS config takes precedence if enabled
|
||||
if (route?.headers?.cors?.enabled) {
|
||||
corsConfig = route.headers.cors;
|
||||
this.logger.debug(`Using route-specific CORS config for ${route.name || 'unnamed route'}`);
|
||||
}
|
||||
// Fall back to global CORS config if available
|
||||
else if (this.options.cors) {
|
||||
corsConfig = this.options.cors;
|
||||
this.logger.debug('Using global CORS config');
|
||||
}
|
||||
|
||||
// If no CORS config available, skip
|
||||
if (!corsConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get origin from request
|
||||
const origin = req.headers.origin;
|
||||
|
||||
// Apply Allow-Origin (with dynamic validation if needed)
|
||||
if (corsConfig.allowOrigin) {
|
||||
// Handle multiple origins in array format
|
||||
if (Array.isArray(corsConfig.allowOrigin)) {
|
||||
if (origin && corsConfig.allowOrigin.includes(origin)) {
|
||||
// Match found, set specific origin
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin'); // Important for caching
|
||||
} else if (corsConfig.allowOrigin.includes('*')) {
|
||||
// Wildcard match
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
}
|
||||
// Handle single origin or wildcard
|
||||
else if (corsConfig.allowOrigin === '*') {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
// Match single origin against request
|
||||
else if (origin && corsConfig.allowOrigin === origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
// Use template variables if present
|
||||
else if (origin && corsConfig.allowOrigin.includes('{')) {
|
||||
const resolvedOrigin = TemplateUtils.resolveTemplateVariables(
|
||||
corsConfig.allowOrigin,
|
||||
{ domain: req.headers.host } as any
|
||||
);
|
||||
if (resolvedOrigin === origin || resolvedOrigin === '*') {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply other CORS headers
|
||||
if (corsConfig.allowMethods) {
|
||||
res.setHeader('Access-Control-Allow-Methods', corsConfig.allowMethods);
|
||||
}
|
||||
|
||||
if (corsConfig.allowHeaders) {
|
||||
res.setHeader('Access-Control-Allow-Headers', corsConfig.allowHeaders);
|
||||
}
|
||||
|
||||
if (corsConfig.allowCredentials) {
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
if (corsConfig.exposeHeaders) {
|
||||
res.setHeader('Access-Control-Expose-Headers', corsConfig.exposeHeaders);
|
||||
}
|
||||
|
||||
if (corsConfig.maxAge) {
|
||||
res.setHeader('Access-Control-Max-Age', corsConfig.maxAge.toString());
|
||||
}
|
||||
|
||||
// Handle CORS preflight requests if enabled (default: true)
|
||||
if (req.method === 'OPTIONS' && corsConfig.preflight !== false) {
|
||||
res.statusCode = 204; // No content
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// First implementation of applyRouteHeaderModifications moved to the second implementation below
|
||||
|
||||
/**
|
||||
* Apply default headers to response
|
||||
*/
|
||||
private applyDefaultHeaders(res: plugins.http.ServerResponse): void {
|
||||
// Apply default headers
|
||||
for (const [key, value] of Object.entries(this.defaultHeaders)) {
|
||||
if (!res.hasHeader(key)) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add server identifier if not already set
|
||||
if (!res.hasHeader('Server')) {
|
||||
res.setHeader('Server', 'NetworkProxy');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply URL rewriting based on route configuration
|
||||
* Implements Phase 5.2: URL rewriting using route context
|
||||
*
|
||||
* @param req The request with the URL to rewrite
|
||||
* @param route The route configuration containing rewrite rules
|
||||
* @param routeContext Context for template variable resolution
|
||||
* @returns True if URL was rewritten, false otherwise
|
||||
*/
|
||||
private applyUrlRewriting(
|
||||
req: plugins.http.IncomingMessage,
|
||||
route: IRouteConfig,
|
||||
routeContext: IHttpRouteContext
|
||||
): boolean {
|
||||
// Check if route has URL rewriting configuration
|
||||
if (!route.action.advanced?.urlRewrite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rewriteConfig = route.action.advanced.urlRewrite;
|
||||
|
||||
// Store original URL for logging
|
||||
const originalUrl = req.url;
|
||||
|
||||
if (rewriteConfig.pattern && rewriteConfig.target) {
|
||||
try {
|
||||
// Create a RegExp from the pattern
|
||||
const regex = new RegExp(rewriteConfig.pattern, rewriteConfig.flags || '');
|
||||
|
||||
// Apply rewriting with template variable resolution
|
||||
let target = rewriteConfig.target;
|
||||
|
||||
// Replace template variables in target with values from context
|
||||
target = TemplateUtils.resolveTemplateVariables(target, routeContext);
|
||||
|
||||
// If onlyRewritePath is set, split URL into path and query parts
|
||||
if (rewriteConfig.onlyRewritePath && req.url) {
|
||||
const [path, query] = req.url.split('?');
|
||||
const rewrittenPath = path.replace(regex, target);
|
||||
req.url = query ? `${rewrittenPath}?${query}` : rewrittenPath;
|
||||
} else {
|
||||
// Perform the replacement on the entire URL
|
||||
req.url = req.url?.replace(regex, target);
|
||||
}
|
||||
|
||||
this.logger.debug(`URL rewritten: ${originalUrl} -> ${req.url}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.error(`Error in URL rewriting: ${err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply header modifications from route configuration
|
||||
* Implements Phase 5.1: Route-based header manipulation
|
||||
*/
|
||||
private applyRouteHeaderModifications(
|
||||
route: IRouteConfig,
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse
|
||||
): void {
|
||||
// Check if route has header modifications
|
||||
if (!route.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply request header modifications (these will be sent to the backend)
|
||||
if (route.headers.request && req.headers) {
|
||||
for (const [key, value] of Object.entries(route.headers.request)) {
|
||||
// Skip if header already exists and we're not overriding
|
||||
if (req.headers[key.toLowerCase()] && !value.startsWith('!')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special delete directive (!delete)
|
||||
if (value === '!delete') {
|
||||
delete req.headers[key.toLowerCase()];
|
||||
this.logger.debug(`Deleted request header: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle forced override (!value)
|
||||
let finalValue: string;
|
||||
if (value.startsWith('!') && value !== '!delete') {
|
||||
// Keep the ! but resolve any templates in the rest
|
||||
const templateValue = value.substring(1);
|
||||
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, {} as IRouteContext);
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = TemplateUtils.resolveTemplateVariables(value, {} as IRouteContext);
|
||||
}
|
||||
|
||||
// Set the header
|
||||
req.headers[key.toLowerCase()] = finalValue;
|
||||
this.logger.debug(`Modified request header: ${key}=${finalValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply response header modifications (these will be stored for later use)
|
||||
if (route.headers.response) {
|
||||
for (const [key, value] of Object.entries(route.headers.response)) {
|
||||
// Skip if header already exists and we're not overriding
|
||||
if (res.hasHeader(key) && !value.startsWith('!')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special delete directive (!delete)
|
||||
if (value === '!delete') {
|
||||
res.removeHeader(key);
|
||||
this.logger.debug(`Deleted response header: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle forced override (!value)
|
||||
let finalValue: string;
|
||||
if (value.startsWith('!') && value !== '!delete') {
|
||||
// Keep the ! but resolve any templates in the rest
|
||||
const templateValue = value.substring(1);
|
||||
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, {} as IRouteContext);
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = TemplateUtils.resolveTemplateVariables(value, {} as IRouteContext);
|
||||
}
|
||||
|
||||
// Set the header
|
||||
res.setHeader(key, finalValue);
|
||||
this.logger.debug(`Modified response header: ${key}=${finalValue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an HTTP request
|
||||
*/
|
||||
public async handleRequest(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse
|
||||
): Promise<void> {
|
||||
// Record start time for logging
|
||||
const startTime = Date.now();
|
||||
|
||||
// Get route before applying CORS (we might need its settings)
|
||||
// Try to find a matching route using RouteManager
|
||||
let matchingRoute: IRouteConfig | null = null;
|
||||
if (this.routeManager) {
|
||||
try {
|
||||
// Create a connection ID for this request
|
||||
const connectionId = `http-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
// Create route context for function-based targets
|
||||
const routeContext = this.contextCreator.createHttpRouteContext(req, {
|
||||
connectionId,
|
||||
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
tlsVersion: req.socket.getTLSVersion?.() || undefined
|
||||
});
|
||||
|
||||
const matchResult = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
||||
matchingRoute = matchResult?.route || null;
|
||||
} catch (err) {
|
||||
this.logger.error('Error finding matching route', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply CORS headers with route-specific settings if available
|
||||
this.applyCorsHeaders(res, req, matchingRoute);
|
||||
|
||||
// If this is an OPTIONS request, the response has already been ended in applyCorsHeaders
|
||||
// so we should return early to avoid trying to set more headers
|
||||
if (req.method === 'OPTIONS') {
|
||||
// Increment metrics for OPTIONS requests too
|
||||
if (this.metricsTracker) {
|
||||
this.metricsTracker.incrementRequestsServed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply default headers
|
||||
this.applyDefaultHeaders(res);
|
||||
|
||||
// We already have the connection ID and routeContext from CORS handling
|
||||
const connectionId = `http-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
// Create route context for function-based targets (if we don't already have one)
|
||||
const routeContext = this.contextCreator.createHttpRouteContext(req, {
|
||||
connectionId,
|
||||
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
tlsVersion: req.socket.getTLSVersion?.() || undefined
|
||||
});
|
||||
|
||||
// Check security restrictions if we have a matching route
|
||||
if (matchingRoute) {
|
||||
// Check IP filtering and rate limiting
|
||||
if (!this.securityManager.isAllowed(matchingRoute, routeContext)) {
|
||||
this.logger.warn(`Access denied for ${routeContext.clientIp} to ${matchingRoute.name || 'unnamed'}`);
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden: Access denied by security policy');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check basic auth
|
||||
if (matchingRoute.security?.basicAuth?.enabled) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||
// No auth header provided - send 401 with WWW-Authenticate header
|
||||
res.statusCode = 401;
|
||||
const realm = matchingRoute.security.basicAuth.realm || 'Protected Area';
|
||||
res.setHeader('WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`);
|
||||
res.end('Authentication Required');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify credentials
|
||||
try {
|
||||
const credentials = Buffer.from(authHeader.substring(6), 'base64').toString('utf-8');
|
||||
const [username, password] = credentials.split(':');
|
||||
|
||||
if (!this.securityManager.checkBasicAuth(matchingRoute, username, password)) {
|
||||
res.statusCode = 401;
|
||||
const realm = matchingRoute.security.basicAuth.realm || 'Protected Area';
|
||||
res.setHeader('WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`);
|
||||
res.end('Invalid Credentials');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error verifying basic auth: ${err}`);
|
||||
res.statusCode = 401;
|
||||
res.end('Authentication Error');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check JWT auth
|
||||
if (matchingRoute.security?.jwtAuth?.enabled) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
// No auth header provided - send 401
|
||||
res.statusCode = 401;
|
||||
res.end('Authentication Required: JWT token missing');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const token = authHeader.substring(7);
|
||||
if (!this.securityManager.verifyJwtToken(matchingRoute, token)) {
|
||||
res.statusCode = 401;
|
||||
res.end('Invalid or Expired JWT');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a matching route with forward action, select appropriate target
|
||||
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
|
||||
this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`);
|
||||
|
||||
// Select the appropriate target from the targets array
|
||||
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
|
||||
port: routeContext.port,
|
||||
path: routeContext.path,
|
||||
headers: routeContext.headers,
|
||||
method: routeContext.method
|
||||
});
|
||||
|
||||
if (!selectedTarget) {
|
||||
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
|
||||
req.socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract target information, resolving functions if needed
|
||||
let targetHost: string | string[];
|
||||
let targetPort: number;
|
||||
|
||||
try {
|
||||
// Check function cache for host and resolve or use cached value
|
||||
if (typeof selectedTarget.host === 'function') {
|
||||
// Generate a function ID for caching (use route name or ID if available)
|
||||
const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
||||
|
||||
// Check if we have a cached result
|
||||
if (this.functionCache) {
|
||||
const cachedHost = this.functionCache.getCachedHost(routeContext, functionId);
|
||||
if (cachedHost !== undefined) {
|
||||
targetHost = cachedHost;
|
||||
this.logger.debug(`Using cached host value for ${functionId}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
||||
targetHost = resolvedHost;
|
||||
|
||||
// Cache the result
|
||||
this.functionCache.cacheHost(routeContext, functionId, resolvedHost);
|
||||
this.logger.debug(`Resolved and cached function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedHost = selectedTarget.host(routeContext);
|
||||
targetHost = resolvedHost;
|
||||
this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
}
|
||||
} else {
|
||||
targetHost = selectedTarget.host;
|
||||
}
|
||||
|
||||
// Check function cache for port and resolve or use cached value
|
||||
if (typeof selectedTarget.port === 'function') {
|
||||
// Generate a function ID for caching
|
||||
const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
||||
|
||||
// Check if we have a cached result
|
||||
if (this.functionCache) {
|
||||
const cachedPort = this.functionCache.getCachedPort(routeContext, functionId);
|
||||
if (cachedPort !== undefined) {
|
||||
targetPort = cachedPort;
|
||||
this.logger.debug(`Using cached port value for ${functionId}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
|
||||
targetPort = resolvedPort;
|
||||
|
||||
// Cache the result
|
||||
this.functionCache.cachePort(routeContext, functionId, resolvedPort);
|
||||
this.logger.debug(`Resolved and cached function-based port to: ${resolvedPort}`);
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedPort = selectedTarget.port(routeContext);
|
||||
targetPort = resolvedPort;
|
||||
this.logger.debug(`Resolved function-based port to: ${resolvedPort}`);
|
||||
}
|
||||
} else {
|
||||
targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
|
||||
}
|
||||
|
||||
// Select a single host if an array was provided
|
||||
const selectedHost = Array.isArray(targetHost)
|
||||
? targetHost[Math.floor(Math.random() * targetHost.length)]
|
||||
: targetHost;
|
||||
|
||||
// Create a destination for the connection pool
|
||||
const destination = {
|
||||
host: selectedHost,
|
||||
port: targetPort
|
||||
};
|
||||
|
||||
// Apply URL rewriting if configured
|
||||
this.applyUrlRewriting(req, matchingRoute, routeContext);
|
||||
|
||||
// Apply header modifications if configured
|
||||
this.applyRouteHeaderModifications(matchingRoute, req, res);
|
||||
|
||||
// Continue with handling using the resolved destination
|
||||
HttpRequestHandler.handleHttpRequestWithDestination(
|
||||
req,
|
||||
res,
|
||||
destination,
|
||||
routeContext,
|
||||
startTime,
|
||||
this.logger,
|
||||
this.metricsTracker,
|
||||
matchingRoute // Pass the route config for additional processing
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.error(`Error evaluating function-based target: ${err}`);
|
||||
res.statusCode = 500;
|
||||
res.end('Internal Server Error: Failed to evaluate target functions');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If no route was found, return 404
|
||||
this.logger.warn(`No route configuration for host: ${req.headers.host}`);
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found: No route configuration for this host');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP/2 stream requests with function-based target support
|
||||
*/
|
||||
public async handleHttp2(stream: plugins.http2.ServerHttp2Stream, headers: plugins.http2.IncomingHttpHeaders): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create a connection ID for this HTTP/2 stream
|
||||
const connectionId = `http2-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
// Get client IP and server IP from the socket
|
||||
const socket = (stream.session as any)?.socket;
|
||||
const clientIp = socket?.remoteAddress?.replace('::ffff:', '') || '0.0.0.0';
|
||||
const serverIp = socket?.localAddress?.replace('::ffff:', '') || '0.0.0.0';
|
||||
|
||||
// Create route context for function-based targets
|
||||
const routeContext = this.contextCreator.createHttp2RouteContext(stream, headers, {
|
||||
connectionId,
|
||||
clientIp,
|
||||
serverIp
|
||||
});
|
||||
|
||||
// Try to find a matching route using RouteManager
|
||||
let matchingRoute: IRouteConfig | null = null;
|
||||
if (this.routeManager) {
|
||||
try {
|
||||
const matchResult = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
||||
matchingRoute = matchResult?.route || null;
|
||||
} catch (err) {
|
||||
this.logger.error('Error finding matching route for HTTP/2 request', err);
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a matching route with forward action, select appropriate target
|
||||
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
|
||||
this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`);
|
||||
|
||||
// Select the appropriate target from the targets array
|
||||
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
|
||||
port: routeContext.port,
|
||||
path: routeContext.path,
|
||||
headers: routeContext.headers,
|
||||
method: routeContext.method
|
||||
});
|
||||
|
||||
if (!selectedTarget) {
|
||||
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
|
||||
stream.respond({ ':status': 502 });
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract target information, resolving functions if needed
|
||||
let targetHost: string | string[];
|
||||
let targetPort: number;
|
||||
|
||||
try {
|
||||
// Check function cache for host and resolve or use cached value
|
||||
if (typeof selectedTarget.host === 'function') {
|
||||
// Generate a function ID for caching (use route name or ID if available)
|
||||
const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
||||
|
||||
// Check if we have a cached result
|
||||
if (this.functionCache) {
|
||||
const cachedHost = this.functionCache.getCachedHost(routeContext, functionId);
|
||||
if (cachedHost !== undefined) {
|
||||
targetHost = cachedHost;
|
||||
this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
||||
targetHost = resolvedHost;
|
||||
|
||||
// Cache the result
|
||||
this.functionCache.cacheHost(routeContext, functionId, resolvedHost);
|
||||
this.logger.debug(`Resolved and cached HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedHost = selectedTarget.host(routeContext);
|
||||
targetHost = resolvedHost;
|
||||
this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
}
|
||||
} else {
|
||||
targetHost = selectedTarget.host;
|
||||
}
|
||||
|
||||
// Check function cache for port and resolve or use cached value
|
||||
if (typeof selectedTarget.port === 'function') {
|
||||
// Generate a function ID for caching
|
||||
const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
||||
|
||||
// Check if we have a cached result
|
||||
if (this.functionCache) {
|
||||
const cachedPort = this.functionCache.getCachedPort(routeContext, functionId);
|
||||
if (cachedPort !== undefined) {
|
||||
targetPort = cachedPort;
|
||||
this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`);
|
||||
} else {
|
||||
// Resolve the function and cache the result
|
||||
const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
|
||||
targetPort = resolvedPort;
|
||||
|
||||
// Cache the result
|
||||
this.functionCache.cachePort(routeContext, functionId, resolvedPort);
|
||||
this.logger.debug(`Resolved and cached HTTP/2 function-based port to: ${resolvedPort}`);
|
||||
}
|
||||
} else {
|
||||
// No cache available, just resolve
|
||||
const resolvedPort = selectedTarget.port(routeContext);
|
||||
targetPort = resolvedPort;
|
||||
this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`);
|
||||
}
|
||||
} else {
|
||||
targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
|
||||
}
|
||||
|
||||
// Select a single host if an array was provided
|
||||
const selectedHost = Array.isArray(targetHost)
|
||||
? targetHost[Math.floor(Math.random() * targetHost.length)]
|
||||
: targetHost;
|
||||
|
||||
// Create a destination for forwarding
|
||||
const destination = {
|
||||
host: selectedHost,
|
||||
port: targetPort
|
||||
};
|
||||
|
||||
// Handle HTTP/2 stream based on backend protocol
|
||||
const backendProtocol = matchingRoute.action.options?.backendProtocol || this.options.backendProtocol;
|
||||
|
||||
if (backendProtocol === 'http2') {
|
||||
// Forward to HTTP/2 backend
|
||||
return Http2RequestHandler.handleHttp2WithHttp2Destination(
|
||||
stream,
|
||||
headers,
|
||||
destination,
|
||||
routeContext,
|
||||
this.h2Sessions,
|
||||
this.logger,
|
||||
this.metricsTracker
|
||||
);
|
||||
} else {
|
||||
// Forward to HTTP/1.1 backend
|
||||
return Http2RequestHandler.handleHttp2WithHttp1Destination(
|
||||
stream,
|
||||
headers,
|
||||
destination,
|
||||
routeContext,
|
||||
this.logger,
|
||||
this.metricsTracker
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error evaluating function-based target for HTTP/2: ${err}`);
|
||||
stream.respond({ ':status': 500 });
|
||||
stream.end('Internal Server Error: Failed to evaluate target functions');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy routing if no matching route found
|
||||
const method = headers[':method'] || 'GET';
|
||||
const path = headers[':path'] || '/';
|
||||
|
||||
// No route was found
|
||||
stream.respond({ ':status': 404 });
|
||||
stream.end('Not Found: No route configuration for this request');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources and stop intervals
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.rateLimitCleanupInterval) {
|
||||
clearInterval(this.rateLimitCleanupInterval);
|
||||
this.rateLimitCleanupInterval = null;
|
||||
}
|
||||
|
||||
// Close all HTTP/2 sessions
|
||||
for (const [key, session] of this.h2Sessions) {
|
||||
session.close();
|
||||
}
|
||||
this.h2Sessions.clear();
|
||||
|
||||
// Clear function cache if it has a destroy method
|
||||
if (this.functionCache && typeof this.functionCache.destroy === 'function') {
|
||||
this.functionCache.destroy();
|
||||
}
|
||||
|
||||
this.logger.debug('RequestHandler destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
import type { ILogger } from './models/types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import {
|
||||
isIPAuthorized,
|
||||
normalizeIP,
|
||||
parseBasicAuthHeader,
|
||||
cleanupExpiredRateLimits,
|
||||
type IRateLimitInfo
|
||||
} from '../../core/utils/security-utils.js';
|
||||
|
||||
/**
|
||||
* Manages security features for the HttpProxy
|
||||
* Implements IP filtering, rate limiting, and authentication.
|
||||
* Uses shared utilities from security-utils.ts.
|
||||
*/
|
||||
export class SecurityManager {
|
||||
// Cache IP filtering results to avoid constant regex matching
|
||||
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
|
||||
|
||||
// Store rate limits per route and key
|
||||
private rateLimits: Map<string, Map<string, IRateLimitInfo>> = new Map();
|
||||
|
||||
// Connection tracking by IP
|
||||
private connectionsByIP: Map<string, Set<string>> = new Map();
|
||||
private connectionRateByIP: Map<string, number[]> = new Map();
|
||||
|
||||
constructor(
|
||||
private logger: ILogger,
|
||||
private routes: IRouteConfig[] = [],
|
||||
private maxConnectionsPerIP: number = 100,
|
||||
private connectionRateLimitPerMinute: number = 300
|
||||
) {
|
||||
// Start periodic cleanup for connection tracking
|
||||
this.startPeriodicIpCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the routes configuration
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = routes;
|
||||
// Reset caches when routes change
|
||||
this.ipFilterCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client is allowed to access a specific route
|
||||
*
|
||||
* @param route The route to check access for
|
||||
* @param context The route context with client information
|
||||
* @returns True if access is allowed, false otherwise
|
||||
*/
|
||||
public isAllowed(route: IRouteConfig, context: IRouteContext): boolean {
|
||||
if (!route.security) {
|
||||
return true; // No security restrictions
|
||||
}
|
||||
|
||||
// --- IP filtering ---
|
||||
if (!this.isIpAllowed(route, context.clientIp)) {
|
||||
this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || 'unnamed'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Rate limiting ---
|
||||
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
|
||||
this.logger.debug(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Basic Auth (handled at HTTP level) ---
|
||||
// Basic auth is not checked here as it requires HTTP headers
|
||||
// and is handled in the RequestHandler
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is allowed based on route security settings
|
||||
*/
|
||||
private isIpAllowed(route: IRouteConfig, clientIp: string): boolean {
|
||||
if (!route.security) {
|
||||
return true; // No security restrictions
|
||||
}
|
||||
|
||||
const routeId = route.name || 'unnamed';
|
||||
|
||||
// Check cache first
|
||||
if (!this.ipFilterCache.has(routeId)) {
|
||||
this.ipFilterCache.set(routeId, new Map());
|
||||
}
|
||||
|
||||
const routeCache = this.ipFilterCache.get(routeId)!;
|
||||
if (routeCache.has(clientIp)) {
|
||||
return routeCache.get(clientIp)!;
|
||||
}
|
||||
|
||||
// Use shared utility for IP authorization
|
||||
const allowed = isIPAuthorized(
|
||||
clientIp,
|
||||
route.security.ipAllowList,
|
||||
route.security.ipBlockList
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
routeCache.set(clientIp, allowed);
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is within rate limit
|
||||
*/
|
||||
private isWithinRateLimit(route: IRouteConfig, context: IRouteContext): boolean {
|
||||
if (!route.security?.rateLimit?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rateLimit = route.security.rateLimit;
|
||||
const routeId = route.name || 'unnamed';
|
||||
|
||||
// Determine rate limit key (by IP, path, or header)
|
||||
let key = context.clientIp; // Default to IP
|
||||
|
||||
if (rateLimit.keyBy === 'path' && context.path) {
|
||||
key = `${context.clientIp}:${context.path}`;
|
||||
} else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) {
|
||||
const headerValue = context.headers[rateLimit.headerName.toLowerCase()];
|
||||
if (headerValue) {
|
||||
key = `${context.clientIp}:${headerValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create rate limit tracking for this route
|
||||
if (!this.rateLimits.has(routeId)) {
|
||||
this.rateLimits.set(routeId, new Map());
|
||||
}
|
||||
|
||||
const routeLimits = this.rateLimits.get(routeId)!;
|
||||
const now = Date.now();
|
||||
|
||||
// Get or create rate limit tracking for this key
|
||||
let limit = routeLimits.get(key);
|
||||
if (!limit || limit.expiry < now) {
|
||||
// Create new rate limit or reset expired one
|
||||
limit = {
|
||||
count: 1,
|
||||
expiry: now + (rateLimit.window * 1000)
|
||||
};
|
||||
routeLimits.set(key, limit);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Increment the counter
|
||||
limit.count++;
|
||||
|
||||
// Check if rate limit is exceeded
|
||||
return limit.count <= rateLimit.maxRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired rate limits
|
||||
* Should be called periodically to prevent memory leaks
|
||||
*/
|
||||
public cleanupExpiredRateLimits(): void {
|
||||
cleanupExpiredRateLimits(this.rateLimits, {
|
||||
info: this.logger.info.bind(this.logger),
|
||||
warn: this.logger.warn.bind(this.logger),
|
||||
error: this.logger.error.bind(this.logger),
|
||||
debug: this.logger.debug?.bind(this.logger)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check basic auth credentials
|
||||
*
|
||||
* @param route The route to check auth for
|
||||
* @param username The provided username
|
||||
* @param password The provided password
|
||||
* @returns True if credentials are valid, false otherwise
|
||||
*/
|
||||
public checkBasicAuth(route: IRouteConfig, username: string, password: string): boolean {
|
||||
if (!route.security?.basicAuth?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const basicAuth = route.security.basicAuth;
|
||||
|
||||
// Check credentials against configured users
|
||||
for (const user of basicAuth.users) {
|
||||
if (user.username === username && user.password === password) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*
|
||||
* @param route The route to verify the token for
|
||||
* @param token The JWT token to verify
|
||||
* @returns True if the token is valid, false otherwise
|
||||
*/
|
||||
public verifyJwtToken(route: IRouteConfig, token: string): boolean {
|
||||
if (!route.security?.jwtAuth?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtAuth = route.security.jwtAuth;
|
||||
|
||||
// Verify structure
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
|
||||
// Check expiration
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check issuer
|
||||
if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check audience
|
||||
if (jwtAuth.audience && payload.aud !== jwtAuth.audience) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: In a real implementation, you'd also verify the signature
|
||||
// using the secret and algorithm specified in jwtAuth
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.error(`Error verifying JWT: ${err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connections count by IP (checks normalized variants)
|
||||
*/
|
||||
public getConnectionCountByIP(ip: string): number {
|
||||
// Check all normalized variants of the IP
|
||||
const variants = normalizeIP(ip);
|
||||
for (const variant of variants) {
|
||||
const connections = this.connectionsByIP.get(variant);
|
||||
if (connections) {
|
||||
return connections.size;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and update connection rate for an IP
|
||||
* @returns true if within rate limit, false if exceeding limit
|
||||
*/
|
||||
public checkConnectionRate(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const minute = 60 * 1000;
|
||||
|
||||
// Find existing rate tracking (check normalized variants)
|
||||
const variants = normalizeIP(ip);
|
||||
let existingKey: string | null = null;
|
||||
for (const variant of variants) {
|
||||
if (this.connectionRateByIP.has(variant)) {
|
||||
existingKey = variant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const key = existingKey || ip;
|
||||
|
||||
if (!this.connectionRateByIP.has(key)) {
|
||||
this.connectionRateByIP.set(key, [now]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get timestamps and filter out entries older than 1 minute
|
||||
const timestamps = this.connectionRateByIP.get(key)!.filter((time) => now - time < minute);
|
||||
timestamps.push(now);
|
||||
this.connectionRateByIP.set(key, timestamps);
|
||||
|
||||
// Check if rate exceeds limit
|
||||
return timestamps.length <= this.connectionRateLimitPerMinute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track connection by IP
|
||||
*/
|
||||
public trackConnectionByIP(ip: string, connectionId: string): void {
|
||||
// Check if any variant already exists
|
||||
const variants = normalizeIP(ip);
|
||||
let existingKey: string | null = null;
|
||||
|
||||
for (const variant of variants) {
|
||||
if (this.connectionsByIP.has(variant)) {
|
||||
existingKey = variant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const key = existingKey || ip;
|
||||
if (!this.connectionsByIP.has(key)) {
|
||||
this.connectionsByIP.set(key, new Set());
|
||||
}
|
||||
this.connectionsByIP.get(key)!.add(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove connection tracking for an IP
|
||||
*/
|
||||
public removeConnectionByIP(ip: string, connectionId: string): void {
|
||||
// Check all variants to find where the connection is tracked
|
||||
const variants = normalizeIP(ip);
|
||||
|
||||
for (const variant of variants) {
|
||||
if (this.connectionsByIP.has(variant)) {
|
||||
const connections = this.connectionsByIP.get(variant)!;
|
||||
connections.delete(connectionId);
|
||||
if (connections.size === 0) {
|
||||
this.connectionsByIP.delete(variant);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP should be allowed considering connection rate and max connections
|
||||
* @returns Object with result and reason
|
||||
*/
|
||||
public validateIP(ip: string): { allowed: boolean; reason?: string } {
|
||||
// Check connection count limit
|
||||
if (this.getConnectionCountByIP(ip) >= this.maxConnectionsPerIP) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Maximum connections per IP (${this.maxConnectionsPerIP}) exceeded`
|
||||
};
|
||||
}
|
||||
|
||||
// Check connection rate limit
|
||||
if (!this.checkConnectionRate(ip)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded`
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all IP tracking data (for shutdown)
|
||||
*/
|
||||
public clearIPTracking(): void {
|
||||
this.connectionsByIP.clear();
|
||||
this.connectionRateByIP.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of IP tracking data
|
||||
*/
|
||||
private startPeriodicIpCleanup(): void {
|
||||
// Clean up IP tracking data every minute
|
||||
setInterval(() => {
|
||||
this.performIpCleanup();
|
||||
}, 60000).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cleanup of expired IP data
|
||||
*/
|
||||
private performIpCleanup(): void {
|
||||
const now = Date.now();
|
||||
const minute = 60 * 1000;
|
||||
let cleanedRateLimits = 0;
|
||||
let cleanedIPs = 0;
|
||||
|
||||
// Clean up expired rate limit timestamps
|
||||
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
|
||||
const validTimestamps = timestamps.filter((time) => now - time < minute);
|
||||
|
||||
if (validTimestamps.length === 0) {
|
||||
this.connectionRateByIP.delete(ip);
|
||||
cleanedRateLimits++;
|
||||
} else if (validTimestamps.length < timestamps.length) {
|
||||
this.connectionRateByIP.set(ip, validTimestamps);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up IPs with no active connections
|
||||
for (const [ip, connections] of this.connectionsByIP.entries()) {
|
||||
if (connections.size === 0) {
|
||||
this.connectionsByIP.delete(ip);
|
||||
cleanedIPs++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
|
||||
this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { HttpRouter } from '../../routing/router/index.js';
|
||||
import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import { toBaseContext } from '../../core/models/route-context.js';
|
||||
import { ContextCreator } from './context-creator.js';
|
||||
import { SecurityManager } from './security-manager.js';
|
||||
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
||||
import { getMessageSize, toBuffer } from '../../core/utils/websocket-utils.js';
|
||||
|
||||
/**
|
||||
* Handles WebSocket connections and proxying
|
||||
*/
|
||||
export class WebSocketHandler {
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private wsServer: plugins.ws.WebSocketServer | null = null;
|
||||
private logger: ILogger;
|
||||
private contextCreator: ContextCreator = new ContextCreator();
|
||||
private router: HttpRouter | null = null;
|
||||
private securityManager: SecurityManager;
|
||||
|
||||
constructor(
|
||||
private options: IHttpProxyOptions,
|
||||
private connectionPool: ConnectionPool,
|
||||
private routes: IRouteConfig[] = []
|
||||
) {
|
||||
this.logger = createLogger(options.logLevel || 'info');
|
||||
this.securityManager = new SecurityManager(this.logger, routes);
|
||||
|
||||
// Initialize router if we have routes
|
||||
if (routes.length > 0) {
|
||||
this.router = new HttpRouter(routes, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the route configurations
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = routes;
|
||||
|
||||
// Initialize or update the route router
|
||||
if (!this.router) {
|
||||
this.router = new HttpRouter(routes, this.logger);
|
||||
} else {
|
||||
this.router.setRoutes(routes);
|
||||
}
|
||||
|
||||
// Update the security manager
|
||||
this.securityManager.setRoutes(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the appropriate target from the targets array based on sub-matching criteria
|
||||
*/
|
||||
private selectTarget(
|
||||
targets: IRouteTarget[],
|
||||
context: {
|
||||
port: number;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
}
|
||||
): IRouteTarget | null {
|
||||
// Sort targets by priority (higher first)
|
||||
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
|
||||
// Find the first matching target
|
||||
for (const target of sortedTargets) {
|
||||
if (!target.match) {
|
||||
// No match criteria means this is a default/fallback target
|
||||
return target;
|
||||
}
|
||||
|
||||
// Check port match
|
||||
if (target.match.ports && !target.match.ports.includes(context.port)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check path match (supports wildcards)
|
||||
if (target.match.path && context.path) {
|
||||
const pathPattern = target.match.path.replace(/\*/g, '.*');
|
||||
const pathRegex = new RegExp(`^${pathPattern}$`);
|
||||
if (!pathRegex.test(context.path)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check method match
|
||||
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check headers match
|
||||
if (target.match.headers && context.headers) {
|
||||
let headersMatch = true;
|
||||
for (const [key, pattern] of Object.entries(target.match.headers)) {
|
||||
const headerValue = context.headers[key.toLowerCase()];
|
||||
if (!headerValue) {
|
||||
headersMatch = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (pattern instanceof RegExp) {
|
||||
if (!pattern.test(headerValue)) {
|
||||
headersMatch = false;
|
||||
break;
|
||||
}
|
||||
} else if (headerValue !== pattern) {
|
||||
headersMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!headersMatch) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// All criteria matched
|
||||
return target;
|
||||
}
|
||||
|
||||
// No matching target found, return the first target without match criteria (default)
|
||||
return sortedTargets.find(t => !t.match) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize WebSocket server on an existing HTTPS server
|
||||
*/
|
||||
public initialize(server: plugins.https.Server): void {
|
||||
// Create WebSocket server
|
||||
this.wsServer = new plugins.ws.WebSocketServer({
|
||||
server: server,
|
||||
clientTracking: true
|
||||
});
|
||||
|
||||
// Handle WebSocket connections
|
||||
this.wsServer.on('connection', (wsIncoming: IWebSocketWithHeartbeat, req: plugins.http.IncomingMessage) => {
|
||||
this.handleWebSocketConnection(wsIncoming, req);
|
||||
});
|
||||
|
||||
// Start the heartbeat interval
|
||||
this.startHeartbeat();
|
||||
|
||||
this.logger.info('WebSocket handler initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the heartbeat interval to check for inactive WebSocket connections
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
// Clean up existing interval if any
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
|
||||
// Set up the heartbeat interval (check every 30 seconds)
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (!this.wsServer || this.wsServer.clients.size === 0) {
|
||||
return; // Skip if no active connections
|
||||
}
|
||||
|
||||
this.logger.debug(`WebSocket heartbeat check for ${this.wsServer.clients.size} clients`);
|
||||
|
||||
this.wsServer.clients.forEach((ws: plugins.wsDefault) => {
|
||||
const wsWithHeartbeat = ws as IWebSocketWithHeartbeat;
|
||||
|
||||
if (wsWithHeartbeat.isAlive === false) {
|
||||
this.logger.debug('Terminating inactive WebSocket connection');
|
||||
return wsWithHeartbeat.terminate();
|
||||
}
|
||||
|
||||
wsWithHeartbeat.isAlive = false;
|
||||
wsWithHeartbeat.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
// Make sure the interval doesn't keep the process alive
|
||||
if (this.heartbeatInterval.unref) {
|
||||
this.heartbeatInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new WebSocket connection
|
||||
*/
|
||||
private handleWebSocketConnection(wsIncoming: IWebSocketWithHeartbeat, req: plugins.http.IncomingMessage): void {
|
||||
this.logger.debug(`WebSocket connection initiated from ${req.headers.host}`);
|
||||
|
||||
try {
|
||||
// Initialize heartbeat tracking
|
||||
wsIncoming.isAlive = true;
|
||||
wsIncoming.lastPong = Date.now();
|
||||
|
||||
// Handle pong messages to track liveness
|
||||
wsIncoming.on('pong', () => {
|
||||
wsIncoming.isAlive = true;
|
||||
wsIncoming.lastPong = Date.now();
|
||||
});
|
||||
|
||||
// Create a context for routing
|
||||
const connectionId = `ws-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const routeContext = this.contextCreator.createHttpRouteContext(req, {
|
||||
connectionId,
|
||||
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
||||
tlsVersion: req.socket.getTLSVersion?.() || undefined
|
||||
});
|
||||
|
||||
// Try modern router first if available
|
||||
let route: IRouteConfig | undefined;
|
||||
if (this.router) {
|
||||
route = this.router.routeReq(req);
|
||||
}
|
||||
|
||||
// Define destination variables
|
||||
let destination: { host: string; port: number };
|
||||
|
||||
// If we found a route with the modern router, use it
|
||||
if (route && route.action.type === 'forward' && route.action.targets && route.action.targets.length > 0) {
|
||||
this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`);
|
||||
|
||||
// Select the appropriate target from the targets array
|
||||
const selectedTarget = this.selectTarget(route.action.targets, {
|
||||
port: routeContext.port,
|
||||
path: routeContext.path,
|
||||
headers: routeContext.headers,
|
||||
method: routeContext.method
|
||||
});
|
||||
|
||||
if (!selectedTarget) {
|
||||
this.logger.error(`No matching target found for route ${route.name}`);
|
||||
wsIncoming.close(1003, 'No matching target');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if WebSockets are enabled for this route
|
||||
if (route.action.websocket?.enabled === false) {
|
||||
this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`);
|
||||
wsIncoming.close(1003, 'WebSockets not supported for this route');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check security restrictions if configured to authenticate WebSocket requests
|
||||
if (route.action.websocket?.authenticateRequest !== false && route.security) {
|
||||
if (!this.securityManager.isAllowed(route, toBaseContext(routeContext))) {
|
||||
this.logger.warn(`WebSocket connection denied by security policy for ${routeContext.clientIp}`);
|
||||
wsIncoming.close(1008, 'Access denied by security policy');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check origin restrictions if configured
|
||||
const origin = req.headers.origin;
|
||||
if (origin && route.action.websocket?.allowedOrigins && route.action.websocket.allowedOrigins.length > 0) {
|
||||
const isAllowed = route.action.websocket.allowedOrigins.some(allowedOrigin => {
|
||||
// Handle wildcards and template variables
|
||||
if (allowedOrigin.includes('*') || allowedOrigin.includes('{')) {
|
||||
const pattern = allowedOrigin.replace(/\*/g, '.*');
|
||||
const resolvedPattern = TemplateUtils.resolveTemplateVariables(pattern, routeContext);
|
||||
const regex = new RegExp(`^${resolvedPattern}$`);
|
||||
return regex.test(origin);
|
||||
}
|
||||
return allowedOrigin === origin;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
this.logger.warn(`WebSocket origin ${origin} not allowed for route: ${route.name || 'unnamed'}`);
|
||||
wsIncoming.close(1008, 'Origin not allowed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract target information, resolving functions if needed
|
||||
let targetHost: string | string[];
|
||||
let targetPort: number;
|
||||
|
||||
try {
|
||||
// Resolve host if it's a function
|
||||
if (typeof selectedTarget.host === 'function') {
|
||||
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
||||
targetHost = resolvedHost;
|
||||
this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
||||
} else {
|
||||
targetHost = selectedTarget.host;
|
||||
}
|
||||
|
||||
// Resolve port if it's a function
|
||||
if (typeof selectedTarget.port === 'function') {
|
||||
targetPort = selectedTarget.port(toBaseContext(routeContext));
|
||||
this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`);
|
||||
} else {
|
||||
targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
|
||||
}
|
||||
|
||||
// Select a single host if an array was provided
|
||||
const selectedHost = Array.isArray(targetHost)
|
||||
? targetHost[Math.floor(Math.random() * targetHost.length)]
|
||||
: targetHost;
|
||||
|
||||
// Create a destination for the WebSocket connection
|
||||
destination = {
|
||||
host: selectedHost,
|
||||
port: targetPort
|
||||
};
|
||||
|
||||
this.logger.debug(`WebSocket destination resolved: ${selectedHost}:${targetPort}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Error evaluating function-based target for WebSocket: ${err}`);
|
||||
wsIncoming.close(1011, 'Internal server error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No route found
|
||||
this.logger.warn(`No route configuration for WebSocket host: ${req.headers.host}`);
|
||||
wsIncoming.close(1008, 'No route configuration for this host');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build target URL with potential path rewriting
|
||||
// Determine protocol based on the target's configuration
|
||||
// For WebSocket connections, we use ws for HTTP backends and wss for HTTPS backends
|
||||
const isTargetSecure = destination.port === 443;
|
||||
const protocol = isTargetSecure ? 'wss' : 'ws';
|
||||
let targetPath = req.url || '/';
|
||||
|
||||
// Apply path rewriting if configured
|
||||
if (route?.action.websocket?.rewritePath) {
|
||||
const originalPath = targetPath;
|
||||
targetPath = TemplateUtils.resolveTemplateVariables(
|
||||
route.action.websocket.rewritePath,
|
||||
{...routeContext, path: targetPath}
|
||||
);
|
||||
this.logger.debug(`WebSocket path rewritten: ${originalPath} -> ${targetPath}`);
|
||||
}
|
||||
|
||||
const targetUrl = `${protocol}://${destination.host}:${destination.port}${targetPath}`;
|
||||
|
||||
this.logger.debug(`WebSocket connection from ${req.socket.remoteAddress} to ${targetUrl}`);
|
||||
|
||||
// Create headers for outgoing WebSocket connection
|
||||
const headers: { [key: string]: string } = {};
|
||||
|
||||
// Copy relevant headers from incoming request
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value && typeof value === 'string' &&
|
||||
key.toLowerCase() !== 'connection' &&
|
||||
key.toLowerCase() !== 'upgrade' &&
|
||||
key.toLowerCase() !== 'sec-websocket-key' &&
|
||||
key.toLowerCase() !== 'sec-websocket-version') {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Always rewrite host header for WebSockets for consistency
|
||||
headers['host'] = `${destination.host}:${destination.port}`;
|
||||
|
||||
// Add custom headers from route configuration
|
||||
if (route?.action.websocket?.customHeaders) {
|
||||
for (const [key, value] of Object.entries(route.action.websocket.customHeaders)) {
|
||||
// Skip if header already exists and we're not overriding
|
||||
if (headers[key.toLowerCase()] && !value.startsWith('!')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special delete directive (!delete)
|
||||
if (value === '!delete') {
|
||||
delete headers[key.toLowerCase()];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle forced override (!value)
|
||||
let finalValue: string;
|
||||
if (value.startsWith('!') && value !== '!delete') {
|
||||
// Keep the ! but resolve any templates in the rest
|
||||
const templateValue = value.substring(1);
|
||||
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, routeContext);
|
||||
} else {
|
||||
// Resolve templates in the entire value
|
||||
finalValue = TemplateUtils.resolveTemplateVariables(value, routeContext);
|
||||
}
|
||||
|
||||
// Set the header
|
||||
headers[key.toLowerCase()] = finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Create WebSocket connection options
|
||||
const wsOptions: any = {
|
||||
headers: headers,
|
||||
followRedirects: true
|
||||
};
|
||||
|
||||
// Add subprotocols if configured
|
||||
if (route?.action.websocket?.subprotocols && route.action.websocket.subprotocols.length > 0) {
|
||||
wsOptions.protocols = route.action.websocket.subprotocols;
|
||||
} else if (req.headers['sec-websocket-protocol']) {
|
||||
// Pass through client requested protocols
|
||||
wsOptions.protocols = req.headers['sec-websocket-protocol'].split(',').map(p => p.trim());
|
||||
}
|
||||
|
||||
// Create outgoing WebSocket connection
|
||||
this.logger.debug(`Creating WebSocket connection to ${targetUrl} with options:`, {
|
||||
headers: wsOptions.headers,
|
||||
protocols: wsOptions.protocols
|
||||
});
|
||||
const wsOutgoing = new plugins.wsDefault(targetUrl, wsOptions);
|
||||
this.logger.debug(`WebSocket instance created, waiting for connection...`);
|
||||
|
||||
// Handle connection errors
|
||||
wsOutgoing.on('error', (err) => {
|
||||
this.logger.error(`WebSocket target connection error: ${err.message}`);
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
wsIncoming.close(1011, 'Internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle outgoing connection open
|
||||
wsOutgoing.on('open', () => {
|
||||
this.logger.debug(`WebSocket target connection opened to ${targetUrl}`);
|
||||
// Set up custom ping interval if configured
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
if (route?.action.websocket?.pingInterval && route.action.websocket.pingInterval > 0) {
|
||||
pingInterval = setInterval(() => {
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
wsIncoming.ping();
|
||||
this.logger.debug(`Sent WebSocket ping to client for route: ${route.name || 'unnamed'}`);
|
||||
}
|
||||
}, route.action.websocket.pingInterval);
|
||||
|
||||
// Don't keep process alive just for pings
|
||||
if (pingInterval.unref) pingInterval.unref();
|
||||
}
|
||||
|
||||
// Set up custom ping timeout if configured
|
||||
let pingTimeout: NodeJS.Timeout | null = null;
|
||||
const pingTimeoutMs = route?.action.websocket?.pingTimeout || 60000; // Default 60s
|
||||
|
||||
// Define timeout function for cleaner code
|
||||
const resetPingTimeout = () => {
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
pingTimeout = setTimeout(() => {
|
||||
this.logger.debug(`WebSocket ping timeout for client connection on route: ${route?.name || 'unnamed'}`);
|
||||
wsIncoming.terminate();
|
||||
}, pingTimeoutMs);
|
||||
|
||||
// Don't keep process alive just for timeouts
|
||||
if (pingTimeout.unref) pingTimeout.unref();
|
||||
};
|
||||
|
||||
// Reset timeout on pong
|
||||
wsIncoming.on('pong', () => {
|
||||
wsIncoming.isAlive = true;
|
||||
wsIncoming.lastPong = Date.now();
|
||||
resetPingTimeout();
|
||||
});
|
||||
|
||||
// Initial ping timeout
|
||||
resetPingTimeout();
|
||||
|
||||
// Handle potential message size limits
|
||||
const maxSize = route?.action.websocket?.maxPayloadSize || 0;
|
||||
|
||||
// Forward incoming messages to outgoing connection
|
||||
wsIncoming.on('message', (data, isBinary) => {
|
||||
this.logger.debug(`WebSocket forwarding message from client to target: ${data.toString()}`);
|
||||
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
||||
// Check message size if limit is set
|
||||
const messageSize = getMessageSize(data);
|
||||
if (maxSize > 0 && messageSize > maxSize) {
|
||||
this.logger.warn(`WebSocket message exceeds max size (${messageSize} > ${maxSize})`);
|
||||
wsIncoming.close(1009, 'Message too big');
|
||||
return;
|
||||
}
|
||||
|
||||
wsOutgoing.send(data, { binary: isBinary });
|
||||
} else {
|
||||
this.logger.warn(`WebSocket target connection not open (state: ${wsOutgoing.readyState})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward outgoing messages to incoming connection
|
||||
wsOutgoing.on('message', (data, isBinary) => {
|
||||
this.logger.debug(`WebSocket forwarding message from target to client: ${data.toString()}`);
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
wsIncoming.send(data, { binary: isBinary });
|
||||
} else {
|
||||
this.logger.warn(`WebSocket client connection not open (state: ${wsIncoming.readyState})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle closing of connections
|
||||
wsIncoming.on('close', (code, reason) => {
|
||||
this.logger.debug(`WebSocket client connection closed: ${code} ${reason}`);
|
||||
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
||||
// Ensure code is a valid WebSocket close code number
|
||||
const validCode = typeof code === 'number' && code >= 1000 && code <= 4999 ? code : 1000;
|
||||
try {
|
||||
const reasonString = reason ? toBuffer(reason).toString() : '';
|
||||
wsOutgoing.close(validCode, reasonString);
|
||||
} catch (err) {
|
||||
this.logger.error('Error closing wsOutgoing:', err);
|
||||
wsOutgoing.close(validCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up timers
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
});
|
||||
|
||||
wsOutgoing.on('close', (code, reason) => {
|
||||
this.logger.debug(`WebSocket target connection closed: ${code} ${reason}`);
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
// Ensure code is a valid WebSocket close code number
|
||||
const validCode = typeof code === 'number' && code >= 1000 && code <= 4999 ? code : 1000;
|
||||
try {
|
||||
const reasonString = reason ? toBuffer(reason).toString() : '';
|
||||
wsIncoming.close(validCode, reasonString);
|
||||
} catch (err) {
|
||||
this.logger.error('Error closing wsIncoming:', err);
|
||||
wsIncoming.close(validCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up timers
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
});
|
||||
|
||||
this.logger.debug(`WebSocket connection established: ${req.headers.host} -> ${destination.host}:${destination.port}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling WebSocket connection: ${error.message}`);
|
||||
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
||||
wsIncoming.close(1011, 'Internal server error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about active WebSocket connections
|
||||
*/
|
||||
public getConnectionInfo(): { activeConnections: number } {
|
||||
return {
|
||||
activeConnections: this.wsServer ? this.wsServer.clients.size : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the WebSocket handler
|
||||
*/
|
||||
public shutdown(): void {
|
||||
// Stop heartbeat interval
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
|
||||
// Close all WebSocket connections
|
||||
if (this.wsServer) {
|
||||
this.logger.info(`Closing ${this.wsServer.clients.size} WebSocket connections`);
|
||||
|
||||
for (const client of this.wsServer.clients) {
|
||||
try {
|
||||
client.terminate();
|
||||
} catch (error) {
|
||||
this.logger.error('Error terminating WebSocket client', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the server
|
||||
this.wsServer.close();
|
||||
this.wsServer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,8 @@
|
||||
* Proxy implementations module
|
||||
*/
|
||||
|
||||
// Export HttpProxy with selective imports to avoid conflicts
|
||||
export { HttpProxy, CertificateManager, ConnectionPool, RequestHandler, WebSocketHandler } from './http-proxy/index.js';
|
||||
export type { IMetricsTracker, MetricsTracker } from './http-proxy/index.js';
|
||||
// Export http-proxy models except IAcmeOptions
|
||||
export type { IHttpProxyOptions, ICertificateEntry, ILogger } from './http-proxy/models/types.js';
|
||||
// RouteManager has been unified - use SharedRouteManager from core/routing
|
||||
export { SharedRouteManager as HttpProxyRouteManager } from '../core/routing/route-manager.js';
|
||||
|
||||
// Export SmartProxy with selective imports to avoid conflicts
|
||||
export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, HttpProxyBridge, RouteConnectionHandler } from './smart-proxy/index.js';
|
||||
export { SmartProxy } from './smart-proxy/index.js';
|
||||
export { SharedRouteManager as SmartProxyRouteManager } from '../core/routing/route-manager.js';
|
||||
export * from './smart-proxy/utils/index.js';
|
||||
// Export smart-proxy models except IAcmeOptions
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import type { IRouteConfig } from './models/route-types.js';
|
||||
|
||||
/**
|
||||
* Global state store for ACME operations
|
||||
* Tracks active challenge routes and port allocations
|
||||
*/
|
||||
export class AcmeStateManager {
|
||||
private activeChallengeRoutes: Map<string, IRouteConfig> = new Map();
|
||||
private acmePortAllocations: Set<number> = new Set();
|
||||
private primaryChallengeRoute: IRouteConfig | null = null;
|
||||
|
||||
/**
|
||||
* Check if a challenge route is active
|
||||
*/
|
||||
public isChallengeRouteActive(): boolean {
|
||||
return this.activeChallengeRoutes.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a challenge route as active
|
||||
*/
|
||||
public addChallengeRoute(route: IRouteConfig): void {
|
||||
this.activeChallengeRoutes.set(route.name, route);
|
||||
|
||||
// Track the primary challenge route
|
||||
if (!this.primaryChallengeRoute || route.priority > (this.primaryChallengeRoute.priority || 0)) {
|
||||
this.primaryChallengeRoute = route;
|
||||
}
|
||||
|
||||
// Track port allocations
|
||||
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
|
||||
ports.forEach(port => this.acmePortAllocations.add(port));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a challenge route
|
||||
*/
|
||||
public removeChallengeRoute(routeName: string): void {
|
||||
const route = this.activeChallengeRoutes.get(routeName);
|
||||
if (!route) return;
|
||||
|
||||
this.activeChallengeRoutes.delete(routeName);
|
||||
|
||||
// Update primary challenge route if needed
|
||||
if (this.primaryChallengeRoute?.name === routeName) {
|
||||
this.primaryChallengeRoute = null;
|
||||
// Find new primary route with highest priority
|
||||
let highestPriority = -1;
|
||||
for (const [_, activeRoute] of this.activeChallengeRoutes) {
|
||||
const priority = activeRoute.priority || 0;
|
||||
if (priority > highestPriority) {
|
||||
highestPriority = priority;
|
||||
this.primaryChallengeRoute = activeRoute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update port allocations - only remove if no other routes use this port
|
||||
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
|
||||
ports.forEach(port => {
|
||||
let portStillUsed = false;
|
||||
for (const [_, activeRoute] of this.activeChallengeRoutes) {
|
||||
const activePorts = Array.isArray(activeRoute.match.ports) ?
|
||||
activeRoute.match.ports : [activeRoute.match.ports];
|
||||
if (activePorts.includes(port)) {
|
||||
portStillUsed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!portStillUsed) {
|
||||
this.acmePortAllocations.delete(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active challenge routes
|
||||
*/
|
||||
public getActiveChallengeRoutes(): IRouteConfig[] {
|
||||
return Array.from(this.activeChallengeRoutes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary challenge route
|
||||
*/
|
||||
public getPrimaryChallengeRoute(): IRouteConfig | null {
|
||||
return this.primaryChallengeRoute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is allocated for ACME
|
||||
*/
|
||||
public isPortAllocatedForAcme(port: number): boolean {
|
||||
return this.acmePortAllocations.has(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ACME ports
|
||||
*/
|
||||
public getAcmePorts(): number[] {
|
||||
return Array.from(this.acmePortAllocations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all state (for shutdown or reset)
|
||||
*/
|
||||
public clear(): void {
|
||||
this.activeChallengeRoutes.clear();
|
||||
this.acmePortAllocations.clear();
|
||||
this.primaryChallengeRoute = null;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import type { ICertificateData } from './certificate-manager.js';
|
||||
|
||||
export class CertStore {
|
||||
constructor(private certDir: string) {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await AsyncFileSystem.ensureDir(this.certDir);
|
||||
}
|
||||
|
||||
public async getCertificate(routeName: string): Promise<ICertificateData | null> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
const metaPath = `${certPath}/meta.json`;
|
||||
|
||||
if (!await AsyncFileSystem.exists(metaPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = await AsyncFileSystem.readJSON(metaPath);
|
||||
|
||||
const [cert, key] = await Promise.all([
|
||||
AsyncFileSystem.readFile(`${certPath}/cert.pem`),
|
||||
AsyncFileSystem.readFile(`${certPath}/key.pem`)
|
||||
]);
|
||||
|
||||
let ca: string | undefined;
|
||||
const caPath = `${certPath}/ca.pem`;
|
||||
if (await AsyncFileSystem.exists(caPath)) {
|
||||
ca = await AsyncFileSystem.readFile(caPath);
|
||||
}
|
||||
|
||||
return {
|
||||
cert,
|
||||
key,
|
||||
ca,
|
||||
expiryDate: new Date(meta.expiryDate),
|
||||
issueDate: new Date(meta.issueDate)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to load certificate for ${routeName}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async saveCertificate(
|
||||
routeName: string,
|
||||
certData: ICertificateData
|
||||
): Promise<void> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
await AsyncFileSystem.ensureDir(certPath);
|
||||
|
||||
// Save certificate files in parallel
|
||||
const savePromises = [
|
||||
AsyncFileSystem.writeFile(`${certPath}/cert.pem`, certData.cert),
|
||||
AsyncFileSystem.writeFile(`${certPath}/key.pem`, certData.key)
|
||||
];
|
||||
|
||||
if (certData.ca) {
|
||||
savePromises.push(
|
||||
AsyncFileSystem.writeFile(`${certPath}/ca.pem`, certData.ca)
|
||||
);
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
const meta = {
|
||||
expiryDate: certData.expiryDate.toISOString(),
|
||||
issueDate: certData.issueDate.toISOString(),
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
savePromises.push(
|
||||
AsyncFileSystem.writeJSON(`${certPath}/meta.json`, meta)
|
||||
);
|
||||
|
||||
await Promise.all(savePromises);
|
||||
}
|
||||
|
||||
public async deleteCertificate(routeName: string): Promise<void> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
if (await AsyncFileSystem.isDirectory(certPath)) {
|
||||
await AsyncFileSystem.removeDir(certPath);
|
||||
}
|
||||
}
|
||||
|
||||
private getCertPath(routeName: string): string {
|
||||
// Sanitize route name for filesystem
|
||||
const safeName = routeName.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
return `${this.certDir}/${safeName}`;
|
||||
}
|
||||
}
|
||||
@@ -1,895 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { HttpProxy } from '../http-proxy/index.js';
|
||||
import type { IRouteConfig, IRouteTls } from './models/route-types.js';
|
||||
import type { IAcmeOptions } from './models/interfaces.js';
|
||||
import { CertStore } from './cert-store.js';
|
||||
import type { AcmeStateManager } from './acme-state-manager.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { SocketHandlers } from './utils/route-helpers.js';
|
||||
|
||||
export interface ICertStatus {
|
||||
domain: string;
|
||||
status: 'valid' | 'pending' | 'expired' | 'error';
|
||||
expiryDate?: Date;
|
||||
issueDate?: Date;
|
||||
source: 'static' | 'acme' | 'custom';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ICertificateData {
|
||||
cert: string;
|
||||
key: string;
|
||||
ca?: string;
|
||||
expiryDate: Date;
|
||||
issueDate: Date;
|
||||
source?: 'static' | 'acme' | 'custom';
|
||||
}
|
||||
|
||||
export class SmartCertManager {
|
||||
private certStore: CertStore;
|
||||
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
||||
private httpProxy: HttpProxy | null = null;
|
||||
private renewalTimer: NodeJS.Timeout | null = null;
|
||||
private pendingChallenges: Map<string, string> = new Map();
|
||||
private challengeRoute: IRouteConfig | null = null;
|
||||
|
||||
// Track certificate status by route name
|
||||
private certStatus: Map<string, ICertStatus> = new Map();
|
||||
|
||||
// Global ACME defaults from top-level configuration
|
||||
private globalAcmeDefaults: IAcmeOptions | null = null;
|
||||
|
||||
// Callback to update SmartProxy routes for challenges
|
||||
private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
|
||||
|
||||
// Flag to track if challenge route is currently active
|
||||
private challengeRouteActive: boolean = false;
|
||||
|
||||
// Flag to track if provisioning is in progress
|
||||
private isProvisioning: boolean = false;
|
||||
|
||||
// ACME state manager reference
|
||||
private acmeStateManager: AcmeStateManager | null = null;
|
||||
|
||||
// Custom certificate provision function
|
||||
private certProvisionFunction?: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>;
|
||||
|
||||
// Whether to fallback to ACME if custom provision fails
|
||||
private certProvisionFallbackToAcme: boolean = true;
|
||||
|
||||
constructor(
|
||||
private routes: IRouteConfig[],
|
||||
private certDir: string = './certs',
|
||||
private acmeOptions?: {
|
||||
email?: string;
|
||||
useProduction?: boolean;
|
||||
port?: number;
|
||||
},
|
||||
private initialState?: {
|
||||
challengeRouteActive?: boolean;
|
||||
}
|
||||
) {
|
||||
this.certStore = new CertStore(certDir);
|
||||
|
||||
// Apply initial state if provided
|
||||
if (initialState) {
|
||||
this.challengeRouteActive = initialState.challengeRouteActive || false;
|
||||
}
|
||||
}
|
||||
|
||||
public setHttpProxy(httpProxy: HttpProxy): void {
|
||||
this.httpProxy = httpProxy;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the ACME state manager
|
||||
*/
|
||||
public setAcmeStateManager(stateManager: AcmeStateManager): void {
|
||||
this.acmeStateManager = stateManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global ACME defaults from top-level configuration
|
||||
*/
|
||||
public setGlobalAcmeDefaults(defaults: IAcmeOptions): void {
|
||||
this.globalAcmeDefaults = defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom certificate provision function
|
||||
*/
|
||||
public setCertProvisionFunction(fn: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>): void {
|
||||
this.certProvisionFunction = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to fallback to ACME if custom provision fails
|
||||
*/
|
||||
public setCertProvisionFallbackToAcme(fallback: boolean): void {
|
||||
this.certProvisionFallbackToAcme = fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the routes array to keep it in sync with SmartProxy
|
||||
* This prevents stale route data when adding/removing challenge routes
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for updating routes (used for challenge routes)
|
||||
*/
|
||||
public setUpdateRoutesCallback(callback: (routes: IRouteConfig[]) => Promise<void>): void {
|
||||
this.updateRoutesCallback = callback;
|
||||
try {
|
||||
logger.log('debug', 'Route update callback set successfully', { component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log('[DEBUG] Route update callback set successfully');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize certificate manager and provision certificates for all routes
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
// Create certificate directory if it doesn't exist
|
||||
await this.certStore.initialize();
|
||||
|
||||
// Initialize SmartAcme if we have any ACME routes
|
||||
const hasAcmeRoutes = this.routes.some(r =>
|
||||
r.action.tls?.certificate === 'auto'
|
||||
);
|
||||
|
||||
if (hasAcmeRoutes && this.acmeOptions?.email) {
|
||||
// Create HTTP-01 challenge handler
|
||||
const http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
||||
|
||||
// Set up challenge handler integration with our routing
|
||||
this.setupChallengeHandler(http01Handler);
|
||||
|
||||
// Create SmartAcme instance with built-in MemoryCertManager and HTTP-01 handler
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: this.acmeOptions.email,
|
||||
environment: this.acmeOptions.useProduction ? 'production' : 'integration',
|
||||
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
||||
challengeHandlers: [http01Handler]
|
||||
});
|
||||
|
||||
await this.smartAcme.start();
|
||||
|
||||
// Add challenge route once at initialization if not already active
|
||||
if (!this.challengeRouteActive) {
|
||||
logger.log('info', 'Adding ACME challenge route during initialization', { component: 'certificate-manager' });
|
||||
await this.addChallengeRoute();
|
||||
} else {
|
||||
logger.log('info', 'Challenge route already active from previous instance', { component: 'certificate-manager' });
|
||||
}
|
||||
}
|
||||
|
||||
// Skip automatic certificate provisioning during initialization
|
||||
// This will be called later after ports are listening
|
||||
logger.log('info', 'Certificate manager initialized. Deferring certificate provisioning until after ports are listening.', { component: 'certificate-manager' });
|
||||
|
||||
// Start renewal timer
|
||||
this.startRenewalTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision certificates for all routes that need them
|
||||
*/
|
||||
public async provisionAllCertificates(): Promise<void> {
|
||||
const certRoutes = this.routes.filter(r =>
|
||||
r.action.tls?.mode === 'terminate' ||
|
||||
r.action.tls?.mode === 'terminate-and-reencrypt'
|
||||
);
|
||||
|
||||
// Set provisioning flag to prevent concurrent operations
|
||||
this.isProvisioning = true;
|
||||
|
||||
try {
|
||||
for (const route of certRoutes) {
|
||||
try {
|
||||
await this.provisionCertificate(route, true); // Allow concurrent since we're managing it here
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to provision certificate for route ${route.name}`, { routeName: route.name, error, component: 'certificate-manager' });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isProvisioning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision certificate for a single route
|
||||
*/
|
||||
public async provisionCertificate(route: IRouteConfig, allowConcurrent: boolean = false): Promise<void> {
|
||||
const tls = route.action.tls;
|
||||
if (!tls || (tls.mode !== 'terminate' && tls.mode !== 'terminate-and-reencrypt')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if provisioning is already in progress (prevent concurrent provisioning)
|
||||
if (!allowConcurrent && this.isProvisioning) {
|
||||
logger.log('info', `Certificate provisioning already in progress, skipping ${route.name}`, { routeName: route.name, component: 'certificate-manager' });
|
||||
return;
|
||||
}
|
||||
|
||||
const domains = this.extractDomainsFromRoute(route);
|
||||
if (domains.length === 0) {
|
||||
logger.log('warn', `Route ${route.name} has TLS termination but no domains`, { routeName: route.name, component: 'certificate-manager' });
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryDomain = domains[0];
|
||||
|
||||
if (tls.certificate === 'auto') {
|
||||
// ACME certificate
|
||||
await this.provisionAcmeCertificate(route, domains);
|
||||
} else if (typeof tls.certificate === 'object') {
|
||||
// Static certificate
|
||||
await this.provisionStaticCertificate(route, primaryDomain, tls.certificate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision ACME certificate
|
||||
*/
|
||||
private async provisionAcmeCertificate(
|
||||
route: IRouteConfig,
|
||||
domains: string[]
|
||||
): Promise<void> {
|
||||
const primaryDomain = domains[0];
|
||||
const routeName = route.name || primaryDomain;
|
||||
|
||||
// Check if we already have a valid certificate
|
||||
const existingCert = await this.certStore.getCertificate(routeName);
|
||||
if (existingCert && this.isCertificateValid(existingCert)) {
|
||||
logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
await this.applyCertificate(primaryDomain, existingCert);
|
||||
this.updateCertStatus(routeName, 'valid', existingCert.source || 'acme', existingCert);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for custom provision function first
|
||||
if (this.certProvisionFunction) {
|
||||
try {
|
||||
logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
const result = await this.certProvisionFunction(primaryDomain);
|
||||
|
||||
if (result === 'http01') {
|
||||
logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
// Continue with existing ACME logic below
|
||||
} else {
|
||||
// Use custom certificate
|
||||
const customCert = result as plugins.tsclass.network.ICert;
|
||||
|
||||
// Convert to internal certificate format
|
||||
const certData: ICertificateData = {
|
||||
cert: customCert.publicKey,
|
||||
key: customCert.privateKey,
|
||||
ca: '',
|
||||
issueDate: new Date(),
|
||||
expiryDate: this.extractExpiryDate(customCert.publicKey),
|
||||
source: 'custom'
|
||||
};
|
||||
|
||||
// Store and apply certificate
|
||||
await this.certStore.saveCertificate(routeName, certData);
|
||||
await this.applyCertificate(primaryDomain, certData);
|
||||
this.updateCertStatus(routeName, 'valid', 'custom', certData);
|
||||
|
||||
logger.log('info', `Custom certificate applied for ${primaryDomain}`, {
|
||||
domain: primaryDomain,
|
||||
expiryDate: certData.expiryDate,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
|
||||
domain: primaryDomain,
|
||||
error: error.message,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
// Check if we should fallback to ACME
|
||||
if (!this.certProvisionFallbackToAcme) {
|
||||
throw error;
|
||||
}
|
||||
logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.smartAcme) {
|
||||
throw new Error(
|
||||
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
|
||||
'Please ensure you have configured ACME with an email address either:\n' +
|
||||
'1. In the top-level "acme" configuration\n' +
|
||||
'2. In the route\'s "tls.acme" configuration'
|
||||
);
|
||||
}
|
||||
|
||||
// Apply renewal threshold from global defaults or route config
|
||||
const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
|
||||
this.globalAcmeDefaults?.renewThresholdDays ||
|
||||
30;
|
||||
|
||||
logger.log('info', `Requesting ACME certificate for ${domains.join(', ')} (renew ${renewThreshold} days before expiry)`, { domains: domains.join(', '), renewThreshold, component: 'certificate-manager' });
|
||||
this.updateCertStatus(routeName, 'pending', 'acme');
|
||||
|
||||
try {
|
||||
// Challenge route should already be active from initialization
|
||||
// No need to add it for each certificate
|
||||
|
||||
// Determine if we should request a wildcard certificate
|
||||
// Only request wildcards if:
|
||||
// 1. The primary domain is not already a wildcard
|
||||
// 2. The domain has multiple parts (can have subdomains)
|
||||
// 3. We have DNS-01 challenge support (required for wildcards)
|
||||
const hasDnsChallenge = (this.smartAcme as any).challengeHandlers?.some((handler: any) =>
|
||||
handler.getSupportedTypes && handler.getSupportedTypes().includes('dns-01')
|
||||
);
|
||||
|
||||
const shouldIncludeWildcard = !primaryDomain.startsWith('*.') &&
|
||||
primaryDomain.includes('.') &&
|
||||
primaryDomain.split('.').length >= 2 &&
|
||||
hasDnsChallenge;
|
||||
|
||||
if (shouldIncludeWildcard) {
|
||||
logger.log('info', `Requesting wildcard certificate for ${primaryDomain} (DNS-01 available)`, { domain: primaryDomain, challengeType: 'DNS-01', component: 'certificate-manager' });
|
||||
}
|
||||
|
||||
// Use smartacme to get certificate with optional wildcard
|
||||
const cert = await this.smartAcme.getCertificateForDomain(
|
||||
primaryDomain,
|
||||
shouldIncludeWildcard ? { includeWildcard: true } : undefined
|
||||
);
|
||||
|
||||
// SmartAcme's Cert object has these properties:
|
||||
// - publicKey: The certificate PEM string
|
||||
// - privateKey: The private key PEM string
|
||||
// - csr: Certificate signing request
|
||||
// - validUntil: Timestamp in milliseconds
|
||||
// - domainName: The domain name
|
||||
const certData: ICertificateData = {
|
||||
cert: cert.publicKey,
|
||||
key: cert.privateKey,
|
||||
ca: cert.publicKey, // Use same as cert for now
|
||||
expiryDate: new Date(cert.validUntil),
|
||||
issueDate: new Date(cert.created),
|
||||
source: 'acme'
|
||||
};
|
||||
|
||||
await this.certStore.saveCertificate(routeName, certData);
|
||||
await this.applyCertificate(primaryDomain, certData);
|
||||
this.updateCertStatus(routeName, 'valid', 'acme', certData);
|
||||
|
||||
logger.log('info', `Successfully provisioned ACME certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to provision ACME certificate for ${primaryDomain}: ${error.message}`, { domain: primaryDomain, error: error.message, component: 'certificate-manager' });
|
||||
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision static certificate
|
||||
*/
|
||||
private async provisionStaticCertificate(
|
||||
route: IRouteConfig,
|
||||
domain: string,
|
||||
certConfig: { key: string; cert: string; keyFile?: string; certFile?: string }
|
||||
): Promise<void> {
|
||||
const routeName = route.name || domain;
|
||||
|
||||
try {
|
||||
let key: string = certConfig.key;
|
||||
let cert: string = certConfig.cert;
|
||||
|
||||
// Load from files if paths are provided
|
||||
const smartFileFactory = plugins.smartfile.SmartFileFactory.nodeFs();
|
||||
if (certConfig.keyFile) {
|
||||
const keyFile = await smartFileFactory.fromFilePath(certConfig.keyFile);
|
||||
key = keyFile.contents.toString();
|
||||
}
|
||||
if (certConfig.certFile) {
|
||||
const certFile = await smartFileFactory.fromFilePath(certConfig.certFile);
|
||||
cert = certFile.contents.toString();
|
||||
}
|
||||
|
||||
// Parse certificate to get dates
|
||||
const expiryDate = this.extractExpiryDate(cert);
|
||||
const issueDate = new Date(); // Current date as issue date
|
||||
|
||||
const certData: ICertificateData = {
|
||||
cert,
|
||||
key,
|
||||
expiryDate,
|
||||
issueDate,
|
||||
source: 'static'
|
||||
};
|
||||
|
||||
// Save to store for consistency
|
||||
await this.certStore.saveCertificate(routeName, certData);
|
||||
await this.applyCertificate(domain, certData);
|
||||
this.updateCertStatus(routeName, 'valid', 'static', certData);
|
||||
|
||||
logger.log('info', `Successfully loaded static certificate for ${domain}`, { domain, component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to provision static certificate for ${domain}: ${error.message}`, { domain, error: error.message, component: 'certificate-manager' });
|
||||
this.updateCertStatus(routeName, 'error', 'static', undefined, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply certificate to HttpProxy
|
||||
*/
|
||||
private async applyCertificate(domain: string, certData: ICertificateData): Promise<void> {
|
||||
if (!this.httpProxy) {
|
||||
logger.log('warn', `HttpProxy not set, cannot apply certificate for domain ${domain}`, { domain, component: 'certificate-manager' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply certificate to HttpProxy
|
||||
this.httpProxy.updateCertificate(domain, certData.cert, certData.key);
|
||||
|
||||
// Also apply for wildcard if it's a subdomain
|
||||
if (domain.includes('.') && !domain.startsWith('*.')) {
|
||||
const parts = domain.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const wildcardDomain = `*.${parts.slice(-2).join('.')}`;
|
||||
this.httpProxy.updateCertificate(wildcardDomain, certData.cert, certData.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domains from route configuration
|
||||
*/
|
||||
private extractDomainsFromRoute(route: IRouteConfig): string[] {
|
||||
if (!route.match.domains) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
// Filter out wildcards and patterns
|
||||
return domains.filter(d =>
|
||||
!d.includes('*') &&
|
||||
!d.includes('{') &&
|
||||
d.includes('.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is valid
|
||||
*/
|
||||
private isCertificateValid(cert: ICertificateData): boolean {
|
||||
const now = new Date();
|
||||
|
||||
// Use renewal threshold from global defaults or fallback to 30 days
|
||||
const renewThresholdDays = this.globalAcmeDefaults?.renewThresholdDays || 30;
|
||||
const expiryThreshold = new Date(now.getTime() + renewThresholdDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
return cert.expiryDate > expiryThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract expiry date from a PEM certificate
|
||||
*/
|
||||
private extractExpiryDate(_certPem: string): Date {
|
||||
// For now, we'll default to 90 days for custom certificates
|
||||
// In production, you might want to use a proper X.509 parser
|
||||
// or require the custom cert provider to include expiry info
|
||||
logger.log('info', 'Using default 90-day expiry for custom certificate', {
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add challenge route to SmartProxy
|
||||
*
|
||||
* This method adds a special route for ACME HTTP-01 challenges, which typically uses port 80.
|
||||
* Since we may already be listening on port 80 for regular routes, we need to be
|
||||
* careful about how we add this route to avoid binding conflicts.
|
||||
*/
|
||||
private async addChallengeRoute(): Promise<void> {
|
||||
// Check with state manager first - avoid duplication
|
||||
if (this.acmeStateManager && this.acmeStateManager.isChallengeRouteActive()) {
|
||||
try {
|
||||
logger.log('info', 'Challenge route already active in global state, skipping', { component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log('[INFO] Challenge route already active in global state, skipping');
|
||||
}
|
||||
this.challengeRouteActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.challengeRouteActive) {
|
||||
try {
|
||||
logger.log('info', 'Challenge route already active locally, skipping', { component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log('[INFO] Challenge route already active locally, skipping');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.updateRoutesCallback) {
|
||||
throw new Error('No route update callback set');
|
||||
}
|
||||
|
||||
if (!this.challengeRoute) {
|
||||
throw new Error('Challenge route not initialized');
|
||||
}
|
||||
|
||||
// Get the challenge port
|
||||
const challengePort = this.globalAcmeDefaults?.port || 80;
|
||||
|
||||
// Check if any existing routes are already using this port
|
||||
// This helps us determine if we need to create a new binding or can reuse existing one
|
||||
const portInUseByRoutes = this.routes.some(route => {
|
||||
const routePorts = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
|
||||
return routePorts.some(p => {
|
||||
// Handle both number and port range objects
|
||||
if (typeof p === 'number') {
|
||||
return p === challengePort;
|
||||
} else if (typeof p === 'object' && 'from' in p && 'to' in p) {
|
||||
// Port range case - check if challengePort is in range
|
||||
return challengePort >= p.from && challengePort <= p.to;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// Log whether port is already in use by other routes
|
||||
if (portInUseByRoutes) {
|
||||
try {
|
||||
logger.log('info', `Port ${challengePort} is already used by another route, merging ACME challenge route`, {
|
||||
port: challengePort,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[INFO] Port ${challengePort} is already used by another route, merging ACME challenge route`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
logger.log('info', `Adding new ACME challenge route on port ${challengePort}`, {
|
||||
port: challengePort,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[INFO] Adding new ACME challenge route on port ${challengePort}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the challenge route to the existing routes
|
||||
const challengeRoute = this.challengeRoute;
|
||||
const updatedRoutes = [...this.routes, challengeRoute];
|
||||
|
||||
// With the re-ordering of start(), port binding should already be done
|
||||
// This updateRoutes call should just add the route without binding again
|
||||
await this.updateRoutesCallback(updatedRoutes);
|
||||
// Keep local routes in sync after updating
|
||||
this.routes = updatedRoutes;
|
||||
this.challengeRouteActive = true;
|
||||
|
||||
// Register with state manager
|
||||
if (this.acmeStateManager) {
|
||||
this.acmeStateManager.addChallengeRoute(challengeRoute);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.log('info', 'ACME challenge route successfully added', { component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log('[INFO] ACME challenge route successfully added');
|
||||
}
|
||||
} catch (error) {
|
||||
// Enhanced error handling based on error type
|
||||
if ((error as any).code === 'EADDRINUSE') {
|
||||
try {
|
||||
logger.log('warn', `Challenge port ${challengePort} is unavailable - it's already in use by another process. Consider configuring a different ACME port.`, {
|
||||
port: challengePort,
|
||||
error: (error as Error).message,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
} catch (logError) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[WARN] Challenge port ${challengePort} is unavailable - it's already in use by another process. Consider configuring a different ACME port.`);
|
||||
}
|
||||
|
||||
// Provide a more informative and actionable error message
|
||||
throw new Error(
|
||||
`ACME HTTP-01 challenge port ${challengePort} is already in use by another process. ` +
|
||||
`Please configure a different port using the acme.port setting (e.g., 8080).`
|
||||
);
|
||||
} else if (error.message && error.message.includes('EADDRINUSE')) {
|
||||
// Some Node.js versions embed the error code in the message rather than the code property
|
||||
try {
|
||||
logger.log('warn', `Port ${challengePort} conflict detected: ${error.message}`, {
|
||||
port: challengePort,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
} catch (logError) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[WARN] Port ${challengePort} conflict detected: ${error.message}`);
|
||||
}
|
||||
|
||||
// More detailed error message with suggestions
|
||||
throw new Error(
|
||||
`ACME HTTP challenge port ${challengePort} conflict detected. ` +
|
||||
`To resolve this issue, try one of these approaches:\n` +
|
||||
`1. Configure a different port in ACME settings (acme.port)\n` +
|
||||
`2. Add a regular route that uses port ${challengePort} before initializing the certificate manager\n` +
|
||||
`3. Stop any other services that might be using port ${challengePort}`
|
||||
);
|
||||
}
|
||||
|
||||
// Log and rethrow other types of errors
|
||||
try {
|
||||
logger.log('error', `Failed to add challenge route: ${(error as Error).message}`, {
|
||||
error: (error as Error).message,
|
||||
component: 'certificate-manager'
|
||||
});
|
||||
} catch (logError) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[ERROR] Failed to add challenge route: ${(error as Error).message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove challenge route from SmartProxy
|
||||
*/
|
||||
private async removeChallengeRoute(): Promise<void> {
|
||||
if (!this.challengeRouteActive) {
|
||||
try {
|
||||
logger.log('info', 'Challenge route not active, skipping removal', { component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log('[INFO] Challenge route not active, skipping removal');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.updateRoutesCallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
|
||||
await this.updateRoutesCallback(filteredRoutes);
|
||||
// Keep local routes in sync after updating
|
||||
this.routes = filteredRoutes;
|
||||
this.challengeRouteActive = false;
|
||||
|
||||
// Remove from state manager
|
||||
if (this.acmeStateManager) {
|
||||
this.acmeStateManager.removeChallengeRoute('acme-challenge');
|
||||
}
|
||||
|
||||
try {
|
||||
logger.log('info', 'ACME challenge route successfully removed', { component: 'certificate-manager' });
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log('[INFO] ACME challenge route successfully removed');
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
logger.log('error', `Failed to remove challenge route: ${error.message}`, { error: error.message, component: 'certificate-manager' });
|
||||
} catch (logError) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[ERROR] Failed to remove challenge route: ${error.message}`);
|
||||
}
|
||||
// Reset the flag even on error to avoid getting stuck
|
||||
this.challengeRouteActive = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start renewal timer
|
||||
*/
|
||||
private startRenewalTimer(): void {
|
||||
// Check for renewals every 12 hours
|
||||
this.renewalTimer = setInterval(() => {
|
||||
this.checkAndRenewCertificates();
|
||||
}, 12 * 60 * 60 * 1000);
|
||||
|
||||
// Unref the timer so it doesn't keep the process alive
|
||||
if (this.renewalTimer.unref) {
|
||||
this.renewalTimer.unref();
|
||||
}
|
||||
|
||||
// Also do an immediate check
|
||||
this.checkAndRenewCertificates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and renew certificates that are expiring
|
||||
*/
|
||||
private async checkAndRenewCertificates(): Promise<void> {
|
||||
for (const route of this.routes) {
|
||||
if (route.action.tls?.certificate === 'auto') {
|
||||
const routeName = route.name || this.extractDomainsFromRoute(route)[0];
|
||||
const cert = await this.certStore.getCertificate(routeName);
|
||||
|
||||
if (cert && !this.isCertificateValid(cert)) {
|
||||
logger.log('info', `Certificate for ${routeName} needs renewal`, { routeName, component: 'certificate-manager' });
|
||||
try {
|
||||
await this.provisionCertificate(route);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to renew certificate for ${routeName}: ${error.message}`, { routeName, error: error.message, component: 'certificate-manager' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update certificate status
|
||||
*/
|
||||
private updateCertStatus(
|
||||
routeName: string,
|
||||
status: ICertStatus['status'],
|
||||
source: ICertStatus['source'],
|
||||
certData?: ICertificateData,
|
||||
error?: string
|
||||
): void {
|
||||
this.certStatus.set(routeName, {
|
||||
domain: routeName,
|
||||
status,
|
||||
source,
|
||||
expiryDate: certData?.expiryDate,
|
||||
issueDate: certData?.issueDate,
|
||||
error
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get certificate status for a route
|
||||
*/
|
||||
public getCertificateStatus(routeName: string): ICertStatus | undefined {
|
||||
return this.certStatus.get(routeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force renewal of a certificate
|
||||
*/
|
||||
public async renewCertificate(routeName: string): Promise<void> {
|
||||
const route = this.routes.find(r => r.name === routeName);
|
||||
if (!route) {
|
||||
throw new Error(`Route ${routeName} not found`);
|
||||
}
|
||||
|
||||
// Remove existing certificate to force renewal
|
||||
await this.certStore.deleteCertificate(routeName);
|
||||
await this.provisionCertificate(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup challenge handler integration with SmartProxy routing
|
||||
*/
|
||||
private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void {
|
||||
// Use challenge port from global config or default to 80
|
||||
const challengePort = this.globalAcmeDefaults?.port || 80;
|
||||
|
||||
// Create a challenge route that delegates to SmartAcme's HTTP-01 handler
|
||||
const challengeRoute: IRouteConfig = {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000, // High priority
|
||||
match: {
|
||||
ports: challengePort,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpServer((req, res) => {
|
||||
// Extract the token from the path
|
||||
const token = req.url?.split('/').pop();
|
||||
if (!token) {
|
||||
res.status(404);
|
||||
res.send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create mock request/response objects for SmartAcme
|
||||
let responseData: any = null;
|
||||
const mockReq = {
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers
|
||||
};
|
||||
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
setHeader: (name: string, value: string) => {},
|
||||
end: (data: any) => {
|
||||
responseData = data;
|
||||
}
|
||||
};
|
||||
|
||||
// Use SmartAcme's handler
|
||||
const handleAcme = () => {
|
||||
http01Handler.handleRequest(mockReq as any, mockRes as any, () => {
|
||||
// Not handled by ACME
|
||||
res.status(404);
|
||||
res.send('Not found');
|
||||
});
|
||||
|
||||
// Give it a moment to process, then send response
|
||||
setTimeout(() => {
|
||||
if (responseData) {
|
||||
res.header('Content-Type', 'text/plain');
|
||||
res.send(String(responseData));
|
||||
} else {
|
||||
res.status(404);
|
||||
res.send('Not found');
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
handleAcme();
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Store the challenge route to add it when needed
|
||||
this.challengeRoute = challengeRoute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop certificate manager
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.renewalTimer) {
|
||||
clearInterval(this.renewalTimer);
|
||||
this.renewalTimer = null;
|
||||
}
|
||||
|
||||
// Always remove challenge route on shutdown
|
||||
if (this.challengeRoute) {
|
||||
logger.log('info', 'Removing ACME challenge route during shutdown', { component: 'certificate-manager' });
|
||||
await this.removeChallengeRoute();
|
||||
}
|
||||
|
||||
if (this.smartAcme) {
|
||||
await this.smartAcme.stop();
|
||||
}
|
||||
|
||||
// Clear any pending challenges
|
||||
if (this.pendingChallenges.size > 0) {
|
||||
this.pendingChallenges.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ACME options (for recreating after route updates)
|
||||
*/
|
||||
public getAcmeOptions(): { email?: string; useProduction?: boolean; port?: number } | undefined {
|
||||
return this.acmeOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get certificate manager state
|
||||
*/
|
||||
public getState(): { challengeRouteActive: boolean } {
|
||||
return {
|
||||
challengeRouteActive: this.challengeRouteActive
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,809 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IConnectionRecord } from './models/interfaces.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
||||
import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
import { ProtocolDetector } from '../../detection/index.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
/**
|
||||
* Manages connection lifecycle, tracking, and cleanup with performance optimizations
|
||||
*/
|
||||
export class ConnectionManager extends LifecycleComponent {
|
||||
private connectionRecords: Map<string, IConnectionRecord> = new Map();
|
||||
private terminationStats: {
|
||||
incoming: Record<string, number>;
|
||||
outgoing: Record<string, number>;
|
||||
} = { incoming: {}, outgoing: {} };
|
||||
|
||||
// Performance optimization: Track connections needing inactivity check
|
||||
private nextInactivityCheck: Map<string, number> = new Map();
|
||||
|
||||
// Connection limits
|
||||
private readonly maxConnections: number;
|
||||
private readonly cleanupBatchSize: number = 100;
|
||||
|
||||
// Cleanup queue for batched processing
|
||||
private cleanupQueue: Set<string> = new Set();
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
private isProcessingCleanup: boolean = false;
|
||||
|
||||
// Route-level connection tracking
|
||||
private connectionsByRoute: Map<string, Set<string>> = new Map();
|
||||
|
||||
constructor(
|
||||
private smartProxy: SmartProxy
|
||||
) {
|
||||
super();
|
||||
|
||||
// Set reasonable defaults for connection limits
|
||||
this.maxConnections = smartProxy.settings.defaults?.security?.maxConnections || 10000;
|
||||
|
||||
// Start inactivity check timer if not disabled
|
||||
if (!smartProxy.settings.disableInactivityCheck) {
|
||||
this.startInactivityCheckTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique connection ID
|
||||
*/
|
||||
public generateConnectionId(): string {
|
||||
return Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and track a new connection
|
||||
* Accepts either a regular net.Socket or a WrappedSocket for transparent PROXY protocol support
|
||||
*
|
||||
* @param socket - The socket for the connection
|
||||
* @param options - Optional configuration
|
||||
* @param options.connectionId - Pre-generated connection ID (for atomic IP tracking)
|
||||
* @param options.skipIpTracking - Skip IP tracking (if already done atomically)
|
||||
*/
|
||||
public createConnection(
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
options?: { connectionId?: string; skipIpTracking?: boolean }
|
||||
): IConnectionRecord | null {
|
||||
// Enforce connection limit
|
||||
if (this.connectionRecords.size >= this.maxConnections) {
|
||||
// Use deduplicated logging for connection limit
|
||||
connectionLogDeduplicator.log(
|
||||
'connection-rejected',
|
||||
'warn',
|
||||
'Global connection limit reached',
|
||||
{
|
||||
reason: 'global-limit',
|
||||
currentConnections: this.connectionRecords.size,
|
||||
maxConnections: this.maxConnections,
|
||||
component: 'connection-manager'
|
||||
},
|
||||
'global-limit'
|
||||
);
|
||||
socket.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionId = options?.connectionId || this.generateConnectionId();
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
const remotePort = socket.remotePort || 0;
|
||||
const localPort = socket.localPort || 0;
|
||||
const now = Date.now();
|
||||
|
||||
const record: IConnectionRecord = {
|
||||
id: connectionId,
|
||||
incoming: socket,
|
||||
outgoing: null,
|
||||
incomingStartTime: now,
|
||||
lastActivity: now,
|
||||
connectionClosed: false,
|
||||
pendingData: [],
|
||||
pendingDataSize: 0,
|
||||
bytesReceived: 0,
|
||||
bytesSent: 0,
|
||||
remoteIP,
|
||||
remotePort,
|
||||
localPort,
|
||||
isTLS: false,
|
||||
tlsHandshakeComplete: false,
|
||||
hasReceivedInitialData: false,
|
||||
hasKeepAlive: false,
|
||||
incomingTerminationReason: null,
|
||||
outgoingTerminationReason: null,
|
||||
usingNetworkProxy: false,
|
||||
isBrowserConnection: false,
|
||||
domainSwitches: 0
|
||||
};
|
||||
|
||||
this.trackConnection(connectionId, record, options?.skipIpTracking);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an existing connection
|
||||
* @param connectionId - The connection ID
|
||||
* @param record - The connection record
|
||||
* @param skipIpTracking - Skip IP tracking if already done atomically
|
||||
*/
|
||||
public trackConnection(connectionId: string, record: IConnectionRecord, skipIpTracking?: boolean): void {
|
||||
this.connectionRecords.set(connectionId, record);
|
||||
if (!skipIpTracking) {
|
||||
this.smartProxy.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
|
||||
}
|
||||
|
||||
// Schedule inactivity check
|
||||
if (!this.smartProxy.settings.disableInactivityCheck) {
|
||||
this.scheduleInactivityCheck(connectionId, record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next inactivity check for a connection
|
||||
*/
|
||||
private scheduleInactivityCheck(connectionId: string, record: IConnectionRecord): void {
|
||||
let timeout = this.smartProxy.settings.inactivityTimeout!;
|
||||
|
||||
if (record.hasKeepAlive) {
|
||||
if (this.smartProxy.settings.keepAliveTreatment === 'immortal') {
|
||||
// Don't schedule check for immortal connections
|
||||
return;
|
||||
} else if (this.smartProxy.settings.keepAliveTreatment === 'extended') {
|
||||
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
|
||||
timeout = timeout * multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const checkTime = Date.now() + timeout;
|
||||
this.nextInactivityCheck.set(connectionId, checkTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the inactivity check timer
|
||||
*/
|
||||
private startInactivityCheckTimer(): void {
|
||||
// Check more frequently (every 10 seconds) to catch zombies and stuck connections faster
|
||||
this.setInterval(() => {
|
||||
this.performOptimizedInactivityCheck();
|
||||
}, 10000);
|
||||
// Note: LifecycleComponent's setInterval already calls unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a connection by ID
|
||||
*/
|
||||
public getConnection(connectionId: string): IConnectionRecord | undefined {
|
||||
return this.connectionRecords.get(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active connections
|
||||
*/
|
||||
public getConnections(): Map<string, IConnectionRecord> {
|
||||
return this.connectionRecords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of active connections
|
||||
*/
|
||||
public getConnectionCount(): number {
|
||||
return this.connectionRecords.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track connection by route
|
||||
*/
|
||||
public trackConnectionByRoute(routeId: string, connectionId: string): void {
|
||||
if (!this.connectionsByRoute.has(routeId)) {
|
||||
this.connectionsByRoute.set(routeId, new Set());
|
||||
}
|
||||
this.connectionsByRoute.get(routeId)!.add(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove connection tracking for a route
|
||||
*/
|
||||
public removeConnectionByRoute(routeId: string, connectionId: string): void {
|
||||
if (this.connectionsByRoute.has(routeId)) {
|
||||
const connections = this.connectionsByRoute.get(routeId)!;
|
||||
connections.delete(connectionId);
|
||||
if (connections.size === 0) {
|
||||
this.connectionsByRoute.delete(routeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection count by route
|
||||
*/
|
||||
public getConnectionCountByRoute(routeId: string): number {
|
||||
return this.connectionsByRoute.get(routeId)?.size || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates cleanup once for a connection
|
||||
*/
|
||||
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||
// Use deduplicated logging for cleanup events
|
||||
connectionLogDeduplicator.log(
|
||||
'connection-cleanup',
|
||||
'info',
|
||||
`Connection cleanup: ${reason}`,
|
||||
{
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
reason,
|
||||
component: 'connection-manager'
|
||||
},
|
||||
reason
|
||||
);
|
||||
|
||||
if (record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
}
|
||||
|
||||
// Add to cleanup queue for batched processing
|
||||
this.queueCleanup(record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a connection for cleanup
|
||||
*/
|
||||
private queueCleanup(connectionId: string): void {
|
||||
// Check if connection is already being processed
|
||||
const record = this.connectionRecords.get(connectionId);
|
||||
if (!record || record.connectionClosed) {
|
||||
// Already cleaned up or doesn't exist, skip
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupQueue.add(connectionId);
|
||||
|
||||
// Process immediately if queue is getting large and not already processing
|
||||
if (this.cleanupQueue.size >= this.cleanupBatchSize && !this.isProcessingCleanup) {
|
||||
this.processCleanupQueue();
|
||||
} else if (!this.cleanupTimer && !this.isProcessingCleanup) {
|
||||
// Otherwise, schedule batch processing
|
||||
this.cleanupTimer = this.setTimeout(() => {
|
||||
this.processCleanupQueue();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the cleanup queue in batches
|
||||
*/
|
||||
private processCleanupQueue(): void {
|
||||
// Prevent concurrent processing
|
||||
if (this.isProcessingCleanup) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingCleanup = true;
|
||||
|
||||
if (this.cleanupTimer) {
|
||||
this.clearTimeout(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Take a snapshot of items to process
|
||||
const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize);
|
||||
|
||||
// Remove only the items we're processing from the queue
|
||||
for (const connectionId of toCleanup) {
|
||||
this.cleanupQueue.delete(connectionId);
|
||||
const record = this.connectionRecords.get(connectionId);
|
||||
if (record) {
|
||||
this.cleanupConnection(record, record.incomingTerminationReason || 'normal');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Always reset the processing flag
|
||||
this.isProcessingCleanup = false;
|
||||
|
||||
// Check if more items were added while we were processing
|
||||
if (this.cleanupQueue.size > 0) {
|
||||
this.cleanupTimer = this.setTimeout(() => {
|
||||
this.processCleanupQueue();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a connection record
|
||||
*/
|
||||
public cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||
if (!record.connectionClosed) {
|
||||
record.connectionClosed = true;
|
||||
|
||||
// Remove from inactivity check
|
||||
this.nextInactivityCheck.delete(record.id);
|
||||
|
||||
// Track connection termination
|
||||
this.smartProxy.securityManager.removeConnectionByIP(record.remoteIP, record.id);
|
||||
|
||||
// Remove from route tracking
|
||||
if (record.routeId) {
|
||||
this.removeConnectionByRoute(record.routeId, record.id);
|
||||
}
|
||||
|
||||
// Remove from metrics tracking
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.removeConnection(record.id);
|
||||
}
|
||||
|
||||
// Clean up protocol detection fragments
|
||||
const context = ProtocolDetector.createConnectionContext({
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: record.incoming?.remotePort || 0,
|
||||
destIp: record.incoming?.localAddress || '',
|
||||
destPort: record.localPort,
|
||||
socketId: record.id
|
||||
});
|
||||
|
||||
// Clean up any pending detection fragments for this connection
|
||||
ProtocolDetector.cleanupConnection(context);
|
||||
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// Calculate metrics once
|
||||
const duration = Date.now() - record.incomingStartTime;
|
||||
const logData = {
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
localPort: record.localPort,
|
||||
reason,
|
||||
duration: plugins.prettyMs(duration),
|
||||
bytes: { in: record.bytesReceived, out: record.bytesSent },
|
||||
tls: record.isTLS,
|
||||
keepAlive: record.hasKeepAlive,
|
||||
usingNetworkProxy: record.usingNetworkProxy,
|
||||
domainSwitches: record.domainSwitches || 0,
|
||||
component: 'connection-manager'
|
||||
};
|
||||
|
||||
// Remove all data handlers to make sure we clean up properly
|
||||
if (record.incoming) {
|
||||
try {
|
||||
record.incoming.removeAllListeners('data');
|
||||
record.renegotiationHandler = undefined;
|
||||
} catch (err) {
|
||||
logger.log('error', `Error removing data handlers: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle socket cleanup - check if sockets are still active
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
|
||||
if (record.incoming) {
|
||||
// Extract underlying socket if it's a WrappedSocket
|
||||
const incomingSocket = record.incoming instanceof WrappedSocket ? record.incoming.socket : record.incoming;
|
||||
if (!record.incoming.writable || record.incoming.destroyed) {
|
||||
// Socket is not active, clean up immediately
|
||||
cleanupPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming`, { immediate: true }));
|
||||
} else {
|
||||
// Socket is still active, allow graceful cleanup
|
||||
cleanupPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming`, { allowDrain: true, gracePeriod: 5000 }));
|
||||
}
|
||||
}
|
||||
|
||||
if (record.outgoing) {
|
||||
// Extract underlying socket if it's a WrappedSocket
|
||||
const outgoingSocket = record.outgoing instanceof WrappedSocket ? record.outgoing.socket : record.outgoing;
|
||||
if (!record.outgoing.writable || record.outgoing.destroyed) {
|
||||
// Socket is not active, clean up immediately
|
||||
cleanupPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing`, { immediate: true }));
|
||||
} else {
|
||||
// Socket is still active, allow graceful cleanup
|
||||
cleanupPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing`, { allowDrain: true, gracePeriod: 5000 }));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for cleanup to complete
|
||||
Promise.all(cleanupPromises).catch(err => {
|
||||
logger.log('error', `Error during socket cleanup: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
});
|
||||
|
||||
// Clear pendingData to avoid memory leaks
|
||||
record.pendingData = [];
|
||||
record.pendingDataSize = 0;
|
||||
|
||||
// Remove the record from the tracking map
|
||||
this.connectionRecords.delete(record.id);
|
||||
|
||||
// Use deduplicated logging for connection termination
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
// For detailed logging, include more info but still deduplicate by IP+reason
|
||||
connectionLogDeduplicator.log(
|
||||
'connection-terminated',
|
||||
'info',
|
||||
`Connection terminated: ${record.remoteIP}:${record.localPort}`,
|
||||
{
|
||||
...logData,
|
||||
duration_ms: duration,
|
||||
bytesIn: record.bytesReceived,
|
||||
bytesOut: record.bytesSent
|
||||
},
|
||||
`${record.remoteIP}-${reason}`
|
||||
);
|
||||
} else {
|
||||
// For normal logging, deduplicate by termination reason
|
||||
connectionLogDeduplicator.log(
|
||||
'connection-terminated',
|
||||
'info',
|
||||
`Connection terminated`,
|
||||
{
|
||||
remoteIP: record.remoteIP,
|
||||
reason,
|
||||
activeConnections: this.connectionRecords.size,
|
||||
component: 'connection-manager'
|
||||
},
|
||||
reason // Group by termination reason
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a generic error handler for incoming or outgoing sockets
|
||||
*/
|
||||
public handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
|
||||
return (err: Error) => {
|
||||
const code = (err as any).code;
|
||||
let reason = 'error';
|
||||
|
||||
const now = Date.now();
|
||||
const connectionDuration = now - record.incomingStartTime;
|
||||
const lastActivityAge = now - record.lastActivity;
|
||||
|
||||
// Update activity tracking
|
||||
if (side === 'incoming') {
|
||||
record.lastActivity = now;
|
||||
this.scheduleInactivityCheck(record.id, record);
|
||||
}
|
||||
|
||||
const errorData = {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
};
|
||||
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
reason = 'econnreset';
|
||||
logger.log('warn', `ECONNRESET on ${side}: ${record.remoteIP}`, errorData);
|
||||
break;
|
||||
case 'ETIMEDOUT':
|
||||
reason = 'etimedout';
|
||||
logger.log('warn', `ETIMEDOUT on ${side}: ${record.remoteIP}`, errorData);
|
||||
break;
|
||||
default:
|
||||
logger.log('error', `Error on ${side}: ${record.remoteIP} - ${err.message}`, errorData);
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason == null) {
|
||||
record.outgoingTerminationReason = reason;
|
||||
this.incrementTerminationStat('outgoing', reason);
|
||||
}
|
||||
|
||||
this.initiateCleanupOnce(record, reason);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a generic close handler for incoming or outgoing sockets
|
||||
*/
|
||||
public handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
|
||||
return () => {
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection closed on ${side} side`, {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = 'normal';
|
||||
this.incrementTerminationStat('incoming', 'normal');
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason == null) {
|
||||
record.outgoingTerminationReason = 'normal';
|
||||
this.incrementTerminationStat('outgoing', 'normal');
|
||||
record.outgoingClosedTime = Date.now();
|
||||
}
|
||||
|
||||
this.initiateCleanupOnce(record, 'closed_' + side);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment termination statistics
|
||||
*/
|
||||
public incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
||||
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get termination statistics
|
||||
*/
|
||||
public getTerminationStats(): { incoming: Record<string, number>; outgoing: Record<string, number> } {
|
||||
return this.terminationStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized inactivity check - only checks connections that are due
|
||||
*/
|
||||
private performOptimizedInactivityCheck(): void {
|
||||
const now = Date.now();
|
||||
const connectionsToCheck: string[] = [];
|
||||
|
||||
// Find connections that need checking
|
||||
for (const [connectionId, checkTime] of this.nextInactivityCheck) {
|
||||
if (checkTime <= now) {
|
||||
connectionsToCheck.push(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check ALL connections for zombie state (destroyed sockets but not cleaned up)
|
||||
// This is critical for proxy chains where sockets can be destroyed without events
|
||||
for (const [connectionId, record] of this.connectionRecords) {
|
||||
if (!record.connectionClosed) {
|
||||
const incomingDestroyed = record.incoming?.destroyed || false;
|
||||
const outgoingDestroyed = record.outgoing?.destroyed || false;
|
||||
|
||||
// Check for zombie connections: both sockets destroyed but connection not cleaned up
|
||||
if (incomingDestroyed && outgoingDestroyed) {
|
||||
logger.log('warn', `Zombie connection detected: ${connectionId} - both sockets destroyed but not cleaned up`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
age: plugins.prettyMs(now - record.incomingStartTime),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
|
||||
// Clean up immediately
|
||||
this.cleanupConnection(record, 'zombie_cleanup');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for half-zombie: one socket destroyed
|
||||
if (incomingDestroyed || outgoingDestroyed) {
|
||||
const age = now - record.incomingStartTime;
|
||||
// Use longer grace period for encrypted connections (5 minutes vs 30 seconds)
|
||||
const gracePeriod = record.isTLS ? 300000 : 30000;
|
||||
|
||||
// Also ensure connection is old enough to avoid premature cleanup
|
||||
if (age > gracePeriod && age > 10000) {
|
||||
logger.log('warn', `Half-zombie connection detected: ${connectionId} - ${incomingDestroyed ? 'incoming' : 'outgoing'} destroyed`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
age: plugins.prettyMs(age),
|
||||
incomingDestroyed,
|
||||
outgoingDestroyed,
|
||||
isTLS: record.isTLS,
|
||||
gracePeriod: plugins.prettyMs(gracePeriod),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.cleanupConnection(record, 'half_zombie_cleanup');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stuck connections: no data sent back to client
|
||||
if (!record.connectionClosed && record.outgoing && record.bytesReceived > 0 && record.bytesSent === 0) {
|
||||
const age = now - record.incomingStartTime;
|
||||
// Use longer grace period for encrypted connections (5 minutes vs 60 seconds)
|
||||
const stuckThreshold = record.isTLS ? 300000 : 60000;
|
||||
|
||||
// If connection is older than threshold and no data sent back, likely stuck
|
||||
if (age > stuckThreshold) {
|
||||
logger.log('warn', `Stuck connection detected: ${connectionId} - received ${record.bytesReceived} bytes but sent 0 bytes`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
age: plugins.prettyMs(age),
|
||||
bytesReceived: record.bytesReceived,
|
||||
targetHost: record.targetHost,
|
||||
targetPort: record.targetPort,
|
||||
isTLS: record.isTLS,
|
||||
threshold: plugins.prettyMs(stuckThreshold),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
|
||||
// Set termination reason and increment stats
|
||||
if (record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = 'stuck_no_response';
|
||||
this.incrementTerminationStat('incoming', 'stuck_no_response');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.cleanupConnection(record, 'stuck_no_response');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process only connections that need checking
|
||||
for (const connectionId of connectionsToCheck) {
|
||||
const record = this.connectionRecords.get(connectionId);
|
||||
if (!record || record.connectionClosed) {
|
||||
this.nextInactivityCheck.delete(connectionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const inactivityTime = now - record.lastActivity;
|
||||
|
||||
// Use extended timeout for extended-treatment keep-alive connections
|
||||
let effectiveTimeout = this.smartProxy.settings.inactivityTimeout!;
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
|
||||
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
|
||||
effectiveTimeout = effectiveTimeout * multiplier;
|
||||
}
|
||||
|
||||
if (inactivityTime > effectiveTimeout) {
|
||||
// For keep-alive connections, issue a warning first
|
||||
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
|
||||
logger.log('warn', `Keep-alive connection inactive: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
inactiveFor: plugins.prettyMs(inactivityTime),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
|
||||
record.inactivityWarningIssued = true;
|
||||
|
||||
// Reschedule check for 10 minutes later
|
||||
this.nextInactivityCheck.set(connectionId, now + 600000);
|
||||
|
||||
// Try to stimulate activity with a probe packet
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
try {
|
||||
record.outgoing.write(Buffer.alloc(0));
|
||||
} catch (err) {
|
||||
logger.log('error', `Error sending probe packet: ${err}`, {
|
||||
connectionId,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Close the connection
|
||||
logger.log('warn', `Closing inactive connection: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
inactiveFor: plugins.prettyMs(inactivityTime),
|
||||
hasKeepAlive: record.hasKeepAlive,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
this.cleanupConnection(record, 'inactivity');
|
||||
}
|
||||
} else {
|
||||
// Reschedule next check
|
||||
this.scheduleInactivityCheck(connectionId, record);
|
||||
}
|
||||
|
||||
// Parity check: if outgoing socket closed and incoming remains active
|
||||
// Increased from 2 minutes to 30 minutes for long-lived connections
|
||||
if (
|
||||
record.outgoingClosedTime &&
|
||||
!record.incoming.destroyed &&
|
||||
!record.connectionClosed &&
|
||||
now - record.outgoingClosedTime > 1800000 // 30 minutes
|
||||
) {
|
||||
// Only close if no data activity for 10 minutes
|
||||
if (now - record.lastActivity > 600000) {
|
||||
logger.log('warn', `Parity check failed after extended timeout: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
timeElapsed: plugins.prettyMs(now - record.outgoingClosedTime),
|
||||
inactiveFor: plugins.prettyMs(now - record.lastActivity),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
this.cleanupConnection(record, 'parity_check');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility
|
||||
*/
|
||||
public performInactivityCheck(): void {
|
||||
this.performOptimizedInactivityCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all connections (for shutdown)
|
||||
*/
|
||||
public async clearConnections(): Promise<void> {
|
||||
// Delegate to LifecycleComponent's cleanup
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override LifecycleComponent's onCleanup method
|
||||
*/
|
||||
protected async onCleanup(): Promise<void> {
|
||||
|
||||
// Process connections in batches to avoid blocking
|
||||
const connections = Array.from(this.connectionRecords.values());
|
||||
const batchSize = 100;
|
||||
let index = 0;
|
||||
|
||||
const processBatch = () => {
|
||||
const batch = connections.slice(index, index + batchSize);
|
||||
|
||||
for (const record of batch) {
|
||||
try {
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// Immediate destruction using socket-utils
|
||||
const shutdownPromises: Promise<void>[] = [];
|
||||
|
||||
if (record.incoming) {
|
||||
const incomingSocket = record.incoming instanceof WrappedSocket ? record.incoming.socket : record.incoming;
|
||||
shutdownPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming-shutdown`, { immediate: true }));
|
||||
}
|
||||
|
||||
if (record.outgoing) {
|
||||
const outgoingSocket = record.outgoing instanceof WrappedSocket ? record.outgoing.socket : record.outgoing;
|
||||
shutdownPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing-shutdown`, { immediate: true }));
|
||||
}
|
||||
|
||||
// Don't wait for shutdown cleanup in this batch processing
|
||||
Promise.all(shutdownPromises).catch(() => {});
|
||||
} catch (err) {
|
||||
logger.log('error', `Error during connection cleanup: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
index += batchSize;
|
||||
|
||||
// Continue with next batch if needed
|
||||
if (index < connections.length) {
|
||||
setImmediate(processBatch);
|
||||
} else {
|
||||
// Clear all maps
|
||||
this.connectionRecords.clear();
|
||||
this.nextInactivityCheck.clear();
|
||||
this.cleanupQueue.clear();
|
||||
this.terminationStats = { incoming: {}, outgoing: {} };
|
||||
}
|
||||
};
|
||||
|
||||
// Start batch processing
|
||||
setImmediate(processBatch);
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { HttpProxy } from '../http-proxy/index.js';
|
||||
import { setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
||||
import type { IConnectionRecord } from './models/interfaces.js';
|
||||
import type { IRouteConfig } from './models/route-types.js';
|
||||
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
export class HttpProxyBridge {
|
||||
private httpProxy: HttpProxy | null = null;
|
||||
|
||||
constructor(private smartProxy: SmartProxy) {}
|
||||
|
||||
/**
|
||||
* Get the HttpProxy instance
|
||||
*/
|
||||
public getHttpProxy(): HttpProxy | null {
|
||||
return this.httpProxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize HttpProxy instance
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (!this.httpProxy && this.smartProxy.settings.useHttpProxy && this.smartProxy.settings.useHttpProxy.length > 0) {
|
||||
const httpProxyOptions: any = {
|
||||
port: this.smartProxy.settings.httpProxyPort!,
|
||||
portProxyIntegration: true,
|
||||
logLevel: this.smartProxy.settings.enableDetailedLogging ? 'debug' : 'info'
|
||||
};
|
||||
|
||||
this.httpProxy = new HttpProxy(httpProxyOptions);
|
||||
console.log(`Initialized HttpProxy on port ${this.smartProxy.settings.httpProxyPort}`);
|
||||
|
||||
// Apply route configurations to HttpProxy
|
||||
await this.syncRoutesToHttpProxy(this.smartProxy.settings.routes || []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync routes to HttpProxy
|
||||
*/
|
||||
public async syncRoutesToHttpProxy(routes: IRouteConfig[]): Promise<void> {
|
||||
if (!this.httpProxy) return;
|
||||
|
||||
// Convert routes to HttpProxy format
|
||||
const httpProxyConfigs = routes
|
||||
.filter(route => {
|
||||
// Check if this route matches any of the specified network proxy ports
|
||||
const routePorts = Array.isArray(route.match.ports)
|
||||
? route.match.ports
|
||||
: [route.match.ports];
|
||||
|
||||
return routePorts.some(port =>
|
||||
this.smartProxy.settings.useHttpProxy?.includes(port)
|
||||
);
|
||||
})
|
||||
.map(route => this.routeToHttpProxyConfig(route));
|
||||
|
||||
// Apply configurations to HttpProxy
|
||||
await this.httpProxy.updateRouteConfigs(httpProxyConfigs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert route to HttpProxy configuration
|
||||
*/
|
||||
private routeToHttpProxyConfig(route: IRouteConfig): any {
|
||||
// Convert route to HttpProxy domain config format
|
||||
let domain = '*';
|
||||
if (route.match.domains) {
|
||||
if (Array.isArray(route.match.domains)) {
|
||||
domain = route.match.domains[0] || '*';
|
||||
} else {
|
||||
domain = route.match.domains;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...route, // Keep the original route structure
|
||||
match: {
|
||||
...route.match,
|
||||
domains: domain // Ensure domains is always set for HttpProxy
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection should use HttpProxy
|
||||
*/
|
||||
public shouldUseHttpProxy(connection: IConnectionRecord, routeMatch: any): boolean {
|
||||
// Only use HttpProxy for TLS termination
|
||||
return (
|
||||
routeMatch.route.action.tls?.mode === 'terminate' ||
|
||||
routeMatch.route.action.tls?.mode === 'terminate-and-reencrypt'
|
||||
) && this.httpProxy !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward connection to HttpProxy
|
||||
*/
|
||||
public async forwardToHttpProxy(
|
||||
connectionId: string,
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
initialChunk: Buffer,
|
||||
httpProxyPort: number,
|
||||
cleanupCallback: (reason: string) => void
|
||||
): Promise<void> {
|
||||
if (!this.httpProxy) {
|
||||
throw new Error('HttpProxy not initialized');
|
||||
}
|
||||
|
||||
// Check if client socket is already destroyed before proceeding
|
||||
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
|
||||
if (underlyingSocket.destroyed) {
|
||||
console.log(`[${connectionId}] Client socket already destroyed, skipping HttpProxy forwarding`);
|
||||
cleanupCallback('client_disconnected_before_proxy');
|
||||
return;
|
||||
}
|
||||
|
||||
const proxySocket = new plugins.net.Socket();
|
||||
|
||||
// Handle client disconnect during proxy connection setup
|
||||
const clientDisconnectHandler = () => {
|
||||
console.log(`[${connectionId}] Client disconnected during HttpProxy connection setup`);
|
||||
proxySocket.destroy();
|
||||
cleanupCallback('client_disconnected_during_setup');
|
||||
};
|
||||
underlyingSocket.once('close', clientDisconnectHandler);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
proxySocket.connect(httpProxyPort, 'localhost', () => {
|
||||
console.log(`[${connectionId}] Connected to HttpProxy for termination`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
proxySocket.on('error', reject);
|
||||
});
|
||||
} finally {
|
||||
// Remove the disconnect handler after connection attempt
|
||||
underlyingSocket.removeListener('close', clientDisconnectHandler);
|
||||
}
|
||||
|
||||
// Double-check client socket is still connected after async operation
|
||||
if (underlyingSocket.destroyed) {
|
||||
console.log(`[${connectionId}] Client disconnected while connecting to HttpProxy`);
|
||||
proxySocket.destroy();
|
||||
cleanupCallback('client_disconnected_after_proxy_connect');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send client IP information header first (custom protocol)
|
||||
// Format: "CLIENT_IP:<ip>\r\n"
|
||||
const clientIPHeader = Buffer.from(`CLIENT_IP:${record.remoteIP}\r\n`);
|
||||
proxySocket.write(clientIPHeader);
|
||||
|
||||
// Send initial chunk if present
|
||||
if (initialChunk) {
|
||||
// Count the initial chunk bytes
|
||||
record.bytesReceived += initialChunk.length;
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.recordBytes(record.id, initialChunk.length, 0);
|
||||
}
|
||||
proxySocket.write(initialChunk);
|
||||
}
|
||||
|
||||
// Use centralized bidirectional forwarding (underlyingSocket already extracted above)
|
||||
setupBidirectionalForwarding(underlyingSocket, proxySocket, {
|
||||
onClientData: (chunk) => {
|
||||
// Update stats - this is the ONLY place bytes are counted for HttpProxy connections
|
||||
if (record) {
|
||||
record.bytesReceived += chunk.length;
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.recordBytes(record.id, chunk.length, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
onServerData: (chunk) => {
|
||||
// Update stats - this is the ONLY place bytes are counted for HttpProxy connections
|
||||
if (record) {
|
||||
record.bytesSent += chunk.length;
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.recordBytes(record.id, 0, chunk.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
onCleanup: (reason) => {
|
||||
cleanupCallback(reason);
|
||||
},
|
||||
enableHalfOpen: false // Close both when one closes (required for proxy chains)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start HttpProxy
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.httpProxy) {
|
||||
await this.httpProxy.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop HttpProxy
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.httpProxy) {
|
||||
await this.httpProxy.stop();
|
||||
this.httpProxy = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* SmartProxy implementation
|
||||
*
|
||||
* Version 14.0.0: Unified Route-Based Configuration API
|
||||
* Version 23.0.0: Rust-backed proxy engine
|
||||
*/
|
||||
// Re-export models
|
||||
export * from './models/index.js';
|
||||
@@ -9,21 +9,14 @@ export * from './models/index.js';
|
||||
// Export the main SmartProxy class
|
||||
export { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
// Export core supporting classes
|
||||
export { ConnectionManager } from './connection-manager.js';
|
||||
export { SecurityManager } from './security-manager.js';
|
||||
export { TimeoutManager } from './timeout-manager.js';
|
||||
export { TlsManager } from './tls-manager.js';
|
||||
export { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
// Export Rust bridge and helpers
|
||||
export { RustProxyBridge } from './rust-proxy-bridge.js';
|
||||
export { RoutePreprocessor } from './route-preprocessor.js';
|
||||
export { SocketHandlerServer } from './socket-handler-server.js';
|
||||
export { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
||||
|
||||
// Export route-based components
|
||||
export { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
export { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
export { NFTablesManager } from './nftables-manager.js';
|
||||
export { RouteOrchestrator } from './route-orchestrator.js';
|
||||
|
||||
// Export certificate management
|
||||
export { SmartCertManager } from './certificate-manager.js';
|
||||
|
||||
// Export all helper functions from the utils directory
|
||||
export * from './utils/index.js';
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
import type {
|
||||
IMetrics,
|
||||
IThroughputData,
|
||||
IThroughputHistoryPoint,
|
||||
IByteTracker
|
||||
} from './models/metrics-types.js';
|
||||
import { ThroughputTracker } from './throughput-tracker.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
|
||||
/**
|
||||
* Collects and provides metrics for SmartProxy with clean API
|
||||
*/
|
||||
export class MetricsCollector implements IMetrics {
|
||||
// Throughput tracking
|
||||
private throughputTracker: ThroughputTracker;
|
||||
private routeThroughputTrackers = new Map<string, ThroughputTracker>();
|
||||
private ipThroughputTrackers = new Map<string, ThroughputTracker>();
|
||||
|
||||
// Request tracking
|
||||
private requestTimestamps: number[] = [];
|
||||
private totalRequests: number = 0;
|
||||
|
||||
// Connection byte tracking for per-route/IP metrics
|
||||
private connectionByteTrackers = new Map<string, IByteTracker>();
|
||||
|
||||
// Subscriptions
|
||||
private samplingInterval?: NodeJS.Timeout;
|
||||
private connectionSubscription?: plugins.smartrx.rxjs.Subscription;
|
||||
|
||||
// Configuration
|
||||
private readonly sampleIntervalMs: number;
|
||||
private readonly retentionSeconds: number;
|
||||
|
||||
// Track connection durations for percentile calculations
|
||||
private connectionDurations: number[] = [];
|
||||
private bytesInArray: number[] = [];
|
||||
private bytesOutArray: number[] = [];
|
||||
|
||||
constructor(
|
||||
private smartProxy: SmartProxy,
|
||||
config?: {
|
||||
sampleIntervalMs?: number;
|
||||
retentionSeconds?: number;
|
||||
}
|
||||
) {
|
||||
this.sampleIntervalMs = config?.sampleIntervalMs || 1000;
|
||||
this.retentionSeconds = config?.retentionSeconds || 3600;
|
||||
this.throughputTracker = new ThroughputTracker(this.retentionSeconds);
|
||||
}
|
||||
|
||||
// Connection metrics implementation
|
||||
public connections = {
|
||||
active: (): number => {
|
||||
return this.smartProxy.connectionManager.getConnectionCount();
|
||||
},
|
||||
|
||||
total: (): number => {
|
||||
const stats = this.smartProxy.connectionManager.getTerminationStats();
|
||||
let total = this.smartProxy.connectionManager.getConnectionCount();
|
||||
|
||||
for (const reason in stats.incoming) {
|
||||
total += stats.incoming[reason];
|
||||
}
|
||||
|
||||
return total;
|
||||
},
|
||||
|
||||
byRoute: (): Map<string, number> => {
|
||||
const routeCounts = new Map<string, number>();
|
||||
const connections = this.smartProxy.connectionManager.getConnections();
|
||||
|
||||
for (const [_, record] of connections) {
|
||||
const routeName = (record as any).routeName ||
|
||||
record.routeConfig?.name ||
|
||||
'unknown';
|
||||
|
||||
const current = routeCounts.get(routeName) || 0;
|
||||
routeCounts.set(routeName, current + 1);
|
||||
}
|
||||
|
||||
return routeCounts;
|
||||
},
|
||||
|
||||
byIP: (): Map<string, number> => {
|
||||
const ipCounts = new Map<string, number>();
|
||||
|
||||
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
|
||||
const ip = record.remoteIP;
|
||||
const current = ipCounts.get(ip) || 0;
|
||||
ipCounts.set(ip, current + 1);
|
||||
}
|
||||
|
||||
return ipCounts;
|
||||
},
|
||||
|
||||
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
|
||||
const ipCounts = this.connections.byIP();
|
||||
return Array.from(ipCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([ip, count]) => ({ ip, count }));
|
||||
}
|
||||
};
|
||||
|
||||
// Throughput metrics implementation
|
||||
public throughput = {
|
||||
instant: (): IThroughputData => {
|
||||
return this.throughputTracker.getRate(1);
|
||||
},
|
||||
|
||||
recent: (): IThroughputData => {
|
||||
return this.throughputTracker.getRate(10);
|
||||
},
|
||||
|
||||
average: (): IThroughputData => {
|
||||
return this.throughputTracker.getRate(60);
|
||||
},
|
||||
|
||||
custom: (seconds: number): IThroughputData => {
|
||||
return this.throughputTracker.getRate(seconds);
|
||||
},
|
||||
|
||||
history: (seconds: number): Array<IThroughputHistoryPoint> => {
|
||||
return this.throughputTracker.getHistory(seconds);
|
||||
},
|
||||
|
||||
byRoute: (windowSeconds: number = 1): Map<string, IThroughputData> => {
|
||||
const routeThroughput = new Map<string, IThroughputData>();
|
||||
|
||||
// Get throughput from each route's dedicated tracker
|
||||
for (const [route, tracker] of this.routeThroughputTrackers) {
|
||||
const rate = tracker.getRate(windowSeconds);
|
||||
if (rate.in > 0 || rate.out > 0) {
|
||||
routeThroughput.set(route, rate);
|
||||
}
|
||||
}
|
||||
|
||||
return routeThroughput;
|
||||
},
|
||||
|
||||
byIP: (windowSeconds: number = 1): Map<string, IThroughputData> => {
|
||||
const ipThroughput = new Map<string, IThroughputData>();
|
||||
|
||||
// Get throughput from each IP's dedicated tracker
|
||||
for (const [ip, tracker] of this.ipThroughputTrackers) {
|
||||
const rate = tracker.getRate(windowSeconds);
|
||||
if (rate.in > 0 || rate.out > 0) {
|
||||
ipThroughput.set(ip, rate);
|
||||
}
|
||||
}
|
||||
|
||||
return ipThroughput;
|
||||
}
|
||||
};
|
||||
|
||||
// Request metrics implementation
|
||||
public requests = {
|
||||
perSecond: (): number => {
|
||||
const now = Date.now();
|
||||
const oneSecondAgo = now - 1000;
|
||||
|
||||
// Clean old timestamps
|
||||
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > now - 60000);
|
||||
|
||||
// Count requests in last second
|
||||
const recentRequests = this.requestTimestamps.filter(ts => ts > oneSecondAgo);
|
||||
return recentRequests.length;
|
||||
},
|
||||
|
||||
perMinute: (): number => {
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60000;
|
||||
|
||||
// Count requests in last minute
|
||||
const recentRequests = this.requestTimestamps.filter(ts => ts > oneMinuteAgo);
|
||||
return recentRequests.length;
|
||||
},
|
||||
|
||||
total: (): number => {
|
||||
return this.totalRequests;
|
||||
}
|
||||
};
|
||||
|
||||
// Totals implementation
|
||||
public totals = {
|
||||
bytesIn: (): number => {
|
||||
let total = 0;
|
||||
|
||||
// Sum from all active connections
|
||||
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
|
||||
total += record.bytesReceived;
|
||||
}
|
||||
|
||||
// TODO: Add historical data from terminated connections
|
||||
|
||||
return total;
|
||||
},
|
||||
|
||||
bytesOut: (): number => {
|
||||
let total = 0;
|
||||
|
||||
// Sum from all active connections
|
||||
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
|
||||
total += record.bytesSent;
|
||||
}
|
||||
|
||||
// TODO: Add historical data from terminated connections
|
||||
|
||||
return total;
|
||||
},
|
||||
|
||||
connections: (): number => {
|
||||
return this.connections.total();
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to calculate percentiles from an array
|
||||
private calculatePercentile(arr: number[], percentile: number): number {
|
||||
if (arr.length === 0) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const index = Math.floor((sorted.length - 1) * percentile);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
// Percentiles implementation
|
||||
public percentiles = {
|
||||
connectionDuration: (): { p50: number; p95: number; p99: number } => {
|
||||
return {
|
||||
p50: this.calculatePercentile(this.connectionDurations, 0.5),
|
||||
p95: this.calculatePercentile(this.connectionDurations, 0.95),
|
||||
p99: this.calculatePercentile(this.connectionDurations, 0.99)
|
||||
};
|
||||
},
|
||||
|
||||
bytesTransferred: (): {
|
||||
in: { p50: number; p95: number; p99: number };
|
||||
out: { p50: number; p95: number; p99: number };
|
||||
} => {
|
||||
return {
|
||||
in: {
|
||||
p50: this.calculatePercentile(this.bytesInArray, 0.5),
|
||||
p95: this.calculatePercentile(this.bytesInArray, 0.95),
|
||||
p99: this.calculatePercentile(this.bytesInArray, 0.99)
|
||||
},
|
||||
out: {
|
||||
p50: this.calculatePercentile(this.bytesOutArray, 0.5),
|
||||
p95: this.calculatePercentile(this.bytesOutArray, 0.95),
|
||||
p99: this.calculatePercentile(this.bytesOutArray, 0.99)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a new request
|
||||
*/
|
||||
public recordRequest(connectionId: string, routeName: string, remoteIP: string): void {
|
||||
const now = Date.now();
|
||||
this.requestTimestamps.push(now);
|
||||
this.totalRequests++;
|
||||
|
||||
// Initialize byte tracker for this connection
|
||||
this.connectionByteTrackers.set(connectionId, {
|
||||
connectionId,
|
||||
routeName,
|
||||
remoteIP,
|
||||
bytesIn: 0,
|
||||
bytesOut: 0,
|
||||
startTime: now,
|
||||
lastUpdate: now
|
||||
});
|
||||
|
||||
// Cleanup old request timestamps
|
||||
if (this.requestTimestamps.length > 5000) {
|
||||
// First try to clean up old timestamps (older than 1 minute)
|
||||
const cutoff = now - 60000;
|
||||
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > cutoff);
|
||||
|
||||
// If still too many, enforce hard cap of 5000 most recent
|
||||
if (this.requestTimestamps.length > 5000) {
|
||||
this.requestTimestamps = this.requestTimestamps.slice(-5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record bytes transferred for a connection
|
||||
*/
|
||||
public recordBytes(connectionId: string, bytesIn: number, bytesOut: number): void {
|
||||
// Update global throughput tracker
|
||||
this.throughputTracker.recordBytes(bytesIn, bytesOut);
|
||||
|
||||
// Update connection-specific tracker
|
||||
const tracker = this.connectionByteTrackers.get(connectionId);
|
||||
if (tracker) {
|
||||
tracker.bytesIn += bytesIn;
|
||||
tracker.bytesOut += bytesOut;
|
||||
tracker.lastUpdate = Date.now();
|
||||
|
||||
// Update per-route throughput tracker
|
||||
let routeTracker = this.routeThroughputTrackers.get(tracker.routeName);
|
||||
if (!routeTracker) {
|
||||
routeTracker = new ThroughputTracker(this.retentionSeconds);
|
||||
this.routeThroughputTrackers.set(tracker.routeName, routeTracker);
|
||||
}
|
||||
routeTracker.recordBytes(bytesIn, bytesOut);
|
||||
|
||||
// Update per-IP throughput tracker
|
||||
let ipTracker = this.ipThroughputTrackers.get(tracker.remoteIP);
|
||||
if (!ipTracker) {
|
||||
ipTracker = new ThroughputTracker(this.retentionSeconds);
|
||||
this.ipThroughputTrackers.set(tracker.remoteIP, ipTracker);
|
||||
}
|
||||
ipTracker.recordBytes(bytesIn, bytesOut);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up tracking for a closed connection
|
||||
*/
|
||||
public removeConnection(connectionId: string): void {
|
||||
const tracker = this.connectionByteTrackers.get(connectionId);
|
||||
if (tracker) {
|
||||
// Calculate connection duration
|
||||
const duration = Date.now() - tracker.startTime;
|
||||
|
||||
// Add to arrays for percentile calculations (bounded to prevent memory growth)
|
||||
const MAX_SAMPLES = 5000;
|
||||
|
||||
this.connectionDurations.push(duration);
|
||||
if (this.connectionDurations.length > MAX_SAMPLES) {
|
||||
this.connectionDurations.shift();
|
||||
}
|
||||
|
||||
this.bytesInArray.push(tracker.bytesIn);
|
||||
if (this.bytesInArray.length > MAX_SAMPLES) {
|
||||
this.bytesInArray.shift();
|
||||
}
|
||||
|
||||
this.bytesOutArray.push(tracker.bytesOut);
|
||||
if (this.bytesOutArray.length > MAX_SAMPLES) {
|
||||
this.bytesOutArray.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.connectionByteTrackers.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metrics collector
|
||||
*/
|
||||
public start(): void {
|
||||
if (!this.smartProxy.routeConnectionHandler) {
|
||||
throw new Error('MetricsCollector: RouteConnectionHandler not available');
|
||||
}
|
||||
|
||||
// Start periodic sampling
|
||||
this.samplingInterval = setInterval(() => {
|
||||
// Sample global throughput
|
||||
this.throughputTracker.takeSample();
|
||||
|
||||
// Sample per-route throughput
|
||||
for (const [_, tracker] of this.routeThroughputTrackers) {
|
||||
tracker.takeSample();
|
||||
}
|
||||
|
||||
// Sample per-IP throughput
|
||||
for (const [_, tracker] of this.ipThroughputTrackers) {
|
||||
tracker.takeSample();
|
||||
}
|
||||
|
||||
// Clean up old connection trackers (connections closed more than 5 minutes ago)
|
||||
const cutoff = Date.now() - 300000;
|
||||
for (const [id, tracker] of this.connectionByteTrackers) {
|
||||
if (tracker.lastUpdate < cutoff) {
|
||||
this.connectionByteTrackers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up unused route trackers
|
||||
const activeRoutes = new Set(Array.from(this.connectionByteTrackers.values()).map(t => t.routeName));
|
||||
for (const [route, _] of this.routeThroughputTrackers) {
|
||||
if (!activeRoutes.has(route)) {
|
||||
this.routeThroughputTrackers.delete(route);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up unused IP trackers
|
||||
const activeIPs = new Set(Array.from(this.connectionByteTrackers.values()).map(t => t.remoteIP));
|
||||
for (const [ip, _] of this.ipThroughputTrackers) {
|
||||
if (!activeIPs.has(ip)) {
|
||||
this.ipThroughputTrackers.delete(ip);
|
||||
}
|
||||
}
|
||||
}, this.sampleIntervalMs);
|
||||
|
||||
// Unref the interval so it doesn't keep the process alive
|
||||
if (this.samplingInterval.unref) {
|
||||
this.samplingInterval.unref();
|
||||
}
|
||||
|
||||
// Subscribe to new connections
|
||||
this.connectionSubscription = this.smartProxy.routeConnectionHandler.newConnectionSubject.subscribe({
|
||||
next: (record) => {
|
||||
const routeName = record.routeConfig?.name || 'unknown';
|
||||
this.recordRequest(record.id, routeName, record.remoteIP);
|
||||
|
||||
if (this.smartProxy.settings?.enableDetailedLogging) {
|
||||
logger.log('debug', `MetricsCollector: New connection recorded`, {
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
routeName,
|
||||
component: 'metrics'
|
||||
});
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
logger.log('error', `MetricsCollector: Error in connection subscription`, {
|
||||
error: err.message,
|
||||
component: 'metrics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
logger.log('debug', 'MetricsCollector started', { component: 'metrics' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the metrics collector
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.samplingInterval) {
|
||||
clearInterval(this.samplingInterval);
|
||||
this.samplingInterval = undefined;
|
||||
}
|
||||
|
||||
if (this.connectionSubscription) {
|
||||
this.connectionSubscription.unsubscribe();
|
||||
this.connectionSubscription = undefined;
|
||||
}
|
||||
|
||||
logger.log('debug', 'MetricsCollector stopped', { component: 'metrics' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for stop() for compatibility
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
@@ -99,10 +99,6 @@ export interface ISmartProxyOptions {
|
||||
keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections
|
||||
extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms)
|
||||
|
||||
// HttpProxy integration
|
||||
useHttpProxy?: number[]; // Array of ports to forward to HttpProxy
|
||||
httpProxyPort?: number; // Port where HttpProxy is listening (default: 8443)
|
||||
|
||||
// Metrics configuration
|
||||
metrics?: {
|
||||
enabled?: boolean;
|
||||
@@ -139,6 +135,12 @@ export interface ISmartProxyOptions {
|
||||
* Default: true
|
||||
*/
|
||||
certProvisionFallbackToAcme?: boolean;
|
||||
|
||||
/**
|
||||
* Path to the RustProxy binary. If not set, the binary is located
|
||||
* automatically via env var, platform package, local build, or PATH.
|
||||
*/
|
||||
rustBinaryPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { NfTablesProxy } from '../nftables-proxy/nftables-proxy.js';
|
||||
import type {
|
||||
NfTableProxyOptions,
|
||||
PortRange,
|
||||
NfTablesStatus
|
||||
} from '../nftables-proxy/models/interfaces.js';
|
||||
import type {
|
||||
IRouteConfig,
|
||||
TPortRange,
|
||||
INfTablesOptions
|
||||
} from './models/route-types.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
/**
|
||||
* Manages NFTables rules based on SmartProxy route configurations
|
||||
*
|
||||
* This class bridges the gap between SmartProxy routes and the NFTablesProxy,
|
||||
* allowing high-performance kernel-level packet forwarding for routes that
|
||||
* specify NFTables as their forwarding engine.
|
||||
*/
|
||||
export class NFTablesManager {
|
||||
private rulesMap: Map<string, NfTablesProxy> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new NFTablesManager
|
||||
*
|
||||
* @param smartProxy The SmartProxy instance
|
||||
*/
|
||||
constructor(private smartProxy: SmartProxy) {}
|
||||
|
||||
/**
|
||||
* Provision NFTables rules for a route
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns A promise that resolves to true if successful, false otherwise
|
||||
*/
|
||||
public async provisionRoute(route: IRouteConfig): Promise<boolean> {
|
||||
// Generate a unique ID for this route
|
||||
const routeId = this.generateRouteId(route);
|
||||
|
||||
// Skip if route doesn't use NFTables
|
||||
if (route.action.forwardingEngine !== 'nftables') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create NFTables options from route configuration
|
||||
const nftOptions = this.createNfTablesOptions(route);
|
||||
|
||||
// Create and start an NFTablesProxy instance
|
||||
const proxy = new NfTablesProxy(nftOptions);
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
this.rulesMap.set(routeId, proxy);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to provision NFTables rules for route ${route.name || 'unnamed'}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove NFTables rules for a route
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns A promise that resolves to true if successful, false otherwise
|
||||
*/
|
||||
public async deprovisionRoute(route: IRouteConfig): Promise<boolean> {
|
||||
const routeId = this.generateRouteId(route);
|
||||
|
||||
const proxy = this.rulesMap.get(routeId);
|
||||
if (!proxy) {
|
||||
return true; // Nothing to remove
|
||||
}
|
||||
|
||||
try {
|
||||
await proxy.stop();
|
||||
this.rulesMap.delete(routeId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to deprovision NFTables rules for route ${route.name || 'unnamed'}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NFTables rules when route changes
|
||||
*
|
||||
* @param oldRoute The previous route configuration
|
||||
* @param newRoute The new route configuration
|
||||
* @returns A promise that resolves to true if successful, false otherwise
|
||||
*/
|
||||
public async updateRoute(oldRoute: IRouteConfig, newRoute: IRouteConfig): Promise<boolean> {
|
||||
// Remove old rules and add new ones
|
||||
await this.deprovisionRoute(oldRoute);
|
||||
return this.provisionRoute(newRoute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a route
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns A unique ID string
|
||||
*/
|
||||
private generateRouteId(route: IRouteConfig): string {
|
||||
// Generate a unique ID based on route properties
|
||||
// Include the route name, match criteria, and a timestamp
|
||||
const matchStr = JSON.stringify({
|
||||
ports: route.match.ports,
|
||||
domains: route.match.domains
|
||||
});
|
||||
|
||||
return `${route.name || 'unnamed'}-${matchStr}-${route.id || Date.now().toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create NFTablesProxy options from a route configuration
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns NFTableProxyOptions object
|
||||
*/
|
||||
private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions {
|
||||
const { action } = route;
|
||||
|
||||
// Ensure we have targets
|
||||
if (!action.targets || action.targets.length === 0) {
|
||||
throw new Error('Route must have targets to use NFTables forwarding');
|
||||
}
|
||||
|
||||
// NFTables can only handle a single target, so we use the first target without match criteria
|
||||
// or the first target if all have match criteria
|
||||
const defaultTarget = action.targets.find(t => !t.match) || action.targets[0];
|
||||
|
||||
// Convert port specifications
|
||||
const fromPorts = this.expandPortRange(route.match.ports);
|
||||
|
||||
// Determine target port
|
||||
let toPorts: number | PortRange | Array<number | PortRange>;
|
||||
|
||||
if (defaultTarget.port === 'preserve') {
|
||||
// 'preserve' means use the same ports as the source
|
||||
toPorts = fromPorts;
|
||||
} else if (typeof defaultTarget.port === 'function') {
|
||||
// For function-based ports, we can't determine at setup time
|
||||
// Use the "preserve" approach and let NFTables handle it
|
||||
toPorts = fromPorts;
|
||||
} else {
|
||||
toPorts = defaultTarget.port;
|
||||
}
|
||||
|
||||
// Determine target host
|
||||
let toHost: string;
|
||||
if (typeof defaultTarget.host === 'function') {
|
||||
// Can't determine at setup time, use localhost as a placeholder
|
||||
// and rely on run-time handling
|
||||
toHost = 'localhost';
|
||||
} else if (Array.isArray(defaultTarget.host)) {
|
||||
// Use first host for now - NFTables will do simple round-robin
|
||||
toHost = defaultTarget.host[0];
|
||||
} else {
|
||||
toHost = defaultTarget.host;
|
||||
}
|
||||
|
||||
// Create options
|
||||
const options: NfTableProxyOptions = {
|
||||
fromPort: fromPorts,
|
||||
toPort: toPorts,
|
||||
toHost: toHost,
|
||||
protocol: action.nftables?.protocol || 'tcp',
|
||||
preserveSourceIP: action.nftables?.preserveSourceIP !== undefined ?
|
||||
action.nftables.preserveSourceIP :
|
||||
this.smartProxy.settings.preserveSourceIP,
|
||||
useIPSets: action.nftables?.useIPSets !== false,
|
||||
useAdvancedNAT: action.nftables?.useAdvancedNAT,
|
||||
enableLogging: this.smartProxy.settings.enableDetailedLogging,
|
||||
deleteOnExit: true,
|
||||
tableName: action.nftables?.tableName || 'smartproxy'
|
||||
};
|
||||
|
||||
// Add security-related options
|
||||
if (route.security?.ipAllowList?.length) {
|
||||
options.ipAllowList = route.security.ipAllowList;
|
||||
}
|
||||
|
||||
if (route.security?.ipBlockList?.length) {
|
||||
options.ipBlockList = route.security.ipBlockList;
|
||||
}
|
||||
|
||||
// Add QoS options
|
||||
if (action.nftables?.maxRate || action.nftables?.priority) {
|
||||
options.qos = {
|
||||
enabled: true,
|
||||
maxRate: action.nftables.maxRate,
|
||||
priority: action.nftables.priority
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand port range specifications
|
||||
*
|
||||
* @param ports The port range specification
|
||||
* @returns Expanded port range
|
||||
*/
|
||||
private expandPortRange(ports: TPortRange): number | PortRange | Array<number | PortRange> {
|
||||
// Process different port specifications
|
||||
if (typeof ports === 'number') {
|
||||
return ports;
|
||||
} else if (Array.isArray(ports)) {
|
||||
const result: Array<number | PortRange> = [];
|
||||
|
||||
for (const item of ports) {
|
||||
if (typeof item === 'number') {
|
||||
result.push(item);
|
||||
} else if ('from' in item && 'to' in item) {
|
||||
result.push({ from: item.from, to: item.to });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} else if (typeof ports === 'object' && ports !== null && 'from' in ports && 'to' in ports) {
|
||||
return { from: (ports as any).from, to: (ports as any).to };
|
||||
}
|
||||
|
||||
// Fallback to port 80 if something went wrong
|
||||
console.warn('Invalid port range specification, using port 80 as fallback');
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all managed rules
|
||||
*
|
||||
* @returns A promise that resolves to a record of NFTables status objects
|
||||
*/
|
||||
public async getStatus(): Promise<Record<string, NfTablesStatus>> {
|
||||
const result: Record<string, NfTablesStatus> = {};
|
||||
|
||||
for (const [routeId, proxy] of this.rulesMap.entries()) {
|
||||
result[routeId] = await proxy.getStatus();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is currently provisioned
|
||||
*
|
||||
* @param route The route configuration
|
||||
* @returns True if the route is provisioned, false otherwise
|
||||
*/
|
||||
public isRouteProvisioned(route: IRouteConfig): boolean {
|
||||
const routeId = this.generateRouteId(route);
|
||||
return this.rulesMap.has(routeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all NFTables rules
|
||||
*
|
||||
* @returns A promise that resolves when all rules have been stopped
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
// Stop all NFTables proxies
|
||||
const stopPromises = Array.from(this.rulesMap.values()).map(proxy => proxy.stop());
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
this.rulesMap.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
/**
|
||||
* PortManager handles the dynamic creation and removal of port listeners
|
||||
*
|
||||
* This class provides methods to add and remove listening ports at runtime,
|
||||
* allowing SmartProxy to adapt to configuration changes without requiring
|
||||
* a full restart.
|
||||
*
|
||||
* It includes a reference counting system to track how many routes are using
|
||||
* each port, so ports can be automatically released when they are no longer needed.
|
||||
*/
|
||||
export class PortManager {
|
||||
private servers: Map<number, plugins.net.Server> = new Map();
|
||||
private isShuttingDown: boolean = false;
|
||||
// Track how many routes are using each port
|
||||
private portRefCounts: Map<number, number> = new Map();
|
||||
|
||||
/**
|
||||
* Create a new PortManager
|
||||
*
|
||||
* @param smartProxy The SmartProxy instance
|
||||
*/
|
||||
constructor(
|
||||
private smartProxy: SmartProxy
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start listening on a specific port
|
||||
*
|
||||
* @param port The port number to listen on
|
||||
* @returns Promise that resolves when the server is listening or rejects on error
|
||||
*/
|
||||
public async addPort(port: number): Promise<void> {
|
||||
// Check if we're already listening on this port
|
||||
if (this.servers.has(port)) {
|
||||
// Port is already bound, just increment the reference count
|
||||
this.incrementPortRefCount(port);
|
||||
try {
|
||||
logger.log('debug', `PortManager: Port ${port} is already bound by SmartProxy, reusing binding`, {
|
||||
port,
|
||||
component: 'port-manager'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`[DEBUG] PortManager: Port ${port} is already bound by SmartProxy, reusing binding`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize reference count for new port
|
||||
this.portRefCounts.set(port, 1);
|
||||
|
||||
// Create a server for this port
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
// Check if shutting down
|
||||
if (this.isShuttingDown) {
|
||||
cleanupSocket(socket, 'port-manager-shutdown', { immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to route connection handler
|
||||
this.smartProxy.routeConnectionHandler.handleConnection(socket);
|
||||
}).on('error', (err: Error) => {
|
||||
try {
|
||||
logger.log('error', `Server Error on port ${port}: ${err.message}`, {
|
||||
port,
|
||||
error: err.message,
|
||||
component: 'port-manager'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[ERROR] Server Error on port ${port}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Start listening on the port
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
server.listen(port, () => {
|
||||
const isHttpProxyPort = this.smartProxy.settings.useHttpProxy?.includes(port);
|
||||
try {
|
||||
logger.log('info', `SmartProxy -> OK: Now listening on port ${port}${
|
||||
isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''
|
||||
}`, {
|
||||
port,
|
||||
isHttpProxyPort: !!isHttpProxyPort,
|
||||
component: 'port-manager'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`[INFO] SmartProxy -> OK: Now listening on port ${port}${
|
||||
isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''
|
||||
}`);
|
||||
}
|
||||
|
||||
// Store the server reference
|
||||
this.servers.set(port, server);
|
||||
resolve();
|
||||
}).on('error', (err) => {
|
||||
// Check if this is an external conflict
|
||||
const { isConflict, isExternal } = this.isPortConflict(err);
|
||||
|
||||
if (isConflict && !isExternal) {
|
||||
// This is an internal conflict (port already bound by SmartProxy)
|
||||
// This shouldn't normally happen because we check servers.has(port) above
|
||||
logger.log('warn', `Port ${port} binding conflict: already in use by SmartProxy`, {
|
||||
port,
|
||||
component: 'port-manager'
|
||||
});
|
||||
// Still increment reference count to maintain tracking
|
||||
this.incrementPortRefCount(port);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the error and propagate it
|
||||
logger.log('error', `Failed to listen on port ${port}: ${err.message}`, {
|
||||
port,
|
||||
error: err.message,
|
||||
code: (err as any).code,
|
||||
component: 'port-manager'
|
||||
});
|
||||
|
||||
// Clean up reference count since binding failed
|
||||
this.portRefCounts.delete(port);
|
||||
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening on a specific port
|
||||
*
|
||||
* @param port The port to stop listening on
|
||||
* @returns Promise that resolves when the server is closed
|
||||
*/
|
||||
public async removePort(port: number): Promise<void> {
|
||||
// Decrement the reference count first
|
||||
const newRefCount = this.decrementPortRefCount(port);
|
||||
|
||||
// If there are still references to this port, keep it open
|
||||
if (newRefCount > 0) {
|
||||
logger.log('debug', `PortManager: Port ${port} still has ${newRefCount} references, keeping open`, {
|
||||
port,
|
||||
refCount: newRefCount,
|
||||
component: 'port-manager'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the server for this port
|
||||
const server = this.servers.get(port);
|
||||
if (!server) {
|
||||
logger.log('warn', `PortManager: Not listening on port ${port}`, {
|
||||
port,
|
||||
component: 'port-manager'
|
||||
});
|
||||
// Ensure reference count is reset
|
||||
this.portRefCounts.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the server
|
||||
return new Promise<void>((resolve) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
logger.log('error', `Error closing server on port ${port}: ${err.message}`, {
|
||||
port,
|
||||
error: err.message,
|
||||
component: 'port-manager'
|
||||
});
|
||||
} else {
|
||||
logger.log('info', `SmartProxy -> Stopped listening on port ${port}`, {
|
||||
port,
|
||||
component: 'port-manager'
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the server reference and clean up reference counting
|
||||
this.servers.delete(port);
|
||||
this.portRefCounts.delete(port);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple ports at once
|
||||
*
|
||||
* @param ports Array of ports to add
|
||||
* @returns Promise that resolves when all servers are listening
|
||||
*/
|
||||
public async addPorts(ports: number[]): Promise<void> {
|
||||
const uniquePorts = [...new Set(ports)];
|
||||
await Promise.all(uniquePorts.map(port => this.addPort(port)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple ports at once
|
||||
*
|
||||
* @param ports Array of ports to remove
|
||||
* @returns Promise that resolves when all servers are closed
|
||||
*/
|
||||
public async removePorts(ports: number[]): Promise<void> {
|
||||
const uniquePorts = [...new Set(ports)];
|
||||
await Promise.all(uniquePorts.map(port => this.removePort(port)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update listening ports to match the provided list
|
||||
*
|
||||
* This will add any ports that aren't currently listening,
|
||||
* and remove any ports that are no longer needed.
|
||||
*
|
||||
* @param ports Array of ports that should be listening
|
||||
* @returns Promise that resolves when all operations are complete
|
||||
*/
|
||||
public async updatePorts(ports: number[]): Promise<void> {
|
||||
const targetPorts = new Set(ports);
|
||||
const currentPorts = new Set(this.servers.keys());
|
||||
|
||||
// Find ports to add and remove
|
||||
const portsToAdd = ports.filter(port => !currentPorts.has(port));
|
||||
const portsToRemove = Array.from(currentPorts).filter(port => !targetPorts.has(port));
|
||||
|
||||
// Log the changes
|
||||
if (portsToAdd.length > 0) {
|
||||
console.log(`PortManager: Adding new listeners for ports: ${portsToAdd.join(', ')}`);
|
||||
}
|
||||
|
||||
if (portsToRemove.length > 0) {
|
||||
console.log(`PortManager: Removing listeners for ports: ${portsToRemove.join(', ')}`);
|
||||
}
|
||||
|
||||
// Add and remove ports
|
||||
await this.removePorts(portsToRemove);
|
||||
await this.addPorts(portsToAdd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ports that are currently listening
|
||||
*
|
||||
* @returns Array of port numbers
|
||||
*/
|
||||
public getListeningPorts(): number[] {
|
||||
return Array.from(this.servers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the port manager as shutting down
|
||||
*/
|
||||
public setShuttingDown(isShuttingDown: boolean): void {
|
||||
this.isShuttingDown = isShuttingDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all listening servers
|
||||
*
|
||||
* @returns Promise that resolves when all servers are closed
|
||||
*/
|
||||
public async closeAll(): Promise<void> {
|
||||
const allPorts = Array.from(this.servers.keys());
|
||||
await this.removePorts(allPorts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all server instances (for testing or debugging)
|
||||
*/
|
||||
public getServers(): Map<number, plugins.net.Server> {
|
||||
return new Map(this.servers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is bound by this SmartProxy instance
|
||||
*
|
||||
* @param port The port number to check
|
||||
* @returns True if the port is currently bound by SmartProxy
|
||||
*/
|
||||
public isPortBoundBySmartProxy(port: number): boolean {
|
||||
return this.servers.has(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current reference count for a port
|
||||
*
|
||||
* @param port The port number to check
|
||||
* @returns The number of routes using this port, 0 if none
|
||||
*/
|
||||
public getPortRefCount(port: number): number {
|
||||
return this.portRefCounts.get(port) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the reference count for a port
|
||||
*
|
||||
* @param port The port number to increment
|
||||
* @returns The new reference count
|
||||
*/
|
||||
public incrementPortRefCount(port: number): number {
|
||||
const currentCount = this.portRefCounts.get(port) || 0;
|
||||
const newCount = currentCount + 1;
|
||||
this.portRefCounts.set(port, newCount);
|
||||
|
||||
logger.log('debug', `Port ${port} reference count increased to ${newCount}`, {
|
||||
port,
|
||||
refCount: newCount,
|
||||
component: 'port-manager'
|
||||
});
|
||||
|
||||
return newCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the reference count for a port
|
||||
*
|
||||
* @param port The port number to decrement
|
||||
* @returns The new reference count
|
||||
*/
|
||||
public decrementPortRefCount(port: number): number {
|
||||
const currentCount = this.portRefCounts.get(port) || 0;
|
||||
|
||||
if (currentCount <= 0) {
|
||||
logger.log('warn', `Attempted to decrement reference count for port ${port} below zero`, {
|
||||
port,
|
||||
component: 'port-manager'
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
const newCount = currentCount - 1;
|
||||
this.portRefCounts.set(port, newCount);
|
||||
|
||||
logger.log('debug', `Port ${port} reference count decreased to ${newCount}`, {
|
||||
port,
|
||||
refCount: newCount,
|
||||
component: 'port-manager'
|
||||
});
|
||||
|
||||
return newCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a port binding error is due to an external or internal conflict
|
||||
*
|
||||
* @param error The error object from a failed port binding
|
||||
* @returns Object indicating if this is a conflict and if it's external
|
||||
*/
|
||||
private isPortConflict(error: any): { isConflict: boolean; isExternal: boolean } {
|
||||
if (error.code !== 'EADDRINUSE') {
|
||||
return { isConflict: false, isExternal: false };
|
||||
}
|
||||
|
||||
// Check if we already have this port
|
||||
const isBoundInternally = this.servers.has(Number(error.port));
|
||||
return { isConflict: true, isExternal: !isBoundInternally };
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,297 +0,0 @@
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import type { IRouteConfig } from './models/route-types.js';
|
||||
import type { ILogger } from '../http-proxy/models/types.js';
|
||||
import { RouteValidator } from './utils/route-validator.js';
|
||||
import { Mutex } from './utils/mutex.js';
|
||||
import type { PortManager } from './port-manager.js';
|
||||
import type { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import type { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
import type { NFTablesManager } from './nftables-manager.js';
|
||||
import type { SmartCertManager } from './certificate-manager.js';
|
||||
|
||||
/**
|
||||
* Orchestrates route updates and coordination between components
|
||||
* Extracted from SmartProxy to reduce class complexity
|
||||
*/
|
||||
export class RouteOrchestrator {
|
||||
private routeUpdateLock: Mutex;
|
||||
private portManager: PortManager;
|
||||
private routeManager: RouteManager;
|
||||
private httpProxyBridge: HttpProxyBridge;
|
||||
private nftablesManager: NFTablesManager;
|
||||
private certManager: SmartCertManager | null = null;
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
portManager: PortManager,
|
||||
routeManager: RouteManager,
|
||||
httpProxyBridge: HttpProxyBridge,
|
||||
nftablesManager: NFTablesManager,
|
||||
certManager: SmartCertManager | null,
|
||||
logger: ILogger
|
||||
) {
|
||||
this.portManager = portManager;
|
||||
this.routeManager = routeManager;
|
||||
this.httpProxyBridge = httpProxyBridge;
|
||||
this.nftablesManager = nftablesManager;
|
||||
this.certManager = certManager;
|
||||
this.logger = logger;
|
||||
this.routeUpdateLock = new Mutex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update certificate manager reference
|
||||
*/
|
||||
public setCertManager(certManager: SmartCertManager | null): void {
|
||||
this.certManager = certManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get certificate manager reference
|
||||
*/
|
||||
public getCertManager(): SmartCertManager | null {
|
||||
return this.certManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routes with validation and coordination
|
||||
*/
|
||||
public async updateRoutes(
|
||||
oldRoutes: IRouteConfig[],
|
||||
newRoutes: IRouteConfig[],
|
||||
options: {
|
||||
acmePort?: number;
|
||||
acmeOptions?: any;
|
||||
acmeState?: any;
|
||||
globalChallengeRouteActive?: boolean;
|
||||
createCertificateManager?: (
|
||||
routes: IRouteConfig[],
|
||||
certStore: string,
|
||||
acmeOptions?: any,
|
||||
initialState?: any
|
||||
) => Promise<SmartCertManager>;
|
||||
verifyChallengeRouteRemoved?: () => Promise<void>;
|
||||
} = {}
|
||||
): Promise<{
|
||||
portUsageMap: Map<number, Set<string>>;
|
||||
newChallengeRouteActive: boolean;
|
||||
newCertManager?: SmartCertManager;
|
||||
}> {
|
||||
return this.routeUpdateLock.runExclusive(async () => {
|
||||
// Validate route configurations
|
||||
const validation = RouteValidator.validateRoutes(newRoutes);
|
||||
if (!validation.valid) {
|
||||
RouteValidator.logValidationErrors(validation.errors);
|
||||
throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`);
|
||||
}
|
||||
|
||||
// Track port usage before and after updates
|
||||
const oldPortUsage = this.updatePortUsageMap(oldRoutes);
|
||||
const newPortUsage = this.updatePortUsageMap(newRoutes);
|
||||
|
||||
// Get the lists of currently listening ports and new ports needed
|
||||
const currentPorts = new Set(this.portManager.getListeningPorts());
|
||||
const newPortsSet = new Set(newPortUsage.keys());
|
||||
|
||||
// Log the port usage for debugging
|
||||
this.logger.debug(`Current listening ports: ${Array.from(currentPorts).join(', ')}`);
|
||||
this.logger.debug(`Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`);
|
||||
|
||||
// Find orphaned ports - ports that no longer have any routes
|
||||
const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
|
||||
|
||||
// Find new ports that need binding (only ports that we aren't already listening on)
|
||||
const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p));
|
||||
|
||||
// Check for ACME challenge port to give it special handling
|
||||
const acmePort = options.acmePort || 80;
|
||||
const acmePortNeeded = newPortsSet.has(acmePort);
|
||||
const acmePortListed = newBindingPorts.includes(acmePort);
|
||||
|
||||
if (acmePortNeeded && acmePortListed) {
|
||||
this.logger.info(`Adding ACME challenge port ${acmePort} to routes`);
|
||||
}
|
||||
|
||||
// Update NFTables routes
|
||||
await this.updateNfTablesRoutes(oldRoutes, newRoutes);
|
||||
|
||||
// Update routes in RouteManager
|
||||
this.routeManager.updateRoutes(newRoutes);
|
||||
|
||||
// Release orphaned ports first to free resources
|
||||
if (orphanedPorts.length > 0) {
|
||||
this.logger.info(`Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`);
|
||||
await this.portManager.removePorts(orphanedPorts);
|
||||
}
|
||||
|
||||
// Add new ports if needed
|
||||
if (newBindingPorts.length > 0) {
|
||||
this.logger.info(`Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`);
|
||||
|
||||
// Handle port binding with improved error recovery
|
||||
try {
|
||||
await this.portManager.addPorts(newBindingPorts);
|
||||
} catch (error) {
|
||||
// Special handling for port binding errors
|
||||
if ((error as any).code === 'EADDRINUSE') {
|
||||
const port = (error as any).port || newBindingPorts[0];
|
||||
const isAcmePort = port === acmePort;
|
||||
|
||||
if (isAcmePort) {
|
||||
this.logger.warn(`Could not bind to ACME challenge port ${port}. It may be in use by another application.`);
|
||||
|
||||
// Re-throw with more helpful message
|
||||
throw new Error(
|
||||
`ACME challenge port ${port} is already in use by another application. ` +
|
||||
`Configure a different port in settings.acme.port (e.g., 8080) or free up port ${port}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw the original error for other cases
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If HttpProxy is initialized, resync the configurations
|
||||
if (this.httpProxyBridge.getHttpProxy()) {
|
||||
await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes);
|
||||
}
|
||||
|
||||
// Update certificate manager if needed
|
||||
let newCertManager: SmartCertManager | undefined;
|
||||
let newChallengeRouteActive = options.globalChallengeRouteActive || false;
|
||||
|
||||
if (this.certManager && options.createCertificateManager) {
|
||||
const existingAcmeOptions = this.certManager.getAcmeOptions();
|
||||
const existingState = this.certManager.getState();
|
||||
|
||||
// Store global state before stopping
|
||||
newChallengeRouteActive = existingState.challengeRouteActive;
|
||||
|
||||
// Keep certificate manager routes in sync before stopping
|
||||
this.certManager.setRoutes(newRoutes);
|
||||
|
||||
await this.certManager.stop();
|
||||
|
||||
// Verify the challenge route has been properly removed
|
||||
if (options.verifyChallengeRouteRemoved) {
|
||||
await options.verifyChallengeRouteRemoved();
|
||||
}
|
||||
|
||||
// Create new certificate manager with preserved state
|
||||
newCertManager = await options.createCertificateManager(
|
||||
newRoutes,
|
||||
'./certs',
|
||||
existingAcmeOptions,
|
||||
{ challengeRouteActive: newChallengeRouteActive }
|
||||
);
|
||||
|
||||
this.certManager = newCertManager;
|
||||
}
|
||||
|
||||
return {
|
||||
portUsageMap: newPortUsage,
|
||||
newChallengeRouteActive,
|
||||
newCertManager
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update port usage map based on the provided routes
|
||||
*/
|
||||
public updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
|
||||
const portUsage = new Map<number, Set<string>>();
|
||||
|
||||
for (const route of routes) {
|
||||
// Get the ports for this route
|
||||
const portsConfig = Array.isArray(route.match.ports)
|
||||
? route.match.ports
|
||||
: [route.match.ports];
|
||||
|
||||
// Expand port range objects to individual port numbers
|
||||
const expandedPorts: number[] = [];
|
||||
for (const portConfig of portsConfig) {
|
||||
if (typeof portConfig === 'number') {
|
||||
expandedPorts.push(portConfig);
|
||||
} else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) {
|
||||
// Expand the port range
|
||||
for (let p = portConfig.from; p <= portConfig.to; p++) {
|
||||
expandedPorts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use route name if available, otherwise generate a unique ID
|
||||
const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Add each port to the usage map
|
||||
for (const port of expandedPorts) {
|
||||
if (!portUsage.has(port)) {
|
||||
portUsage.set(port, new Set());
|
||||
}
|
||||
portUsage.get(port)!.add(routeName);
|
||||
}
|
||||
}
|
||||
|
||||
// Log port usage for debugging
|
||||
for (const [port, routes] of portUsage.entries()) {
|
||||
this.logger.debug(`Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`);
|
||||
}
|
||||
|
||||
return portUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ports that have no routes in the new configuration
|
||||
*/
|
||||
private findOrphanedPorts(oldUsage: Map<number, Set<string>>, newUsage: Map<number, Set<string>>): number[] {
|
||||
const orphanedPorts: number[] = [];
|
||||
|
||||
for (const [port, routes] of oldUsage.entries()) {
|
||||
if (!newUsage.has(port) || newUsage.get(port)!.size === 0) {
|
||||
orphanedPorts.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
return orphanedPorts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NFTables routes
|
||||
*/
|
||||
private async updateNfTablesRoutes(oldRoutes: IRouteConfig[], newRoutes: IRouteConfig[]): Promise<void> {
|
||||
// Get existing routes that use NFTables and update them
|
||||
const oldNfTablesRoutes = oldRoutes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
const newNfTablesRoutes = newRoutes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
// Update existing NFTables routes
|
||||
for (const oldRoute of oldNfTablesRoutes) {
|
||||
const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
|
||||
|
||||
if (!newRoute) {
|
||||
// Route was removed
|
||||
await this.nftablesManager.deprovisionRoute(oldRoute);
|
||||
} else {
|
||||
// Route was updated
|
||||
await this.nftablesManager.updateRoute(oldRoute, newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new NFTables routes
|
||||
for (const newRoute of newNfTablesRoutes) {
|
||||
const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
|
||||
|
||||
if (!oldRoute) {
|
||||
// New route
|
||||
await this.nftablesManager.provisionRoute(newRoute);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
ts/proxies/smart-proxy/route-preprocessor.ts
Normal file
122
ts/proxies/smart-proxy/route-preprocessor.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
|
||||
/**
|
||||
* Preprocesses routes before sending them to Rust.
|
||||
*
|
||||
* Strips non-serializable fields (functions, callbacks) and classifies
|
||||
* routes that must be handled by TypeScript (socket-handler, dynamic host/port).
|
||||
*/
|
||||
export class RoutePreprocessor {
|
||||
/**
|
||||
* Map of route name/id → original route config (with JS functions preserved).
|
||||
* Used by the socket handler server to look up the original handler.
|
||||
*/
|
||||
private originalRoutes = new Map<string, IRouteConfig>();
|
||||
|
||||
/**
|
||||
* Preprocess routes for the Rust binary.
|
||||
*
|
||||
* - Routes with `socketHandler` callbacks are marked as socket-handler type
|
||||
* (Rust will relay these back to TS)
|
||||
* - Routes with dynamic `host`/`port` functions are converted to socket-handler
|
||||
* type (Rust relays, TS resolves the function)
|
||||
* - Non-serializable fields are stripped
|
||||
* - Original routes are preserved in the local map for handler lookup
|
||||
*/
|
||||
public preprocessForRust(routes: IRouteConfig[]): IRouteConfig[] {
|
||||
this.originalRoutes.clear();
|
||||
return routes.map((route, index) => this.preprocessRoute(route, index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original route config (with JS functions) by route name or id.
|
||||
*/
|
||||
public getOriginalRoute(routeKey: string): IRouteConfig | undefined {
|
||||
return this.originalRoutes.get(routeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all original routes that have socket handlers or dynamic functions.
|
||||
*/
|
||||
public getHandlerRoutes(): Map<string, IRouteConfig> {
|
||||
return new Map(this.originalRoutes);
|
||||
}
|
||||
|
||||
private preprocessRoute(route: IRouteConfig, index: number): IRouteConfig {
|
||||
const routeKey = route.name || route.id || `route_${index}`;
|
||||
|
||||
// Check if this route needs TS-side handling
|
||||
const needsTsHandling = this.routeNeedsTsHandling(route);
|
||||
|
||||
if (needsTsHandling) {
|
||||
// Store the original route for handler lookup
|
||||
this.originalRoutes.set(routeKey, route);
|
||||
}
|
||||
|
||||
// Create a clean copy for Rust
|
||||
const cleanRoute: IRouteConfig = {
|
||||
...route,
|
||||
action: this.cleanAction(route.action, routeKey, needsTsHandling),
|
||||
};
|
||||
|
||||
// Ensure we have a name for handler lookup
|
||||
if (!cleanRoute.name && !cleanRoute.id) {
|
||||
cleanRoute.name = routeKey;
|
||||
}
|
||||
|
||||
return cleanRoute;
|
||||
}
|
||||
|
||||
private routeNeedsTsHandling(route: IRouteConfig): boolean {
|
||||
// Socket handler routes always need TS
|
||||
if (route.action.type === 'socket-handler' && route.action.socketHandler) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Routes with dynamic host/port functions need TS
|
||||
if (route.action.targets) {
|
||||
for (const target of route.action.targets) {
|
||||
if (typeof target.host === 'function' || typeof target.port === 'function') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private cleanAction(action: IRouteAction, routeKey: string, needsTsHandling: boolean): IRouteAction {
|
||||
const cleanAction: IRouteAction = { ...action };
|
||||
|
||||
if (needsTsHandling) {
|
||||
// Convert to socket-handler type for Rust (Rust will relay back to TS)
|
||||
cleanAction.type = 'socket-handler';
|
||||
// Remove the JS handler (not serializable)
|
||||
delete (cleanAction as any).socketHandler;
|
||||
}
|
||||
|
||||
// Clean targets - replace functions with static values
|
||||
if (cleanAction.targets) {
|
||||
cleanAction.targets = cleanAction.targets.map(t => this.cleanTarget(t));
|
||||
}
|
||||
|
||||
return cleanAction;
|
||||
}
|
||||
|
||||
private cleanTarget(target: IRouteTarget): IRouteTarget {
|
||||
const clean: IRouteTarget = { ...target };
|
||||
|
||||
// Replace function host with placeholder
|
||||
if (typeof clean.host === 'function') {
|
||||
clean.host = 'localhost';
|
||||
}
|
||||
|
||||
// Replace function port with placeholder
|
||||
if (typeof clean.port === 'function') {
|
||||
clean.port = 0;
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
}
|
||||
112
ts/proxies/smart-proxy/rust-binary-locator.ts
Normal file
112
ts/proxies/smart-proxy/rust-binary-locator.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
|
||||
/**
|
||||
* Locates the RustProxy binary using a priority-ordered search strategy:
|
||||
* 1. SMARTPROXY_RUST_BINARY environment variable
|
||||
* 2. Platform-specific optional npm package
|
||||
* 3. Local development build at ./rust/target/release/rustproxy
|
||||
* 4. System PATH
|
||||
*/
|
||||
export class RustBinaryLocator {
|
||||
private cachedPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Find the RustProxy binary path.
|
||||
* Returns null if no binary is available.
|
||||
*/
|
||||
public async findBinary(): Promise<string | null> {
|
||||
if (this.cachedPath !== null) {
|
||||
return this.cachedPath;
|
||||
}
|
||||
|
||||
const path = await this.searchBinary();
|
||||
this.cachedPath = path;
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached binary path (e.g., after a failed launch).
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.cachedPath = null;
|
||||
}
|
||||
|
||||
private async searchBinary(): Promise<string | null> {
|
||||
// 1. Environment variable override
|
||||
const envPath = process.env.SMARTPROXY_RUST_BINARY;
|
||||
if (envPath) {
|
||||
if (await this.isExecutable(envPath)) {
|
||||
logger.log('info', `RustProxy binary found via SMARTPROXY_RUST_BINARY: ${envPath}`, { component: 'rust-locator' });
|
||||
return envPath;
|
||||
}
|
||||
logger.log('warn', `SMARTPROXY_RUST_BINARY set but not executable: ${envPath}`, { component: 'rust-locator' });
|
||||
}
|
||||
|
||||
// 2. Platform-specific optional npm package
|
||||
const platformBinary = await this.findPlatformPackageBinary();
|
||||
if (platformBinary) {
|
||||
logger.log('info', `RustProxy binary found in platform package: ${platformBinary}`, { component: 'rust-locator' });
|
||||
return platformBinary;
|
||||
}
|
||||
|
||||
// 3. Local development build
|
||||
const localPaths = [
|
||||
plugins.path.resolve(process.cwd(), 'rust/target/release/rustproxy'),
|
||||
plugins.path.resolve(process.cwd(), 'rust/target/debug/rustproxy'),
|
||||
];
|
||||
for (const localPath of localPaths) {
|
||||
if (await this.isExecutable(localPath)) {
|
||||
logger.log('info', `RustProxy binary found at local path: ${localPath}`, { component: 'rust-locator' });
|
||||
return localPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. System PATH
|
||||
const systemPath = await this.findInPath('rustproxy');
|
||||
if (systemPath) {
|
||||
logger.log('info', `RustProxy binary found in system PATH: ${systemPath}`, { component: 'rust-locator' });
|
||||
return systemPath;
|
||||
}
|
||||
|
||||
logger.log('error', 'No RustProxy binary found. Set SMARTPROXY_RUST_BINARY, install the platform package, or build with: cd rust && cargo build --release', { component: 'rust-locator' });
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findPlatformPackageBinary(): Promise<string | null> {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const packageName = `@push.rocks/smartproxy-${platform}-${arch}`;
|
||||
|
||||
try {
|
||||
// Try to resolve the platform-specific package
|
||||
const packagePath = require.resolve(`${packageName}/rustproxy`);
|
||||
if (await this.isExecutable(packagePath)) {
|
||||
return packagePath;
|
||||
}
|
||||
} catch {
|
||||
// Package not installed - expected for development
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async isExecutable(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await plugins.fs.promises.access(filePath, plugins.fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async findInPath(binaryName: string): Promise<string | null> {
|
||||
const pathDirs = (process.env.PATH || '').split(plugins.path.delimiter);
|
||||
for (const dir of pathDirs) {
|
||||
const fullPath = plugins.path.join(dir, binaryName);
|
||||
if (await this.isExecutable(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
136
ts/proxies/smart-proxy/rust-metrics-adapter.ts
Normal file
136
ts/proxies/smart-proxy/rust-metrics-adapter.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { IMetrics, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js';
|
||||
import type { RustProxyBridge } from './rust-proxy-bridge.js';
|
||||
|
||||
/**
|
||||
* Adapts Rust JSON metrics to the IMetrics interface.
|
||||
*
|
||||
* Polls the Rust binary periodically via the bridge and caches the result.
|
||||
* All IMetrics getters read from the cache synchronously.
|
||||
* Fields not yet in Rust (percentiles, per-IP, history) return zero/empty.
|
||||
*/
|
||||
export class RustMetricsAdapter implements IMetrics {
|
||||
private bridge: RustProxyBridge;
|
||||
private cache: any = null;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pollIntervalMs: number;
|
||||
|
||||
// Cumulative totals tracked across polls
|
||||
private cumulativeBytesIn = 0;
|
||||
private cumulativeBytesOut = 0;
|
||||
private cumulativeConnections = 0;
|
||||
|
||||
constructor(bridge: RustProxyBridge, pollIntervalMs = 1000) {
|
||||
this.bridge = bridge;
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
}
|
||||
|
||||
public startPolling(): void {
|
||||
if (this.pollTimer) return;
|
||||
this.pollTimer = setInterval(async () => {
|
||||
try {
|
||||
this.cache = await this.bridge.getMetrics();
|
||||
// Update cumulative totals
|
||||
if (this.cache) {
|
||||
this.cumulativeBytesIn = this.cache.totalBytesIn ?? this.cache.total_bytes_in ?? 0;
|
||||
this.cumulativeBytesOut = this.cache.totalBytesOut ?? this.cache.total_bytes_out ?? 0;
|
||||
this.cumulativeConnections = this.cache.totalConnections ?? this.cache.total_connections ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// Ignore poll errors (bridge may be shutting down)
|
||||
}
|
||||
}, this.pollIntervalMs);
|
||||
if (this.pollTimer.unref) {
|
||||
this.pollTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
public stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- IMetrics implementation ---
|
||||
|
||||
public connections = {
|
||||
active: (): number => {
|
||||
return this.cache?.activeConnections ?? this.cache?.active_connections ?? 0;
|
||||
},
|
||||
total: (): number => {
|
||||
return this.cumulativeConnections;
|
||||
},
|
||||
byRoute: (): Map<string, number> => {
|
||||
return new Map();
|
||||
},
|
||||
byIP: (): Map<string, number> => {
|
||||
return new Map();
|
||||
},
|
||||
topIPs: (_limit?: number): Array<{ ip: string; count: number }> => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
public throughput = {
|
||||
instant: (): IThroughputData => {
|
||||
return { in: this.cache?.bytesInPerSecond ?? 0, out: this.cache?.bytesOutPerSecond ?? 0 };
|
||||
},
|
||||
recent: (): IThroughputData => {
|
||||
return this.throughput.instant();
|
||||
},
|
||||
average: (): IThroughputData => {
|
||||
return this.throughput.instant();
|
||||
},
|
||||
custom: (_seconds: number): IThroughputData => {
|
||||
return this.throughput.instant();
|
||||
},
|
||||
history: (_seconds: number): Array<IThroughputHistoryPoint> => {
|
||||
return [];
|
||||
},
|
||||
byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
||||
return new Map();
|
||||
},
|
||||
byIP: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
||||
return new Map();
|
||||
},
|
||||
};
|
||||
|
||||
public requests = {
|
||||
perSecond: (): number => {
|
||||
return this.cache?.requestsPerSecond ?? 0;
|
||||
},
|
||||
perMinute: (): number => {
|
||||
return (this.cache?.requestsPerSecond ?? 0) * 60;
|
||||
},
|
||||
total: (): number => {
|
||||
return this.cache?.totalRequests ?? this.cache?.total_requests ?? 0;
|
||||
},
|
||||
};
|
||||
|
||||
public totals = {
|
||||
bytesIn: (): number => {
|
||||
return this.cumulativeBytesIn;
|
||||
},
|
||||
bytesOut: (): number => {
|
||||
return this.cumulativeBytesOut;
|
||||
},
|
||||
connections: (): number => {
|
||||
return this.cumulativeConnections;
|
||||
},
|
||||
};
|
||||
|
||||
public percentiles = {
|
||||
connectionDuration: (): { p50: number; p95: number; p99: number } => {
|
||||
return { p50: 0, p95: 0, p99: 0 };
|
||||
},
|
||||
bytesTransferred: (): {
|
||||
in: { p50: number; p95: number; p99: number };
|
||||
out: { p50: number; p95: number; p99: number };
|
||||
} => {
|
||||
return {
|
||||
in: { p50: 0, p95: 0, p99: 0 },
|
||||
out: { p50: 0, p95: 0, p99: 0 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
278
ts/proxies/smart-proxy/rust-proxy-bridge.ts
Normal file
278
ts/proxies/smart-proxy/rust-proxy-bridge.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { RustBinaryLocator } from './rust-binary-locator.js';
|
||||
import type { IRouteConfig } from './models/route-types.js';
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import { createInterface, Interface as ReadlineInterface } from 'readline';
|
||||
|
||||
/**
|
||||
* Management request sent to the Rust binary via stdin.
|
||||
*/
|
||||
interface IManagementRequest {
|
||||
id: string;
|
||||
method: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Management response received from the Rust binary via stdout.
|
||||
*/
|
||||
interface IManagementResponse {
|
||||
id: string;
|
||||
success: boolean;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Management event received from the Rust binary (unsolicited).
|
||||
*/
|
||||
interface IManagementEvent {
|
||||
event: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between TypeScript SmartProxy and the Rust binary.
|
||||
* Communicates via JSON-over-stdin/stdout IPC protocol.
|
||||
*/
|
||||
export class RustProxyBridge extends plugins.EventEmitter {
|
||||
private locator = new RustBinaryLocator();
|
||||
private process: ChildProcess | null = null;
|
||||
private readline: ReadlineInterface | null = null;
|
||||
private pendingRequests = new Map<string, {
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}>();
|
||||
private requestCounter = 0;
|
||||
private isRunning = false;
|
||||
private binaryPath: string | null = null;
|
||||
private readonly requestTimeoutMs = 30000;
|
||||
|
||||
/**
|
||||
* Spawn the Rust binary in management mode.
|
||||
* Returns true if the binary was found and spawned successfully.
|
||||
*/
|
||||
public async spawn(): Promise<boolean> {
|
||||
this.binaryPath = await this.locator.findBinary();
|
||||
if (!this.binaryPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
this.process = spawn(this.binaryPath!, ['--management'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Handle stderr (logging from Rust goes here)
|
||||
this.process.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
logger.log('debug', `[rustproxy] ${line}`, { component: 'rust-bridge' });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stdout (JSON IPC)
|
||||
this.readline = createInterface({ input: this.process.stdout! });
|
||||
this.readline.on('line', (line: string) => {
|
||||
this.handleLine(line.trim());
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
this.process.on('exit', (code, signal) => {
|
||||
logger.log('info', `RustProxy process exited (code=${code}, signal=${signal})`, { component: 'rust-bridge' });
|
||||
this.cleanup();
|
||||
this.emit('exit', code, signal);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
logger.log('error', `RustProxy process error: ${err.message}`, { component: 'rust-bridge' });
|
||||
this.cleanup();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
// Wait for the 'ready' event from Rust
|
||||
const readyTimeout = setTimeout(() => {
|
||||
logger.log('error', 'RustProxy did not send ready event within 10s', { component: 'rust-bridge' });
|
||||
this.kill();
|
||||
resolve(false);
|
||||
}, 10000);
|
||||
|
||||
this.once('management:ready', () => {
|
||||
clearTimeout(readyTimeout);
|
||||
this.isRunning = true;
|
||||
logger.log('info', 'RustProxy bridge connected', { component: 'rust-bridge' });
|
||||
resolve(true);
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.log('error', `Failed to spawn RustProxy: ${err.message}`, { component: 'rust-bridge' });
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a management command to the Rust process and wait for the response.
|
||||
*/
|
||||
public async sendCommand(method: string, params: Record<string, any> = {}): Promise<any> {
|
||||
if (!this.process || !this.isRunning) {
|
||||
throw new Error('RustProxy bridge is not running');
|
||||
}
|
||||
|
||||
const id = `req_${++this.requestCounter}`;
|
||||
const request: IManagementRequest = { id, method, params };
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`RustProxy command '${method}' timed out after ${this.requestTimeoutMs}ms`));
|
||||
}, this.requestTimeoutMs);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
|
||||
const json = JSON.stringify(request) + '\n';
|
||||
this.process!.stdin!.write(json, (err) => {
|
||||
if (err) {
|
||||
clearTimeout(timer);
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Failed to write to RustProxy stdin: ${err.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience methods for each management command
|
||||
|
||||
public async startProxy(config: any): Promise<void> {
|
||||
await this.sendCommand('start', { config });
|
||||
}
|
||||
|
||||
public async stopProxy(): Promise<void> {
|
||||
await this.sendCommand('stop');
|
||||
}
|
||||
|
||||
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
||||
await this.sendCommand('updateRoutes', { routes });
|
||||
}
|
||||
|
||||
public async getMetrics(): Promise<any> {
|
||||
return this.sendCommand('getMetrics');
|
||||
}
|
||||
|
||||
public async getStatistics(): Promise<any> {
|
||||
return this.sendCommand('getStatistics');
|
||||
}
|
||||
|
||||
public async provisionCertificate(routeName: string): Promise<void> {
|
||||
await this.sendCommand('provisionCertificate', { routeName });
|
||||
}
|
||||
|
||||
public async renewCertificate(routeName: string): Promise<void> {
|
||||
await this.sendCommand('renewCertificate', { routeName });
|
||||
}
|
||||
|
||||
public async getCertificateStatus(routeName: string): Promise<any> {
|
||||
return this.sendCommand('getCertificateStatus', { routeName });
|
||||
}
|
||||
|
||||
public async getListeningPorts(): Promise<number[]> {
|
||||
const result = await this.sendCommand('getListeningPorts');
|
||||
return result?.ports ?? [];
|
||||
}
|
||||
|
||||
public async getNftablesStatus(): Promise<any> {
|
||||
return this.sendCommand('getNftablesStatus');
|
||||
}
|
||||
|
||||
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
||||
await this.sendCommand('setSocketHandlerRelay', { socketPath });
|
||||
}
|
||||
|
||||
public async addListeningPort(port: number): Promise<void> {
|
||||
await this.sendCommand('addListeningPort', { port });
|
||||
}
|
||||
|
||||
public async removeListeningPort(port: number): Promise<void> {
|
||||
await this.sendCommand('removeListeningPort', { port });
|
||||
}
|
||||
|
||||
public async loadCertificate(domain: string, cert: string, key: string, ca?: string): Promise<void> {
|
||||
await this.sendCommand('loadCertificate', { domain, cert, key, ca });
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the Rust process.
|
||||
*/
|
||||
public kill(): void {
|
||||
if (this.process) {
|
||||
this.process.kill('SIGTERM');
|
||||
// Force kill after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.process) {
|
||||
this.process.kill('SIGKILL');
|
||||
}
|
||||
}, 5000).unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bridge is currently running.
|
||||
*/
|
||||
public get running(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
if (!line) return;
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
logger.log('warn', `Non-JSON output from RustProxy: ${line}`, { component: 'rust-bridge' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's an event (has 'event' field)
|
||||
if ('event' in parsed) {
|
||||
const event = parsed as IManagementEvent;
|
||||
this.emit(`management:${event.event}`, event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise it's a response (has 'id' field)
|
||||
if ('id' in parsed) {
|
||||
const response = parsed as IManagementResponse;
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(response.id);
|
||||
if (response.success) {
|
||||
pending.resolve(response.result);
|
||||
} else {
|
||||
pending.reject(new Error(response.error || 'Unknown error from RustProxy'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.isRunning = false;
|
||||
this.process = null;
|
||||
|
||||
if (this.readline) {
|
||||
this.readline.close();
|
||||
this.readline = null;
|
||||
}
|
||||
|
||||
// Reject all pending requests
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('RustProxy process exited'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
||||
import { isIPAuthorized, normalizeIP } from '../../core/utils/security-utils.js';
|
||||
|
||||
/**
|
||||
* Handles security aspects like IP tracking, rate limiting, and authorization
|
||||
* for SmartProxy. This is a lightweight wrapper that uses shared utilities.
|
||||
*/
|
||||
export class SecurityManager {
|
||||
private connectionsByIP: Map<string, Set<string>> = new Map();
|
||||
private connectionRateByIP: Map<string, number[]> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private smartProxy: SmartProxy) {
|
||||
// Start periodic cleanup every 60 seconds
|
||||
this.startPeriodicCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connections count by IP (checks normalized variants)
|
||||
*/
|
||||
public getConnectionCountByIP(ip: string): number {
|
||||
// Check all normalized variants of the IP
|
||||
const variants = normalizeIP(ip);
|
||||
for (const variant of variants) {
|
||||
const connections = this.connectionsByIP.get(variant);
|
||||
if (connections) {
|
||||
return connections.size;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and update connection rate for an IP
|
||||
* @returns true if within rate limit, false if exceeding limit
|
||||
*/
|
||||
public checkConnectionRate(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const minute = 60 * 1000;
|
||||
|
||||
// Find existing rate tracking (check normalized variants)
|
||||
const variants = normalizeIP(ip);
|
||||
let existingKey: string | null = null;
|
||||
for (const variant of variants) {
|
||||
if (this.connectionRateByIP.has(variant)) {
|
||||
existingKey = variant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const key = existingKey || ip;
|
||||
|
||||
if (!this.connectionRateByIP.has(key)) {
|
||||
this.connectionRateByIP.set(key, [now]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get timestamps and filter out entries older than 1 minute
|
||||
const timestamps = this.connectionRateByIP.get(key)!.filter((time) => now - time < minute);
|
||||
timestamps.push(now);
|
||||
this.connectionRateByIP.set(key, timestamps);
|
||||
|
||||
// Check if rate exceeds limit
|
||||
return timestamps.length <= this.smartProxy.settings.connectionRateLimitPerMinute!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track connection by IP
|
||||
*/
|
||||
public trackConnectionByIP(ip: string, connectionId: string): void {
|
||||
// Check if any variant already exists
|
||||
const variants = normalizeIP(ip);
|
||||
let existingKey: string | null = null;
|
||||
|
||||
for (const variant of variants) {
|
||||
if (this.connectionsByIP.has(variant)) {
|
||||
existingKey = variant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const key = existingKey || ip;
|
||||
if (!this.connectionsByIP.has(key)) {
|
||||
this.connectionsByIP.set(key, new Set());
|
||||
}
|
||||
this.connectionsByIP.get(key)!.add(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove connection tracking for an IP
|
||||
*/
|
||||
public removeConnectionByIP(ip: string, connectionId: string): void {
|
||||
// Check all variants to find where the connection is tracked
|
||||
const variants = normalizeIP(ip);
|
||||
|
||||
for (const variant of variants) {
|
||||
if (this.connectionsByIP.has(variant)) {
|
||||
const connections = this.connectionsByIP.get(variant)!;
|
||||
connections.delete(connectionId);
|
||||
if (connections.size === 0) {
|
||||
this.connectionsByIP.delete(variant);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is authorized using security rules
|
||||
*
|
||||
* This method is used to determine if an IP is allowed to connect, based on security
|
||||
* rules configured in the route configuration. The allowed and blocked IPs are
|
||||
* typically derived from route.security.ipAllowList and ipBlockList.
|
||||
*
|
||||
* @param ip - The IP address to check
|
||||
* @param allowedIPs - Array of allowed IP patterns from security.ipAllowList
|
||||
* @param blockedIPs - Array of blocked IP patterns from security.ipBlockList
|
||||
* @returns true if IP is authorized, false if blocked
|
||||
*/
|
||||
public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean {
|
||||
return isIPAuthorized(ip, allowedIPs, blockedIPs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP should be allowed considering connection rate and max connections
|
||||
* @returns Object with result and reason
|
||||
*/
|
||||
public validateIP(ip: string): { allowed: boolean; reason?: string } {
|
||||
// Check connection count limit
|
||||
if (
|
||||
this.smartProxy.settings.maxConnectionsPerIP &&
|
||||
this.getConnectionCountByIP(ip) >= this.smartProxy.settings.maxConnectionsPerIP
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Maximum connections per IP (${this.smartProxy.settings.maxConnectionsPerIP}) exceeded`
|
||||
};
|
||||
}
|
||||
|
||||
// Check connection rate limit
|
||||
if (
|
||||
this.smartProxy.settings.connectionRateLimitPerMinute &&
|
||||
!this.checkConnectionRate(ip)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Connection rate limit (${this.smartProxy.settings.connectionRateLimitPerMinute}/min) exceeded`
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically validate an IP and track the connection if allowed.
|
||||
* This prevents race conditions where concurrent connections could bypass per-IP limits.
|
||||
*
|
||||
* @param ip - The IP address to validate
|
||||
* @param connectionId - The connection ID to track if validation passes
|
||||
* @returns Object with validation result and reason
|
||||
*/
|
||||
public validateAndTrackIP(ip: string, connectionId: string): { allowed: boolean; reason?: string } {
|
||||
// Check connection count limit BEFORE tracking
|
||||
if (
|
||||
this.smartProxy.settings.maxConnectionsPerIP &&
|
||||
this.getConnectionCountByIP(ip) >= this.smartProxy.settings.maxConnectionsPerIP
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Maximum connections per IP (${this.smartProxy.settings.maxConnectionsPerIP}) exceeded`
|
||||
};
|
||||
}
|
||||
|
||||
// Check connection rate limit
|
||||
if (
|
||||
this.smartProxy.settings.connectionRateLimitPerMinute &&
|
||||
!this.checkConnectionRate(ip)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Connection rate limit (${this.smartProxy.settings.connectionRateLimitPerMinute}/min) exceeded`
|
||||
};
|
||||
}
|
||||
|
||||
// Validation passed - immediately track to prevent race conditions
|
||||
this.trackConnectionByIP(ip, connectionId);
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all IP tracking data (for shutdown)
|
||||
*/
|
||||
public clearIPTracking(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
this.connectionsByIP.clear();
|
||||
this.connectionRateByIP.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of expired data
|
||||
*/
|
||||
private startPeriodicCleanup(): void {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.performCleanup();
|
||||
}, 60000); // Run every minute
|
||||
|
||||
// Unref the timer so it doesn't keep the process alive
|
||||
if (this.cleanupInterval.unref) {
|
||||
this.cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cleanup of expired rate limits and empty IP entries
|
||||
*/
|
||||
private performCleanup(): void {
|
||||
const now = Date.now();
|
||||
const minute = 60 * 1000;
|
||||
let cleanedRateLimits = 0;
|
||||
let cleanedIPs = 0;
|
||||
|
||||
// Clean up expired rate limit timestamps
|
||||
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
|
||||
const validTimestamps = timestamps.filter(time => now - time < minute);
|
||||
|
||||
if (validTimestamps.length === 0) {
|
||||
// No valid timestamps, remove the IP entry
|
||||
this.connectionRateByIP.delete(ip);
|
||||
cleanedRateLimits++;
|
||||
} else if (validTimestamps.length < timestamps.length) {
|
||||
// Some timestamps expired, update with valid ones
|
||||
this.connectionRateByIP.set(ip, validTimestamps);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up IPs with no active connections
|
||||
for (const [ip, connections] of this.connectionsByIP.entries()) {
|
||||
if (connections.size === 0) {
|
||||
this.connectionsByIP.delete(ip);
|
||||
cleanedIPs++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log cleanup stats if anything was cleaned
|
||||
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
connectionLogDeduplicator.log(
|
||||
'ip-cleanup',
|
||||
'debug',
|
||||
'IP tracking cleanup completed',
|
||||
{
|
||||
cleanedRateLimits,
|
||||
cleanedIPs,
|
||||
remainingIPs: this.connectionsByIP.size,
|
||||
remainingRateLimits: this.connectionRateByIP.size,
|
||||
component: 'security-manager'
|
||||
},
|
||||
'periodic-cleanup'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
178
ts/proxies/smart-proxy/socket-handler-server.ts
Normal file
178
ts/proxies/smart-proxy/socket-handler-server.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import type { IRouteConfig, IRouteContext } from './models/route-types.js';
|
||||
import type { RoutePreprocessor } from './route-preprocessor.js';
|
||||
|
||||
/**
|
||||
* Unix domain socket server that receives relayed connections from the Rust proxy.
|
||||
*
|
||||
* When Rust encounters a route of type `socket-handler`, it connects to this
|
||||
* Unix socket, sends a JSON metadata line, then proxies the raw TCP bytes.
|
||||
* This server reads the metadata, finds the original JS handler, builds an
|
||||
* IRouteContext, and hands the socket to the handler.
|
||||
*/
|
||||
export class SocketHandlerServer {
|
||||
private server: plugins.net.Server | null = null;
|
||||
private socketPath: string;
|
||||
private preprocessor: RoutePreprocessor;
|
||||
|
||||
constructor(preprocessor: RoutePreprocessor) {
|
||||
this.preprocessor = preprocessor;
|
||||
this.socketPath = `/tmp/smartproxy-relay-${process.pid}.sock`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Unix socket path this server listens on.
|
||||
*/
|
||||
public getSocketPath(): string {
|
||||
return this.socketPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for relayed connections from Rust.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Clean up stale socket file
|
||||
try {
|
||||
await plugins.fs.promises.unlink(this.socketPath);
|
||||
} catch {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.server = plugins.net.createServer((socket) => {
|
||||
this.handleConnection(socket);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.log('error', `SocketHandlerServer error: ${err.message}`, { component: 'socket-handler-server' });
|
||||
});
|
||||
|
||||
this.server.listen(this.socketPath, () => {
|
||||
logger.log('info', `SocketHandlerServer listening on ${this.socketPath}`, { component: 'socket-handler-server' });
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server and clean up.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.server) {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.server!.close(() => {
|
||||
this.server = null;
|
||||
// Clean up socket file
|
||||
plugins.fs.unlink(this.socketPath, () => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming relayed connection from Rust.
|
||||
*
|
||||
* Protocol: Rust sends a single JSON line with metadata, then raw bytes follow.
|
||||
* JSON format: { "routeKey": "my-route", "remoteIP": "1.2.3.4", "remotePort": 12345,
|
||||
* "localPort": 443, "isTLS": true, "domain": "example.com" }
|
||||
*/
|
||||
private handleConnection(socket: plugins.net.Socket): void {
|
||||
let metadataBuffer = '';
|
||||
let metadataParsed = false;
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
if (metadataParsed) return;
|
||||
|
||||
metadataBuffer += chunk.toString('utf8');
|
||||
const newlineIndex = metadataBuffer.indexOf('\n');
|
||||
|
||||
if (newlineIndex === -1) {
|
||||
// Haven't received full metadata line yet
|
||||
if (metadataBuffer.length > 8192) {
|
||||
logger.log('error', 'Socket handler metadata too large, closing', { component: 'socket-handler-server' });
|
||||
socket.destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
metadataParsed = true;
|
||||
socket.removeListener('data', onData);
|
||||
|
||||
const metadataJson = metadataBuffer.slice(0, newlineIndex);
|
||||
const remainingData = metadataBuffer.slice(newlineIndex + 1);
|
||||
|
||||
let metadata: any;
|
||||
try {
|
||||
metadata = JSON.parse(metadataJson);
|
||||
} catch {
|
||||
logger.log('error', `Invalid socket handler metadata JSON: ${metadataJson.slice(0, 200)}`, { component: 'socket-handler-server' });
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchToHandler(socket, metadata, remainingData);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.on('error', (err) => {
|
||||
logger.log('error', `Socket handler relay error: ${err.message}`, { component: 'socket-handler-server' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a relayed connection to the appropriate JS handler.
|
||||
*/
|
||||
private dispatchToHandler(socket: plugins.net.Socket, metadata: any, remainingData: string): void {
|
||||
const routeKey = metadata.routeKey as string;
|
||||
if (!routeKey) {
|
||||
logger.log('error', 'Socket handler relay missing routeKey', { component: 'socket-handler-server' });
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalRoute = this.preprocessor.getOriginalRoute(routeKey);
|
||||
if (!originalRoute) {
|
||||
logger.log('error', `No handler found for route: ${routeKey}`, { component: 'socket-handler-server' });
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = originalRoute.action.socketHandler;
|
||||
if (!handler) {
|
||||
logger.log('error', `Route ${routeKey} has no socketHandler`, { component: 'socket-handler-server' });
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build route context
|
||||
const context: IRouteContext = {
|
||||
port: metadata.localPort || 0,
|
||||
domain: metadata.domain,
|
||||
clientIp: metadata.remoteIP || 'unknown',
|
||||
serverIp: '0.0.0.0',
|
||||
path: metadata.path,
|
||||
isTls: metadata.isTLS || false,
|
||||
tlsVersion: metadata.tlsVersion,
|
||||
routeName: originalRoute.name,
|
||||
routeId: originalRoute.id,
|
||||
timestamp: Date.now(),
|
||||
connectionId: metadata.connectionId || `relay-${Date.now()}`,
|
||||
};
|
||||
|
||||
// If there was remaining data after the metadata line, push it back
|
||||
if (remainingData.length > 0) {
|
||||
socket.unshift(Buffer.from(remainingData, 'utf8'));
|
||||
}
|
||||
|
||||
// Call the handler
|
||||
try {
|
||||
handler(socket, context);
|
||||
} catch (err: any) {
|
||||
logger.log('error', `Socket handler threw for route ${routeKey}: ${err.message}`, { component: 'socket-handler-server' });
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { IThroughputSample, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js';
|
||||
|
||||
/**
|
||||
* Tracks throughput data using time-series sampling
|
||||
*/
|
||||
export class ThroughputTracker {
|
||||
private samples: IThroughputSample[] = [];
|
||||
private readonly maxSamples: number;
|
||||
private accumulatedBytesIn: number = 0;
|
||||
private accumulatedBytesOut: number = 0;
|
||||
private lastSampleTime: number = 0;
|
||||
|
||||
constructor(retentionSeconds: number = 3600) {
|
||||
// Keep samples for the retention period at 1 sample per second
|
||||
this.maxSamples = retentionSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record bytes transferred (called on every data transfer)
|
||||
*/
|
||||
public recordBytes(bytesIn: number, bytesOut: number): void {
|
||||
this.accumulatedBytesIn += bytesIn;
|
||||
this.accumulatedBytesOut += bytesOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a sample of accumulated bytes (called every second)
|
||||
*/
|
||||
public takeSample(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Record accumulated bytes since last sample
|
||||
this.samples.push({
|
||||
timestamp: now,
|
||||
bytesIn: this.accumulatedBytesIn,
|
||||
bytesOut: this.accumulatedBytesOut
|
||||
});
|
||||
|
||||
// Reset accumulators
|
||||
this.accumulatedBytesIn = 0;
|
||||
this.accumulatedBytesOut = 0;
|
||||
this.lastSampleTime = now;
|
||||
|
||||
// Maintain circular buffer - remove oldest samples
|
||||
if (this.samples.length > this.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get throughput rate over specified window (bytes per second)
|
||||
*/
|
||||
public getRate(windowSeconds: number): IThroughputData {
|
||||
if (this.samples.length === 0) {
|
||||
return { in: 0, out: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = now - (windowSeconds * 1000);
|
||||
|
||||
// Find samples within the window
|
||||
const relevantSamples = this.samples.filter(s => s.timestamp > windowStart);
|
||||
|
||||
if (relevantSamples.length === 0) {
|
||||
return { in: 0, out: 0 };
|
||||
}
|
||||
|
||||
// Calculate total bytes in window
|
||||
const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0);
|
||||
const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0);
|
||||
|
||||
// Use actual number of seconds covered by samples for accurate rate
|
||||
const oldestSampleTime = relevantSamples[0].timestamp;
|
||||
const newestSampleTime = relevantSamples[relevantSamples.length - 1].timestamp;
|
||||
const actualSeconds = Math.max(1, (newestSampleTime - oldestSampleTime) / 1000 + 1);
|
||||
|
||||
return {
|
||||
in: Math.round(totalBytesIn / actualSeconds),
|
||||
out: Math.round(totalBytesOut / actualSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get throughput history for specified duration
|
||||
*/
|
||||
public getHistory(durationSeconds: number): IThroughputHistoryPoint[] {
|
||||
const now = Date.now();
|
||||
const startTime = now - (durationSeconds * 1000);
|
||||
|
||||
// Filter samples within duration
|
||||
const relevantSamples = this.samples.filter(s => s.timestamp > startTime);
|
||||
|
||||
// Convert to history points with per-second rates
|
||||
const history: IThroughputHistoryPoint[] = [];
|
||||
|
||||
for (let i = 0; i < relevantSamples.length; i++) {
|
||||
const sample = relevantSamples[i];
|
||||
|
||||
// For the first sample or samples after gaps, we can't calculate rate
|
||||
if (i === 0 || sample.timestamp - relevantSamples[i - 1].timestamp > 2000) {
|
||||
history.push({
|
||||
timestamp: sample.timestamp,
|
||||
in: sample.bytesIn,
|
||||
out: sample.bytesOut
|
||||
});
|
||||
} else {
|
||||
// Calculate rate based on time since previous sample
|
||||
const prevSample = relevantSamples[i - 1];
|
||||
const timeDelta = (sample.timestamp - prevSample.timestamp) / 1000;
|
||||
|
||||
history.push({
|
||||
timestamp: sample.timestamp,
|
||||
in: Math.round(sample.bytesIn / timeDelta),
|
||||
out: Math.round(sample.bytesOut / timeDelta)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all samples
|
||||
*/
|
||||
public clear(): void {
|
||||
this.samples = [];
|
||||
this.accumulatedBytesIn = 0;
|
||||
this.accumulatedBytesOut = 0;
|
||||
this.lastSampleTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample count for debugging
|
||||
*/
|
||||
public getSampleCount(): number {
|
||||
return this.samples.length;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import type { IConnectionRecord } from './models/interfaces.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
/**
|
||||
* Manages timeouts and inactivity tracking for connections
|
||||
*/
|
||||
export class TimeoutManager {
|
||||
constructor(private smartProxy: SmartProxy) {}
|
||||
|
||||
/**
|
||||
* Ensure timeout values don't exceed Node.js max safe integer
|
||||
*/
|
||||
public ensureSafeTimeout(timeout: number): number {
|
||||
const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1)
|
||||
return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slightly randomized timeout to prevent thundering herd
|
||||
*/
|
||||
public randomizeTimeout(baseTimeout: number, variationPercent: number = 5): number {
|
||||
const safeBaseTimeout = this.ensureSafeTimeout(baseTimeout);
|
||||
const variation = safeBaseTimeout * (variationPercent / 100);
|
||||
return this.ensureSafeTimeout(
|
||||
safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection activity timestamp
|
||||
*/
|
||||
public updateActivity(record: IConnectionRecord): void {
|
||||
record.lastActivity = Date.now();
|
||||
|
||||
// Clear any inactivity warning
|
||||
if (record.inactivityWarningIssued) {
|
||||
record.inactivityWarningIssued = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate effective inactivity timeout based on connection type
|
||||
*/
|
||||
public getEffectiveInactivityTimeout(record: IConnectionRecord): number {
|
||||
let effectiveTimeout = this.smartProxy.settings.inactivityTimeout || 14400000; // 4 hours default
|
||||
|
||||
// For immortal keep-alive connections, use an extremely long timeout
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// For extended keep-alive connections, apply multiplier
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
|
||||
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
|
||||
effectiveTimeout = effectiveTimeout * multiplier;
|
||||
}
|
||||
|
||||
return this.ensureSafeTimeout(effectiveTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate effective max lifetime based on connection type
|
||||
*/
|
||||
public getEffectiveMaxLifetime(record: IConnectionRecord): number {
|
||||
// Use route-specific timeout if available from the routeConfig
|
||||
const baseTimeout = record.routeConfig?.action.advanced?.timeout ||
|
||||
this.smartProxy.settings.maxConnectionLifetime ||
|
||||
86400000; // 24 hours default
|
||||
|
||||
// For immortal keep-alive connections, use an extremely long lifetime
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// For extended keep-alive connections, use the extended lifetime setting
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
|
||||
return this.ensureSafeTimeout(
|
||||
this.smartProxy.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
|
||||
);
|
||||
}
|
||||
|
||||
// Apply randomization if enabled
|
||||
if (this.smartProxy.settings.enableRandomizedTimeouts) {
|
||||
return this.randomizeTimeout(baseTimeout);
|
||||
}
|
||||
|
||||
return this.ensureSafeTimeout(baseTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup connection timeout
|
||||
* @returns The cleanup timer
|
||||
*/
|
||||
public setupConnectionTimeout(
|
||||
record: IConnectionRecord,
|
||||
onTimeout: (record: IConnectionRecord, reason: string) => void
|
||||
): NodeJS.Timeout | null {
|
||||
// Clear any existing timer
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
}
|
||||
|
||||
// Skip timeout for immortal keep-alive connections
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate effective timeout
|
||||
const effectiveLifetime = this.getEffectiveMaxLifetime(record);
|
||||
|
||||
// Set up the timeout
|
||||
const timer = setTimeout(() => {
|
||||
// Call the provided callback
|
||||
onTimeout(record, 'connection_timeout');
|
||||
}, effectiveLifetime);
|
||||
|
||||
// Make sure timeout doesn't keep the process alive
|
||||
if (timer.unref) {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for inactivity on a connection
|
||||
* @returns Object with check results
|
||||
*/
|
||||
public checkInactivity(record: IConnectionRecord): {
|
||||
isInactive: boolean;
|
||||
shouldWarn: boolean;
|
||||
inactivityTime: number;
|
||||
effectiveTimeout: number;
|
||||
} {
|
||||
// Skip for connections with inactivity check disabled
|
||||
if (this.smartProxy.settings.disableInactivityCheck) {
|
||||
return {
|
||||
isInactive: false,
|
||||
shouldWarn: false,
|
||||
inactivityTime: 0,
|
||||
effectiveTimeout: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Skip for immortal keep-alive connections
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
|
||||
return {
|
||||
isInactive: false,
|
||||
shouldWarn: false,
|
||||
inactivityTime: 0,
|
||||
effectiveTimeout: 0
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const inactivityTime = now - record.lastActivity;
|
||||
const effectiveTimeout = this.getEffectiveInactivityTimeout(record);
|
||||
|
||||
// Check if inactive
|
||||
const isInactive = inactivityTime > effectiveTimeout;
|
||||
|
||||
// For keep-alive connections, we should warn first
|
||||
const shouldWarn = record.hasKeepAlive &&
|
||||
isInactive &&
|
||||
!record.inactivityWarningIssued;
|
||||
|
||||
return {
|
||||
isInactive,
|
||||
shouldWarn,
|
||||
inactivityTime,
|
||||
effectiveTimeout
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply socket timeout settings
|
||||
*/
|
||||
public applySocketTimeouts(record: IConnectionRecord): void {
|
||||
// Skip for immortal keep-alive connections
|
||||
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
|
||||
// Disable timeouts completely for immortal connections
|
||||
record.incoming.setTimeout(0);
|
||||
if (record.outgoing) {
|
||||
record.outgoing.setTimeout(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply normal timeouts
|
||||
const timeout = this.ensureSafeTimeout(this.smartProxy.settings.socketTimeout || 3600000); // 1 hour default
|
||||
record.incoming.setTimeout(timeout);
|
||||
if (record.outgoing) {
|
||||
record.outgoing.setTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { SniHandler } from '../../tls/sni/sni-handler.js';
|
||||
import { ProtocolDetector, TlsDetector } from '../../detection/index.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
/**
|
||||
* Interface for connection information used for SNI extraction
|
||||
*/
|
||||
interface IConnectionInfo {
|
||||
sourceIp: string;
|
||||
sourcePort: number;
|
||||
destIp: string;
|
||||
destPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages TLS-related operations including SNI extraction and validation
|
||||
*/
|
||||
export class TlsManager {
|
||||
constructor(private smartProxy: SmartProxy) {}
|
||||
|
||||
/**
|
||||
* Check if a data chunk appears to be a TLS handshake
|
||||
*/
|
||||
public isTlsHandshake(chunk: Buffer): boolean {
|
||||
return SniHandler.isTlsHandshake(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a data chunk appears to be a TLS ClientHello
|
||||
*/
|
||||
public isClientHello(chunk: Buffer): boolean {
|
||||
return SniHandler.isClientHello(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Server Name Indication (SNI) from TLS handshake
|
||||
*/
|
||||
public extractSNI(
|
||||
chunk: Buffer,
|
||||
connInfo: IConnectionInfo,
|
||||
previousDomain?: string
|
||||
): string | undefined {
|
||||
// Use the SniHandler to process the TLS packet
|
||||
return SniHandler.processTlsPacket(
|
||||
chunk,
|
||||
connInfo,
|
||||
this.smartProxy.settings.enableTlsDebugLogging || false,
|
||||
previousDomain
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for SNI mismatch during renegotiation
|
||||
*/
|
||||
public checkRenegotiationSNI(
|
||||
chunk: Buffer,
|
||||
connInfo: IConnectionInfo,
|
||||
expectedDomain: string,
|
||||
connectionId: string
|
||||
): { hasMismatch: boolean; extractedSNI?: string } {
|
||||
// Only process if this looks like a TLS ClientHello
|
||||
if (!this.isClientHello(chunk)) {
|
||||
return { hasMismatch: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract SNI with renegotiation support
|
||||
const newSNI = SniHandler.extractSNIWithResumptionSupport(
|
||||
chunk,
|
||||
connInfo,
|
||||
this.smartProxy.settings.enableTlsDebugLogging || false
|
||||
);
|
||||
|
||||
// Skip if no SNI was found
|
||||
if (!newSNI) return { hasMismatch: false };
|
||||
|
||||
// Check for SNI mismatch
|
||||
if (newSNI !== expectedDomain) {
|
||||
if (this.smartProxy.settings.enableTlsDebugLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Renegotiation with different SNI: ${expectedDomain} -> ${newSNI}. ` +
|
||||
`Terminating connection - SNI domain switching is not allowed.`
|
||||
);
|
||||
}
|
||||
return { hasMismatch: true, extractedSNI: newSNI };
|
||||
} else if (this.smartProxy.settings.enableTlsDebugLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
`[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.`
|
||||
);
|
||||
}
|
||||
|
||||
return { hasMismatch: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a renegotiation handler function for a connection
|
||||
*/
|
||||
public createRenegotiationHandler(
|
||||
connectionId: string,
|
||||
lockedDomain: string,
|
||||
connInfo: IConnectionInfo,
|
||||
onMismatch: (connectionId: string, reason: string) => void
|
||||
): (chunk: Buffer) => void {
|
||||
return (chunk: Buffer) => {
|
||||
const result = this.checkRenegotiationSNI(chunk, connInfo, lockedDomain, connectionId);
|
||||
if (result.hasMismatch) {
|
||||
onMismatch(connectionId, 'sni_mismatch');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze TLS connection for browser fingerprinting
|
||||
* This helps identify browser vs non-browser connections
|
||||
*/
|
||||
public analyzeClientHello(chunk: Buffer): {
|
||||
isBrowserConnection: boolean;
|
||||
isRenewal: boolean;
|
||||
hasSNI: boolean;
|
||||
} {
|
||||
// Default result
|
||||
const result = {
|
||||
isBrowserConnection: false,
|
||||
isRenewal: false,
|
||||
hasSNI: false
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if it's a ClientHello
|
||||
if (!this.isClientHello(chunk)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for session resumption
|
||||
const resumptionInfo = SniHandler.hasSessionResumption(
|
||||
chunk,
|
||||
this.smartProxy.settings.enableTlsDebugLogging || false
|
||||
);
|
||||
|
||||
// Extract SNI
|
||||
const sni = SniHandler.extractSNI(
|
||||
chunk,
|
||||
this.smartProxy.settings.enableTlsDebugLogging || false
|
||||
);
|
||||
|
||||
// Update result
|
||||
result.isRenewal = resumptionInfo.isResumption;
|
||||
result.hasSNI = !!sni;
|
||||
|
||||
// Browsers typically:
|
||||
// 1. Send SNI extension
|
||||
// 2. Have a variety of extensions (ALPN, etc.)
|
||||
// 3. Use standard cipher suites
|
||||
// ...more complex heuristics could be implemented here
|
||||
|
||||
// Simple heuristic: presence of SNI suggests browser
|
||||
result.isBrowserConnection = !!sni;
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.log(`Error analyzing ClientHello: ${err}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* Routing functionality module
|
||||
*/
|
||||
|
||||
// Export types and models from HttpProxy
|
||||
export * from '../proxies/http-proxy/models/http-types.js';
|
||||
// Export types and models
|
||||
export * from './models/http-types.js';
|
||||
|
||||
// Export router functionality
|
||||
export * from './router/index.js';
|
||||
|
||||
@@ -1,6 +1,149 @@
|
||||
/**
|
||||
* This file re-exports HTTP types from the HttpProxy module
|
||||
* for backward compatibility. All HTTP types are now consolidated
|
||||
* in the HttpProxy module.
|
||||
* HTTP types for routing module.
|
||||
* These were previously in http-proxy and are now self-contained here.
|
||||
*/
|
||||
export * from '../../proxies/http-proxy/models/http-types.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { HttpStatus as ProtocolHttpStatus, getStatusText as getProtocolStatusText } from '../../protocols/http/index.js';
|
||||
|
||||
/**
|
||||
* HTTP-specific event types
|
||||
*/
|
||||
export enum HttpEvents {
|
||||
REQUEST_RECEIVED = 'request-received',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
REQUEST_HANDLED = 'request-handled',
|
||||
REQUEST_ERROR = 'request-error',
|
||||
}
|
||||
|
||||
// Re-export for backward compatibility with subset of commonly used codes
|
||||
export const HttpStatus = {
|
||||
OK: ProtocolHttpStatus.OK,
|
||||
MOVED_PERMANENTLY: ProtocolHttpStatus.MOVED_PERMANENTLY,
|
||||
FOUND: ProtocolHttpStatus.FOUND,
|
||||
TEMPORARY_REDIRECT: ProtocolHttpStatus.TEMPORARY_REDIRECT,
|
||||
PERMANENT_REDIRECT: ProtocolHttpStatus.PERMANENT_REDIRECT,
|
||||
BAD_REQUEST: ProtocolHttpStatus.BAD_REQUEST,
|
||||
UNAUTHORIZED: ProtocolHttpStatus.UNAUTHORIZED,
|
||||
FORBIDDEN: ProtocolHttpStatus.FORBIDDEN,
|
||||
NOT_FOUND: ProtocolHttpStatus.NOT_FOUND,
|
||||
METHOD_NOT_ALLOWED: ProtocolHttpStatus.METHOD_NOT_ALLOWED,
|
||||
REQUEST_TIMEOUT: ProtocolHttpStatus.REQUEST_TIMEOUT,
|
||||
TOO_MANY_REQUESTS: ProtocolHttpStatus.TOO_MANY_REQUESTS,
|
||||
INTERNAL_SERVER_ERROR: ProtocolHttpStatus.INTERNAL_SERVER_ERROR,
|
||||
NOT_IMPLEMENTED: ProtocolHttpStatus.NOT_IMPLEMENTED,
|
||||
BAD_GATEWAY: ProtocolHttpStatus.BAD_GATEWAY,
|
||||
SERVICE_UNAVAILABLE: ProtocolHttpStatus.SERVICE_UNAVAILABLE,
|
||||
GATEWAY_TIMEOUT: ProtocolHttpStatus.GATEWAY_TIMEOUT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Base error class for HTTP-related errors
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(message: string, public readonly statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to certificate operations
|
||||
*/
|
||||
export class CertificateError extends HttpError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly domain: string,
|
||||
public readonly isRenewal: boolean = false
|
||||
) {
|
||||
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
this.name = 'CertificateError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to server operations
|
||||
*/
|
||||
export class ServerError extends HttpError {
|
||||
constructor(message: string, public readonly code?: string, statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
super(message, statusCode);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for bad requests
|
||||
*/
|
||||
export class BadRequestError extends HttpError {
|
||||
constructor(message: string) {
|
||||
super(message, HttpStatus.BAD_REQUEST);
|
||||
this.name = 'BadRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for not found resources
|
||||
*/
|
||||
export class NotFoundError extends HttpError {
|
||||
constructor(message: string = 'Resource not found') {
|
||||
super(message, HttpStatus.NOT_FOUND);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect configuration for HTTP requests
|
||||
*/
|
||||
export interface IRedirectConfig {
|
||||
source: string;
|
||||
destination: string;
|
||||
type: number;
|
||||
preserveQuery?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP router configuration
|
||||
*/
|
||||
export interface IRouterConfig {
|
||||
routes: Array<{
|
||||
path: string;
|
||||
method?: string;
|
||||
handler: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void | Promise<void>;
|
||||
}>;
|
||||
notFoundHandler?: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
errorHandler?: (error: Error, req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request method types
|
||||
*/
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
|
||||
|
||||
/**
|
||||
* Helper function to get HTTP status text
|
||||
*/
|
||||
export function getStatusText(status: number): string {
|
||||
return getProtocolStatusText(status as ProtocolHttpStatus);
|
||||
}
|
||||
|
||||
// Legacy interfaces for backward compatibility
|
||||
export interface IDomainOptions {
|
||||
domainName: string;
|
||||
sslRedirect: boolean;
|
||||
acmeMaintenance: boolean;
|
||||
forward?: { ip: string; port: number };
|
||||
acmeForward?: { ip: string; port: number };
|
||||
}
|
||||
|
||||
export interface IDomainCertificate {
|
||||
options: IDomainOptions;
|
||||
certObtained: boolean;
|
||||
obtainingInProgress: boolean;
|
||||
certificate?: string;
|
||||
privateKey?: string;
|
||||
expiryDate?: Date;
|
||||
lastRenewalAttempt?: Date;
|
||||
}
|
||||
|
||||
// Backward compatibility exports
|
||||
export { HttpError as Port80HandlerError };
|
||||
export { CertificateError as CertError };
|
||||
|
||||
Reference in New Issue
Block a user