fix(security): critical security and stability fixes
Some checks failed
Default (tags) / security (push) Successful in 1m2s
Default (tags) / test (push) Failing after 46m14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped

This commit is contained in:
2025-08-14 14:30:54 +00:00
parent 6eac957baf
commit 5fbcf81c2c
19 changed files with 1647 additions and 1301 deletions

Binary file not shown.

68
.serena/project.yml Normal file
View File

@@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed)on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "smartproxy"

View File

@@ -1,5 +1,5 @@
{ {
"expiryDate": "2025-10-20T11:32:30.675Z", "expiryDate": "2025-11-12T14:20:10.043Z",
"issueDate": "2025-07-22T11:32:30.675Z", "issueDate": "2025-08-14T14:20:10.043Z",
"savedAt": "2025-07-22T11:32:30.675Z" "savedAt": "2025-08-14T14:20:10.044Z"
} }

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 2025-08-14 - 21.1.4 - fix(security)
Critical security and stability fixes
- Fixed critical socket.emit override vulnerability that was breaking TLS connections
- Implemented comprehensive socket cleanup with new socket tracker utility
- Improved code organization by extracting RouteOrchestrator from SmartProxy
- Fixed IPv6 loopback detection for proper IPv6 support
- Added memory bounds to prevent unbounded collection growth
- Fixed certificate manager race conditions with proper synchronization
- Unreferenced long-lived timers to prevent process hanging
- Enhanced route validation for socket-handler actions
- Fixed header parsing when extractFullHeaders option is enabled
## 2025-07-22 - 21.1.1 - fix(detection) ## 2025-07-22 - 21.1.1 - fix(detection)
Fix SNI detection in TLS detector Fix SNI detection in TLS detector

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "21.1.3", "version": "21.1.4",
"private": false, "private": false,
"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.", "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.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -19,7 +19,8 @@
"@git.zone/tsrun": "^1.2.44", "@git.zone/tsrun": "^1.2.44",
"@git.zone/tstest": "^2.3.1", "@git.zone/tstest": "^2.3.1",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"why-is-node-running": "^3.2.2"
}, },
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.2.2", "@push.rocks/lik": "^6.2.2",

10
pnpm-lock.yaml generated
View File

@@ -78,6 +78,9 @@ importers:
typescript: typescript:
specifier: ^5.8.3 specifier: ^5.8.3
version: 5.8.3 version: 5.8.3
why-is-node-running:
specifier: ^3.2.2
version: 3.2.2
packages: packages:
@@ -4096,6 +4099,11 @@ packages:
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true hasBin: true
why-is-node-running@3.2.2:
resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==}
engines: {node: '>=20.11'}
hasBin: true
winston-transport@4.9.0: winston-transport@4.9.0:
resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -10082,6 +10090,8 @@ snapshots:
dependencies: dependencies:
isexe: 3.1.1 isexe: 3.1.1
why-is-node-running@3.2.2: {}
winston-transport@4.9.0: winston-transport@4.9.0:
dependencies: dependencies:
logform: 2.7.0 logform: 2.7.0

1413
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,11 @@ declare module 'net' {
getTLSVersion?(): string; // Returns the TLS version (e.g., 'TLSv1.2', 'TLSv1.3') getTLSVersion?(): string; // Returns the TLS version (e.g., 'TLSv1.2', 'TLSv1.3')
getPeerCertificate?(detailed?: boolean): any; // Returns the peer's certificate getPeerCertificate?(detailed?: boolean): any; // Returns the peer's certificate
getSession?(): Buffer; // Returns the TLS session data getSession?(): Buffer; // Returns the TLS session data
// Connection tracking properties (used by HttpProxy)
_connectionId?: string; // Unique identifier for the connection
_remoteIP?: string; // Remote IP address
_realRemoteIP?: string; // Real remote IP (when proxied)
} }
} }

View File

@@ -0,0 +1,63 @@
/**
* Socket Tracker Utility
* Provides standardized socket cleanup with proper listener and timer management
*/
import type { Socket } from 'net';
export type SocketTracked = {
cleanup: () => void;
addListener: <E extends string>(event: E, listener: (...args: any[]) => void) => void;
addTimer: (t: NodeJS.Timeout | null | undefined) => void;
safeDestroy: (reason?: Error) => void;
};
/**
* Create a socket tracker to manage listeners and timers
* Ensures proper cleanup and prevents memory leaks
*/
export function createSocketTracker(socket: Socket): SocketTracked {
const listeners: Array<{ event: string; listener: (...args: any[]) => void }> = [];
const timers: NodeJS.Timeout[] = [];
let cleaned = false;
const addListener = (event: string, listener: (...args: any[]) => void) => {
socket.on(event, listener);
listeners.push({ event, listener });
};
const addTimer = (t: NodeJS.Timeout | null | undefined) => {
if (!t) return;
timers.push(t);
// Unref timer so it doesn't keep process alive
if (typeof t.unref === 'function') {
t.unref();
}
};
const cleanup = () => {
if (cleaned) return;
cleaned = true;
// Clear all tracked timers
for (const t of timers) {
clearTimeout(t);
}
timers.length = 0;
// Remove all tracked listeners
for (const { event, listener } of listeners) {
socket.off(event, listener);
}
listeners.length = 0;
};
const safeDestroy = (reason?: Error) => {
cleanup();
if (!socket.destroyed) {
socket.destroy(reason);
}
};
return { cleanup, addListener, addTimer, safeDestroy };
}

View File

