diff --git a/.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl b/.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl new file mode 100644 index 0000000..a3a15bb Binary files /dev/null and b/.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl differ diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..d8be338 --- /dev/null +++ b/.serena/project.yml @@ -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" diff --git a/certs/static-route/meta.json b/certs/static-route/meta.json index f3f10eb..c226e2e 100644 --- a/certs/static-route/meta.json +++ b/certs/static-route/meta.json @@ -1,5 +1,5 @@ { - "expiryDate": "2025-10-20T11:32:30.675Z", - "issueDate": "2025-07-22T11:32:30.675Z", - "savedAt": "2025-07-22T11:32:30.675Z" + "expiryDate": "2025-11-12T14:20:10.043Z", + "issueDate": "2025-08-14T14:20:10.043Z", + "savedAt": "2025-08-14T14:20:10.044Z" } \ No newline at end of file diff --git a/changelog.md b/changelog.md index b2399bd..44611c3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # 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) Fix SNI detection in TLS detector diff --git a/package.json b/package.json index cdc7960..a7cd73e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@push.rocks/smartproxy", - "version": "21.1.3", + "version": "21.1.4", "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.", "main": "dist_ts/index.js", @@ -19,7 +19,8 @@ "@git.zone/tsrun": "^1.2.44", "@git.zone/tstest": "^2.3.1", "@types/node": "^22.15.29", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "why-is-node-running": "^3.2.2" }, "dependencies": { "@push.rocks/lik": "^6.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 160fef9..de50cf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + why-is-node-running: + specifier: ^3.2.2 + version: 3.2.2 packages: @@ -4096,6 +4099,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} 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: resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} engines: {node: '>= 12.0.0'} @@ -10082,6 +10090,8 @@ snapshots: dependencies: isexe: 3.1.1 + why-is-node-running@3.2.2: {} + winston-transport@4.9.0: dependencies: logform: 2.7.0 diff --git a/readme.md b/readme.md index 69ca6c8..32d3b98 100644 --- a/readme.md +++ b/readme.md @@ -1,124 +1,472 @@ -# @push.rocks/smartproxy +# @push.rocks/smartproxy 🚀 -A unified high-performance proxy toolkit for Node.js, with **SmartProxy** as the central API to handle all your proxy needs: +**The Swiss Army Knife of Node.js Proxies** - A unified, high-performance proxy toolkit that handles everything from simple HTTP forwarding to complex enterprise routing scenarios. -- **Unified Route-Based Configuration**: Match/action pattern for clean, consistent traffic routing -- **SSL/TLS Support**: Automatic HTTPS with Let's Encrypt certificate provisioning -- **Flexible Matching Patterns**: Route by port, domain, path, client IP, and TLS version -- **Advanced SNI Handling**: Smart TCP/SNI-based forwarding with IP filtering -- **Multiple Action Types**: Forward traffic or handle with custom socket handlers -- **Dynamic Port Management**: Add or remove listening ports at runtime without restart -- **Security Features**: Route-specific IP allowlists, blocklists, connection limits, and authentication -- **NFTables Integration**: High-performance kernel-level packet forwarding with Linux NFTables -- **Socket Handlers**: Custom socket handling for specialized protocols and use cases -- **Multiple Targets**: Load balancing and failover with support for multiple upstream targets +## 🎯 What is SmartProxy? -## Project Architecture Overview +SmartProxy is a modern, production-ready proxy solution that brings order to the chaos of traffic management. Whether you're building microservices, deploying edge infrastructure, or need a battle-tested reverse proxy, SmartProxy has you covered. -SmartProxy has been restructured using a modern, modular architecture with a unified route-based configuration system: +### ⚡ Key Features -``` -/ts -├── /core # Core functionality -│ ├── /models # Data models and interfaces -│ ├── /utils # Shared utilities (IP validation, logging, etc.) -│ └── /events # Common event definitions -├── /forwarding # Forwarding system -│ ├── /handlers # Various forwarding handlers -│ │ ├── base-handler.ts # Abstract base handler -│ │ ├── http-handler.ts # HTTP-only handler -│ │ └── ... # Other handlers -│ ├── /config # Configuration models -│ └── /factory # Factory for creating handlers -├── /proxies # Different proxy implementations -│ ├── /smart-proxy # SmartProxy implementation -│ │ ├── /models # SmartProxy-specific interfaces -│ │ │ ├── route-types.ts # Route-based configuration types -│ │ │ └── interfaces.ts # SmartProxy interfaces -│ │ ├── certificate-manager.ts # SmartCertManager -│ │ ├── cert-store.ts # Certificate file storage -│ │ ├── route-helpers.ts # Helper functions for creating routes -│ │ ├── route-manager.ts # Route management system -│ │ ├── smart-proxy.ts # Main SmartProxy class -│ │ └── ... # Supporting classes -│ ├── /http-proxy # HttpProxy implementation (HTTP/HTTPS handling) -│ └── /nftables-proxy # NfTablesProxy implementation -├── /tls # TLS-specific functionality -│ ├── /sni # SNI handling components -│ └── /alerts # TLS alerts system -└── /routing # Routing functionality - └── /router # HTTP routing system -``` +- **🔀 Unified Route-Based Configuration** - Clean match/action patterns for intuitive traffic routing +- **🔒 Automatic SSL/TLS with Let's Encrypt** - Zero-config HTTPS with automatic certificate provisioning +- **🎯 Flexible Matching Patterns** - Route by port, domain, path, client IP, TLS version, or custom logic +- **🚄 High-Performance Forwarding** - Choose between user-space or kernel-level (NFTables) forwarding +- **⚖️ Built-in Load Balancing** - Distribute traffic across multiple backends with health checks +- **🛡️ Enterprise Security** - IP filtering, rate limiting, authentication, and connection limits +- **🔌 WebSocket Support** - First-class WebSocket proxying with ping/pong management +- **🎮 Custom Socket Handlers** - Implement any protocol with full socket control +- **📊 Dynamic Port Management** - Add/remove ports at runtime without restarts +- **🔧 Protocol Detection** - Smart protocol detection for mixed-mode operation -## Main Components +## 📦 Installation -### Primary API (Recommended) - -- **SmartProxy** (`ts/proxies/smart-proxy/smart-proxy.ts`) - The central unified API for all proxy needs, featuring: - - Route-based configuration with match/action pattern - - Flexible matching criteria (ports, domains, paths, client IPs) - - Multiple action types (forward, redirect, block, socket-handler) - - Automatic certificate management - - Advanced security controls - - Custom socket handling capabilities - - Load balancing with multiple targets - -### Helper Functions - -- **createHttpRoute**, **createHttpsTerminateRoute**, **createHttpsPassthroughRoute** - Helper functions to create different route configurations with clean syntax -- **createHttpToHttpsRedirect** - Helper function for HTTP to HTTPS redirects using socket handlers -- **createLoadBalancerRoute**, **createCompleteHttpsServer** - Helper functions for complex configurations -- **createSocketHandlerRoute**, **SocketHandlers** - Helper functions for custom socket handling -- **createNfTablesRoute**, **createNfTablesTerminateRoute**, **createCompleteNfTablesHttpsServer** - Helper functions for NFTables-based high-performance kernel-level routing -- **createPortMappingRoute**, **createOffsetPortMappingRoute**, **createDynamicRoute**, **createSmartLoadBalancer** - Helper functions for dynamic routing and port mapping -- **createApiGatewayRoute**, **addRateLimiting**, **addBasicAuth**, **addJwtAuth** - Helper functions for API gateway features and authentication - -### Specialized Components - -- **HttpProxy** (`ts/proxies/http-proxy/http-proxy.ts`) - HTTP/HTTPS reverse proxy with TLS termination and WebSocket support -- **NfTablesProxy** (`ts/proxies/nftables-proxy/nftables-proxy.ts`) - Low-level port forwarding using nftables NAT rules -- **SniHandler** (`ts/tls/sni/sni-handler.ts`) - Utilities for SNI extraction from TLS handshakes - -### Core Utilities - -- **ValidationUtils** (`ts/core/utils/validation-utils.ts`) - Domain, port, and configuration validation -- **IpUtils** (`ts/core/utils/ip-utils.ts`) - IP address validation and filtering with glob patterns - -### Interfaces and Types - -- `IRouteConfig`, `IRouteMatch`, `IRouteAction` (`ts/proxies/smart-proxy/models/route-types.ts`) -- `IRoutedSmartProxyOptions` (`ts/proxies/smart-proxy/models/route-types.ts`) -- `IHttpProxyOptions` (`ts/proxies/http-proxy/models/types.ts`) -- `INfTableProxySettings` (`ts/proxies/nftables-proxy/models/interfaces.ts`) - -## Installation -Install via npm: ```bash npm install @push.rocks/smartproxy ``` -## Quick Start with SmartProxy +## 🚀 Quick Start -SmartProxy v20.0.0 provides a unified route-based configuration system with enhanced certificate management, NFTables integration for high-performance kernel-level routing, custom socket handling, and improved helper functions for common proxy setups. +Let's get you up and running in 30 seconds: -**⚠️ Breaking Change in v20.0.0**: The route action configuration has changed from single `target` to `targets` array to support multiple upstream targets for load balancing and failover. +```typescript +import { SmartProxy, createCompleteHttpsServer } from '@push.rocks/smartproxy'; + +// Create a proxy with automatic HTTPS +const proxy = new SmartProxy({ + acme: { + email: 'ssl@example.com', // Your email for Let's Encrypt + useProduction: true // Use Let's Encrypt production servers + }, + routes: [ + // Complete HTTPS setup with one line + ...createCompleteHttpsServer('app.example.com', { + host: 'localhost', + port: 3000 + }, { + certificate: 'auto' // Magic! 🎩 + }) + ] +}); + +await proxy.start(); +console.log('🚀 Proxy running with automatic HTTPS!'); +``` + +## 📚 Core Concepts + +### 🏗️ Route-Based Architecture + +SmartProxy uses a powerful match/action pattern that makes routing predictable and maintainable: + +```typescript +{ + match: { + ports: 443, + domains: 'api.example.com', + path: '/v1/*' + }, + action: { + type: 'forward', + targets: [{ host: 'backend', port: 8080 }], + tls: { mode: 'terminate', certificate: 'auto' } + } +} +``` + +Every route has: +- **Match criteria** - What traffic to capture +- **Action** - What to do with it +- **Security** (optional) - Access controls and limits +- **Metadata** (optional) - Name, priority, tags + +## 💡 Common Use Cases + +### 🌐 Simple HTTP to HTTPS Redirect + +```typescript +import { SmartProxy, createHttpToHttpsRedirect } from '@push.rocks/smartproxy'; + +const proxy = new SmartProxy({ + routes: [ + // Redirect all HTTP traffic to HTTPS + createHttpToHttpsRedirect(['example.com', '*.example.com']) + ] +}); +``` + +### ⚖️ Load Balancer with Health Checks + +```typescript +import { createLoadBalancerRoute } from '@push.rocks/smartproxy'; + +const route = createLoadBalancerRoute( + 'app.example.com', + [ + { host: 'server1.internal', port: 8080 }, + { host: 'server2.internal', port: 8080 }, + { host: 'server3.internal', port: 8080 } + ], + { + tls: { mode: 'terminate', certificate: 'auto' }, + loadBalancing: { + algorithm: 'round-robin', + healthCheck: { + path: '/health', + interval: 30000, + timeout: 5000 + } + } + } +); +``` + +### 🔌 WebSocket Proxy + +```typescript +import { createWebSocketRoute } from '@push.rocks/smartproxy'; + +const route = createWebSocketRoute( + 'ws.example.com', + { host: 'websocket-server', port: 8080 }, + { + path: '/socket', + useTls: true, + certificate: 'auto', + pingInterval: 30000 // Keep connections alive + } +); +``` + +### 🚦 API Gateway with Rate Limiting + +```typescript +import { createApiGatewayRoute, addRateLimiting } from '@push.rocks/smartproxy'; + +let route = createApiGatewayRoute( + 'api.example.com', + '/api', + { host: 'api-backend', port: 8080 }, + { + useTls: true, + certificate: 'auto', + addCorsHeaders: true + } +); + +// Add rate limiting +route = addRateLimiting(route, { + maxRequests: 100, + window: 60, // seconds + keyBy: 'ip' +}); +``` + +### 🎮 Custom Protocol Handler + +```typescript +import { createSocketHandlerRoute, SocketHandlers } from '@push.rocks/smartproxy'; + +// Pre-built handlers +const echoRoute = createSocketHandlerRoute( + 'echo.example.com', + 7777, + SocketHandlers.echo +); + +// Custom handler +const customRoute = createSocketHandlerRoute( + 'custom.example.com', + 9999, + async (socket, context) => { + console.log(`Connection from ${context.clientIp}`); + + socket.write('Welcome to my custom protocol!\n'); + + socket.on('data', (data) => { + const command = data.toString().trim(); + + if (command === 'HELLO') { + socket.write('World!\n'); + } else if (command === 'EXIT') { + socket.end('Goodbye!\n'); + } + }); + } +); +``` + +### ⚡ High-Performance NFTables Forwarding + +For ultra-low latency, use kernel-level forwarding (Linux only, requires root): + +```typescript +import { createNfTablesTerminateRoute } from '@push.rocks/smartproxy'; + +const route = createNfTablesTerminateRoute( + 'fast.example.com', + { host: 'backend', port: 8080 }, + { + ports: 443, + certificate: 'auto', + preserveSourceIP: true, + maxRate: '1gbps' + } +); +``` + +## 🔧 Advanced Features + +### 🎯 Dynamic Routing + +Route traffic based on runtime conditions: + +```typescript +{ + match: { + ports: 443, + customMatcher: async (context) => { + // Route based on time of day + const hour = new Date().getHours(); + return hour >= 9 && hour < 17; // Business hours only + } + }, + action: { + type: 'forward', + targets: [{ + host: (context) => { + // Dynamic host selection + return context.path.startsWith('/premium') + ? 'premium-backend' + : 'standard-backend'; + }, + port: 8080 + }] + } +} +``` + +### 🔒 Security Controls + +Comprehensive security options per route: + +```typescript +{ + security: { + // IP-based access control + ipAllowList: ['10.0.0.0/8', '192.168.*'], + ipBlockList: ['192.168.1.100'], + + // Connection limits + maxConnections: 1000, + maxConnectionsPerIp: 10, + + // Rate limiting + rateLimit: { + maxRequests: 100, + windowMs: 60000 + }, + + // Authentication + authentication: { + type: 'jwt', + secret: process.env.JWT_SECRET, + algorithms: ['HS256'] + } + } +} +``` + +### 📊 Runtime Management + +Control your proxy without restarts: + +```typescript +// Add/remove ports dynamically +await proxy.addListeningPort(8443); +await proxy.removeListeningPort(8080); + +// Update routes on the fly +await proxy.updateRoutes([...newRoutes]); + +// Monitor status +const status = proxy.getStatus(); +const metrics = proxy.getMetrics(); + +// Certificate management +await proxy.renewCertificate('example.com'); +const certInfo = proxy.getCertificateInfo('example.com'); +``` + +### 🔄 Header Manipulation + +Transform requests and responses: + +```typescript +{ + action: { + headers: { + request: { + 'X-Real-IP': '{clientIp}', // Template variables + 'X-Request-ID': '{uuid}', + 'X-Custom': 'value' + }, + response: { + 'X-Powered-By': 'SmartProxy', + 'Strict-Transport-Security': 'max-age=31536000', + 'X-Frame-Options': 'DENY' + } + } + } +} +``` + +## 🏛️ Architecture + +SmartProxy is built with a modular, extensible architecture: + +``` +SmartProxy +├── 📋 Route Manager # Route matching and prioritization +├── 🔌 Port Manager # Dynamic port lifecycle +├── 🔒 Certificate Manager # ACME/Let's Encrypt automation +├── 🚦 Connection Manager # Connection pooling and limits +├── 📊 Metrics Collector # Performance monitoring +├── 🛡️ Security Manager # Access control and rate limiting +└── 🔧 Protocol Detectors # Smart protocol identification +``` + +## 🎯 Route Configuration Reference + +### Match Criteria + +```typescript +interface IRouteMatch { + ports: number | number[] | string; // 80, [80, 443], '8000-8999' + domains?: string | string[]; // 'example.com', '*.example.com' + path?: string; // '/api/*', '/users/:id' + clientIp?: string | string[]; // '10.0.0.0/8', ['192.168.*'] + protocol?: 'tcp' | 'udp' | 'http' | 'https' | 'ws' | 'wss'; + tlsVersion?: string | string[]; // ['TLSv1.2', 'TLSv1.3'] + customMatcher?: (context) => boolean; // Custom logic +} +``` + +### Action Types + +```typescript +interface IRouteAction { + type: 'forward' | 'redirect' | 'block' | 'socket-handler'; + + // For 'forward' + targets?: Array<{ + host: string | string[] | ((context) => string); + port: number | ((context) => number); + }>; + + // For 'redirect' + redirectUrl?: string; // With {domain}, {path}, {clientIp} templates + redirectCode?: number; // 301, 302, etc. + + // For 'socket-handler' + socketHandler?: (socket, context) => void | Promise; + + // TLS options + tls?: { + mode: 'terminate' | 'passthrough' | 'terminate-and-reencrypt'; + certificate: 'auto' | { key: string; cert: string }; + }; + + // WebSocket options + websocket?: { + enabled: boolean; + pingInterval?: number; + pingTimeout?: number; + }; +} +``` + +## 🐛 Troubleshooting + +### Certificate Issues +- ✅ Ensure domain points to your server +- ✅ Port 80 must be accessible for ACME challenges +- ✅ Check DNS propagation with `nslookup` +- ✅ Verify email in ACME configuration + +### Connection Problems +- ✅ Check route priorities (higher = matched first) +- ✅ Verify security rules aren't blocking +- ✅ Test with `curl -v` for detailed output +- ✅ Enable debug mode for verbose logging + +### Performance Tuning +- ✅ Use NFTables for high-traffic routes +- ✅ Enable connection pooling +- ✅ Adjust keep-alive settings +- ✅ Monitor with built-in metrics + +### Debug Mode +```typescript +const proxy = new SmartProxy({ + debug: true, // Enable verbose logging + routes: [...] +}); +``` + +## 🚀 Migration from v20.x to v21.x + +No breaking changes! v21.x adds enhanced socket cleanup, improved connection tracking, and better process exit handling. + +## 🏆 Best Practices + +1. **📝 Use Helper Functions** - They provide sensible defaults and prevent errors +2. **🎯 Set Route Priorities** - More specific routes should have higher priority +3. **🔒 Always Enable Security** - Use IP filtering and rate limiting for public services +4. **📊 Monitor Performance** - Use metrics to identify bottlenecks +5. **🔄 Regular Certificate Checks** - Monitor expiration and renewal status +6. **🛑 Graceful Shutdown** - Always call `proxy.stop()` for clean shutdown +7. **🎮 Test Your Routes** - Use the route testing utilities before production + +## 📖 API Documentation + +### SmartProxy Class + +```typescript +class SmartProxy { + constructor(options: IRoutedSmartProxyOptions); + + // Lifecycle + start(): Promise; + stop(): Promise; + + // Route Management + updateRoutes(routes: IRouteConfig[]): Promise; + addRoute(route: IRouteConfig): Promise; + removeRoute(routeName: string): Promise; + findMatchingRoute(context: Partial): IRouteConfig | null; + + // Port Management + addListeningPort(port: number): Promise; + removeListeningPort(port: number): Promise; + getListeningPorts(): number[]; + + // Certificate Management + getCertificateInfo(domain: string): ICertificateInfo | null; + renewCertificate(domain: string): Promise; + + // Monitoring + getStatus(): IProxyStatus; + getMetrics(): IProxyMetrics; +} +``` + +### Helper Functions + +All helper functions are fully typed and documented. Import them from the main package: ```typescript import { - SmartProxy, createHttpRoute, createHttpsTerminateRoute, createHttpsPassthroughRoute, @@ -129,871 +477,16 @@ import { createWebSocketRoute, createSocketHandlerRoute, createNfTablesRoute, - createNfTablesTerminateRoute, - createCompleteNfTablesHttpsServer, createPortMappingRoute, - createOffsetPortMappingRoute, createDynamicRoute, - createSmartLoadBalancer, createApiGatewayRoute, addRateLimiting, addBasicAuth, addJwtAuth, SocketHandlers } from '@push.rocks/smartproxy'; - -// Create a new SmartProxy instance with route-based configuration -const proxy = new SmartProxy({ - // Global ACME settings for all routes with certificate: 'auto' - acme: { - email: 'ssl@example.com', // Required for Let's Encrypt - useProduction: false, // Use staging by default - renewThresholdDays: 30, // Renew 30 days before expiry - port: 80, // Port for HTTP-01 challenges (use 8080 for non-privileged) - autoRenew: true, // Enable automatic renewal - renewCheckIntervalHours: 24 // Check for renewals daily - }, - - // Define all your routing rules in a single array - routes: [ - // Basic HTTP route - forward traffic from port 80 to internal service - createHttpRoute('api.example.com', { host: 'localhost', port: 3000 }), - - // HTTPS route with TLS termination and automatic certificates - createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8080 }, { - certificate: 'auto' // Uses global ACME settings - }), - - // HTTPS passthrough for legacy systems - createHttpsPassthroughRoute('legacy.example.com', { host: '192.168.1.10', port: 443 }), - - // Redirect HTTP to HTTPS for all domains and subdomains - createHttpToHttpsRedirect(['example.com', '*.example.com']), - - // Complete HTTPS server (creates both HTTPS route and HTTP redirect) - ...createCompleteHttpsServer('complete.example.com', { host: 'localhost', port: 3000 }, { - certificate: 'auto' - }), - - // API route with CORS headers - createApiRoute('api.service.com', '/v1', { host: 'api-backend', port: 8081 }, { - useTls: true, - certificate: 'auto', - addCorsHeaders: true - }), - - // WebSocket route for real-time communication - createWebSocketRoute('ws.example.com', '/socket', { host: 'socket-server', port: 8082 }, { - useTls: true, - certificate: 'auto', - pingInterval: 30000 - }), - - - // Load balancer with multiple backend servers - createLoadBalancerRoute( - 'app.example.com', - ['192.168.1.10', '192.168.1.11', '192.168.1.12'], - 8080, - { - tls: { - mode: 'terminate', - certificate: 'auto' - } - } - ), - - // Custom socket handler for specialized protocols - createSocketHandlerRoute('telnet.example.com', 23, SocketHandlers.lineProtocol((line, socket) => { - console.log('Received:', line); - socket.write(`Echo: ${line}\n`); - })), - - // High-performance NFTables route (requires root/sudo) - createNfTablesRoute('fast.example.com', { host: 'backend-server', port: 8080 }, { - ports: 80, - protocol: 'tcp', - preserveSourceIP: true, - ipAllowList: ['10.0.0.*'] - }), - - // NFTables HTTPS termination for ultra-fast TLS handling - createNfTablesTerminateRoute('secure-fast.example.com', { host: 'backend-ssl', port: 443 }, { - ports: 443, - certificate: 'auto', - maxRate: '100mbps' - }), - - // Route with security configuration - { - name: 'secure-admin', - match: { - ports: 443, - domains: 'admin.example.com' - }, - action: { - type: 'forward', - targets: [{ host: 'localhost', port: 8080 }], // Note: targets is an array - tls: { - mode: 'terminate', - certificate: 'auto' - } - }, - security: { - ipAllowList: ['10.0.0.*', '192.168.1.*'], - ipBlockList: ['192.168.1.100'], - maxConnections: 100 - } - } - ] -}); - -// Start the proxy -await proxy.start(); - -// Dynamically add new routes later -await proxy.updateRoutes([ - ...proxy.settings.routes, - createHttpsTerminateRoute('new-domain.com', { host: 'localhost', port: 9000 }, { - certificate: 'auto' - }) -]); - -// Dynamically add or remove port listeners -await proxy.addListeningPort(8081); -await proxy.removeListeningPort(8081); -console.log('Currently listening on ports:', proxy.getListeningPorts()); - -// Later, gracefully shut down -await proxy.stop(); ``` -## Route-Based Configuration System - -SmartProxy uses a unified route configuration system based on the `IRouteConfig` interface. This system follows a match/action pattern that makes routing more powerful, flexible, and declarative. - -### IRouteConfig Interface - -The `IRouteConfig` interface is the core building block of SmartProxy's configuration system. Each route definition consists of match criteria and an action to perform on matched traffic: - -```typescript -interface IRouteConfig { - // What traffic to match (required) - match: IRouteMatch; - - // What to do with matched traffic (required) - action: IRouteAction; - - // Security configuration (optional) - security?: IRouteSecurity; - - // Metadata (all optional) - name?: string; // Human-readable name for this route - description?: string; // Description of the route's purpose - priority?: number; // Controls matching order (higher = matched first) - tags?: string[]; // Arbitrary tags for categorization - enabled?: boolean; // Whether the route is active (default: true) -} -``` - -#### Match Criteria (IRouteMatch) - -The `match` property defines criteria for identifying which incoming traffic should be handled by this route: - -```typescript -interface IRouteMatch { - // Port(s) to match - ports: number | number[] | string; // Single port, array, or range like '8000-8999' - - // Domain matching (optional - if not specified, matches all domains) - domains?: string | string[]; // Exact domains or patterns with wildcards - - // Path matching (optional) - path?: string; // URL path pattern (supports wildcards) - - // Client IP matching (optional) - clientIp?: string | string[]; // IP addresses or CIDR ranges - - // Protocol matching (optional) - protocol?: 'tcp' | 'udp' | 'http' | 'https' | 'ws' | 'wss'; - - // TLS version matching (optional) - tlsVersion?: string | string[]; // e.g., ['TLSv1.2', 'TLSv1.3'] - - // Custom matcher function (optional) - customMatcher?: (context: IRouteContext) => boolean | Promise; -} -``` - -**Domain Matching Patterns:** -- Exact match: `example.com` -- Wildcard subdomain: `*.example.com` -- Multiple domains: `['example.com', '*.example.com', 'example.org']` -- All domains: omit the `domains` field - -**Path Matching Patterns:** -- Exact path: `/api/users` -- Path prefix with wildcard: `/api/*` -- Path with parameter: `/api/users/:id` -- Multiple path segments: `/api/*/details` - -#### Action Types (IRouteAction) - -The `action` property defines what to do with matched traffic: - -```typescript -interface IRouteAction { - // Action type (required) - type: 'forward' | 'redirect' | 'block' | 'socket-handler'; - - // For 'forward' type - array of upstream targets - targets?: IRouteTarget[]; - - // For 'redirect' type - redirectUrl?: string; // URL template with placeholders - redirectCode?: number; // HTTP status code (301, 302, etc.) - - // For 'socket-handler' type - socketHandler?: (socket: net.Socket, context: IRouteContext) => void | Promise; - - // TLS configuration (optional) - tls?: IRouteTls; - - // WebSocket configuration (optional) - websocket?: { - enabled: boolean; - pingInterval?: number; // Milliseconds between pings - pingTimeout?: number; // Milliseconds to wait for pong - }; - - // Headers manipulation (optional) - headers?: { - request?: Record; // Headers to add to requests - response?: Record; // Headers to add to responses - }; -} -``` - -**Forward Action with Multiple Targets:** -```typescript -{ - type: 'forward', - targets: [ - { host: 'backend1.example.com', port: 8080 }, - { host: 'backend2.example.com', port: 8080 }, - { host: 'backend3.example.com', port: 8080 } - ] -} -``` - -**Redirect Action:** -```typescript -{ - type: 'redirect', - redirectUrl: 'https://{domain}/{path}', // Placeholders: {domain}, {path}, {clientIp} - redirectCode: 301 -} -``` - -**Socket Handler Action:** -```typescript -{ - type: 'socket-handler', - socketHandler: (socket, context) => { - // Custom logic for handling the socket - socket.write('Hello from custom handler\n'); - socket.end(); - } -} -``` - -### Route Examples - -#### Basic HTTP Forwarding -```typescript -{ - match: { - ports: 80, - domains: 'api.example.com' - }, - action: { - type: 'forward', - targets: [{ host: 'localhost', port: 3000 }] - } -} -``` - -#### HTTPS with TLS Termination and Load Balancing -```typescript -{ - match: { - ports: 443, - domains: ['secure.example.com', '*.secure.example.com'] - }, - action: { - type: 'forward', - targets: [ - { host: '10.0.0.10', port: 8080 }, - { host: '10.0.0.11', port: 8080 }, - { host: '10.0.0.12', port: 8080 } - ], - tls: { - mode: 'terminate', - certificate: 'auto' // Automatic Let's Encrypt certificate - } - } -} -``` - -#### WebSocket Route -```typescript -{ - match: { - ports: 443, - domains: 'ws.example.com', - path: '/socket/*' - }, - action: { - type: 'forward', - targets: [{ host: 'websocket-server', port: 8080 }], - tls: { - mode: 'terminate', - certificate: 'auto' - }, - websocket: { - enabled: true, - pingInterval: 30000, - pingTimeout: 5000 - } - } -} -``` - -#### API Gateway with Security -```typescript -{ - match: { - ports: 443, - domains: 'api.example.com', - path: '/v1/*' - }, - action: { - type: 'forward', - targets: [{ host: 'api-backend', port: 8080 }], - tls: { - mode: 'terminate', - certificate: 'auto' - }, - headers: { - request: { - 'X-API-Version': 'v1', - 'X-Real-IP': '{clientIp}' - }, - response: { - 'Access-Control-Allow-Origin': '*', - 'X-Powered-By': 'SmartProxy' - } - } - }, - security: { - ipAllowList: ['10.0.0.0/8', '172.16.0.0/12'], - rateLimit: { - maxRequests: 100, - windowMs: 60000 - }, - authentication: { - type: 'basic', - realm: 'API Access', - users: { - 'apiuser': 'hashedpassword' - } - } - } -} -``` - -## NFTables Integration - -SmartProxy includes high-performance kernel-level packet forwarding using Linux NFTables. This provides ultra-low latency forwarding by operating at the kernel level. - -### Requirements -- Linux kernel with NFTables support -- Root/sudo privileges -- `nft` command-line tool installed - -### NFTables Route Example -```typescript -// Basic NFTables forwarding -createNfTablesRoute('fast.example.com', { host: 'backend', port: 8080 }, { - ports: 80, - protocol: 'tcp', - preserveSourceIP: true -}) - -// NFTables with TLS termination -createNfTablesTerminateRoute('secure-fast.example.com', { host: 'backend', port: 8080 }, { - ports: 443, - certificate: 'auto', - maxRate: '100mbps' -}) -``` - -## Socket Handlers - -SmartProxy supports custom socket handlers for implementing specialized protocols or custom logic: - -### Pre-built Socket Handlers - -```typescript -// Echo server -createSocketHandlerRoute('echo.example.com', 7, SocketHandlers.echo) - -// HTTP redirect -createHttpToHttpsRedirect('example.com') - -// Line-based protocol -createSocketHandlerRoute('telnet.example.com', 23, SocketHandlers.lineProtocol((line, socket) => { - socket.write(`You said: ${line}\n`); -})) - -// HTTP server for custom logic -createSocketHandlerRoute('custom.example.com', 8080, SocketHandlers.httpServer((req, res) => { - if (req.url === '/health') { - res.status(200); - res.send('OK'); - } else { - res.status(404); - res.send('Not Found'); - } - res.end(); -})) - -// Block connections -createSocketHandlerRoute('blocked.example.com', 443, SocketHandlers.block('Access Denied')) - -// TCP proxy -createSocketHandlerRoute('proxy.example.com', 8080, SocketHandlers.proxy('internal-server', 3000)) -``` - -### Custom Socket Handler - -```typescript -{ - match: { - ports: 9999, - domains: 'custom.example.com' - }, - action: { - type: 'socket-handler', - socketHandler: async (socket, context) => { - console.log(`New connection from ${context.clientIp} to ${context.domain}`); - - socket.write('Welcome to the custom protocol server\n'); - - socket.on('data', (data) => { - // Process incoming data - const command = data.toString().trim(); - - if (command === 'QUIT') { - socket.end('Goodbye\n'); - } else { - socket.write(`Unknown command: ${command}\n`); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - }); - } - } -} -``` - -## Dynamic Port Management - -SmartProxy allows you to dynamically add or remove listening ports without restarting: - -```typescript -// Add a new listening port -await proxy.addListeningPort(8443); - -// Remove a listening port -await proxy.removeListeningPort(8080); - -// Get all currently listening ports -const ports = proxy.getListeningPorts(); // [80, 443, 8443] -``` - -## Certificate Management - -SmartProxy includes automatic certificate management with Let's Encrypt support: - -### Automatic Certificates (Let's Encrypt) -```typescript -{ - action: { - tls: { - mode: 'terminate', - certificate: 'auto' // Automatic Let's Encrypt certificate - } - } -} -``` - -### Manual Certificates -```typescript -{ - action: { - tls: { - mode: 'terminate', - certificate: { - key: fs.readFileSync('./certs/private.key', 'utf8'), - cert: fs.readFileSync('./certs/certificate.crt', 'utf8') - } - } - } -} -``` - -### Certificate Store -Certificates are automatically stored and managed: -- Auto certificates: `./certificates/{domain}/` -- Manual certificates: In-memory only - -## Security Features - -### IP-Based Access Control -```typescript -{ - security: { - ipAllowList: ['10.0.0.0/8', '192.168.*', '::1'], - ipBlockList: ['192.168.1.100', '10.0.0.0/24'] - } -} -``` - -### Connection Limits -```typescript -{ - security: { - maxConnections: 1000, // Total connections - maxConnectionsPerIp: 10 // Per IP address - } -} -``` - -### Rate Limiting -```typescript -{ - security: { - rateLimit: { - maxRequests: 100, // Maximum requests - windowMs: 60000 // Time window (1 minute) - } - } -} -``` - -### Authentication -```typescript -// Basic Authentication -{ - security: { - authentication: { - type: 'basic', - realm: 'Protected Area', - users: { - 'admin': 'hashedpassword' - } - } - } -} - -// JWT Authentication -{ - security: { - authentication: { - type: 'jwt', - secret: 'your-secret-key', - algorithms: ['HS256'] - } - } -} -``` - -## Advanced Features - -### Custom Route Matching -```typescript -{ - match: { - ports: 443, - customMatcher: async (context) => { - // Custom logic to determine if route should match - const hour = new Date().getHours(); - return hour >= 9 && hour < 17; // Only match during business hours - } - } -} -``` - -### Header Manipulation -```typescript -{ - action: { - headers: { - request: { - 'X-Real-IP': '{clientIp}', - 'X-Forwarded-For': '{clientIp}', - 'X-Custom-Header': 'value' - }, - response: { - 'X-Powered-By': 'SmartProxy', - 'Strict-Transport-Security': 'max-age=31536000' - } - } - } -} -``` - -### Dynamic Target Selection -```typescript -{ - action: { - type: 'forward', - targets: [ - { - host: ['backend1.example.com', 'backend2.example.com'], // Round-robin - port: (context) => { - // Dynamic port based on path - return context.path.startsWith('/api/v1') ? 8081 : 8080; - } - } - ] - } -} -``` - -## Complete Examples - -### Multi-Domain HTTPS Server with Redirects -```typescript -const proxy = new SmartProxy({ - acme: { - email: 'admin@example.com', - useProduction: true - }, - routes: [ - // HTTPS routes - ...['example.com', 'app.example.com', 'api.example.com'].map(domain => - createHttpsTerminateRoute(domain, { host: 'localhost', port: 3000 }, { - certificate: 'auto' - }) - ), - - // HTTP to HTTPS redirects - createHttpToHttpsRedirect(['example.com', '*.example.com']) - ] -}); -``` - -### API Gateway with Multiple Services -```typescript -const proxy = new SmartProxy({ - routes: [ - // User service - createApiRoute('api.example.com', '/users', { host: 'user-service', port: 8081 }), - - // Product service - createApiRoute('api.example.com', '/products', { host: 'product-service', port: 8082 }), - - // Order service with authentication - { - match: { - ports: 443, - domains: 'api.example.com', - path: '/orders/*' - }, - action: { - type: 'forward', - targets: [{ host: 'order-service', port: 8083 }], - tls: { - mode: 'terminate', - certificate: 'auto' - } - }, - security: { - authentication: { - type: 'jwt', - secret: process.env.JWT_SECRET - } - } - } - ] -}); -``` - -### WebSocket Server with Load Balancing -```typescript -const proxy = new SmartProxy({ - routes: [ - { - match: { - ports: 443, - domains: 'ws.example.com' - }, - action: { - type: 'forward', - targets: [ - { host: 'ws-server-1', port: 8080 }, - { host: 'ws-server-2', port: 8080 }, - { host: 'ws-server-3', port: 8080 } - ], - tls: { - mode: 'terminate', - certificate: 'auto' - }, - websocket: { - enabled: true, - pingInterval: 30000 - } - } - } - ] -}); -``` - -## Troubleshooting - -### Common Issues - -#### Certificate Provisioning -- Ensure domains are publicly accessible -- Check firewall rules for port 80 (ACME challenges) -- Verify DNS resolution -- Check ACME email configuration - -#### Connection Issues -- Verify route matching criteria -- Check security rules (IP lists, authentication) -- Ensure target servers are accessible -- Check for port conflicts - -#### Performance Issues -- Consider using NFTables for high-traffic routes -- Adjust connection pool sizes -- Enable connection keep-alive -- Monitor resource usage - -### Debug Mode -Enable detailed logging: -```typescript -const proxy = new SmartProxy({ - debug: true, - routes: [...] -}); -``` - -### Route Testing -Test route matching: -```typescript -const matchedRoute = proxy.findMatchingRoute({ - port: 443, - domain: 'example.com', - path: '/api/users', - clientIp: '192.168.1.100' -}); - -console.log('Matched route:', matchedRoute?.name); -``` - -## Migration Guide - -### From v19.x to v20.x - -The main breaking change is the route action configuration: - -**Before (v19.x):** -```typescript -{ - action: { - type: 'forward', - target: { host: 'localhost', port: 8080 } // Single target - } -} -``` - -**After (v20.x):** -```typescript -{ - action: { - type: 'forward', - targets: [{ host: 'localhost', port: 8080 }] // Array of targets - } -} -``` - -Helper functions have been updated to use the new format automatically. - -## Best Practices - -1. **Use Helper Functions**: They provide sensible defaults and reduce configuration errors -2. **Set Route Priorities**: Higher priority routes are matched first -3. **Use Specific Matches**: More specific routes should have higher priorities -4. **Enable Security Features**: Always use IP filtering and rate limiting for public services -5. **Monitor Performance**: Use debug logging and metrics to identify bottlenecks -6. **Regular Certificate Checks**: Monitor certificate expiration and renewal -7. **Graceful Shutdown**: Always call `proxy.stop()` for clean shutdown - -## API Reference - -### SmartProxy Class - -```typescript -class SmartProxy { - constructor(options: IRoutedSmartProxyOptions); - - // Lifecycle methods - start(): Promise; - stop(): Promise; - - // Route management - updateRoutes(routes: IRouteConfig[]): Promise; - addRoute(route: IRouteConfig): Promise; - removeRoute(routeName: string): Promise; - findMatchingRoute(context: Partial): IRouteConfig | null; - - // Port management - addListeningPort(port: number): Promise; - removeListeningPort(port: number): Promise; - getListeningPorts(): number[]; - - // Certificate management - getCertificateInfo(domain: string): ICertificateInfo | null; - renewCertificate(domain: string): Promise; - - // Status and monitoring - getStatus(): IProxyStatus; - getMetrics(): IProxyMetrics; -} -``` - -### Route Configuration Types - -See the TypeScript definitions in: -- `ts/proxies/smart-proxy/models/route-types.ts` -- `ts/proxies/smart-proxy/models/interfaces.ts` - -## Contributing - -Contributions are welcome! Please follow these guidelines: - -1. Fork the repository -2. Create a feature branch -3. Write tests for new functionality -4. Ensure all tests pass -5. Submit a pull request - ## License and Legal Information This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. diff --git a/ts/core/models/socket-augmentation.ts b/ts/core/models/socket-augmentation.ts index 2bb26e3..2f69914 100644 --- a/ts/core/models/socket-augmentation.ts +++ b/ts/core/models/socket-augmentation.ts @@ -12,6 +12,11 @@ declare module 'net' { getTLSVersion?(): string; // Returns the TLS version (e.g., 'TLSv1.2', 'TLSv1.3') getPeerCertificate?(detailed?: boolean): any; // Returns the peer's certificate 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) } } diff --git a/ts/core/utils/socket-tracker.ts b/ts/core/utils/socket-tracker.ts new file mode 100644 index 0000000..962017d --- /dev/null +++ b/ts/core/utils/socket-tracker.ts @@ -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: (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 }; +} \ No newline at end of file diff --git a/ts/detection/detectors/http-detector.ts b/ts/detection/detectors/http-detector.ts index 61d5574..9b9851a 100644 --- a/ts/detection/detectors/http-detector.ts +++ b/ts/detection/detectors/http-detector.ts @@ -11,6 +11,7 @@ import type { THttpMethod } from '../../protocols/http/index.js'; import { QuickProtocolDetector } from './quick-detector.js'; import { RoutingExtractor } from './routing-extractor.js'; import { DetectionFragmentManager } from '../utils/fragment-manager.js'; +import { HttpParser } from '../../protocols/http/parser.js'; /** * Simplified HTTP detector @@ -56,6 +57,17 @@ export class HttpDetector implements IProtocolDetector { // Extract routing information const routing = RoutingExtractor.extract(buffer, 'http'); + // Extract headers if requested and we have complete headers + let headers: Record | 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 (quickResult.confidence >= 95 && !options?.extractFullHeaders && isComplete) { return { @@ -76,7 +88,8 @@ export class HttpDetector implements IProtocolDetector { protocol: 'http', domain: routing?.domain, path: routing?.path, - method: quickResult.metadata?.method as THttpMethod + method: quickResult.metadata?.method as THttpMethod, + headers: headers }, isComplete, bytesNeeded: isComplete ? undefined : buffer.length + 512 // Need more for headers diff --git a/ts/detection/protocol-detector.ts b/ts/detection/protocol-detector.ts index f7adf5c..8376699 100644 --- a/ts/detection/protocol-detector.ts +++ b/ts/detection/protocol-detector.ts @@ -233,6 +233,7 @@ export class ProtocolDetector { private destroyInstance(): void { this.fragmentManager.destroy(); + this.connectionProtocols.clear(); } /** diff --git a/ts/proxies/http-proxy/http-proxy.ts b/ts/proxies/http-proxy/http-proxy.ts index e1437ef..ab9b87c 100644 --- a/ts/proxies/http-proxy/http-proxy.ts +++ b/ts/proxies/http-proxy/http-proxy.ts @@ -35,7 +35,7 @@ export class HttpProxy implements IMetricsTracker { public routes: IRouteConfig[] = []; // Server instances (HTTP/2 with HTTP/1 fallback) - public httpsServer: any; + public httpsServer: plugins.http2.Http2SecureServer; // Core components private certificateManager: CertificateManager; @@ -196,8 +196,9 @@ export class HttpProxy implements IMetricsTracker { this.options.keepAliveTimeout = keepAliveTimeout; if (this.httpsServer) { - this.httpsServer.keepAliveTimeout = keepAliveTimeout; - this.logger.info(`Updated keep-alive timeout to ${keepAliveTimeout}ms`); + // HTTP/2 servers have setTimeout method for timeout management + this.httpsServer.setTimeout(keepAliveTimeout); + this.logger.info(`Updated server timeout to ${keepAliveTimeout}ms`); } } @@ -249,18 +250,19 @@ export class HttpProxy implements IMetricsTracker { this.setupConnectionTracking(); // 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); }); // 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); }); // 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 - this.webSocketHandler.initialize(this.httpsServer); + this.webSocketHandler.initialize(this.httpsServer as any); // Start metrics logging this.setupMetricsCollection(); // 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 */ @@ -282,30 +299,47 @@ export class HttpProxy implements IMetricsTracker { 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 && connection.remoteAddress?.includes('127.0.0.1'); + const isFromSmartProxy = this.options.portProxyIntegration && this.isLoopback(connection.remoteAddress); // For SmartProxy connections, wait for CLIENT_IP header if (isFromSmartProxy) { - let headerBuffer = Buffer.alloc(0); - let headerParsed = false; - - const parseHeader = (data: Buffer) => { - if (headerParsed) return data; + const MAX_PREFACE = 256; // bytes - prevent DoS + const HEADER_TIMEOUT_MS = 500; // timeout for header parsing + let headerTimer: NodeJS.Timeout | undefined; + let buffered = Buffer.alloc(0); + + const onData = (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); - headerBuffer = Buffer.concat([headerBuffer, data]); - const headerStr = headerBuffer.toString(); - const headerEnd = headerStr.indexOf('\r\n'); + // 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; + } - if (headerEnd !== -1) { - const header = headerStr.substring(0, headerEnd); - if (header.startsWith('CLIENT_IP:')) { - remoteIP = header.substring(10); // Extract IP after "CLIENT_IP:" + 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}`); } - 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 - (connection as any)._realRemoteIP = remoteIP; + connection._realRemoteIP = remoteIP; // Validate the real IP const ipValidation = this.securityManager.validateIP(remoteIP); @@ -318,35 +352,26 @@ export class HttpProxy implements IMetricsTracker { remoteIP ); connection.destroy(); - return null; + return; } // Track connection by real IP 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 - const originalEmit = connection.emit; - connection.emit = function(event: string, ...args: any[]) { - if (event === 'data' && !headerParsed) { - const remaining = parseHeader(args[0]); - 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; + // 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); @@ -385,8 +410,8 @@ export class HttpProxy implements IMetricsTracker { } // Add connection to tracking with metadata - (connection as any)._connectionId = connectionId; - (connection as any)._remoteIP = remoteIP; + connection._connectionId = connectionId; + connection._remoteIP = remoteIP; this.socketMap.add(connection); this.connectedClients = this.socketMap.getArray().length; @@ -409,8 +434,8 @@ export class HttpProxy implements IMetricsTracker { this.connectedClients = this.socketMap.getArray().length; // Remove IP tracking - const connId = (connection as any)._connectionId; - const connIP = (connection as any)._realRemoteIP || (connection as any)._remoteIP; + const connId = connection._connectionId; + const connIP = connection._realRemoteIP || connection._remoteIP; if (connId && connIP) { this.securityManager.removeConnectionByIP(connIP, connId); } diff --git a/ts/proxies/smart-proxy/certificate-manager.ts b/ts/proxies/smart-proxy/certificate-manager.ts index 9d80ff0..5ead346 100644 --- a/ts/proxies/smart-proxy/certificate-manager.ts +++ b/ts/proxies/smart-proxy/certificate-manager.ts @@ -110,6 +110,14 @@ export class SmartCertManager { 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) */ @@ -391,15 +399,14 @@ export class SmartCertManager { } // Parse certificate to get dates - // Parse certificate to get dates - for now just use defaults - // TODO: Implement actual certificate parsing if needed - const certInfo = { validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), validFrom: new Date() }; + const expiryDate = this.extractExpiryDate(cert); + const issueDate = new Date(); // Current date as issue date const certData: ICertificateData = { cert, key, - expiryDate: certInfo.validTo, - issueDate: certInfo.validFrom, + expiryDate, + issueDate, source: 'static' }; @@ -573,6 +580,8 @@ export class SmartCertManager { // 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 @@ -662,6 +671,8 @@ export class SmartCertManager { 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 @@ -697,6 +708,11 @@ export class SmartCertManager { 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(); } diff --git a/ts/proxies/smart-proxy/index.ts b/ts/proxies/smart-proxy/index.ts index bf7fb7b..9742b13 100644 --- a/ts/proxies/smart-proxy/index.ts +++ b/ts/proxies/smart-proxy/index.ts @@ -20,6 +20,7 @@ export { HttpProxyBridge } from './http-proxy-bridge.js'; 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'; diff --git a/ts/proxies/smart-proxy/metrics-collector.ts b/ts/proxies/smart-proxy/metrics-collector.ts index 0722ab1..7292585 100644 --- a/ts/proxies/smart-proxy/metrics-collector.ts +++ b/ts/proxies/smart-proxy/metrics-collector.ts @@ -33,6 +33,11 @@ export class MetricsCollector implements IMetrics { 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?: { @@ -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 = { connectionDuration: (): { p50: number; p95: number; p99: number } => { - // TODO: Implement percentile calculations - return { p50: 0, p95: 0, p99: 0 }; + 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 }; } => { - // TODO: Implement percentile calculations return { - in: { p50: 0, p95: 0, p99: 0 }, - out: { p50: 0, p95: 0, p99: 0 } + 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) + } }; } }; @@ -298,6 +321,30 @@ export class MetricsCollector implements IMetrics { * 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); } @@ -349,6 +396,11 @@ export class MetricsCollector implements IMetrics { } }, 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) => { diff --git a/ts/proxies/smart-proxy/route-orchestrator.ts b/ts/proxies/smart-proxy/route-orchestrator.ts new file mode 100644 index 0000000..0d2dbc6 --- /dev/null +++ b/ts/proxies/smart-proxy/route-orchestrator.ts @@ -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; + verifyChallengeRouteRemoved?: () => Promise; + } = {} + ): Promise<{ + portUsageMap: Map>; + 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> { + const portUsage = new Map>(); + + 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>, newUsage: Map>): 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 { + // 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); + } + } + } +} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/smart-proxy.ts b/ts/proxies/smart-proxy/smart-proxy.ts index afe5ff3..5bd8ec1 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -25,6 +25,12 @@ import type { IRouteConfig } from './models/route-types.js'; // Import mutex for route update synchronization 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 { AcmeStateManager } from './acme-state-manager.js'; @@ -66,12 +72,15 @@ export class SmartProxy extends plugins.EventEmitter { // Global challenge route tracking private globalChallengeRouteActive: boolean = false; - private routeUpdateLock: any = null; // Will be initialized as AsyncMutex + private routeUpdateLock: Mutex; public acmeStateManager: AcmeStateManager; // Metrics collector public metricsCollector: MetricsCollector; + // Route orchestrator for managing route updates + private routeOrchestrator: RouteOrchestrator; + // Track port usage across route updates private portUsageMap: Map> = new Map(); @@ -175,6 +184,15 @@ export class SmartProxy extends plugins.EventEmitter { 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({ logger: loggerAdapter, enableDetailedLogging: this.settings.enableDetailedLogging, @@ -206,6 +224,16 @@ export class SmartProxy extends plugins.EventEmitter { sampleIntervalMs: this.settings.metrics?.sampleIntervalMs, 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 const listeningPorts = this.routeManager.getListeningPorts(); - // Initialize port usage tracking - this.portUsageMap = this.updatePortUsageMap(this.settings.routes); + // Initialize port usage tracking using RouteOrchestrator + this.portUsageMap = this.routeOrchestrator.updatePortUsageMap(this.settings.routes); // Log port usage for startup 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...'); // Clean up all active connections - this.connectionManager.clearConnections(); + await this.connectionManager.clearConnections(); // Stop HttpProxy await this.httpProxyBridge.stop(); @@ -527,6 +555,10 @@ export class SmartProxy extends plugins.EventEmitter { // Stop metrics collector this.metricsCollector.stop(); + // Clean up ProtocolDetector singleton + const detection = await import('../../detection/index.js'); + detection.ProtocolDetector.destroy(); + // Flush any pending deduplicated logs connectionLogDeduplicator.flushAll(); @@ -606,202 +638,46 @@ export class SmartProxy extends plugins.EventEmitter { try { logger.log('info', `Updating routes (${newRoutes.length} routes)`, { routeCount: newRoutes.length, - component: 'route-manager' + component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Updating routes (${newRoutes.length} routes)`); } - // Track port usage before and after updates - const oldPortUsage = this.updatePortUsageMap(this.settings.routes); - 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 - 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(', ')}`); + // Update route orchestrator dependencies if cert manager changed + if (this.certManager && !this.routeOrchestrator.getCertManager()) { + this.routeOrchestrator.setCertManager(this.certManager); } - // 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 = this.settings.acme?.port || 80; - const acmePortNeeded = newPortsSet.has(acmePort); - const acmePortListed = newBindingPorts.includes(acmePort); - - 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`); + // Delegate the complex route update logic to RouteOrchestrator + const updateResult = await this.routeOrchestrator.updateRoutes( + this.settings.routes, + newRoutes, + { + acmePort: this.settings.acme?.port || 80, + acmeOptions: this.certManager?.getAcmeOptions(), + acmeState: this.certManager?.getState(), + globalChallengeRouteActive: this.globalChallengeRouteActive, + createCertificateManager: this.createCertificateManager.bind(this), + verifyChallengeRouteRemoved: this.verifyChallengeRouteRemoved.bind(this) } - } - - // 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 this.settings.routes = newRoutes; - // Save the new port usage map for future reference - this.portUsageMap = newPortUsage; - - // If HttpProxy is initialized, resync the configurations - if (this.httpProxyBridge.getHttpProxy()) { - await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes); - } - - // Update certificate manager with new routes - if (this.certManager) { - const existingAcmeOptions = this.certManager.getAcmeOptions(); - 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 } - ); + // Update global state from orchestrator results + this.globalChallengeRouteActive = updateResult.newChallengeRouteActive; + + // Update port usage map from orchestrator + this.portUsageMap = updateResult.portUsageMap; + + // If certificate manager was recreated, update our reference + if (updateResult.newCertManager) { + this.certManager = updateResult.newCertManager; + // Update the orchestrator's reference too + this.routeOrchestrator.setCertManager(this.certManager); } }); } @@ -822,87 +698,7 @@ export class SmartProxy extends plugins.EventEmitter { await this.certManager.provisionCertificate(route); } - /** - * 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> { - // Reset the usage map - const portUsage = new Map>(); - - 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>, newUsage: Map>): 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; - } + // Port usage tracking methods moved to RouteOrchestrator /** * Force renewal of a certificate @@ -1024,9 +820,9 @@ export class SmartProxy extends plugins.EventEmitter { terminationStats, acmeEnabled: !!this.certManager, port80HandlerPort: this.certManager ? 80 : null, - routes: this.routeManager.getListeningPorts().length, - listeningPorts: this.portManager.getListeningPorts(), - activePorts: this.portManager.getListeningPorts().length + routeCount: this.settings.routes.length, + activePorts: this.portManager.getListeningPorts().length, + listeningPorts: this.portManager.getListeningPorts() }; } diff --git a/ts/proxies/smart-proxy/utils/route-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers.ts index 29192fb..fdd6151 100644 --- a/ts/proxies/smart-proxy/utils/route-helpers.ts +++ b/ts/proxies/smart-proxy/utils/route-helpers.ts @@ -22,6 +22,7 @@ import * as plugins from '../../../plugins.js'; import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js'; import { mergeRouteConfigs } from './route-utils.js'; import { ProtocolDetector, HttpDetector } from '../../../detection/index.js'; +import { createSocketTracker } from '../../../core/utils/socket-tracker.js'; /** * Create an HTTP-only route configuration @@ -960,11 +961,12 @@ export const SocketHandlers = { * Now uses the centralized detection module for HTTP parsing */ httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => { + const tracker = createSocketTracker(socket); const connectionId = ProtocolDetector.createConnectionId({ socketId: context.connectionId || `${Date.now()}-${Math.random()}` }); - socket.once('data', async (data) => { + const handleData = async (data: Buffer) => { // Use detection module for parsing const detectionResult = await ProtocolDetector.detectWithConnectionTracking( data, @@ -1005,6 +1007,19 @@ export const SocketHandlers = { socket.end(); // Clean up detection state 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 */ httpServer: (handler: (req: { method: string; url: string; headers: Record; 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 responseTimer: NodeJS.Timeout | null = null; const connectionId = ProtocolDetector.createConnectionId({ socketId: context.connectionId || `${Date.now()}-${Math.random()}` }); @@ -1034,6 +1051,8 @@ export const SocketHandlers = { } requestParsed = true; + // Remove data listener after parsing request + socket.removeListener('data', processData); const connInfo = detectionResult.connectionInfo; // Create request object from detection result @@ -1060,6 +1079,12 @@ export const SocketHandlers = { if (ended) return; ended = true; + // Clear response timer since we're sending now + if (responseTimer) { + clearTimeout(responseTimer); + responseTimer = null; + } + if (!responseHeaders['content-type']) { responseHeaders['content-type'] = 'text/plain'; } @@ -1091,30 +1116,44 @@ export const SocketHandlers = { try { handler(req, res); // Ensure response is sent even if handler doesn't call send() - setTimeout(() => { + responseTimer = setTimeout(() => { if (!ended) { res.send(''); } + responseTimer = null; }, 1000); + // Track and unref the timer + tracker.addTimer(responseTimer); } catch (error) { if (!ended) { res.status(500); 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) { - 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 ProtocolDetector.cleanupConnections(); + // Clean up all tracked resources + tracker.cleanup(); }); } }; diff --git a/ts/proxies/smart-proxy/utils/route-validator.ts b/ts/proxies/smart-proxy/utils/route-validator.ts new file mode 100644 index 0000000..1964868 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-validator.ts @@ -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 } { + const errorMap = new Map(); + let valid = true; + + // Check for duplicate route names + const routeNames = new Set(); + 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(); + + 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): 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' + }); + } + } + } +} \ No newline at end of file