@@ -11,6 +11,7 @@ import type { THttpMethod } from '../../protocols/http/index.js';
import { QuickProtocolDetector } from './quick-detector.js'; import { QuickProtocolDetector } from './quick-detector.js';
import { RoutingExtractor } from './routing-extractor.js'; import { RoutingExtractor } from './routing-extractor.js';
import { DetectionFragmentManager } from '../utils/fragment-manager.js'; import { DetectionFragmentManager } from '../utils/fragment-manager.js';
import { HttpParser } from '../../protocols/http/parser.js';
/** /**
* Simplified HTTP detector * Simplified HTTP detector
@@ -56,6 +57,17 @@ export class HttpDetector implements IProtocolDetector {
// Extract routing information // Extract routing information
const routing = RoutingExtractor.extract(buffer, 'http'); const routing = RoutingExtractor.extract(buffer, 'http');
// Extract headers if requested and we have complete headers
let headers: Record<string, string> | undefined;
if (options?.extractFullHeaders && isComplete) {
const headerSection = buffer.slice(0, headersEnd).toString();
const lines = headerSection.split('\r\n');
if (lines.length > 1) {
// Skip the request line and parse headers
headers = HttpParser.parseHeaders(lines.slice(1));
}
}
// If we don't need full headers and we have complete headers, we can return early // If we don't need full headers and we have complete headers, we can return early
if (quickResult.confidence >= 95 && !options?.extractFullHeaders && isComplete) { if (quickResult.confidence >= 95 && !options?.extractFullHeaders && isComplete) {
return { return {
@@ -76,7 +88,8 @@ export class HttpDetector implements IProtocolDetector {
protocol: 'http', protocol: 'http',
domain: routing?.domain, domain: routing?.domain,
path: routing?.path, path: routing?.path,
method: quickResult.metadata?.method as THttpMethod method: quickResult.metadata?.method as THttpMethod,
headers: headers
}, },
isComplete, isComplete,
bytesNeeded: isComplete ? undefined : buffer.length + 512 // Need more for headers bytesNeeded: isComplete ? undefined : buffer.length + 512 // Need more for headers

View File

@@ -233,6 +233,7 @@ export class ProtocolDetector {
private destroyInstance(): void { private destroyInstance(): void {
this.fragmentManager.destroy(); this.fragmentManager.destroy();
this.connectionProtocols.clear();
} }
/** /**

View File

@@ -35,7 +35,7 @@ export class HttpProxy implements IMetricsTracker {
public routes: IRouteConfig[] = []; public routes: IRouteConfig[] = [];
// Server instances (HTTP/2 with HTTP/1 fallback) // Server instances (HTTP/2 with HTTP/1 fallback)
public httpsServer: any; public httpsServer: plugins.http2.Http2SecureServer;
// Core components // Core components
private certificateManager: CertificateManager; private certificateManager: CertificateManager;
@@ -196,8 +196,9 @@ export class HttpProxy implements IMetricsTracker {
this.options.keepAliveTimeout = keepAliveTimeout; this.options.keepAliveTimeout = keepAliveTimeout;
if (this.httpsServer) { if (this.httpsServer) {
this.httpsServer.keepAliveTimeout = keepAliveTimeout; // HTTP/2 servers have setTimeout method for timeout management
this.logger.info(`Updated keep-alive timeout to ${keepAliveTimeout}ms`); this.httpsServer.setTimeout(keepAliveTimeout);
this.logger.info(`Updated server timeout to ${keepAliveTimeout}ms`);
} }
} }
@@ -249,18 +250,19 @@ export class HttpProxy implements IMetricsTracker {
this.setupConnectionTracking(); this.setupConnectionTracking();
// Handle incoming HTTP/2 streams // Handle incoming HTTP/2 streams
this.httpsServer.on('stream', (stream: any, headers: any) => { this.httpsServer.on('stream', (stream: plugins.http2.ServerHttp2Stream, headers: plugins.http2.IncomingHttpHeaders) => {
this.requestHandler.handleHttp2(stream, headers); this.requestHandler.handleHttp2(stream, headers);
}); });
// Handle HTTP/1.x fallback requests // Handle HTTP/1.x fallback requests
this.httpsServer.on('request', (req: any, res: any) => { this.httpsServer.on('request', (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => {
this.requestHandler.handleRequest(req, res); this.requestHandler.handleRequest(req, res);
}); });
// Share server with certificate manager for dynamic contexts // Share server with certificate manager for dynamic contexts
this.certificateManager.setHttpsServer(this.httpsServer); // Cast to https.Server as Http2SecureServer is compatible for certificate contexts
this.certificateManager.setHttpsServer(this.httpsServer as any);
// Setup WebSocket support on HTTP/1 fallback // Setup WebSocket support on HTTP/1 fallback
this.webSocketHandler.initialize(this.httpsServer); this.webSocketHandler.initialize(this.httpsServer as any);
// Start metrics logging // Start metrics logging
this.setupMetricsCollection(); this.setupMetricsCollection();
// Start periodic connection pool cleanup // Start periodic connection pool cleanup
@@ -275,6 +277,21 @@ export class HttpProxy implements IMetricsTracker {
}); });
} }
/**
* 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 * Sets up tracking of TCP connections
*/ */
@@ -282,30 +299,47 @@ export class HttpProxy implements IMetricsTracker {
this.httpsServer.on('connection', (connection: plugins.net.Socket) => { this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
let remoteIP = connection.remoteAddress || ''; let remoteIP = connection.remoteAddress || '';
const connectionId = Math.random().toString(36).substring(2, 15); const connectionId = Math.random().toString(36).substring(2, 15);
const isFromSmartProxy = this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1'); const isFromSmartProxy = this.options.portProxyIntegration && this.isLoopback(connection.remoteAddress);
// For SmartProxy connections, wait for CLIENT_IP header // For SmartProxy connections, wait for CLIENT_IP header
if (isFromSmartProxy) { if (isFromSmartProxy) {
let headerBuffer = Buffer.alloc(0); const MAX_PREFACE = 256; // bytes - prevent DoS
let headerParsed = false; const HEADER_TIMEOUT_MS = 500; // timeout for header parsing
let headerTimer: NodeJS.Timeout | undefined;
const parseHeader = (data: Buffer) => { let buffered = Buffer.alloc(0);
if (headerParsed) return data;
const onData = (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk]);
headerBuffer = Buffer.concat([headerBuffer, data]); // Prevent unbounded growth
const headerStr = headerBuffer.toString(); if (buffered.length > MAX_PREFACE) {
const headerEnd = headerStr.indexOf('\r\n'); connection.removeListener('data', onData);
if (headerTimer) clearTimeout(headerTimer);
this.logger.warn('Header preface too large, closing connection');
connection.destroy();
return;
}
if (headerEnd !== -1) { const idx = buffered.indexOf('\r\n');
const header = headerStr.substring(0, headerEnd); if (idx !== -1) {
if (header.startsWith('CLIENT_IP:')) { const headerLine = buffered.slice(0, idx).toString('utf8');
remoteIP = header.substring(10); // Extract IP after "CLIENT_IP:" if (headerLine.startsWith('CLIENT_IP:')) {
remoteIP = headerLine.substring(10).trim();
this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`); this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`);
} }
headerParsed = true;
// 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 // Store the real IP on the connection
(connection as any)._realRemoteIP = remoteIP; connection._realRemoteIP = remoteIP;
// Validate the real IP // Validate the real IP
const ipValidation = this.securityManager.validateIP(remoteIP); const ipValidation = this.securityManager.validateIP(remoteIP);
@@ -318,35 +352,26 @@ export class HttpProxy implements IMetricsTracker {
remoteIP remoteIP
); );
connection.destroy(); connection.destroy();
return null; return;
} }
// Track connection by real IP // Track connection by real IP
this.securityManager.trackConnectionByIP(remoteIP, connectionId); this.securityManager.trackConnectionByIP(remoteIP, connectionId);
// Return remaining data after header
return headerBuffer.slice(headerEnd + 2);
} }
return null;
}; };
// Set timeout for header parsing
headerTimer = setTimeout(() => {
connection.removeListener('data', onData);
this.logger.warn('Header parsing timeout, closing connection');
connection.destroy();
}, HEADER_TIMEOUT_MS);
// Override the first data handler to parse header // Unref the timer so it doesn't keep the process alive
const originalEmit = connection.emit; if (headerTimer.unref) headerTimer.unref();
connection.emit = function(event: string, ...args: any[]) {
if (event === 'data' && !headerParsed) { // Use prependListener to get data first
const remaining = parseHeader(args[0]); connection.prependListener('data', onData);
if (remaining && remaining.length > 0) {
// Call original emit with remaining data
return originalEmit.apply(connection, ['data', remaining]);
} else if (headerParsed) {
// Header parsed but no remaining data
return true;
}
// Header not complete yet, suppress this data event
return true;
}
return originalEmit.apply(connection, [event, ...args]);
} as any;
} else { } else {
// Direct connection - validate immediately // Direct connection - validate immediately
const ipValidation = this.securityManager.validateIP(remoteIP); const ipValidation = this.securityManager.validateIP(remoteIP);
@@ -385,8 +410,8 @@ export class HttpProxy implements IMetricsTracker {
} }
// Add connection to tracking with metadata // Add connection to tracking with metadata
(connection as any)._connectionId = connectionId; connection._connectionId = connectionId;
(connection as any)._remoteIP = remoteIP; connection._remoteIP = remoteIP;
this.socketMap.add(connection); this.socketMap.add(connection);
this.connectedClients = this.socketMap.getArray().length; this.connectedClients = this.socketMap.getArray().length;
@@ -409,8 +434,8 @@ export class HttpProxy implements IMetricsTracker {
this.connectedClients = this.socketMap.getArray().length; this.connectedClients = this.socketMap.getArray().length;
// Remove IP tracking // Remove IP tracking
const connId = (connection as any)._connectionId; const connId = connection._connectionId;
const connIP = (connection as any)._realRemoteIP || (connection as any)._remoteIP; const connIP = connection._realRemoteIP || connection._remoteIP;
if (connId && connIP) { if (connId && connIP) {
this.securityManager.removeConnectionByIP(connIP, connId); this.securityManager.removeConnectionByIP(connIP, connId);
} }

View File

@@ -110,6 +110,14 @@ export class SmartCertManager {
this.certProvisionFallbackToAcme = fallback; 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) * Set callback for updating routes (used for challenge routes)
*/ */
@@ -391,15 +399,14 @@ export class SmartCertManager {
} }
// Parse certificate to get dates // Parse certificate to get dates
// Parse certificate to get dates - for now just use defaults const expiryDate = this.extractExpiryDate(cert);
// TODO: Implement actual certificate parsing if needed const issueDate = new Date(); // Current date as issue date
const certInfo = { validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), validFrom: new Date() };
const certData: ICertificateData = { const certData: ICertificateData = {
cert, cert,
key, key,
expiryDate: certInfo.validTo, expiryDate,
issueDate: certInfo.validFrom, issueDate,
source: 'static' source: 'static'
}; };
@@ -573,6 +580,8 @@ export class SmartCertManager {
// With the re-ordering of start(), port binding should already be done // With the re-ordering of start(), port binding should already be done
// This updateRoutes call should just add the route without binding again // This updateRoutes call should just add the route without binding again
await this.updateRoutesCallback(updatedRoutes); await this.updateRoutesCallback(updatedRoutes);
// Keep local routes in sync after updating
this.routes = updatedRoutes;
this.challengeRouteActive = true; this.challengeRouteActive = true;
// Register with state manager // Register with state manager
@@ -662,6 +671,8 @@ export class SmartCertManager {
try { try {
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge'); const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
await this.updateRoutesCallback(filteredRoutes); await this.updateRoutesCallback(filteredRoutes);
// Keep local routes in sync after updating
this.routes = filteredRoutes;
this.challengeRouteActive = false; this.challengeRouteActive = false;
// Remove from state manager // Remove from state manager
@@ -697,6 +708,11 @@ export class SmartCertManager {
this.checkAndRenewCertificates(); this.checkAndRenewCertificates();
}, 12 * 60 * 60 * 1000); }, 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 // Also do an immediate check
this.checkAndRenewCertificates(); this.checkAndRenewCertificates();
} }

View File

@@ -20,6 +20,7 @@ export { HttpProxyBridge } from './http-proxy-bridge.js';
export { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; export { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
export { RouteConnectionHandler } from './route-connection-handler.js'; export { RouteConnectionHandler } from './route-connection-handler.js';
export { NFTablesManager } from './nftables-manager.js'; export { NFTablesManager } from './nftables-manager.js';
export { RouteOrchestrator } from './route-orchestrator.js';
// Export certificate management // Export certificate management
export { SmartCertManager } from './certificate-manager.js'; export { SmartCertManager } from './certificate-manager.js';

View File

@@ -33,6 +33,11 @@ export class MetricsCollector implements IMetrics {
private readonly sampleIntervalMs: number; private readonly sampleIntervalMs: number;
private readonly retentionSeconds: number; private readonly retentionSeconds: number;
// Track connection durations for percentile calculations
private connectionDurations: number[] = [];
private bytesInArray: number[] = [];
private bytesOutArray: number[] = [];
constructor( constructor(
private smartProxy: SmartProxy, private smartProxy: SmartProxy,
config?: { config?: {
@@ -211,21 +216,39 @@ export class MetricsCollector implements IMetrics {
} }
}; };
// Percentiles implementation (placeholder for now) // 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 = { public percentiles = {
connectionDuration: (): { p50: number; p95: number; p99: number } => { connectionDuration: (): { p50: number; p95: number; p99: number } => {
// TODO: Implement percentile calculations return {
return { p50: 0, p95: 0, p99: 0 }; p50: this.calculatePercentile(this.connectionDurations, 0.5),
p95: this.calculatePercentile(this.connectionDurations, 0.95),
p99: this.calculatePercentile(this.connectionDurations, 0.99)
};
}, },
bytesTransferred: (): { bytesTransferred: (): {
in: { p50: number; p95: number; p99: number }; in: { p50: number; p95: number; p99: number };
out: { p50: number; p95: number; p99: number }; out: { p50: number; p95: number; p99: number };
} => { } => {
// TODO: Implement percentile calculations
return { return {
in: { p50: 0, p95: 0, p99: 0 }, in: {
out: { p50: 0, p95: 0, p99: 0 } 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)
}
}; };
} }
}; };
@@ -298,6 +321,30 @@ export class MetricsCollector implements IMetrics {
* Clean up tracking for a closed connection * Clean up tracking for a closed connection
*/ */
public removeConnection(connectionId: string): void { 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); this.connectionByteTrackers.delete(connectionId);
} }
@@ -349,6 +396,11 @@ export class MetricsCollector implements IMetrics {
} }
}, this.sampleIntervalMs); }, this.sampleIntervalMs);
// Unref the interval so it doesn't keep the process alive
if (this.samplingInterval.unref) {
this.samplingInterval.unref();
}
// Subscribe to new connections // Subscribe to new connections
this.connectionSubscription = this.smartProxy.routeConnectionHandler.newConnectionSubject.subscribe({ this.connectionSubscription = this.smartProxy.routeConnectionHandler.newConnectionSubject.subscribe({
next: (record) => { next: (record) => {

View File

@@ -0,0 +1,297 @@
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);
}
}
}
}

View File

@@ -25,6 +25,12 @@ import type { IRouteConfig } from './models/route-types.js';
// Import mutex for route update synchronization // Import mutex for route update synchronization
import { Mutex } from './utils/mutex.js'; import { Mutex } from './utils/mutex.js';
// Import route validator
import { RouteValidator } from './utils/route-validator.js';
// Import route orchestrator for route management
import { RouteOrchestrator } from './route-orchestrator.js';
// Import ACME state manager // Import ACME state manager
import { AcmeStateManager } from './acme-state-manager.js'; import { AcmeStateManager } from './acme-state-manager.js';
@@ -66,12 +72,15 @@ export class SmartProxy extends plugins.EventEmitter {
// Global challenge route tracking // Global challenge route tracking
private globalChallengeRouteActive: boolean = false; private globalChallengeRouteActive: boolean = false;
private routeUpdateLock: any = null; // Will be initialized as AsyncMutex private routeUpdateLock: Mutex;
public acmeStateManager: AcmeStateManager; public acmeStateManager: AcmeStateManager;
// Metrics collector // Metrics collector
public metricsCollector: MetricsCollector; public metricsCollector: MetricsCollector;
// Route orchestrator for managing route updates
private routeOrchestrator: RouteOrchestrator;
// Track port usage across route updates // Track port usage across route updates
private portUsageMap: Map<number, Set<string>> = new Map(); private portUsageMap: Map<number, Set<string>> = new Map();
@@ -175,6 +184,15 @@ export class SmartProxy extends plugins.EventEmitter {
error: (message: string, data?: any) => logger.log('error', message, data) error: (message: string, data?: any) => logger.log('error', message, data)
}; };
// Validate initial routes
if (this.settings.routes && this.settings.routes.length > 0) {
const validation = RouteValidator.validateRoutes(this.settings.routes);
if (!validation.valid) {
RouteValidator.logValidationErrors(validation.errors);
throw new Error(`Initial route validation failed: ${validation.errors.size} route(s) have errors`);
}
}
this.routeManager = new RouteManager({ this.routeManager = new RouteManager({
logger: loggerAdapter, logger: loggerAdapter,
enableDetailedLogging: this.settings.enableDetailedLogging, enableDetailedLogging: this.settings.enableDetailedLogging,
@@ -206,6 +224,16 @@ export class SmartProxy extends plugins.EventEmitter {
sampleIntervalMs: this.settings.metrics?.sampleIntervalMs, sampleIntervalMs: this.settings.metrics?.sampleIntervalMs,
retentionSeconds: this.settings.metrics?.retentionSeconds retentionSeconds: this.settings.metrics?.retentionSeconds
}); });
// Initialize route orchestrator for managing route updates
this.routeOrchestrator = new RouteOrchestrator(
this.portManager,
this.routeManager,
this.httpProxyBridge,
this.nftablesManager,
null, // certManager will be set later
loggerAdapter
);
} }
/** /**
@@ -354,8 +382,8 @@ export class SmartProxy extends plugins.EventEmitter {
// Get listening ports from RouteManager // Get listening ports from RouteManager
const listeningPorts = this.routeManager.getListeningPorts(); const listeningPorts = this.routeManager.getListeningPorts();
// Initialize port usage tracking // Initialize port usage tracking using RouteOrchestrator
this.portUsageMap = this.updatePortUsageMap(this.settings.routes); this.portUsageMap = this.routeOrchestrator.updatePortUsageMap(this.settings.routes);
// Log port usage for startup // Log port usage for startup
logger.log('info', `SmartProxy starting with ${listeningPorts.length} ports: ${listeningPorts.join(', ')}`, { logger.log('info', `SmartProxy starting with ${listeningPorts.length} ports: ${listeningPorts.join(', ')}`, {
@@ -516,7 +544,7 @@ export class SmartProxy extends plugins.EventEmitter {
logger.log('info', 'All servers closed. Cleaning up active connections...'); logger.log('info', 'All servers closed. Cleaning up active connections...');
// Clean up all active connections // Clean up all active connections
this.connectionManager.clearConnections(); await this.connectionManager.clearConnections();
// Stop HttpProxy // Stop HttpProxy
await this.httpProxyBridge.stop(); await this.httpProxyBridge.stop();
@@ -527,6 +555,10 @@ export class SmartProxy extends plugins.EventEmitter {
// Stop metrics collector // Stop metrics collector
this.metricsCollector.stop(); this.metricsCollector.stop();
// Clean up ProtocolDetector singleton
const detection = await import('../../detection/index.js');
detection.ProtocolDetector.destroy();
// Flush any pending deduplicated logs // Flush any pending deduplicated logs
connectionLogDeduplicator.flushAll(); connectionLogDeduplicator.flushAll();
@@ -606,202 +638,46 @@ export class SmartProxy extends plugins.EventEmitter {
try { try {
logger.log('info', `Updating routes (${newRoutes.length} routes)`, { logger.log('info', `Updating routes (${newRoutes.length} routes)`, {
routeCount: newRoutes.length, routeCount: newRoutes.length,
component: 'route-manager' component: 'smart-proxy'
}); });
} catch (error) { } catch (error) {
// Silently handle logging errors // Silently handle logging errors
console.log(`[INFO] Updating routes (${newRoutes.length} routes)`); console.log(`[INFO] Updating routes (${newRoutes.length} routes)`);
} }
// Track port usage before and after updates // Update route orchestrator dependencies if cert manager changed
const oldPortUsage = this.updatePortUsageMap(this.settings.routes); if (this.certManager && !this.routeOrchestrator.getCertManager()) {
const newPortUsage = this.updatePortUsageMap(newRoutes); this.routeOrchestrator.setCertManager(this.certManager);
// 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
try {
logger.log('debug', `Current listening ports: ${Array.from(currentPorts).join(', ')}`, {
ports: Array.from(currentPorts),
component: 'smart-proxy'
});
logger.log('debug', `Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`, {
ports: Array.from(newPortsSet),
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[DEBUG] Current listening ports: ${Array.from(currentPorts).join(', ')}`);
console.log(`[DEBUG] Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`);
} }
// Find orphaned ports - ports that no longer have any routes // Delegate the complex route update logic to RouteOrchestrator
const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage); const updateResult = await this.routeOrchestrator.updateRoutes(
this.settings.routes,
// Find new ports that need binding (only ports that we aren't already listening on) newRoutes,
const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p)); {
acmePort: this.settings.acme?.port || 80,
// Check for ACME challenge port to give it special handling acmeOptions: this.certManager?.getAcmeOptions(),
const acmePort = this.settings.acme?.port || 80; acmeState: this.certManager?.getState(),
const acmePortNeeded = newPortsSet.has(acmePort); globalChallengeRouteActive: this.globalChallengeRouteActive,
const acmePortListed = newBindingPorts.includes(acmePort); createCertificateManager: this.createCertificateManager.bind(this),
verifyChallengeRouteRemoved: this.verifyChallengeRouteRemoved.bind(this)
if (acmePortNeeded && acmePortListed) {
try {
logger.log('info', `Adding ACME challenge port ${acmePort} to routes`, {
port: acmePort,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[INFO] Adding ACME challenge port ${acmePort} to routes`);
} }
}
// Get existing routes that use NFTables and update them
const oldNfTablesRoutes = this.settings.routes.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);
}
}
// Update routes in RouteManager
this.routeManager.updateRoutes(newRoutes);
// Release orphaned ports first to free resources
if (orphanedPorts.length > 0) {
try {
logger.log('info', `Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`, {
ports: orphanedPorts,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[INFO] Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`);
}
await this.portManager.removePorts(orphanedPorts);
}
// Add new ports if needed
if (newBindingPorts.length > 0) {
try {
logger.log('info', `Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`, {
ports: newBindingPorts,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[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
// This provides better diagnostics for ACME challenge port conflicts
if ((error as any).code === 'EADDRINUSE') {
const port = (error as any).port || newBindingPorts[0];
const isAcmePort = port === acmePort;
if (isAcmePort) {
try {
logger.log('warn', `Could not bind to ACME challenge port ${port}. It may be in use by another application.`, {
port,
component: 'smart-proxy'
});
} catch (logError) {
console.log(`[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;
}
}
// Update settings with the new routes // Update settings with the new routes
this.settings.routes = newRoutes; this.settings.routes = newRoutes;
// Save the new port usage map for future reference // Update global state from orchestrator results
this.portUsageMap = newPortUsage; this.globalChallengeRouteActive = updateResult.newChallengeRouteActive;
// If HttpProxy is initialized, resync the configurations // Update port usage map from orchestrator
if (this.httpProxyBridge.getHttpProxy()) { this.portUsageMap = updateResult.portUsageMap;
await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes);
} // If certificate manager was recreated, update our reference
if (updateResult.newCertManager) {
// Update certificate manager with new routes this.certManager = updateResult.newCertManager;
if (this.certManager) { // Update the orchestrator's reference too
const existingAcmeOptions = this.certManager.getAcmeOptions(); this.routeOrchestrator.setCertManager(this.certManager);
const existingState = this.certManager.getState();
// Store global state before stopping
this.globalChallengeRouteActive = existingState.challengeRouteActive;
// Only stop the cert manager if absolutely necessary
// First check if there's an ACME route on the same port already
const acmePort = existingAcmeOptions?.port || 80;
const acmePortInUse = newPortUsage.has(acmePort) && newPortUsage.get(acmePort)!.size > 0;
try {
logger.log('debug', `ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`, {
port: acmePort,
inUse: acmePortInUse,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[DEBUG] ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`);
}
await this.certManager.stop();
// Verify the challenge route has been properly removed
await this.verifyChallengeRouteRemoved();
// Create new certificate manager with preserved state
this.certManager = await this.createCertificateManager(
newRoutes,
'./certs',
existingAcmeOptions,
{ challengeRouteActive: this.globalChallengeRouteActive }
);
} }
}); });
} }
@@ -822,87 +698,7 @@ export class SmartProxy extends plugins.EventEmitter {
await this.certManager.provisionCertificate(route); await this.certManager.provisionCertificate(route);
} }
/** // Port usage tracking methods moved to RouteOrchestrator
* Update the port usage map based on the provided routes
*
* This tracks which ports are used by which routes, allowing us to
* detect when a port is no longer needed and can be released.
*/
private updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
// Reset the usage map
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()) {
try {
logger.log('debug', `Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`, {
port,
routeCount: routes.size,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[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);
try {
logger.log('info', `Port ${port} no longer has any associated routes, will be released`, {
port,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[INFO] Port ${port} no longer has any associated routes, will be released`);
}
}
}
return orphanedPorts;
}
/** /**
* Force renewal of a certificate * Force renewal of a certificate
@@ -1024,9 +820,9 @@ export class SmartProxy extends plugins.EventEmitter {
terminationStats, terminationStats,
acmeEnabled: !!this.certManager, acmeEnabled: !!this.certManager,
port80HandlerPort: this.certManager ? 80 : null, port80HandlerPort: this.certManager ? 80 : null,
routes: this.routeManager.getListeningPorts().length, routeCount: this.settings.routes.length,
listeningPorts: this.portManager.getListeningPorts(), activePorts: this.portManager.getListeningPorts().length,
activePorts: this.portManager.getListeningPorts().length listeningPorts: this.portManager.getListeningPorts()
}; };
} }

View File

@@ -22,6 +22,7 @@ import * as plugins from '../../../plugins.js';
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js'; import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js';
import { mergeRouteConfigs } from './route-utils.js'; import { mergeRouteConfigs } from './route-utils.js';
import { ProtocolDetector, HttpDetector } from '../../../detection/index.js'; import { ProtocolDetector, HttpDetector } from '../../../detection/index.js';
import { createSocketTracker } from '../../../core/utils/socket-tracker.js';
/** /**
* Create an HTTP-only route configuration * Create an HTTP-only route configuration
@@ -960,11 +961,12 @@ export const SocketHandlers = {
* Now uses the centralized detection module for HTTP parsing * Now uses the centralized detection module for HTTP parsing
*/ */
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => { httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
const tracker = createSocketTracker(socket);
const connectionId = ProtocolDetector.createConnectionId({ const connectionId = ProtocolDetector.createConnectionId({
socketId: context.connectionId || `${Date.now()}-${Math.random()}` socketId: context.connectionId || `${Date.now()}-${Math.random()}`
}); });
socket.once('data', async (data) => { const handleData = async (data: Buffer) => {
// Use detection module for parsing // Use detection module for parsing
const detectionResult = await ProtocolDetector.detectWithConnectionTracking( const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
data, data,
@@ -1005,6 +1007,19 @@ export const SocketHandlers = {
socket.end(); socket.end();
// Clean up detection state // Clean up detection state
ProtocolDetector.cleanupConnections(); ProtocolDetector.cleanupConnections();
// Clean up all tracked resources
tracker.cleanup();
};
// Use tracker to manage the listener
socket.once('data', handleData);
tracker.addListener('error', (err) => {
tracker.safeDestroy(err);
});
tracker.addListener('close', () => {
tracker.cleanup();
}); });
}, },
@@ -1013,7 +1028,9 @@ export const SocketHandlers = {
* Now uses the centralized detection module for HTTP parsing * Now uses the centralized detection module for HTTP parsing
*/ */
httpServer: (handler: (req: { method: string; url: string; headers: Record<string, string>; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => { httpServer: (handler: (req: { method: string; url: string; headers: Record<string, string>; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
const tracker = createSocketTracker(socket);
let requestParsed = false; let requestParsed = false;
let responseTimer: NodeJS.Timeout | null = null;
const connectionId = ProtocolDetector.createConnectionId({ const connectionId = ProtocolDetector.createConnectionId({
socketId: context.connectionId || `${Date.now()}-${Math.random()}` socketId: context.connectionId || `${Date.now()}-${Math.random()}`
}); });
@@ -1034,6 +1051,8 @@ export const SocketHandlers = {
} }
requestParsed = true; requestParsed = true;
// Remove data listener after parsing request
socket.removeListener('data', processData);
const connInfo = detectionResult.connectionInfo; const connInfo = detectionResult.connectionInfo;
// Create request object from detection result // Create request object from detection result
@@ -1060,6 +1079,12 @@ export const SocketHandlers = {
if (ended) return; if (ended) return;
ended = true; ended = true;
// Clear response timer since we're sending now
if (responseTimer) {
clearTimeout(responseTimer);
responseTimer = null;
}
if (!responseHeaders['content-type']) { if (!responseHeaders['content-type']) {
responseHeaders['content-type'] = 'text/plain'; responseHeaders['content-type'] = 'text/plain';
} }
@@ -1091,30 +1116,44 @@ export const SocketHandlers = {
try { try {
handler(req, res); handler(req, res);
// Ensure response is sent even if handler doesn't call send() // Ensure response is sent even if handler doesn't call send()
setTimeout(() => { responseTimer = setTimeout(() => {
if (!ended) { if (!ended) {
res.send(''); res.send('');
} }
responseTimer = null;
}, 1000); }, 1000);
// Track and unref the timer
tracker.addTimer(responseTimer);
} catch (error) { } catch (error) {
if (!ended) { if (!ended) {
res.status(500); res.status(500);
res.send('Internal Server Error'); res.send('Internal Server Error');
} }
// Use safeDestroy for error cases
tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error'));
} }
}; };
socket.on('data', processData); // Use tracker to manage listeners
tracker.addListener('data', processData);
socket.on('error', () => { tracker.addListener('error', (err) => {
if (!requestParsed) { if (!requestParsed) {
socket.end(); tracker.safeDestroy(err);
} }
}); });
socket.on('close', () => { tracker.addListener('close', () => {
// Cleanup is handled by tracker
// Clear any pending response timer
if (responseTimer) {
clearTimeout(responseTimer);
responseTimer = null;
}
// Clean up detection state // Clean up detection state
ProtocolDetector.cleanupConnections(); ProtocolDetector.cleanupConnections();
// Clean up all tracked resources
tracker.cleanup();
}); });
} }
}; };

View File

@@ -0,0 +1,453 @@
import { logger } from '../../../core/utils/logger.js';
import type { IRouteConfig } from '../models/route-types.js';
/**
* Validates route configurations for correctness and safety
*/
export class RouteValidator {
private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt'];
private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler'];
private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss'];
private static readonly MAX_PORTS = 100;
private static readonly MAX_DOMAINS = 1000;
private static readonly MAX_HEADER_SIZE = 8192;
/**
* Validate a single route configuration
*/
public static validateRoute(route: IRouteConfig): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Validate route has a name
if (!route.name || typeof route.name !== 'string') {
errors.push('Route must have a valid name');
}
// Validate match criteria
if (!route.match) {
errors.push('Route must have match criteria');
} else {
// Validate ports
if (route.match.ports) {
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
if (ports.length > this.MAX_PORTS) {
errors.push(`Too many ports specified (max ${this.MAX_PORTS})`);
}
for (const port of ports) {
if (typeof port === 'number') {
if (!this.isValidPort(port)) {
errors.push(`Invalid port: ${port}. Must be between 1 and 65535`);
}
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
if (!this.isValidPort(port.from)) {
errors.push(`Invalid port range start: ${port.from}. Must be between 1 and 65535`);
}
if (!this.isValidPort(port.to)) {
errors.push(`Invalid port range end: ${port.to}. Must be between 1 and 65535`);
}
if (port.from > port.to) {
errors.push(`Invalid port range: ${port.from}-${port.to} (start > end)`);
}
} else {
errors.push(`Invalid port configuration: ${JSON.stringify(port)}`);
}
}
}
// Validate domains
if (route.match.domains) {
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
if (domains.length > this.MAX_DOMAINS) {
errors.push(`Too many domains specified (max ${this.MAX_DOMAINS})`);
}
for (const domain of domains) {
if (!this.isValidDomain(domain)) {
errors.push(`Invalid domain pattern: ${domain}`);
}
}
}
// Validate paths
if (route.match.path) {
const paths = Array.isArray(route.match.path) ? route.match.path : [route.match.path];
for (const path of paths) {
if (!this.isValidPath(path)) {
errors.push(`Invalid path pattern: ${path}`);
}
}
}
// Validate client IPs
if (route.match.clientIp) {
const ips = Array.isArray(route.match.clientIp) ? route.match.clientIp : [route.match.clientIp];
for (const ip of ips) {
if (!this.isValidIPPattern(ip)) {
errors.push(`Invalid IP pattern: ${ip}`);
}
}
}
// Validate headers
if (route.match.headers) {
for (const [key, value] of Object.entries(route.match.headers)) {
if (key.length > 256) {
errors.push(`Header name too long: ${key}`);
}
const headerValue = String(value);
if (headerValue.length > this.MAX_HEADER_SIZE) {
errors.push(`Header value too long for ${key} (max ${this.MAX_HEADER_SIZE} bytes)`);
}
if (!/^[\x20-\x7E]+$/.test(key)) {
errors.push(`Invalid header name: ${key} (must be printable ASCII)`);
}
}
}
// Protocol validation removed - not part of IRouteMatch interface
}
// Validate action
if (!route.action) {
errors.push('Route must have an action');
} else {
// Validate action type
if (!route.action.type || !this.VALID_ACTION_TYPES.includes(route.action.type)) {
errors.push(`Invalid action type: ${route.action.type}. Must be one of: ${this.VALID_ACTION_TYPES.join(', ')}`);
}
// Validate socket-handler
if (route.action.type === 'socket-handler') {
if (typeof route.action.socketHandler !== 'function') {
errors.push('socket-handler action requires a socketHandler function');
}
}
// Validate forward target
if (route.action.type === 'forward') {
if (!route.action.targets || route.action.targets.length === 0) {
errors.push('Forward action must have at least one target');
} else {
for (const target of route.action.targets) {
if (!target.host) {
errors.push('Target must have a host');
} else if (typeof target.host !== 'string' && !Array.isArray(target.host) && typeof target.host !== 'function') {
errors.push('Target host must be a string, array of strings, or function');
}
if (target.port) {
if (typeof target.port === 'number' && !this.isValidPort(target.port)) {
errors.push(`Invalid target port: ${target.port}`);
} else if (target.port !== 'preserve' && typeof target.port !== 'function' && typeof target.port !== 'number') {
errors.push(`Invalid target port configuration: ${target.port}`);
}
}
}
}
}
// Validate TLS settings
if (route.action.tls) {
if (route.action.tls.mode && !this.VALID_TLS_MODES.includes(route.action.tls.mode)) {
errors.push(`Invalid TLS mode: ${route.action.tls.mode}. Must be one of: ${this.VALID_TLS_MODES.join(', ')}`);
}
if (route.action.tls.certificate) {
if (route.action.tls.certificate !== 'auto' && typeof route.action.tls.certificate !== 'object') {
errors.push('TLS certificate must be "auto" or a certificate configuration object');
}
}
if (route.action.tls.versions) {
for (const version of route.action.tls.versions) {
if (!['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'].includes(version)) {
errors.push(`Invalid TLS version: ${version}`);
}
}
}
}
}
// Validate security settings
if (route.security) {
// Validate IP allow/block lists
if (route.security.ipAllowList) {
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
for (const ip of allowList) {
if (!this.isValidIPPattern(ip)) {
errors.push(`Invalid IP pattern in allow list: ${ip}`);
}
}
}
if (route.security.ipBlockList) {
const blockList = Array.isArray(route.security.ipBlockList) ? route.security.ipBlockList : [route.security.ipBlockList];
for (const ip of blockList) {
if (!this.isValidIPPattern(ip)) {
errors.push(`Invalid IP pattern in block list: ${ip}`);
}
}
}
// Validate rate limits
if (route.security.rateLimit) {
if (route.security.rateLimit.maxRequests && route.security.rateLimit.maxRequests < 0) {
errors.push('Rate limit maxRequests must be positive');
}
if (route.security.rateLimit.window && route.security.rateLimit.window < 0) {
errors.push('Rate limit window must be positive');
}
}
// Validate connection limits
if (route.security.maxConnections && route.security.maxConnections < 0) {
errors.push('Max connections must be positive');
}
}
// Validate priority
if (route.priority !== undefined && (route.priority < 0 || route.priority > 10000)) {
errors.push('Priority must be between 0 and 10000');
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Validate multiple route configurations
*/
public static validateRoutes(routes: IRouteConfig[]): { valid: boolean; errors: Map<string, string[]> } {
const errorMap = new Map<string, string[]>();
let valid = true;
// Check for duplicate route names
const routeNames = new Set<string>();
for (const route of routes) {
if (route.name && routeNames.has(route.name)) {
const existingErrors = errorMap.get(route.name) || [];
existingErrors.push('Duplicate route name');
errorMap.set(route.name, existingErrors);
valid = false;
}
routeNames.add(route.name);
}
// Validate each route
for (const route of routes) {
const result = this.validateRoute(route);
if (!result.valid) {
errorMap.set(route.name || 'unnamed', result.errors);
valid = false;
}
}
// Check for conflicting routes
const conflicts = this.findRouteConflicts(routes);
if (conflicts.length > 0) {
for (const conflict of conflicts) {
const existingErrors = errorMap.get(conflict.route) || [];
existingErrors.push(conflict.message);
errorMap.set(conflict.route, existingErrors);
}
valid = false;
}
return { valid, errors: errorMap };
}
/**
* Find potential conflicts between routes
*/
private static findRouteConflicts(routes: IRouteConfig[]): Array<{ route: string; message: string }> {
const conflicts: Array<{ route: string; message: string }> = [];
// Group routes by port
const portMap = new Map<number, IRouteConfig[]>();
for (const route of routes) {
if (route.match?.ports) {
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
// Expand port ranges to individual ports
const expandedPorts: number[] = [];
for (const port of ports) {
if (typeof port === 'number') {
expandedPorts.push(port);
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
for (let p = port.from; p <= port.to; p++) {
expandedPorts.push(p);
}
}
}
for (const port of expandedPorts) {
const routesOnPort = portMap.get(port) || [];
routesOnPort.push(route);
portMap.set(port, routesOnPort);
}
}
}
// Check for conflicting catch-all routes on the same port
for (const [port, routesOnPort] of portMap) {
const catchAllRoutes = routesOnPort.filter(r =>
!r.match.domains ||
(Array.isArray(r.match.domains) && r.match.domains.includes('*')) ||
r.match.domains === '*'
);
if (catchAllRoutes.length > 1) {
for (const route of catchAllRoutes) {
conflicts.push({
route: route.name,
message: `Multiple catch-all routes on port ${port}`
});
}
}
}
return conflicts;
}
/**
* Validate port number
*/
private static isValidPort(port: number): boolean {
return Number.isInteger(port) && port >= 1 && port <= 65535;
}
/**
* Validate domain pattern
*/
private static isValidDomain(domain: string): boolean {
if (!domain || typeof domain !== 'string') return false;
if (domain === '*') return true;
// Basic domain pattern validation
const domainPattern = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
return domainPattern.test(domain) || domain === 'localhost';
}
/**
* Validate path pattern
*/
private static isValidPath(path: string): boolean {
if (!path || typeof path !== 'string') return false;
if (!path.startsWith('/')) return false;
// Check for invalid characters
if (!/^[a-zA-Z0-9/_*:{}.-]+$/.test(path)) return false;
// Validate parameter syntax
const paramPattern = /\{[a-zA-Z_][a-zA-Z0-9_]*\}/g;
const params = path.match(paramPattern) || [];
for (const param of params) {
if (param.length > 32) return false;
}
return true;
}
/**
* Validate IP pattern
*/
private static isValidIPPattern(ip: string): boolean {
if (!ip || typeof ip !== 'string') return false;
if (ip === '*') return true;
// Check for CIDR notation
if (ip.includes('/')) {
const [addr, prefix] = ip.split('/');
const prefixNum = parseInt(prefix, 10);
if (addr.includes(':')) {
// IPv6 CIDR
return this.isValidIPv6(addr) && prefixNum >= 0 && prefixNum <= 128;
} else {
// IPv4 CIDR
return this.isValidIPv4(addr) && prefixNum >= 0 && prefixNum <= 32;
}
}
// Check for range
if (ip.includes('-')) {
const [start, end] = ip.split('-');
return (this.isValidIPv4(start) && this.isValidIPv4(end)) ||
(this.isValidIPv6(start) && this.isValidIPv6(end));
}
// Check for wildcards in IPv4
if (ip.includes('*') && !ip.includes(':')) {
const parts = ip.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;
if (part !== '*' && parseInt(part, 10) > 255) return false;
}
return true;
}
// Regular IP address
return this.isValidIPv4(ip) || this.isValidIPv6(ip);
}
/**
* Validate IPv4 address
*/
private static isValidIPv4(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
const num = parseInt(part, 10);
if (isNaN(num) || num < 0 || num > 255) return false;
}
return true;
}
/**
* Validate IPv6 address
*/
private static isValidIPv6(ip: string): boolean {
// Simple IPv6 validation
const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){0,6}|::1|::)$/;
return ipv6Pattern.test(ip);
}
/**
* Log validation errors
*/
public static logValidationErrors(errors: Map<string, string[]>): void {
for (const [routeName, routeErrors] of errors) {
logger.log('error', `Route validation failed for ${routeName}:`, {
route: routeName,
errors: routeErrors,
component: 'route-validator'
});
for (const error of routeErrors) {
logger.log('error', ` - ${error}`, {
route: routeName,
component: 'route-validator'
});
}
}
}
}