diff --git a/package.json b/package.json index 0aa9bd2..34884e2 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@push.rocks/smartproxy", - "version": "13.1.3", + "version": "14.0.0", "private": false, - "description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.", + "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", "typings": "dist_ts/index.d.ts", "type": "module", diff --git a/readme.md b/readme.md index 4aca6fd..d76b808 100644 --- a/readme.md +++ b/readme.md @@ -2,16 +2,16 @@ A unified high-performance proxy toolkit for Node.js, with **SmartProxy** as the central API to handle all your proxy needs: -- **Unified Configuration API**: One consistent way to configure various proxy types +- **Unified Route-Based Configuration**: Match/action pattern for clean, consistent traffic routing - **SSL/TLS Support**: Automatic HTTPS with Let's Encrypt certificate provisioning -- **Simplified Domain Management**: Easy routing based on domain names with wildcard support +- **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 Forwarding Types**: HTTP-only, HTTPS passthrough, TLS termination options +- **Multiple Action Types**: Forward (with TLS modes), redirect, or block traffic - **Security Features**: IP allowlists, connection limits, timeouts, and more ## Project Architecture Overview -SmartProxy has been restructured using a modern, modular architecture to improve maintainability and clarity: +SmartProxy has been restructured using a modern, modular architecture with a unified route-based configuration system in v14.0.0: ``` /ts @@ -29,19 +29,17 @@ SmartProxy has been restructured using a modern, modular architecture to improve │ │ ├── http-handler.ts # HTTP-only handler │ │ └── ... # Other handlers │ ├── /config # Configuration models -│ │ ├── forwarding-types.ts # Type definitions -│ │ ├── domain-config.ts # Domain config utilities -│ │ └── domain-manager.ts # Domain routing manager │ └── /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 +│ │ ├── route-helpers.ts # Helper functions for creating routes +│ │ ├── route-manager.ts # Route management system │ │ ├── smart-proxy.ts # Main SmartProxy class │ │ └── ... # Supporting classes │ ├── /network-proxy # NetworkProxy implementation -│ │ ├── /models # NetworkProxy-specific interfaces -│ │ ├── network-proxy.ts # Main NetworkProxy class -│ │ └── ... # Supporting classes │ └── /nftables-proxy # NfTablesProxy implementation ├── /tls # TLS-specific functionality │ ├── /sni # SNI handling components @@ -58,18 +56,20 @@ SmartProxy has been restructured using a modern, modular architecture to improve - **SmartProxy** (`ts/proxies/smart-proxy/smart-proxy.ts`) The central unified API for all proxy needs, featuring: - - Domain-based routing with SNI inspection + - Route-based configuration with match/action pattern + - Flexible matching criteria (ports, domains, paths, client IPs) + - Multiple action types (forward, redirect, block) - Automatic certificate management - - Multiple forwarding types in one configuration - Advanced security controls - - Flexible backend targeting options ### Helper Functions -- **createDomainConfig** - Create domain configuration with clean syntax -- **httpOnly**, **httpsPassthrough**, **tlsTerminateToHttp**, **tlsTerminateToHttps** - Helper functions to create different forwarding configurations +- **createRoute**, **createHttpRoute**, **createHttpsRoute**, **createPassthroughRoute** + Helper functions to create different route configurations with clean syntax +- **createRedirectRoute**, **createHttpToHttpsRedirect**, **createBlockRoute** + Helper functions for common redirect and security configurations +- **createLoadBalancerRoute**, **createHttpsServer** + Helper functions for complex configurations ### Specialized Components @@ -93,8 +93,8 @@ SmartProxy has been restructured using a modern, modular architecture to improve ### Interfaces and Types -- `ISmartProxyOptions`, `IDomainConfig` (`ts/proxies/smart-proxy/models/interfaces.ts`) -- `IForwardConfig`, `TForwardingType` (`ts/forwarding/config/forwarding-types.ts`) +- `IRouteConfig`, `IRouteMatch`, `IRouteAction` (`ts/proxies/smart-proxy/models/route-types.ts`) +- `IRoutedSmartProxyOptions` (`ts/proxies/smart-proxy/models/route-types.ts`) - `INetworkProxyOptions` (`ts/proxies/network-proxy/models/types.ts`) - `IAcmeOptions`, `IDomainOptions` (`ts/certificate/models/certificate-types.ts`) - `INfTableProxySettings` (`ts/proxies/nftables-proxy/models/interfaces.ts`) @@ -105,51 +105,71 @@ Install via npm: npm install @push.rocks/smartproxy ``` -## Quick Start with SmartProxy +## Quick Start with SmartProxy v14.0.0 -SmartProxy is the recommended way to use this library, providing a unified API for all proxy scenarios. +SmartProxy v14.0.0 introduces a new unified route-based configuration system that makes configuring proxies more flexible and intuitive. ```typescript -import { SmartProxy, createDomainConfig, httpOnly, tlsTerminateToHttp, httpsPassthrough } from '@push.rocks/smartproxy'; +import { + SmartProxy, + createHttpRoute, + createHttpsRoute, + createPassthroughRoute, + createHttpToHttpsRedirect +} from '@push.rocks/smartproxy'; -// Create a new SmartProxy instance with all your domain configurations in one place +// Create a new SmartProxy instance with route-based configuration const proxy = new SmartProxy({ - // Listen on port 443 for incoming connections - fromPort: 443, - - // Configure domains and their forwarding rules - domainConfigs: [ - // Basic HTTP forwarding for api.example.com - createDomainConfig('api.example.com', httpOnly({ + // Define all your routing rules in one array + routes: [ + // Basic HTTP route - forward traffic from port 80 to internal service + createHttpRoute({ + ports: 80, + domains: 'api.example.com', target: { host: 'localhost', port: 3000 } - })), + }), - // HTTPS termination with automatic Let's Encrypt certificates - createDomainConfig('secure.example.com', tlsTerminateToHttp({ + // HTTPS route with TLS termination and automatic certificates + createHttpsRoute({ + ports: 443, + domains: 'secure.example.com', target: { host: 'localhost', port: 8080 }, - acme: { - enabled: true, - production: true - } - })), + certificate: 'auto' // Use Let's Encrypt + }), - // Multiple domains with wildcard support - createDomainConfig(['example.com', '*.example.com'], httpsPassthrough({ - target: { - // Load balancing across multiple backend servers - host: ['192.168.1.10', '192.168.1.11'], - port: 443 - }, + // HTTPS passthrough for legacy systems + createPassthroughRoute({ + ports: 443, + domains: 'legacy.example.com', + target: { host: '192.168.1.10', port: 443 } + }), + + // Redirect HTTP to HTTPS + createHttpToHttpsRedirect({ + domains: ['example.com', '*.example.com'] + }), + + // Complex load balancer setup with security controls + createLoadBalancerRoute({ + domains: ['app.example.com'], + targets: ['192.168.1.10', '192.168.1.11', '192.168.1.12'], + targetPort: 8080, + tlsMode: 'terminate', + certificate: 'auto', security: { - // IP filtering for enhanced security allowedIps: ['10.0.0.*', '192.168.1.*'], - blockedIps: ['1.2.3.4'] + blockedIps: ['1.2.3.4'], + maxConnections: 1000 } - })) + }) ], - // Enable SNI-based routing - sniEnabled: true, + // Global settings that apply to all routes + defaults: { + security: { + maxConnections: 500 + } + }, // Automatic Let's Encrypt integration acme: { @@ -167,65 +187,189 @@ proxy.on('certificate', evt => { // Start the proxy await proxy.start(); -// Dynamically add or update domain configurations later -await proxy.updateDomainConfigs([ - createDomainConfig('new-domain.com', tlsTerminateToHttp({ - target: { host: 'localhost', port: 9000 } - })) +// Dynamically add new routes later +await proxy.addRoutes([ + createHttpsRoute({ + domains: 'new-domain.com', + target: { host: 'localhost', port: 9000 }, + certificate: 'auto' + }) ]); // Later, gracefully shut down await proxy.stop(); ``` -### What You Can Do with SmartProxy +## Route-Based Configuration System -1. **Domain-Based Routing** +The new route-based configuration in v14.0.0 follows a match/action pattern, making it more powerful and flexible: + +```typescript +// Basic structure of a route configuration +{ + // What traffic to match + match: { + ports: 443, // Required: port(s) to listen on + domains: 'example.com', // Optional: domain(s) to match + path: '/api', // Optional: URL path pattern + clientIp: ['10.0.0.*'], // Optional: client IP patterns to match + tlsVersion: ['TLSv1.2', 'TLSv1.3'] // Optional: TLS versions to match + }, + + // What to do with matched traffic + action: { + type: 'forward', // 'forward', 'redirect', or 'block' + + // For 'forward' actions + target: { + host: 'localhost', // Target host(s) for forwarding + port: 8080 // Target port + }, + + // TLS handling (for 'forward' actions) + tls: { + mode: 'terminate', // 'passthrough', 'terminate', 'terminate-and-reencrypt' + certificate: 'auto' // 'auto' for Let's Encrypt or {key, cert} + }, + + // For 'redirect' actions + redirect: { + to: 'https://{domain}{path}', // URL pattern with variables + status: 301 // HTTP status code + }, + + // Security controls for any action + security: { + allowedIps: ['10.0.0.*'], // IP allowlist + blockedIps: ['1.2.3.4'], // IP blocklist + maxConnections: 100 // Connection limits + }, + + // Advanced options + advanced: { + timeout: 30000, // Connection timeout + headers: { // Custom headers + 'X-Forwarded-For': '{clientIp}' + }, + keepAlive: true // Connection pooling + } + }, + + // Metadata (optional) + name: 'API Server', // Human-readable name + description: 'Main API endpoints', // Description + priority: 100, // Matching priority (higher = matched first) + tags: ['api', 'internal'] // Arbitrary tags +} +``` + +### Using Helper Functions + +While you can create route configurations manually, SmartProxy provides helper functions to make it easier: + +```typescript +// Instead of building the full object: +const route = { + match: { ports: 80, domains: 'example.com' }, + action: { type: 'forward', target: { host: 'localhost', port: 8080 } }, + name: 'Web Server' +}; + +// Use the helper function: +const route = createHttpRoute({ + domains: 'example.com', + target: { host: 'localhost', port: 8080 }, + name: 'Web Server' +}); +``` + +Available helper functions: +- `createRoute()` - Basic function to create any route configuration +- `createHttpRoute()` - Create an HTTP forwarding route +- `createHttpsRoute()` - Create an HTTPS route with TLS termination +- `createPassthroughRoute()` - Create an HTTPS passthrough route +- `createRedirectRoute()` - Create a generic redirect route +- `createHttpToHttpsRedirect()` - Create an HTTP to HTTPS redirect +- `createBlockRoute()` - Create a route to block specific traffic +- `createLoadBalancerRoute()` - Create a route for load balancing +- `createHttpsServer()` - Create a complete HTTPS server setup with HTTP redirect + +## What You Can Do with SmartProxy + +1. **Route-Based Traffic Management** ```typescript // Route requests for different domains to different backend servers - createDomainConfig('api.example.com', httpOnly({ - target: { host: 'api-server', port: 3000 } - })) + createHttpsRoute({ + domains: 'api.example.com', + target: { host: 'api-server', port: 3000 }, + certificate: 'auto' + }) ``` 2. **Automatic SSL with Let's Encrypt** ```typescript // Get and automatically renew certificates - createDomainConfig('secure.example.com', tlsTerminateToHttp({ + createHttpsRoute({ + domains: 'secure.example.com', target: { host: 'localhost', port: 8080 }, - acme: { enabled: true, production: true } - })) + certificate: 'auto' + }) ``` 3. **Load Balancing** ```typescript // Distribute traffic across multiple backend servers - createDomainConfig('app.example.com', httpOnly({ - target: { - host: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], - port: 8080 - } - })) + createLoadBalancerRoute({ + domains: 'app.example.com', + targets: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], + targetPort: 8080, + tlsMode: 'terminate', + certificate: 'auto' + }) ``` 4. **Security Controls** ```typescript // Restrict access based on IP addresses - createDomainConfig('admin.example.com', httpOnly({ + createHttpsRoute({ + domains: 'admin.example.com', target: { host: 'localhost', port: 8080 }, + certificate: 'auto', security: { allowedIps: ['10.0.0.*', '192.168.1.*'], maxConnections: 100 } - })) + }) ``` 5. **Wildcard Domains** ```typescript // Handle all subdomains with one config - createDomainConfig(['example.com', '*.example.com'], httpsPassthrough({ + createPassthroughRoute({ + domains: ['example.com', '*.example.com'], target: { host: 'backend-server', port: 443 } - })) + }) + ``` + +6. **Path-Based Routing** + ```typescript + // Route based on URL path + createHttpsRoute({ + domains: 'example.com', + path: '/api/*', + target: { host: 'api-server', port: 3000 }, + certificate: 'auto' + }) + ``` + +7. **Block Malicious Traffic** + ```typescript + // Block traffic from specific IPs + createBlockRoute({ + ports: [80, 443], + clientIp: ['1.2.3.*', '5.6.7.*'], + priority: 1000 // High priority to ensure blocking + }) ``` ## Other Components @@ -293,13 +437,57 @@ const redirect = new SslRedirect(80); await redirect.start(); ``` -## API Reference -For full configuration options and type definitions, see the TypeScript interfaces: -- `INetworkProxyOptions` (`ts/proxies/network-proxy/models/types.ts`) -- `IAcmeOptions`, `IDomainOptions` (`ts/certificate/models/certificate-types.ts`) -- `IForwardConfig` (`ts/forwarding/config/forwarding-types.ts`) -- `INfTableProxySettings` (`ts/proxies/nftables-proxy/models/interfaces.ts`) -- `ISmartProxyOptions`, `IDomainConfig` (`ts/proxies/smart-proxy/models/interfaces.ts`) +## Migration from v13.x to v14.0.0 + +Version 14.0.0 introduces a breaking change with the new route-based configuration system: + +### Key Changes + +1. **Configuration Structure**: The configuration now uses the match/action pattern instead of the old domain-based and port-based approach +2. **SmartProxy Options**: Now takes an array of route configurations instead of `domainConfigs` and port ranges +3. **Helper Functions**: New helper functions have been introduced to simplify configuration + +### Migration Example + +**v13.x Configuration**: +```typescript +import { SmartProxy, createDomainConfig, httpOnly, tlsTerminateToHttp } from '@push.rocks/smartproxy'; + +const proxy = new SmartProxy({ + fromPort: 443, + domainConfigs: [ + createDomainConfig('example.com', tlsTerminateToHttp({ + target: { host: 'localhost', port: 8080 }, + acme: { enabled: true, production: true } + })) + ], + sniEnabled: true +}); +``` + +**v14.0.0 Configuration**: +```typescript +import { SmartProxy, createHttpsRoute } from '@push.rocks/smartproxy'; + +const proxy = new SmartProxy({ + routes: [ + createHttpsRoute({ + ports: 443, + domains: 'example.com', + target: { host: 'localhost', port: 8080 }, + certificate: 'auto' + }) + ] +}); +``` + +### Migration Steps + +1. Replace `domainConfigs` with an array of route configurations using `routes` +2. Convert each domain configuration to use the new helper functions +3. Update any code that uses `updateDomainConfigs()` to use `addRoutes()` or `updateRoutes()` +4. For port-only configurations, create route configurations with port matching only +5. For SNI-based routing, SNI is now automatically enabled when needed ## Architecture & Flow Diagrams @@ -309,11 +497,10 @@ flowchart TB subgraph "SmartProxy Components" direction TB - HTTP80["HTTP Port 80
Redirect / SslRedirect"] + RouteConfig["Route Configuration
(Match/Action)"] + RouteManager["Route Manager"] HTTPS443["HTTPS Port 443
NetworkProxy"] SmartProxy["SmartProxy
(TCP/SNI Proxy)"] - NfTables[NfTablesProxy] - Router[ProxyRouter] ACME["Port80Handler
(ACME HTTP-01)"] Certs[(SSL Certificates)] end @@ -324,188 +511,110 @@ flowchart TB Service3[Service 3] end - Client -->|HTTP Request| HTTP80 - HTTP80 -->|Redirect| Client - Client -->|HTTPS Request| HTTPS443 - Client -->|TLS/TCP| SmartProxy + Client -->|HTTP/HTTPS Request| SmartProxy - HTTPS443 -->|Route Request| Router - Router -->|Proxy Request| Service1 - Router -->|Proxy Request| Service2 + SmartProxy -->|Route Matching| RouteManager + RouteManager -->|Use| RouteConfig + RouteManager -->|Execute Action| SmartProxy - SmartProxy -->|Direct TCP| Service2 - SmartProxy -->|Direct TCP| Service3 + SmartProxy -->|Forward| Service1 + SmartProxy -->|Redirect| Client + SmartProxy -->|Forward| Service2 + SmartProxy -->|Forward| Service3 - NfTables -.->|Low-level forwarding| SmartProxy - - HTTP80 -.->|Challenge Response| ACME ACME -.->|Generate/Manage| Certs - Certs -.->|Provide TLS Certs| HTTPS443 + Certs -.->|Provide TLS Certs| SmartProxy classDef component fill:#f9f,stroke:#333,stroke-width:2px; classDef backend fill:#bbf,stroke:#333,stroke-width:1px; classDef client fill:#dfd,stroke:#333,stroke-width:2px; class Client client; - class HTTP80,HTTPS443,SmartProxy,NfTables,Router,ACME component; + class RouteConfig,RouteManager,HTTPS443,SmartProxy,ACME component; class Service1,Service2,Service3 backend; ``` -### HTTPS Reverse Proxy Flow -This diagram shows how HTTPS requests are handled and proxied to backend services: - -```mermaid -sequenceDiagram - participant Client - participant NetworkProxy - participant ProxyRouter - participant Backend - - Client->>NetworkProxy: HTTPS Request - - Note over NetworkProxy: TLS Termination - - NetworkProxy->>ProxyRouter: Route Request - ProxyRouter->>ProxyRouter: Match hostname to config - - alt Authentication Required - NetworkProxy->>Client: Request Authentication - Client->>NetworkProxy: Send Credentials - NetworkProxy->>NetworkProxy: Validate Credentials - end - - NetworkProxy->>Backend: Forward Request - Backend->>NetworkProxy: Response - - Note over NetworkProxy: Add Default Headers - - NetworkProxy->>Client: Forward Response - - alt WebSocket Request - Client->>NetworkProxy: Upgrade to WebSocket - NetworkProxy->>Backend: Upgrade to WebSocket - loop WebSocket Active - Client->>NetworkProxy: WebSocket Message - NetworkProxy->>Backend: Forward Message - Backend->>NetworkProxy: WebSocket Message - NetworkProxy->>Client: Forward Message - NetworkProxy-->>NetworkProxy: Heartbeat Check - end - end -``` - -### SNI-based Connection Handling -This diagram illustrates how TCP connections with SNI (Server Name Indication) are processed and forwarded: +### Route-Based Connection Handling +This diagram illustrates how requests are matched and processed using the route-based configuration: ```mermaid sequenceDiagram participant Client participant SmartProxy + participant RouteManager participant Backend - Client->>SmartProxy: TLS Connection + Client->>SmartProxy: Connection (TCP/HTTP/HTTPS) - alt SNI Enabled - SmartProxy->>Client: Accept Connection - Client->>SmartProxy: TLS ClientHello with SNI - SmartProxy->>SmartProxy: Extract SNI Hostname - SmartProxy->>SmartProxy: Match Domain Config - SmartProxy->>SmartProxy: Validate Client IP + SmartProxy->>RouteManager: Match connection against routes + + RouteManager->>RouteManager: Check port match + RouteManager->>RouteManager: Check domain match (if SNI) + RouteManager->>RouteManager: Check path match (if HTTP) + RouteManager->>RouteManager: Check client IP match + RouteManager->>RouteManager: Check TLS version match + + RouteManager->>RouteManager: Determine highest priority matching route + + alt Forward Action + RouteManager->>SmartProxy: Use forward action - alt IP Allowed - SmartProxy->>Backend: Forward Connection - Note over SmartProxy,Backend: Bidirectional Data Flow - else IP Rejected - SmartProxy->>Client: Close Connection + alt TLS Termination + SmartProxy->>SmartProxy: Terminate TLS + SmartProxy->>Backend: Forward as HTTP/HTTPS + else TLS Passthrough + SmartProxy->>Backend: Forward raw TCP end - else Port-based Routing - SmartProxy->>SmartProxy: Match Port Range - SmartProxy->>SmartProxy: Find Domain Config - SmartProxy->>SmartProxy: Validate Client IP - alt IP Allowed - SmartProxy->>Backend: Forward Connection - Note over SmartProxy,Backend: Bidirectional Data Flow - else IP Rejected - SmartProxy->>Client: Close Connection - end + else Redirect Action + RouteManager->>SmartProxy: Use redirect action + SmartProxy->>Client: Send redirect response + + else Block Action + RouteManager->>SmartProxy: Use block action + SmartProxy->>Client: Close connection end loop Connection Active SmartProxy-->>SmartProxy: Monitor Activity - SmartProxy-->>SmartProxy: Check Max Lifetime - alt Inactivity or Max Lifetime Exceeded + SmartProxy-->>SmartProxy: Check Security Rules + alt Security Violation or Timeout SmartProxy->>Client: Close Connection SmartProxy->>Backend: Close Connection end end ``` -### Let's Encrypt Certificate Acquisition -This diagram shows how certificates are automatically acquired through the ACME protocol: - -```mermaid -sequenceDiagram - participant Client - participant Port80Handler - participant ACME as Let's Encrypt ACME - participant NetworkProxy - - Client->>Port80Handler: HTTP Request for domain - - alt Certificate Exists - Port80Handler->>Client: Redirect to HTTPS - else No Certificate - Port80Handler->>Port80Handler: Mark domain as obtaining cert - Port80Handler->>ACME: Create account & new order - ACME->>Port80Handler: Challenge information - - Port80Handler->>Port80Handler: Store challenge token & key authorization - - ACME->>Port80Handler: HTTP-01 Challenge Request - Port80Handler->>ACME: Challenge Response - - ACME->>ACME: Validate domain ownership - ACME->>Port80Handler: Challenge validated - - Port80Handler->>Port80Handler: Generate CSR - Port80Handler->>ACME: Submit CSR - ACME->>Port80Handler: Issue Certificate - - Port80Handler->>Port80Handler: Store certificate & private key - Port80Handler->>Port80Handler: Mark certificate as obtained - - Note over Port80Handler,NetworkProxy: Certificate available for use - - Client->>Port80Handler: Another HTTP Request - Port80Handler->>Client: Redirect to HTTPS - Client->>NetworkProxy: HTTPS Request - Note over NetworkProxy: Uses new certificate - end -``` - ## Features -- HTTP/HTTPS Reverse Proxy (NetworkProxy) - • TLS termination, virtual-host routing, HTTP/2 & WebSocket support, pooling & metrics +- **Route-Based Traffic Management** + • Match/action pattern for flexible routing + • Port, domain, path, client IP, and TLS version matching + • Multiple action types (forward, redirect, block) -- Automatic ACME Certificates (Port80Handler) - • HTTP-01 challenge handling, certificate issuance/renewal, pluggable storage +- **TLS Handling Options** + • TLS passthrough for end-to-end encryption + • TLS termination for content inspection + • TLS termination with re-encryption for gateway scenarios -- Low-Level Port Forwarding (NfTablesProxy) - • nftables NAT rules for ports/ranges, IPv4/IPv6, IP filtering, QoS & ipset support +- **Automatic ACME Certificates** + • HTTP-01 challenge handling + • Certificate issuance/renewal + • Pluggable storage -- Custom Redirects (Redirect / SslRedirect) - • URL redirects with wildcard host/path, template variables & status codes +- **Security Controls** + • IP allow/block lists with glob pattern support + • Connection limits and rate limiting + • Timeout controls and connection monitoring -- TCP/SNI Proxy (SmartProxy) - • SNI-based routing, IP allow/block lists, port ranges, timeouts & graceful shutdown +- **Load Balancing** + • Round-robin distribution across multiple backends + • Health checks and failure handling -- SNI Utilities (SniHandler) - • Robust ClientHello parsing, fragmentation & session resumption support - -- Core Utilities - • ValidationUtils and IpUtils for configuration validation and IP management +- **Advanced Features** + • Custom header manipulation + • Template variables for dynamic values + • Priority-based route matching ## Certificate Hooks & Events @@ -520,176 +629,171 @@ Provide a `certProvisionFunction(domain)` in SmartProxy settings to supply stati ## SmartProxy: Common Use Cases -The SmartProxy component offers a clean, unified approach to handle virtually any proxy scenario. +The SmartProxy component with route-based configuration offers a clean, unified approach to handle virtually any proxy scenario. ### 1. API Gateway / Backend Routing -Create a flexible API gateway to route traffic to different microservices based on domain: +Create a flexible API gateway to route traffic to different microservices based on domain and path: ```typescript -import { SmartProxy, createDomainConfig, httpOnly, tlsTerminateToHttp } from '@push.rocks/smartproxy'; +import { SmartProxy, createHttpsRoute } from '@push.rocks/smartproxy'; const apiGateway = new SmartProxy({ - fromPort: 443, - domainConfigs: [ + routes: [ // Users API - createDomainConfig('users.api.example.com', tlsTerminateToHttp({ + createHttpsRoute({ + ports: 443, + domains: 'api.example.com', + path: '/users/*', target: { host: 'users-service', port: 3000 }, - acme: { enabled: true, production: true } - })), + certificate: 'auto' + }), // Products API - createDomainConfig('products.api.example.com', tlsTerminateToHttp({ + createHttpsRoute({ + ports: 443, + domains: 'api.example.com', + path: '/products/*', target: { host: 'products-service', port: 3001 }, - acme: { enabled: true, production: true } - })), + certificate: 'auto' + }), - // Admin dashboard gets extra security - createDomainConfig('admin.example.com', tlsTerminateToHttp({ + // Admin dashboard with extra security + createHttpsRoute({ + ports: 443, + domains: 'admin.example.com', target: { host: 'admin-dashboard', port: 8080 }, + certificate: 'auto', security: { allowedIps: ['10.0.0.*', '192.168.1.*'] // Only allow internal network } - })) - ], - sniEnabled: true + }) + ] }); await apiGateway.start(); ``` -### 2. Automatic HTTPS for Development +### 2. Complete HTTPS Server with HTTP Redirect -Easily add HTTPS to your local development environment with automatic certificates: +Easily set up a secure HTTPS server with automatic redirection from HTTP: ```typescript -import { SmartProxy, createDomainConfig, tlsTerminateToHttp } from '@push.rocks/smartproxy'; +import { SmartProxy, createHttpsServer } from '@push.rocks/smartproxy'; -const devProxy = new SmartProxy({ - fromPort: 443, - domainConfigs: [ - createDomainConfig('dev.local', tlsTerminateToHttp({ - target: { host: 'localhost', port: 3000 }, - // For development, use self-signed or existing certificates - https: { - customCert: { - key: fs.readFileSync('dev-cert.key', 'utf8'), - cert: fs.readFileSync('dev-cert.pem', 'utf8') - } - }, - // Auto-redirect HTTP to HTTPS - http: { - enabled: true, - redirectToHttps: true - } - })) +const webServer = new SmartProxy({ + routes: [ + // createHttpsServer creates both the HTTPS route and HTTP redirect + ...createHttpsServer({ + domains: 'example.com', + target: { host: 'localhost', port: 8080 }, + certificate: 'auto', + addHttpRedirect: true + }) ] }); -await devProxy.start(); +await webServer.start(); ``` -### 3. Load Balancing Multiple Servers +### 3. Multi-Tenant Application with Wildcard Domains -Distribute traffic across multiple backend servers with round-robin load balancing: +Support dynamically created tenants with wildcard domain matching: ```typescript -import { SmartProxy, createDomainConfig, tlsTerminateToHttp } from '@push.rocks/smartproxy'; +import { SmartProxy, createHttpsRoute, createHttpToHttpsRedirect } from '@push.rocks/smartproxy'; -const loadBalancer = new SmartProxy({ - fromPort: 443, - domainConfigs: [ - createDomainConfig('app.example.com', tlsTerminateToHttp({ - target: { - // Round-robin across multiple servers - host: [ - '10.0.0.10', - '10.0.0.11', - '10.0.0.12' - ], - port: 8080 - }, - acme: { enabled: true, production: true } - })) - ] -}); - -await loadBalancer.start(); -``` - -### 4. Wildcard Subdomain Handling - -Support multiple or dynamically created subdomains with one configuration: - -```typescript -import { SmartProxy, createDomainConfig, tlsTerminateToHttp } from '@push.rocks/smartproxy'; - -const multiTenantProxy = new SmartProxy({ - fromPort: 443, - domainConfigs: [ - // Handle all customer subdomains with one config - createDomainConfig('*.example.com', tlsTerminateToHttp({ +const multiTenantApp = new SmartProxy({ + routes: [ + // Handle all tenant subdomains with one route + createHttpsRoute({ + ports: 443, + domains: '*.example.com', target: { host: 'tenant-router', port: 8080 }, - acme: { enabled: true, production: true }, + certificate: 'auto', // Pass original hostname to backend for tenant identification advanced: { headers: { 'X-Original-Host': '{sni}' } } - })) - ], - sniEnabled: true + }), + + // Redirect HTTP to HTTPS for all subdomains + createHttpToHttpsRedirect({ + domains: ['*.example.com'] + }) + ] }); -await multiTenantProxy.start(); +await multiTenantApp.start(); ``` -### 5. Comprehensive Proxy Server +### 4. Complex Multi-Service Infrastructure -Create a complete proxy solution with multiple services on a single server: +Create a comprehensive proxy solution with multiple services and security controls: ```typescript -import { SmartProxy, createDomainConfig, httpOnly, tlsTerminateToHttp, tlsTerminateToHttps, httpsPassthrough } from '@push.rocks/smartproxy'; +import { + SmartProxy, + createHttpsRoute, + createPassthroughRoute, + createBlockRoute, + createHttpToHttpsRedirect +} from '@push.rocks/smartproxy'; const enterpriseProxy = new SmartProxy({ - fromPort: 443, - domainConfigs: [ + routes: [ // Web application with automatic HTTPS - createDomainConfig('app.example.com', tlsTerminateToHttp({ + createHttpsRoute({ + ports: 443, + domains: 'app.example.com', target: { host: 'web-app', port: 8080 }, - acme: { enabled: true, production: true }, - http: { enabled: true, redirectToHttps: true } - })), + certificate: 'auto' + }), // Legacy system that needs HTTPS passthrough - createDomainConfig('legacy.example.com', httpsPassthrough({ + createPassthroughRoute({ + ports: 443, + domains: 'legacy.example.com', target: { host: 'legacy-server', port: 443 } - })), + }), // Internal APIs with IP restrictions - createDomainConfig('api.internal.example.com', tlsTerminateToHttp({ + createHttpsRoute({ + ports: 443, + domains: 'api.internal.example.com', target: { host: 'api-gateway', port: 3000 }, + certificate: 'auto', security: { allowedIps: ['10.0.0.0/16', '192.168.0.0/16'], maxConnections: 500 } - })), + }), - // External services with customer certificate - createDomainConfig('external.example.com', tlsTerminateToHttps({ - target: { host: 'external-service', port: 8443 }, - https: { - customCert: { - key: fs.readFileSync('external-key.pem', 'utf8'), - cert: fs.readFileSync('external-cert.pem', 'utf8') - } - } - })) + // Block known malicious IPs + createBlockRoute({ + ports: [80, 443], + clientIp: ['1.2.3.*', '5.6.7.*'], + priority: 1000 + }), + + // Redirect all HTTP to HTTPS + createHttpToHttpsRedirect({ + domains: ['*.example.com', 'example.com'] + }) ], - sniEnabled: true, + + // Global settings that apply to all routes + defaults: { + security: { + maxConnections: 1000 + } + }, + // Enable connection timeouts for security inactivityTimeout: 30000, + // Using global certificate management acme: { enabled: true, @@ -702,116 +806,86 @@ const enterpriseProxy = new SmartProxy({ await enterpriseProxy.start(); ``` -## Unified Forwarding System Details +## Route-Based Configuration Details -SmartProxy's unified forwarding system supports four primary forwarding types: +### Match Criteria Options -1. **HTTP-only (`http-only`)**: Forwards HTTP traffic to a backend server. -2. **HTTPS Passthrough (`https-passthrough`)**: Passes through raw TLS traffic without termination (SNI forwarding). -3. **HTTPS Termination to HTTP (`https-terminate-to-http`)**: Terminates TLS and forwards the decrypted traffic to an HTTP backend. -4. **HTTPS Termination to HTTPS (`https-terminate-to-https`)**: Terminates TLS and creates a new TLS connection to an HTTPS backend. +- **ports**: `number | number[] | Array<{ from: number; to: number }>` (required) + Listen on specific ports or port ranges -### Configuration Format +- **domains**: `string | string[]` (optional) + Match specific domain names, supports wildcards (e.g., `*.example.com`) -Each domain is configured with a forwarding type and target: +- **path**: `string` (optional) + Match specific URL paths, supports glob patterns +- **clientIp**: `string[]` (optional) + Match client IP addresses, supports glob patterns + +- **tlsVersion**: `string[]` (optional) + Match specific TLS versions (e.g., `TLSv1.2`, `TLSv1.3`) + +### Action Types + +1. **Forward**: + ```typescript + { + type: 'forward', + target: { host: 'localhost', port: 8080 }, + tls: { mode: 'terminate', certificate: 'auto' } + } + ``` + +2. **Redirect**: + ```typescript + { + type: 'redirect', + redirect: { to: 'https://{domain}{path}', status: 301 } + } + ``` + +3. **Block**: + ```typescript + { + type: 'block' + } + ``` + +### TLS Modes + +- **passthrough**: Forward raw TLS traffic without decryption +- **terminate**: Terminate TLS and forward as HTTP +- **terminate-and-reencrypt**: Terminate TLS and create a new TLS connection to the backend + +### Template Variables + +Template variables can be used in string values: + +- `{domain}`: The requested domain name +- `{port}`: The incoming port number +- `{path}`: The requested URL path +- `{query}`: The query string +- `{clientIp}`: The client's IP address +- `{sni}`: The SNI hostname + +Example: ```typescript -{ - domains: ['example.com'], // Single domain or array of domains (with wildcard support) - forwarding: { - type: 'http-only', // One of the four forwarding types - target: { - host: 'localhost', // Backend server (string or array for load balancing) - port: 3000 // Backend port - } - // Additional options as needed - } -} +createRedirectRoute({ + domains: 'old.example.com', + redirectTo: 'https://new.example.com{path}?source=redirect' +}) ``` -### Helper Functions - -Helper functions provide a cleaner syntax for creating configurations: - -```typescript -// Instead of manually specifying the type and format -const config = createDomainConfig('example.com', httpOnly({ - target: { host: 'localhost', port: 3000 } -})); - -// Available helper functions: -// - httpOnly() - For HTTP-only traffic -// - httpsPassthrough() - For SNI-based passthrough -// - tlsTerminateToHttp() - For HTTPS termination to HTTP -// - tlsTerminateToHttps() - For HTTPS termination to HTTPS -``` - -### Advanced Configuration Options - -For more complex scenarios, additional options can be specified: - -```typescript -createDomainConfig('api.example.com', tlsTerminateToHttps({ - // Target configuration with load balancing - target: { - host: ['10.0.0.10', '10.0.0.11'], // Round-robin load balancing - port: 8443 - }, - - // HTTP options - http: { - enabled: true, // Listen on HTTP port - redirectToHttps: true // Automatically redirect to HTTPS - }, - - // HTTPS/TLS options - https: { - customCert: { // Provide your own certificate - key: '-----BEGIN PRIVATE KEY-----\n...', - cert: '-----BEGIN CERTIFICATE-----\n...' - }, - forwardSni: true // Forward original SNI to backend - }, - - // Let's Encrypt ACME integration - acme: { - enabled: true, // Enable automatic certificates - production: true, // Use production Let's Encrypt - maintenance: true // Auto-renew certificates - }, - - // Security settings - security: { - allowedIps: ['10.0.0.*'], // IP allowlist (glob patterns) - blockedIps: ['1.2.3.4'], // IP blocklist - maxConnections: 100 // Connection limits - }, - - // Advanced settings - advanced: { - timeout: 30000, // Connection timeout in ms - headers: { // Custom headers to backend - 'X-Forwarded-For': '{clientIp}', - 'X-Original-Host': '{sni}' // Template variables available - }, - keepAlive: true // Keep connections alive - } -})) -``` - -### Extended Configuration Options - -#### IForwardConfig -- `type`: 'http-only' | 'https-passthrough' | 'https-terminate-to-http' | 'https-terminate-to-https' -- `target`: { host: string | string[], port: number } -- `http?`: { enabled?: boolean, redirectToHttps?: boolean, headers?: Record } -- `https?`: { customCert?: { key: string, cert: string }, forwardSni?: boolean } -- `acme?`: { enabled?: boolean, maintenance?: boolean, production?: boolean, forwardChallenges?: { host: string, port: number, useTls?: boolean } } -- `security?`: { allowedIps?: string[], blockedIps?: string[], maxConnections?: number } -- `advanced?`: { portRanges?: Array<{ from: number, to: number }>, networkProxyPort?: number, keepAlive?: boolean, timeout?: number, headers?: Record } - ## Configuration Options +### SmartProxy (IRoutedSmartProxyOptions) +- `routes` (IRouteConfig[], required) - Array of route configurations +- `defaults` (object) - Default settings for all routes +- `acme` (IAcmeOptions) - ACME certificate options +- Connection timeouts: `initialDataTimeout`, `socketTimeout`, `inactivityTimeout`, etc. +- Socket opts: `noDelay`, `keepAlive`, `enableKeepAliveProbes` +- `certProvisionFunction` (callback) - Custom certificate provisioning + ### NetworkProxy (INetworkProxyOptions) - `port` (number, required) - `backendProtocol` ('http1'|'http2', default 'http1') @@ -844,25 +918,22 @@ createDomainConfig('api.example.com', tlsTerminateToHttps({ - `useIPSets` (boolean, default true) - `qos`, `netProxyIntegration` (objects) -### Redirect / SslRedirect -- Constructor options: `httpPort`, `httpsPort`, `sslOptions`, `rules` (IRedirectRule[]) - -### SmartProxy (ISmartProxyOptions) -- `fromPort`, `toPort` (number) -- `domainConfigs` (IDomainConfig[]) - Using unified forwarding configuration -- `sniEnabled`, `preserveSourceIP` (booleans) -- `defaultAllowedIPs`, `defaultBlockedIPs` (string[]) - Default IP allowlists/blocklists -- Timeouts: `initialDataTimeout`, `socketTimeout`, `inactivityTimeout`, etc. -- Socket opts: `noDelay`, `keepAlive`, `enableKeepAliveProbes` -- `acme` (IAcmeOptions), `certProvisionFunction` (callback) -- `useNetworkProxy` (number[]), `networkProxyPort` (number) -- `globalPortRanges` (Array<{ from: number; to: number }>) - ## Troubleshooting +### SmartProxy +- If routes aren't matching as expected, check their priorities +- For domain matching issues, verify SNI extraction is working +- Use higher priority for block routes to ensure they take precedence +- Enable `enableDetailedLogging` or `enableTlsDebugLogging` for debugging + +### TLS/Certificates +- For certificate issues, check the ACME settings and domain validation +- Ensure domains are publicly accessible for Let's Encrypt validation +- For TLS handshake issues, increase `initialDataTimeout` and `maxPendingDataSize` + ### NetworkProxy - Verify ports, certificates and `rejectUnauthorized` for TLS errors -- Configure CORS or use `addDefaultHeaders` for preflight issues +- Configure CORS for preflight issues - Increase `maxConnections` or `connectionPoolSize` under load ### Port80Handler @@ -873,18 +944,6 @@ createDomainConfig('api.example.com', tlsTerminateToHttps({ - Ensure `nft` is installed and run with sufficient privileges - Use `forceCleanSlate:true` to clear conflicting rules -### Redirect / SslRedirect -- Check `fromHost`/`fromPath` patterns and Host headers -- Validate `sslOptions` key/cert correctness - -### SmartProxy & SniHandler -- Increase `initialDataTimeout`/`maxPendingDataSize` for large ClientHello -- Enable `enableTlsDebugLogging` to trace handshake -- Ensure `allowSessionTicket` and fragmentation support for resumption -- Double-check forwarding configuration to ensure correct `type` for your use case -- Use helper functions like `httpOnly()`, `httpsPassthrough()`, etc. to create correct configurations -- For IP filtering issues, check the `security.allowedIps` and `security.blockedIps` settings - ## 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/readme.plan.md b/readme.plan.md index 7f6bde3..3a2e584 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -1,4 +1,4 @@ -# SmartProxy Fully Unified Configuration Plan +# SmartProxy Fully Unified Configuration Plan (Updated) ## Project Goal Redesign SmartProxy's configuration for a more elegant, unified, and comprehensible approach by: @@ -6,6 +6,7 @@ Redesign SmartProxy's configuration for a more elegant, unified, and comprehensi 2. Eliminating the confusion between domain configs and port forwarding 3. Providing a clear, declarative API that makes the intent obvious 4. Enhancing maintainability and extensibility for future features +5. Completely removing legacy code to eliminate technical debt ## Current Issues @@ -132,177 +133,14 @@ interface ISmartProxyOptions { } ``` -### Example Configuration - -```typescript -const proxy = new SmartProxy({ - // All routing is configured in a single array - routes: [ - // Basic HTTPS server with automatic certificates - { - match: { - ports: 443, - domains: ['example.com', '*.example.com'] - }, - action: { - type: 'forward', - target: { - host: 'localhost', - port: 8080 - }, - tls: { - mode: 'terminate', - certificate: 'auto' // Use ACME - } - }, - name: 'Main HTTPS Server' - }, - - // HTTP to HTTPS redirect - { - match: { - ports: 80, - domains: ['example.com', '*.example.com'] - }, - action: { - type: 'redirect', - redirect: { - to: 'https://{domain}{path}', - status: 301 - } - }, - name: 'HTTP to HTTPS Redirect' - }, - - // Admin portal with IP restriction - { - match: { - ports: 8443, - domains: 'admin.example.com' - }, - action: { - type: 'forward', - target: { - host: 'admin-backend', - port: 3000 - }, - tls: { - mode: 'terminate', - certificate: 'auto' - }, - security: { - allowedIps: ['192.168.1.*', '10.0.0.*'], - maxConnections: 10 - } - }, - priority: 100, // Higher priority than default rules - name: 'Admin Portal' - }, - - // Port range for direct forwarding - { - match: { - ports: [{ from: 10000, to: 10010 }], - // No domains = all domains or direct IP - }, - action: { - type: 'forward', - target: { - host: 'backend-server', - port: 10000, - preservePort: true // Use same port number as incoming - }, - tls: { - mode: 'passthrough' // Direct TCP forwarding - } - }, - name: 'Dynamic Port Range' - }, - - // Path-based routing - { - match: { - ports: 443, - domains: 'api.example.com', - path: '/v1/*' - }, - action: { - type: 'forward', - target: { - host: 'api-v1-service', - port: 8001 - }, - tls: { - mode: 'terminate' - } - }, - name: 'API v1 Endpoints' - }, - - // Load balanced backend - { - match: { - ports: 443, - domains: 'app.example.com' - }, - action: { - type: 'forward', - target: { - // Round-robin load balancing - host: [ - 'app-server-1', - 'app-server-2', - 'app-server-3' - ], - port: 8080 - }, - tls: { - mode: 'terminate' - }, - advanced: { - headers: { - 'X-Served-By': '{server}', - 'X-Client-IP': '{clientIp}' - } - } - }, - name: 'Load Balanced App Servers' - } - ], - - // Global defaults - defaults: { - target: { - host: 'localhost', - port: 8080 - }, - security: { - maxConnections: 1000, - // Global security defaults - } - }, - - // ACME configuration for auto certificates - acme: { - enabled: true, - email: 'admin@example.com', - production: true, - renewThresholdDays: 30 - }, - - // Other global settings - // ... -}); -``` - -## Implementation Plan +## Revised Implementation Plan ### Phase 1: Core Design & Interface Definition 1. **Define New Core Interfaces**: - Create `IRouteConfig` interface with `match` and `action` branches - Define all sub-interfaces for matching and actions - - Update `ISmartProxyOptions` to use `routes` array + - Create new `ISmartProxyOptions` to use `routes` array exclusively - Define template variable system for dynamic values 2. **Create Helper Functions**: @@ -319,78 +157,56 @@ const proxy = new SmartProxy({ ### Phase 2: Core Implementation 1. **Create RouteManager**: - - Replaces both PortRangeManager and DomainConfigManager - - Handles all routing decisions in one place - - Provides fast lookup from port+domain+path to action - - Manages server instances for different ports + - Build a new RouteManager to replace both PortRangeManager and DomainConfigManager + - Implement port and domain matching in one unified system + - Create efficient route lookup algorithms -2. **Update ConnectionHandler**: - - Simplify to work with the unified route system - - Implement templating system for dynamic values - - Support more sophisticated routing rules +2. **Implement New ConnectionHandler**: + - Create a new ConnectionHandler built from scratch for routes + - Implement the routing logic with the new match/action pattern + - Support template processing for headers and other dynamic values 3. **Implement New SmartProxy Core**: - - Rewrite initialization to use route-based configuration - - Create network servers based on port specifications + - Create new SmartProxy implementation using routes exclusively + - Build network servers based on port specifications - Manage TLS contexts and certificates -### Phase 3: Feature Implementation +### Phase 3: Legacy Code Removal -1. **Path-Based Routing**: - - Implement HTTP path matching - - Add wildcard and regex support for paths - - Create route differentiation based on HTTP method +1. **Identify Legacy Components**: + - Create an inventory of all files and components to be removed + - Document dependencies between legacy components + - Create a removal plan that minimizes disruption -2. **Enhanced Security**: - - Implement per-route security rules - - Add authentication methods (basic, digest, etc.) - - Support for IP-based access control +2. **Remove Legacy Components**: + - Remove PortRangeManager and related code + - Remove DomainConfigManager and related code + - Remove old ConnectionHandler implementation + - Remove other legacy components -3. **TLS Management**: - - Support multiple certificate types (auto, manual, wildcard) - - Implement certificate selection based on SNI - - Support different TLS modes per route +3. **Clean Interface Adaptations**: + - Remove all legacy interfaces and types + - Update type exports to only expose route-based interfaces + - Remove any adapter or backward compatibility code -4. **Metrics & Monitoring**: - - Per-route statistics - - Named route tracking for better visibility - - Tag-based grouping of metrics - -### Phase 4: Backward Compatibility - -1. **Legacy Adapter Layer**: - - Convert old configuration format to route-based format - - Map fromPort/toPort/domainConfigs to unified routes - - Preserve all functionality during migration - -2. **API Compatibility**: - - Support both old and new configuration methods - - Add deprecation warnings when using legacy properties - - Provide migration utilities - -3. **Documentation**: - - Clear migration guide for existing users - - Examples mapping old config to new config - - Timeline for deprecation - -### Phase 5: Documentation & Examples +### Phase 4: Updated Documentation & Examples 1. **Update Core Documentation**: - - Rewrite README.md with a focus on route-based configuration + - Rewrite README.md with a focus on route-based configuration exclusively - Create interface reference documentation - Document all template variables 2. **Create Example Library**: - - Common configuration patterns + - Common configuration patterns using the new API - Complex use cases for advanced features - Infrastructure-as-code examples -3. **Add Validation Tooling**: +3. **Add Validation Tools**: - Configuration validator to check for issues - Schema definitions for IDE autocomplete - Runtime validation helpers -### Phase 6: Testing +### Phase 5: Testing 1. **Unit Tests**: - Test route matching logic @@ -398,378 +214,103 @@ const proxy = new SmartProxy({ - Test template processing 2. **Integration Tests**: - - Verify full proxy flows + - Verify full proxy flows with the new system - Test complex routing scenarios - - Check backward compatibility + - Ensure all features work as expected 3. **Performance Testing**: - Benchmark routing performance - Evaluate memory usage - Test with large numbers of routes -## Benefits of the New Approach - -1. **Truly Unified Configuration**: - - One "source of truth" for all routing - - Entire routing flow visible in a single configuration - - No overlapping or conflicting configuration systems - -2. **Declarative Intent**: - - Configuration clearly states what to match and what action to take - - Metadata provides context and documentation inline - - Easy to understand the purpose of each route - -3. **Advanced Routing Capabilities**: - - Path-based routing with pattern matching - - Client IP-based conditional routing - - Fine-grained security controls - -4. **Composable and Extensible**: - - Each route is a self-contained unit of configuration - - Routes can be grouped by tags or priority - - New match criteria or actions can be added without breaking changes - -5. **Better Developer Experience**: - - Clear, consistent configuration pattern - - Helper functions for common scenarios - - Better error messages and validation - -## Example Use Cases - -### 1. Complete Reverse Proxy with Auto SSL - -```typescript -const proxy = new SmartProxy({ - routes: [ - // HTTPS server for all domains - { - match: { ports: 443 }, - action: { - type: 'forward', - target: { host: 'localhost', port: 8080 }, - tls: { mode: 'terminate', certificate: 'auto' } - } - }, - // HTTP to HTTPS redirect - { - match: { ports: 80 }, - action: { - type: 'redirect', - redirect: { to: 'https://{domain}{path}', status: 301 } - } - } - ], - acme: { - enabled: true, - email: 'admin@example.com', - production: true - } -}); -``` - -### 2. Microservices API Gateway - -```typescript -const apiGateway = new SmartProxy({ - routes: [ - // Users API - { - match: { - ports: 443, - domains: 'api.example.com', - path: '/users/*' - }, - action: { - type: 'forward', - target: { host: 'users-service', port: 8001 }, - tls: { mode: 'terminate', certificate: 'auto' } - }, - name: 'Users Service' - }, - // Products API - { - match: { - ports: 443, - domains: 'api.example.com', - path: '/products/*' - }, - action: { - type: 'forward', - target: { host: 'products-service', port: 8002 }, - tls: { mode: 'terminate', certificate: 'auto' } - }, - name: 'Products Service' - }, - // Orders API with authentication - { - match: { - ports: 443, - domains: 'api.example.com', - path: '/orders/*' - }, - action: { - type: 'forward', - target: { host: 'orders-service', port: 8003 }, - tls: { mode: 'terminate', certificate: 'auto' }, - security: { - authentication: { - type: 'basic', - users: { 'admin': 'password' } - } - } - }, - name: 'Orders Service (Protected)' - } - ], - acme: { - enabled: true, - email: 'admin@example.com' - } -}); -``` - -### 3. Multi-Environment Setup - -```typescript -const environments = { - production: { - target: { host: 'prod-backend', port: 8080 }, - security: { maxConnections: 1000 } - }, - staging: { - target: { host: 'staging-backend', port: 8080 }, - security: { - allowedIps: ['10.0.0.*', '192.168.1.*'], - maxConnections: 100 - } - }, - development: { - target: { host: 'localhost', port: 3000 }, - security: { - allowedIps: ['127.0.0.1'], - maxConnections: 10 - } - } -}; - -// Select environment based on hostname -const proxy = new SmartProxy({ - routes: [ - // Production environment - { - match: { - ports: [80, 443], - domains: ['app.example.com', 'www.example.com'] - }, - action: { - type: 'forward', - target: environments.production.target, - tls: { mode: 'terminate', certificate: 'auto' }, - security: environments.production.security - }, - name: 'Production Environment' - }, - // Staging environment - { - match: { - ports: [80, 443], - domains: 'staging.example.com' - }, - action: { - type: 'forward', - target: environments.staging.target, - tls: { mode: 'terminate', certificate: 'auto' }, - security: environments.staging.security - }, - name: 'Staging Environment' - }, - // Development environment - { - match: { - ports: [80, 443], - domains: 'dev.example.com' - }, - action: { - type: 'forward', - target: environments.development.target, - tls: { mode: 'terminate', certificate: 'auto' }, - security: environments.development.security - }, - name: 'Development Environment' - } - ], - acme: { enabled: true, email: 'admin@example.com' } -}); -``` - ## Implementation Strategy ### Code Organization 1. **New Files**: - - `route-manager.ts` (core routing engine) - - `route-types.ts` (interface definitions) - - `route-helpers.ts` (helper functions) - - `route-matcher.ts` (matching logic) - - `template-engine.ts` (for variable substitution) + - `route-config.ts` - Core route interfaces + - `route-manager.ts` - Route matching and management + - `route-connection-handler.ts` - Connection handling with routes + - `route-smart-proxy.ts` - Main SmartProxy implementation + - `template-engine.ts` - For variable substitution -2. **Modified Files**: - - `smart-proxy.ts` (update to use route-based configuration) - - `connection-handler.ts` (simplify using route-based approach) - - Replace `port-range-manager.ts` and `domain-config-manager.ts` +2. **File Removal**: + - Remove `port-range-manager.ts` + - Remove `domain-config-manager.ts` + - Remove legacy interfaces and adapter code + - Remove backward compatibility shims -### Backward Compatibility +### Transition Strategy -The backward compatibility layer will convert the legacy configuration to the new format: +1. **Breaking Change Approach**: + - This will be a major version update with breaking changes + - No backward compatibility will be maintained + - Clear migration documentation will guide users to the new API -```typescript -function convertLegacyConfig(legacy: ILegacySmartProxyOptions): ISmartProxyOptions { - const routes: IRouteConfig[] = []; - - // Convert main port configuration - if (legacy.fromPort) { - // Add main listener for fromPort - routes.push({ - match: { ports: legacy.fromPort }, - action: { - type: 'forward', - target: { host: legacy.targetIP || 'localhost', port: legacy.toPort }, - tls: { mode: legacy.sniEnabled ? 'passthrough' : 'terminate' } - }, - name: 'Main Listener (Legacy)' - }); - - // If ACME is enabled, add HTTP listener for challenges - if (legacy.acme?.enabled) { - routes.push({ - match: { ports: 80 }, - action: { - type: 'forward', - target: { host: 'localhost', port: 80 }, - // Special flag for ACME handler - acmeEnabled: true - }, - name: 'ACME Challenge Handler (Legacy)' - }); - } - } - - // Convert domain configs - if (legacy.domainConfigs) { - for (const domainConfig of legacy.domainConfigs) { - const { domains, forwarding } = domainConfig; - - // Determine action based on forwarding type - let action: Partial = { - type: 'forward', - target: { - host: forwarding.target.host, - port: forwarding.target.port - } - }; - - // Set TLS mode based on forwarding type - switch (forwarding.type) { - case 'http-only': - // No TLS - break; - case 'https-passthrough': - action.tls = { mode: 'passthrough' }; - break; - case 'https-terminate-to-http': - action.tls = { - mode: 'terminate', - certificate: forwarding.https?.customCert || 'auto' - }; - break; - case 'https-terminate-to-https': - action.tls = { - mode: 'terminate-and-reencrypt', - certificate: forwarding.https?.customCert || 'auto' - }; - break; - } - - // Security settings - if (forwarding.security) { - action.security = forwarding.security; - } - - // Add HTTP redirect if needed - if (forwarding.http?.redirectToHttps) { - routes.push({ - match: { ports: 80, domains }, - action: { - type: 'redirect', - redirect: { to: 'https://{domain}{path}', status: 301 } - }, - name: `HTTP Redirect for ${domains.join(', ')} (Legacy)` - }); - } - - // Add main route - routes.push({ - match: { - ports: forwarding.type.startsWith('https') ? 443 : 80, - domains - }, - action: action as IRouteAction, - name: `Route for ${domains.join(', ')} (Legacy)` - }); - - // Add port ranges if specified - if (forwarding.advanced?.portRanges) { - for (const range of forwarding.advanced.portRanges) { - routes.push({ - match: { - ports: { from: range.from, to: range.to }, - domains - }, - action: action as IRouteAction, - name: `Port Range ${range.from}-${range.to} for ${domains.join(', ')} (Legacy)` - }); - } - } - } - } - - // Global port ranges - if (legacy.globalPortRanges) { - for (const range of legacy.globalPortRanges) { - routes.push({ - match: { ports: { from: range.from, to: range.to } }, - action: { - type: 'forward', - target: { - host: legacy.targetIP || 'localhost', - port: legacy.forwardAllGlobalRanges ? 0 : legacy.toPort, - preservePort: !!legacy.forwardAllGlobalRanges - }, - tls: { mode: 'passthrough' } - }, - name: `Global Port Range ${range.from}-${range.to} (Legacy)` - }); - } - } - - return { - routes, - defaults: { - target: { - host: legacy.targetIP || 'localhost', - port: legacy.toPort - } - }, - acme: legacy.acme - }; -} -``` +2. **Package Structure**: + - `@push.rocks/smartproxy` package will be updated to v14.0.0 + - Legacy code fully removed, only route-based API exposed + - Support documentation provided for migration -## Success Criteria +3. **Migration Documentation**: + - Provide a migration guide with examples + - Show equivalent route configurations for common legacy patterns + - Offer code transformation helpers for complex setups -- All existing functionality works with the new route-based configuration -- Performance is equal or better than the current implementation -- Configuration is more intuitive and easier to understand -- New features can be added without breaking existing code -- Code is more maintainable with clear separation of concerns -- Migration from old configuration to new is straightforward \ No newline at end of file +## Benefits of the Clean Approach + +1. **Reduced Complexity**: + - No overlapping or conflicting configuration systems + - No dual maintenance of backward compatibility code + - Simplified internal architecture + +2. **Cleaner Code Base**: + - Removal of technical debt + - Better separation of concerns + - More maintainable codebase + +3. **Better User Experience**: + - Consistent, predictable API + - No confusing overlapping options + - Clear documentation of one approach, not two + +4. **Future-Proof Design**: + - Easier to extend with new features + - Better performance without legacy overhead + - Cleaner foundation for future enhancements + +## Migration Support + +While we're removing backward compatibility from the codebase, we'll provide extensive migration support: + +1. **Migration Guide**: + - Detailed documentation on moving from legacy to route-based config + - Pattern-matching examples for all common use cases + - Troubleshooting guide for common migration issues + +2. **Conversion Tool**: + - Provide a standalone one-time conversion tool + - Takes legacy configuration and outputs route-based equivalents + - Will not be included in the main package to avoid bloat + +3. **Version Policy**: + - Maintain the legacy version (13.x) for security updates + - Make the route-based version a clear major version change (14.0.0) + - Clearly communicate the breaking changes + +## Timeline and Versioning + +1. **Development**: + - Develop route-based implementation in a separate branch + - Complete full test coverage of new implementation + - Ensure documentation is complete + +2. **Release**: + - Release as version 14.0.0 + - Clearly mark as breaking change + - Provide migration guide at release time + +3. **Support**: + - Offer extended support for migration questions + - Consider maintaining security updates for v13.x for 6 months + - Focus active development on route-based version only \ No newline at end of file diff --git a/test/test.route-config.ts b/test/test.route-config.ts new file mode 100644 index 0000000..133001f --- /dev/null +++ b/test/test.route-config.ts @@ -0,0 +1,181 @@ +/** + * Tests for the new route-based configuration system + */ +import { expect, tap } from '@push.rocks/tapbundle'; + +// Import from core modules +import { + SmartProxy, + createHttpRoute, + createHttpsRoute, + createPassthroughRoute, + createRedirectRoute, + createHttpToHttpsRedirect, + createHttpsServer, + createLoadBalancerRoute +} from '../ts/proxies/smart-proxy/index.js'; + +// Import test helpers +import { getCertificate } from './helpers/certificates.js'; + +tap.test('Routes: Should create basic HTTP route', async () => { + // Create a simple HTTP route + const httpRoute = createHttpRoute({ + ports: 8080, + domains: 'example.com', + target: { + host: 'localhost', + port: 3000 + }, + name: 'Basic HTTP Route' + }); + + // Validate the route configuration + expect(httpRoute.match.ports).to.equal(8080); + expect(httpRoute.match.domains).to.equal('example.com'); + expect(httpRoute.action.type).to.equal('forward'); + expect(httpRoute.action.target?.host).to.equal('localhost'); + expect(httpRoute.action.target?.port).to.equal(3000); + expect(httpRoute.name).to.equal('Basic HTTP Route'); +}); + +tap.test('Routes: Should create HTTPS route with TLS termination', async () => { + // Create an HTTPS route with TLS termination + const httpsRoute = createHttpsRoute({ + domains: 'secure.example.com', + target: { + host: 'localhost', + port: 8080 + }, + certificate: 'auto', + name: 'HTTPS Route' + }); + + // Validate the route configuration + expect(httpsRoute.match.ports).to.equal(443); // Default HTTPS port + expect(httpsRoute.match.domains).to.equal('secure.example.com'); + expect(httpsRoute.action.type).to.equal('forward'); + expect(httpsRoute.action.tls?.mode).to.equal('terminate'); + expect(httpsRoute.action.tls?.certificate).to.equal('auto'); + expect(httpsRoute.action.target?.host).to.equal('localhost'); + expect(httpsRoute.action.target?.port).to.equal(8080); + expect(httpsRoute.name).to.equal('HTTPS Route'); +}); + +tap.test('Routes: Should create HTTP to HTTPS redirect', async () => { + // Create an HTTP to HTTPS redirect + const redirectRoute = createHttpToHttpsRedirect({ + domains: 'example.com', + statusCode: 301 + }); + + // Validate the route configuration + expect(redirectRoute.match.ports).to.equal(80); + expect(redirectRoute.match.domains).to.equal('example.com'); + expect(redirectRoute.action.type).to.equal('redirect'); + expect(redirectRoute.action.redirect?.to).to.equal('https://{domain}{path}'); + expect(redirectRoute.action.redirect?.status).to.equal(301); +}); + +tap.test('Routes: Should create complete HTTPS server with redirects', async () => { + // Create a complete HTTPS server setup + const routes = createHttpsServer({ + domains: 'example.com', + target: { + host: 'localhost', + port: 8080 + }, + certificate: 'auto', + addHttpRedirect: true + }); + + // Validate that we got two routes (HTTPS route and HTTP redirect) + expect(routes.length).to.equal(2); + + // Validate HTTPS route + const httpsRoute = routes[0]; + expect(httpsRoute.match.ports).to.equal(443); + expect(httpsRoute.match.domains).to.equal('example.com'); + expect(httpsRoute.action.type).to.equal('forward'); + expect(httpsRoute.action.tls?.mode).to.equal('terminate'); + + // Validate HTTP redirect route + const redirectRoute = routes[1]; + expect(redirectRoute.match.ports).to.equal(80); + expect(redirectRoute.action.type).to.equal('redirect'); + expect(redirectRoute.action.redirect?.to).to.equal('https://{domain}{path}'); +}); + +tap.test('Routes: Should create load balancer route', async () => { + // Create a load balancer route + const lbRoute = createLoadBalancerRoute({ + domains: 'app.example.com', + targets: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], + targetPort: 8080, + tlsMode: 'terminate', + certificate: 'auto', + name: 'Load Balanced Route' + }); + + // Validate the route configuration + expect(lbRoute.match.domains).to.equal('app.example.com'); + expect(lbRoute.action.type).to.equal('forward'); + expect(Array.isArray(lbRoute.action.target?.host)).to.equal(true); + expect((lbRoute.action.target?.host as string[]).length).to.equal(3); + expect((lbRoute.action.target?.host as string[])[0]).to.equal('10.0.0.1'); + expect(lbRoute.action.target?.port).to.equal(8080); + expect(lbRoute.action.tls?.mode).to.equal('terminate'); +}); + +tap.test('SmartProxy: Should create instance with route-based config', async () => { + // Create TLS certificates for testing + const cert = await getCertificate(); + + // Create a SmartProxy instance with route-based configuration + const proxy = new SmartProxy({ + routes: [ + createHttpRoute({ + ports: 8080, + domains: 'example.com', + target: { + host: 'localhost', + port: 3000 + }, + name: 'HTTP Route' + }), + createHttpsRoute({ + domains: 'secure.example.com', + target: { + host: 'localhost', + port: 8443 + }, + certificate: { + key: cert.key, + cert: cert.cert + }, + name: 'HTTPS Route' + }) + ], + defaults: { + target: { + host: 'localhost', + port: 8080 + }, + security: { + allowedIPs: ['127.0.0.1', '192.168.0.*'], + maxConnections: 100 + } + }, + // Additional settings + initialDataTimeout: 10000, + inactivityTimeout: 300000, + enableDetailedLogging: true + }); + + // Simply verify the instance was created successfully + expect(proxy).to.be.an('object'); + expect(proxy.start).to.be.a('function'); + expect(proxy.stop).to.be.a('function'); +}); + +export default tap.start(); \ No newline at end of file diff --git a/test/test.smartproxy.ts b/test/test.smartproxy.ts index ba1234b..4fde61b 100644 --- a/test/test.smartproxy.ts +++ b/test/test.smartproxy.ts @@ -66,13 +66,25 @@ function createTestClient(port: number, data: string): Promise { tap.test('setup port proxy test environment', async () => { testServer = await createTestServer(TEST_SERVER_PORT); smartProxy = new SmartProxy({ - fromPort: PROXY_PORT, - toPort: TEST_SERVER_PORT, - targetIP: 'localhost', - domainConfigs: [], - sniEnabled: false, - defaultAllowedIPs: ['127.0.0.1'], - globalPortRanges: [] + routes: [ + { + match: { + ports: PROXY_PORT + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: TEST_SERVER_PORT + } + } + } + ], + defaults: { + security: { + allowedIPs: ['127.0.0.1'] + } + } }); allProxies.push(smartProxy); // Track this proxy }); @@ -92,13 +104,25 @@ tap.test('should forward TCP connections and data to localhost', async () => { // Test proxy with a custom target host. tap.test('should forward TCP connections to custom host', async () => { const customHostProxy = new SmartProxy({ - fromPort: PROXY_PORT + 1, - toPort: TEST_SERVER_PORT, - targetIP: '127.0.0.1', - domainConfigs: [], - sniEnabled: false, - defaultAllowedIPs: ['127.0.0.1'], - globalPortRanges: [] + routes: [ + { + match: { + ports: PROXY_PORT + 1 + }, + action: { + type: 'forward', + target: { + host: '127.0.0.1', + port: TEST_SERVER_PORT + } + } + } + ], + defaults: { + security: { + allowedIPs: ['127.0.0.1'] + } + } }); allProxies.push(customHostProxy); // Track this proxy @@ -125,14 +149,25 @@ tap.test('should forward connections to custom IP', async () => { // We're simulating routing to a different IP by using a different port // This tests the core functionality without requiring multiple IPs const domainProxy = new SmartProxy({ - fromPort: forcedProxyPort, // 4003 - Listen on this port - toPort: targetServerPort, // 4200 - Forward to this port - targetIP: '127.0.0.1', // Always use localhost (works in Docker) - domainConfigs: [], // No domain configs to confuse things - sniEnabled: false, - defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], // Allow localhost - // We'll test the functionality WITHOUT port ranges this time - globalPortRanges: [] + routes: [ + { + match: { + ports: forcedProxyPort + }, + action: { + type: 'forward', + target: { + host: '127.0.0.1', + port: targetServerPort + } + } + } + ], + defaults: { + security: { + allowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'] + } + } }); allProxies.push(domainProxy); // Track this proxy @@ -208,22 +243,46 @@ tap.test('should stop port proxy', async () => { tap.test('should support optional source IP preservation in chained proxies', async () => { // Chained proxies without IP preservation. const firstProxyDefault = new SmartProxy({ - fromPort: PROXY_PORT + 4, - toPort: PROXY_PORT + 5, - targetIP: 'localhost', - domainConfigs: [], - sniEnabled: false, - defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], - globalPortRanges: [] + routes: [ + { + match: { + ports: PROXY_PORT + 4 + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: PROXY_PORT + 5 + } + } + } + ], + defaults: { + security: { + allowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'] + } + } }); const secondProxyDefault = new SmartProxy({ - fromPort: PROXY_PORT + 5, - toPort: TEST_SERVER_PORT, - targetIP: 'localhost', - domainConfigs: [], - sniEnabled: false, - defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], - globalPortRanges: [] + routes: [ + { + match: { + ports: PROXY_PORT + 5 + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: TEST_SERVER_PORT + } + } + } + ], + defaults: { + security: { + allowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'] + } + } }); allProxies.push(firstProxyDefault, secondProxyDefault); // Track these proxies @@ -243,24 +302,50 @@ tap.test('should support optional source IP preservation in chained proxies', as // Chained proxies with IP preservation. const firstProxyPreserved = new SmartProxy({ - fromPort: PROXY_PORT + 6, - toPort: PROXY_PORT + 7, - targetIP: 'localhost', - domainConfigs: [], - sniEnabled: false, - defaultAllowedIPs: ['127.0.0.1'], - preserveSourceIP: true, - globalPortRanges: [] + routes: [ + { + match: { + ports: PROXY_PORT + 6 + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: PROXY_PORT + 7 + } + } + } + ], + defaults: { + security: { + allowedIPs: ['127.0.0.1'] + }, + preserveSourceIP: true + }, + preserveSourceIP: true }); const secondProxyPreserved = new SmartProxy({ - fromPort: PROXY_PORT + 7, - toPort: TEST_SERVER_PORT, - targetIP: 'localhost', - domainConfigs: [], - sniEnabled: false, - defaultAllowedIPs: ['127.0.0.1'], - preserveSourceIP: true, - globalPortRanges: [] + routes: [ + { + match: { + ports: PROXY_PORT + 7 + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: TEST_SERVER_PORT + } + } + } + ], + defaults: { + security: { + allowedIPs: ['127.0.0.1'] + }, + preserveSourceIP: true + }, + preserveSourceIP: true }); allProxies.push(firstProxyPreserved, secondProxyPreserved); // Track these proxies diff --git a/ts/proxies/smart-proxy/index.ts b/ts/proxies/smart-proxy/index.ts index 8642efc..eabf015 100644 --- a/ts/proxies/smart-proxy/index.ts +++ b/ts/proxies/smart-proxy/index.ts @@ -1,5 +1,7 @@ /** * SmartProxy implementation + * + * Version 14.0.0: Unified Route-Based Configuration API */ // Re-export models export * from './models/index.js'; @@ -7,12 +9,26 @@ export * from './models/index.js'; // Export the main SmartProxy class export { SmartProxy } from './smart-proxy.js'; -// Export supporting classes +// Export core supporting classes export { ConnectionManager } from './connection-manager.js'; export { SecurityManager } from './security-manager.js'; -export { DomainConfigManager } from './domain-config-manager.js'; export { TimeoutManager } from './timeout-manager.js'; export { TlsManager } from './tls-manager.js'; export { NetworkProxyBridge } from './network-proxy-bridge.js'; -export { PortRangeManager } from './port-range-manager.js'; -export { ConnectionHandler } from './connection-handler.js'; + +// Export route-based components +export { RouteManager } from './route-manager.js'; +export { RouteConnectionHandler } from './route-connection-handler.js'; + +// Export route helpers for configuration +export { + createRoute, + createHttpRoute, + createHttpsRoute, + createPassthroughRoute, + createRedirectRoute, + createHttpToHttpsRedirect, + createBlockRoute, + createLoadBalancerRoute, + createHttpsServer +} from './route-helpers.js'; diff --git a/ts/proxies/smart-proxy/models/index.ts b/ts/proxies/smart-proxy/models/index.ts index 33a81b2..2e5380f 100644 --- a/ts/proxies/smart-proxy/models/index.ts +++ b/ts/proxies/smart-proxy/models/index.ts @@ -2,3 +2,7 @@ * SmartProxy models */ export * from './interfaces.js'; +export * from './route-types.js'; + +// Re-export IRoutedSmartProxyOptions explicitly to avoid ambiguity +export type { ISmartProxyOptions as IRoutedSmartProxyOptions } from './interfaces.js'; diff --git a/ts/proxies/smart-proxy/models/interfaces.ts b/ts/proxies/smart-proxy/models/interfaces.ts index edbba8a..c2ad26b 100644 --- a/ts/proxies/smart-proxy/models/interfaces.ts +++ b/ts/proxies/smart-proxy/models/interfaces.ts @@ -1,5 +1,7 @@ import * as plugins from '../../../plugins.js'; -import type { IForwardConfig } from '../../../forwarding/config/forwarding-types.js'; +import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js'; +import type { IRouteConfig } from './route-types.js'; +import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js'; /** * Provision object for static or HTTP-01 certificate @@ -7,27 +9,102 @@ import type { IForwardConfig } from '../../../forwarding/config/forwarding-types export type TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'; /** - * Domain configuration with forwarding configuration + * Alias for backward compatibility with code that uses IRoutedSmartProxyOptions + */ +export type IRoutedSmartProxyOptions = ISmartProxyOptions; + +/** + * Legacy domain configuration interface for backward compatibility */ export interface IDomainConfig { - domains: string[]; // Glob patterns for domain(s) - forwarding: IForwardConfig; // Unified forwarding configuration + domains: string[]; + forwarding: { + type: TForwardingType; + target: { + host: string | string[]; + port: number; + }; + acme?: { + enabled?: boolean; + maintenance?: boolean; + production?: boolean; + forwardChallenges?: { + host: string; + port: number; + useTls?: boolean; + }; + }; + http?: { + enabled?: boolean; + redirectToHttps?: boolean; + headers?: Record; + }; + https?: { + customCert?: { + key: string; + cert: string; + }; + forwardSni?: boolean; + }; + security?: { + allowedIps?: string[]; + blockedIps?: string[]; + maxConnections?: number; + }; + advanced?: { + portRanges?: Array<{ from: number; to: number }>; + networkProxyPort?: number; + keepAlive?: boolean; + timeout?: number; + headers?: Record; + }; + }; } /** - * Configuration options for the SmartProxy + * Helper functions for type checking - now always assume route-based config + */ +export function isLegacyOptions(options: any): boolean { + return false; // No longer supporting legacy options +} + +export function isRoutedOptions(options: any): boolean { + return true; // Always assume routed options +} + +/** + * SmartProxy configuration options */ -import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js'; export interface ISmartProxyOptions { - fromPort: number; - toPort: number; - targetIP?: string; // Global target host to proxy to, defaults to 'localhost' - domainConfigs: IDomainConfig[]; + // The unified configuration array (required) + routes: IRouteConfig[]; + + // Legacy options for backward compatibility + fromPort?: number; + toPort?: number; sniEnabled?: boolean; + domainConfigs?: IDomainConfig[]; + targetIP?: string; defaultAllowedIPs?: string[]; defaultBlockedIPs?: string[]; + globalPortRanges?: Array<{ from: number; to: number }>; + forwardAllGlobalRanges?: boolean; preserveSourceIP?: boolean; + // Global/default settings + defaults?: { + target?: { + host: string; // Default host to use when not specified in routes + port: number; // Default port to use when not specified in routes + }; + security?: { + allowedIPs?: string[]; // Default allowed IPs + blockedIPs?: string[]; // Default blocked IPs + maxConnections?: number; // Default max connections + }; + preserveSourceIP?: boolean; // Default source IP preservation + }; + // TLS options pfx?: Buffer; key?: string | Buffer | Array; @@ -50,8 +127,6 @@ export interface ISmartProxyOptions { inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h) gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown - globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges - forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP // Socket optimization settings noDelay?: boolean; // Disable Nagle's algorithm (default: true) @@ -108,6 +183,9 @@ export interface IConnectionRecord { pendingData: Buffer[]; // Buffer to hold data during connection setup pendingDataSize: number; // Track total size of pending data + // Legacy property for backward compatibility + domainConfig?: IDomainConfig; + // Enhanced tracking fields bytesReceived: number; // Total bytes received bytesSent: number; // Total bytes sent @@ -116,7 +194,7 @@ export interface IConnectionRecord { isTLS: boolean; // Whether this connection is a TLS connection tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete hasReceivedInitialData: boolean; // Whether initial data has been received - domainConfig?: IDomainConfig; // Associated domain config for this connection + routeConfig?: IRouteConfig; // Associated route config for this connection // Keep-alive tracking hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection diff --git a/ts/proxies/smart-proxy/models/route-types.ts b/ts/proxies/smart-proxy/models/route-types.ts new file mode 100644 index 0000000..21814a8 --- /dev/null +++ b/ts/proxies/smart-proxy/models/route-types.ts @@ -0,0 +1,184 @@ +import * as plugins from '../../../plugins.js'; +import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js'; +import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js'; + +/** + * Supported action types for route configurations + */ +export type TRouteActionType = 'forward' | 'redirect' | 'block'; + +/** + * TLS handling modes for route configurations + */ +export type TTlsMode = 'passthrough' | 'terminate' | 'terminate-and-reencrypt'; + +/** + * Port range specification format + */ +export type TPortRange = number | number[] | Array<{ from: number; to: number }>; + +/** + * Route match criteria for incoming requests + */ +export interface IRouteMatch { + // Listen on these ports (required) + ports: TPortRange; + + // Optional domain patterns to match (default: all domains) + domains?: string | string[]; + + // Advanced matching criteria + path?: string; // Match specific paths + clientIp?: string[]; // Match specific client IPs + tlsVersion?: string[]; // Match specific TLS versions +} + +/** + * Target configuration for forwarding + */ +export interface IRouteTarget { + host: string | string[]; // Support single host or round-robin + port: number; + preservePort?: boolean; // Use incoming port as target port +} + +/** + * TLS configuration for route actions + */ +export interface IRouteTls { + mode: TTlsMode; + certificate?: 'auto' | { // Auto = use ACME + key: string; + cert: string; + }; +} + +/** + * Redirect configuration for route actions + */ +export interface IRouteRedirect { + to: string; // URL or template with {domain}, {port}, etc. + status: 301 | 302 | 307 | 308; +} + +/** + * Security options for route actions + */ +export interface IRouteSecurity { + allowedIps?: string[]; + blockedIps?: string[]; + maxConnections?: number; + authentication?: { + type: 'basic' | 'digest' | 'oauth'; + // Auth-specific options would go here + }; +} + +/** + * Advanced options for route actions + */ +export interface IRouteAdvanced { + timeout?: number; + headers?: Record; + keepAlive?: boolean; + // Additional advanced options would go here +} + +/** + * Action configuration for route handling + */ +export interface IRouteAction { + // Basic routing + type: TRouteActionType; + + // Target for forwarding + target?: IRouteTarget; + + // TLS handling + tls?: IRouteTls; + + // For redirects + redirect?: IRouteRedirect; + + // Security options + security?: IRouteSecurity; + + // Advanced options + advanced?: IRouteAdvanced; +} + +/** + * The core unified configuration interface + */ +export interface IRouteConfig { + // What to match + match: IRouteMatch; + + // What to do with matched traffic + action: IRouteAction; + + // Optional metadata + 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 +} + +/** + * Unified SmartProxy options with routes-based configuration + */ +export interface IRoutedSmartProxyOptions { + // The unified configuration array (required) + routes: IRouteConfig[]; + + // Global/default settings + defaults?: { + target?: { + host: string; + port: number; + }; + security?: IRouteSecurity; + tls?: IRouteTls; + // ...other defaults + }; + + // Other global settings remain (acme, etc.) + acme?: IAcmeOptions; + + // Connection timeouts and other global settings + initialDataTimeout?: number; + socketTimeout?: number; + inactivityCheckInterval?: number; + maxConnectionLifetime?: number; + inactivityTimeout?: number; + gracefulShutdownTimeout?: number; + + // Socket optimization settings + noDelay?: boolean; + keepAlive?: boolean; + keepAliveInitialDelay?: number; + maxPendingDataSize?: number; + + // Enhanced features + disableInactivityCheck?: boolean; + enableKeepAliveProbes?: boolean; + enableDetailedLogging?: boolean; + enableTlsDebugLogging?: boolean; + enableRandomizedTimeouts?: boolean; + allowSessionTicket?: boolean; + + // Rate limiting and security + maxConnectionsPerIP?: number; + connectionRateLimitPerMinute?: number; + + // Enhanced keep-alive settings + keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; + keepAliveInactivityMultiplier?: number; + extendedKeepAliveLifetime?: number; + + /** + * Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges, + * or a static certificate object for immediate provisioning. + */ + certProvisionFunction?: (domain: string) => Promise; +} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/route-connection-handler.ts b/ts/proxies/smart-proxy/route-connection-handler.ts new file mode 100644 index 0000000..c08a0d4 --- /dev/null +++ b/ts/proxies/smart-proxy/route-connection-handler.ts @@ -0,0 +1,1117 @@ +import * as plugins from '../../plugins.js'; +import type { + IConnectionRecord, + IDomainConfig, + ISmartProxyOptions +} from './models/interfaces.js'; +import { + isRoutedOptions, + isLegacyOptions +} from './models/interfaces.js'; +import type { + IRouteConfig, + IRouteAction +} from './models/route-types.js'; +import { ConnectionManager } from './connection-manager.js'; +import { SecurityManager } from './security-manager.js'; +import { DomainConfigManager } from './domain-config-manager.js'; +import { TlsManager } from './tls-manager.js'; +import { NetworkProxyBridge } from './network-proxy-bridge.js'; +import { TimeoutManager } from './timeout-manager.js'; +import { RouteManager } from './route-manager.js'; +import type { ForwardingHandler } from '../../forwarding/handlers/base-handler.js'; +import type { TForwardingType } from '../../forwarding/config/forwarding-types.js'; + +/** + * Handles new connection processing and setup logic with support for route-based configuration + */ +export class RouteConnectionHandler { + private settings: ISmartProxyOptions; + + constructor( + settings: ISmartProxyOptions, + private connectionManager: ConnectionManager, + private securityManager: SecurityManager, + private domainConfigManager: DomainConfigManager, + private tlsManager: TlsManager, + private networkProxyBridge: NetworkProxyBridge, + private timeoutManager: TimeoutManager, + private routeManager: RouteManager + ) { + this.settings = settings; + } + + /** + * Handle a new incoming connection + */ + public handleConnection(socket: plugins.net.Socket): void { + const remoteIP = socket.remoteAddress || ''; + const localPort = socket.localPort || 0; + + // Validate IP against rate limits and connection limits + const ipValidation = this.securityManager.validateIP(remoteIP); + if (!ipValidation.allowed) { + console.log(`Connection rejected from ${remoteIP}: ${ipValidation.reason}`); + socket.end(); + socket.destroy(); + return; + } + + // Create a new connection record + const record = this.connectionManager.createConnection(socket); + const connectionId = record.id; + + // Apply socket optimizations + socket.setNoDelay(this.settings.noDelay); + + // Apply keep-alive settings if enabled + if (this.settings.keepAlive) { + socket.setKeepAlive(true, this.settings.keepAliveInitialDelay); + record.hasKeepAlive = true; + + // Apply enhanced TCP keep-alive options if enabled + if (this.settings.enableKeepAliveProbes) { + try { + // These are platform-specific and may not be available + if ('setKeepAliveProbes' in socket) { + (socket as any).setKeepAliveProbes(10); + } + if ('setKeepAliveInterval' in socket) { + (socket as any).setKeepAliveInterval(1000); + } + } catch (err) { + // Ignore errors - these are optional enhancements + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`); + } + } + } + } + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` + + `Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` + + `Active connections: ${this.connectionManager.getConnectionCount()}` + ); + } else { + console.log( + `New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionManager.getConnectionCount()}` + ); + } + + // Start TLS SNI handling + this.handleTlsConnection(socket, record); + } + + /** + * Handle a connection and wait for TLS handshake for SNI extraction if needed + */ + private handleTlsConnection(socket: plugins.net.Socket, record: IConnectionRecord): void { + const connectionId = record.id; + const localPort = record.localPort; + let initialDataReceived = false; + + // Set an initial timeout for handshake data + let initialTimeout: NodeJS.Timeout | null = setTimeout(() => { + if (!initialDataReceived) { + console.log( + `[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}` + ); + + // Add a grace period + setTimeout(() => { + if (!initialDataReceived) { + console.log(`[${connectionId}] Final initial data timeout after grace period`); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'initial_timeout'; + this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout'); + } + socket.end(); + this.connectionManager.cleanupConnection(record, 'initial_timeout'); + } + }, 30000); + } + }, this.settings.initialDataTimeout!); + + // Make sure timeout doesn't keep the process alive + if (initialTimeout.unref) { + initialTimeout.unref(); + } + + // Set up error handler + socket.on('error', this.connectionManager.handleError('incoming', record)); + + // First data handler to capture initial TLS handshake + socket.once('data', (chunk: Buffer) => { + // Clear the initial timeout since we've received data + if (initialTimeout) { + clearTimeout(initialTimeout); + initialTimeout = null; + } + + initialDataReceived = true; + record.hasReceivedInitialData = true; + + // Block non-TLS connections on port 443 + if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) { + console.log( + `[${connectionId}] Non-TLS connection detected on port 443. ` + + `Terminating connection - only TLS traffic is allowed on standard HTTPS port.` + ); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'non_tls_blocked'; + this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked'); + } + socket.end(); + this.connectionManager.cleanupConnection(record, 'non_tls_blocked'); + return; + } + + // Check if this looks like a TLS handshake + let serverName = ''; + if (this.tlsManager.isTlsHandshake(chunk)) { + record.isTLS = true; + + // Check for ClientHello to extract SNI + if (this.tlsManager.isClientHello(chunk)) { + // Create connection info for SNI extraction + const connInfo = { + sourceIp: record.remoteIP, + sourcePort: socket.remotePort || 0, + destIp: socket.localAddress || '', + destPort: socket.localPort || 0, + }; + + // Extract SNI + serverName = this.tlsManager.extractSNI(chunk, connInfo) || ''; + + // Lock the connection to the negotiated SNI + record.lockedDomain = serverName; + + // Check if we should reject connections without SNI + if (!serverName && this.settings.allowSessionTicket === false) { + console.log(`[${connectionId}] No SNI detected in TLS ClientHello; sending TLS alert.`); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'session_ticket_blocked_no_sni'; + this.connectionManager.incrementTerminationStat('incoming', 'session_ticket_blocked_no_sni'); + } + const alert = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x70]); + try { + socket.cork(); + socket.write(alert); + socket.uncork(); + socket.end(); + } catch { + socket.end(); + } + this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni'); + return; + } + + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] TLS connection with SNI: ${serverName || '(empty)'}`); + } + } + } + + // Find the appropriate route for this connection + this.routeConnection(socket, record, serverName, chunk); + }); + } + + /** + * Route the connection based on match criteria + */ + private routeConnection( + socket: plugins.net.Socket, + record: IConnectionRecord, + serverName: string, + initialChunk?: Buffer + ): void { + const connectionId = record.id; + const localPort = record.localPort; + const remoteIP = record.remoteIP; + + // Find matching route + const routeMatch = this.routeManager.findMatchingRoute({ + port: localPort, + domain: serverName, + clientIp: remoteIP, + path: undefined, // We don't have path info at this point + tlsVersion: undefined // We don't extract TLS version yet + }); + + if (!routeMatch) { + console.log(`[${connectionId}] No route found for ${serverName || 'connection'} on port ${localPort}`); + + // Fall back to legacy matching if we're using a hybrid configuration + const domainConfig = serverName + ? this.domainConfigManager.findDomainConfig(serverName) + : this.domainConfigManager.findDomainConfigForPort(localPort); + + if (domainConfig) { + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Using legacy domain configuration for ${serverName || 'port ' + localPort}`); + } + + // Associate this domain config with the connection + record.domainConfig = domainConfig; + + // Handle the connection using the legacy setup + return this.handleLegacyConnection(socket, record, serverName, domainConfig, initialChunk); + } + + // No matching route or domain config, use default/fallback handling + console.log(`[${connectionId}] Using default route handling for connection`); + + // Check default security settings + const defaultSecuritySettings = this.settings.defaults?.security; + if (defaultSecuritySettings) { + if (defaultSecuritySettings.allowedIPs && defaultSecuritySettings.allowedIPs.length > 0) { + const isAllowed = this.securityManager.isIPAuthorized( + remoteIP, + defaultSecuritySettings.allowedIPs, + defaultSecuritySettings.blockedIPs || [] + ); + + if (!isAllowed) { + console.log(`[${connectionId}] IP ${remoteIP} not in default allowed list`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'ip_blocked'); + return; + } + } + } else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) { + // Legacy default IP restrictions + const isAllowed = this.securityManager.isIPAuthorized( + remoteIP, + this.settings.defaultAllowedIPs, + this.settings.defaultBlockedIPs || [] + ); + + if (!isAllowed) { + console.log(`[${connectionId}] IP ${remoteIP} not in default allowed list`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'ip_blocked'); + return; + } + } + + // Setup direct connection with default settings + let targetHost: string; + let targetPort: number; + + if (isRoutedOptions(this.settings) && this.settings.defaults?.target) { + // Use defaults from routed configuration + targetHost = this.settings.defaults.target.host; + targetPort = this.settings.defaults.target.port; + } else { + // Fall back to legacy settings + targetHost = this.settings.targetIP || 'localhost'; + targetPort = this.settings.toPort; + } + + return this.setupDirectConnection( + socket, + record, + undefined, + serverName, + initialChunk, + undefined, + targetHost, + targetPort + ); + } + + // A matching route was found + const route = routeMatch.route; + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Route matched: "${route.name || 'unnamed'}" for ${serverName || 'connection'} on port ${localPort}` + ); + } + + // Handle the route based on its action type + switch (route.action.type) { + case 'forward': + return this.handleForwardAction(socket, record, route, initialChunk); + + case 'redirect': + return this.handleRedirectAction(socket, record, route); + + case 'block': + return this.handleBlockAction(socket, record, route); + + default: + console.log(`[${connectionId}] Unknown action type: ${(route.action as any).type}`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'unknown_action'); + } + } + + /** + * Handle a forward action for a route + */ + private handleForwardAction( + socket: plugins.net.Socket, + record: IConnectionRecord, + route: IRouteConfig, + initialChunk?: Buffer + ): void { + const connectionId = record.id; + const action = route.action; + + // We should have a target configuration for forwarding + if (!action.target) { + console.log(`[${connectionId}] Forward action missing target configuration`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'missing_target'); + return; + } + + // Determine if this needs TLS handling + if (action.tls) { + switch (action.tls.mode) { + case 'passthrough': + // For TLS passthrough, just forward directly + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Using TLS passthrough to ${action.target.host}`); + } + + // Allow for array of hosts + const targetHost = Array.isArray(action.target.host) + ? action.target.host[Math.floor(Math.random() * action.target.host.length)] + : action.target.host; + + // Determine target port - either target port or preserve incoming port + const targetPort = action.target.preservePort ? record.localPort : action.target.port; + + return this.setupDirectConnection( + socket, + record, + undefined, + record.lockedDomain, + initialChunk, + undefined, + targetHost, + targetPort + ); + + case 'terminate': + case 'terminate-and-reencrypt': + // For TLS termination, use NetworkProxy + if (this.networkProxyBridge.getNetworkProxy()) { + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Using NetworkProxy for TLS termination to ${action.target.host}` + ); + } + + // If we have an initial chunk with TLS data, start processing it + if (initialChunk && record.isTLS) { + return this.networkProxyBridge.forwardToNetworkProxy( + connectionId, + socket, + record, + initialChunk, + this.settings.networkProxyPort, + (reason) => this.connectionManager.initiateCleanupOnce(record, reason) + ); + } + + // This shouldn't normally happen - we should have TLS data at this point + console.log(`[${connectionId}] TLS termination route without TLS data`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'tls_error'); + return; + } else { + console.log(`[${connectionId}] NetworkProxy not available for TLS termination`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'no_network_proxy'); + return; + } + } + } else { + // No TLS settings - basic forwarding + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Using basic forwarding to ${action.target.host}:${action.target.port}`); + } + + // Allow for array of hosts + const targetHost = Array.isArray(action.target.host) + ? action.target.host[Math.floor(Math.random() * action.target.host.length)] + : action.target.host; + + // Determine target port - either target port or preserve incoming port + const targetPort = action.target.preservePort ? record.localPort : action.target.port; + + return this.setupDirectConnection( + socket, + record, + undefined, + record.lockedDomain, + initialChunk, + undefined, + targetHost, + targetPort + ); + } + } + + /** + * Handle a redirect action for a route + */ + private handleRedirectAction( + socket: plugins.net.Socket, + record: IConnectionRecord, + route: IRouteConfig + ): void { + const connectionId = record.id; + const action = route.action; + + // We should have a redirect configuration + if (!action.redirect) { + console.log(`[${connectionId}] Redirect action missing redirect configuration`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'missing_redirect'); + return; + } + + // For TLS connections, we can't do redirects at the TCP level + if (record.isTLS) { + console.log(`[${connectionId}] Cannot redirect TLS connection at TCP level`); + socket.end(); + this.connectionManager.cleanupConnection(record, 'tls_redirect_error'); + return; + } + + // Wait for the first HTTP request to perform the redirect + const dataListeners: ((chunk: Buffer) => void)[] = []; + + const httpDataHandler = (chunk: Buffer) => { + // Remove all data listeners to avoid duplicated processing + for (const listener of dataListeners) { + socket.removeListener('data', listener); + } + + // Parse HTTP request to get path + try { + const headersEnd = chunk.indexOf('\r\n\r\n'); + if (headersEnd === -1) { + // Not a complete HTTP request, need more data + socket.once('data', httpDataHandler); + dataListeners.push(httpDataHandler); + return; + } + + const httpHeaders = chunk.slice(0, headersEnd).toString(); + const requestLine = httpHeaders.split('\r\n')[0]; + const [method, path] = requestLine.split(' '); + + // Extract Host header + const hostMatch = httpHeaders.match(/Host: (.+?)(\r\n|\r|\n|$)/i); + const host = hostMatch ? hostMatch[1].trim() : record.lockedDomain || ''; + + // Process the redirect URL with template variables + let redirectUrl = action.redirect.to; + redirectUrl = redirectUrl.replace(/\{domain\}/g, host); + redirectUrl = redirectUrl.replace(/\{path\}/g, path || ''); + redirectUrl = redirectUrl.replace(/\{port\}/g, record.localPort.toString()); + + // Prepare the HTTP redirect response + const redirectResponse = [ + `HTTP/1.1 ${action.redirect.status} Moved`, + `Location: ${redirectUrl}`, + 'Connection: close', + 'Content-Length: 0', + '', + '' + ].join('\r\n'); + + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Redirecting to ${redirectUrl} with status ${action.redirect.status}`); + } + + // Send the redirect response + socket.end(redirectResponse); + this.connectionManager.initiateCleanupOnce(record, 'redirect_complete'); + } catch (err) { + console.log(`[${connectionId}] Error processing HTTP redirect: ${err}`); + socket.end(); + this.connectionManager.initiateCleanupOnce(record, 'redirect_error'); + } + }; + + // Setup the HTTP data handler + socket.once('data', httpDataHandler); + dataListeners.push(httpDataHandler); + } + + /** + * Handle a block action for a route + */ + private handleBlockAction( + socket: plugins.net.Socket, + record: IConnectionRecord, + route: IRouteConfig + ): void { + const connectionId = record.id; + + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Blocking connection based on route "${route.name || 'unnamed'}"`); + } + + // Simply close the connection + socket.end(); + this.connectionManager.initiateCleanupOnce(record, 'route_blocked'); + } + + /** + * Handle a connection using legacy domain configuration + */ + private handleLegacyConnection( + socket: plugins.net.Socket, + record: IConnectionRecord, + serverName: string, + domainConfig: IDomainConfig, + initialChunk?: Buffer + ): void { + const connectionId = record.id; + + // Get the forwarding type for this domain + const forwardingType = this.domainConfigManager.getForwardingType(domainConfig); + + // IP validation + const ipRules = this.domainConfigManager.getEffectiveIPRules(domainConfig); + + if (!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)) { + console.log( + `[${connectionId}] Connection rejected: IP ${record.remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}` + ); + socket.end(); + this.connectionManager.initiateCleanupOnce(record, 'ip_blocked'); + return; + } + + // Handle based on forwarding type + switch (forwardingType) { + case 'http-only': + // For HTTP-only configs with TLS traffic + if (record.isTLS) { + console.log(`[${connectionId}] Received TLS connection for HTTP-only domain ${serverName}`); + socket.end(); + this.connectionManager.initiateCleanupOnce(record, 'wrong_protocol'); + return; + } + break; + + case 'https-passthrough': + // For TLS passthrough with TLS traffic + if (record.isTLS) { + try { + const handler = this.domainConfigManager.getForwardingHandler(domainConfig); + + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Using forwarding handler for SNI passthrough to ${serverName}`); + } + + // Handle the connection using the handler + return handler.handleConnection(socket); + } catch (err) { + console.log(`[${connectionId}] Error using forwarding handler: ${err}`); + } + } + break; + + case 'https-terminate-to-http': + case 'https-terminate-to-https': + // For TLS termination with TLS traffic + if (record.isTLS) { + const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig); + + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Using TLS termination (${forwardingType}) for ${serverName} on port ${networkProxyPort}`); + } + + // Forward to NetworkProxy with domain-specific port + return this.networkProxyBridge.forwardToNetworkProxy( + connectionId, + socket, + record, + initialChunk!, + networkProxyPort, + (reason) => this.connectionManager.initiateCleanupOnce(record, reason) + ); + } + break; + } + + // If we're still here, use the forwarding handler if available + try { + const handler = this.domainConfigManager.getForwardingHandler(domainConfig); + + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Using general forwarding handler for domain ${serverName || 'unknown'}`); + } + + // Handle the connection using the handler + return handler.handleConnection(socket); + } catch (err) { + console.log(`[${connectionId}] Error using forwarding handler: ${err}`); + } + + // Fallback: set up direct connection + const targetIp = this.domainConfigManager.getTargetIP(domainConfig); + const targetPort = this.domainConfigManager.getTargetPort(domainConfig, this.settings.toPort); + + return this.setupDirectConnection( + socket, + record, + domainConfig, + serverName, + initialChunk, + undefined, + targetIp, + targetPort + ); + } + + /** + * Sets up a direct connection to the target + */ + private setupDirectConnection( + socket: plugins.net.Socket, + record: IConnectionRecord, + domainConfig?: IDomainConfig, + serverName?: string, + initialChunk?: Buffer, + overridePort?: number, + targetHost?: string, + targetPort?: number + ): void { + const connectionId = record.id; + + // Determine target host and port if not provided + const finalTargetHost = targetHost || (domainConfig + ? this.domainConfigManager.getTargetIP(domainConfig) + : this.settings.defaults?.target?.host + ? this.settings.defaults.target.host + : this.settings.targetIP!); + + // Determine target port - first try explicit port, then forwarding config, then fallback + const finalTargetPort = targetPort || (overridePort !== undefined + ? overridePort + : domainConfig + ? this.domainConfigManager.getTargetPort(domainConfig, this.settings.toPort) + : this.settings.defaults?.target?.port + ? this.settings.defaults.target.port + : this.settings.toPort); + + // Setup connection options + const connectionOptions: plugins.net.NetConnectOpts = { + host: finalTargetHost, + port: finalTargetPort, + }; + + // Preserve source IP if configured + if (this.settings.defaults?.preserveSourceIP || this.settings.preserveSourceIP) { + connectionOptions.localAddress = record.remoteIP.replace('::ffff:', ''); + } + + // Create a safe queue for incoming data + const dataQueue: Buffer[] = []; + let queueSize = 0; + let processingQueue = false; + let drainPending = false; + let pipingEstablished = false; + + // Pause the incoming socket to prevent buffer overflows + socket.pause(); + + // Function to safely process the data queue without losing events + const processDataQueue = () => { + if (processingQueue || dataQueue.length === 0 || pipingEstablished) return; + + processingQueue = true; + + try { + // Process all queued chunks with the current active handler + while (dataQueue.length > 0) { + const chunk = dataQueue.shift()!; + queueSize -= chunk.length; + + // Once piping is established, we shouldn't get here, + // but just in case, pass to the outgoing socket directly + if (pipingEstablished && record.outgoing) { + record.outgoing.write(chunk); + continue; + } + + // Track bytes received + record.bytesReceived += chunk.length; + + // Check for TLS handshake + if (!record.isTLS && this.tlsManager.isTlsHandshake(chunk)) { + record.isTLS = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] TLS handshake detected in tempDataHandler, ${chunk.length} bytes` + ); + } + } + + // Check if adding this chunk would exceed the buffer limit + const newSize = record.pendingDataSize + chunk.length; + + if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) { + console.log( + `[${connectionId}] Buffer limit exceeded for connection from ${record.remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes` + ); + socket.end(); // Gracefully close the socket + this.connectionManager.initiateCleanupOnce(record, 'buffer_limit_exceeded'); + return; + } + + // Buffer the chunk and update the size counter + record.pendingData.push(Buffer.from(chunk)); + record.pendingDataSize = newSize; + this.timeoutManager.updateActivity(record); + } + } finally { + processingQueue = false; + + // If there's a pending drain and we've processed everything, + // signal we're ready for more data if we haven't established piping yet + if (drainPending && dataQueue.length === 0 && !pipingEstablished) { + drainPending = false; + socket.resume(); + } + } + }; + + // Unified data handler that safely queues incoming data + const safeDataHandler = (chunk: Buffer) => { + // If piping is already established, just let the pipe handle it + if (pipingEstablished) return; + + // Add to our queue for orderly processing + dataQueue.push(Buffer.from(chunk)); // Make a copy to be safe + queueSize += chunk.length; + + // If queue is getting large, pause socket until we catch up + if (this.settings.maxPendingDataSize && queueSize > this.settings.maxPendingDataSize * 0.8) { + socket.pause(); + drainPending = true; + } + + // Process the queue + processDataQueue(); + }; + + // Add our safe data handler + socket.on('data', safeDataHandler); + + // Add initial chunk to pending data if present + if (initialChunk) { + record.bytesReceived += initialChunk.length; + record.pendingData.push(Buffer.from(initialChunk)); + record.pendingDataSize = initialChunk.length; + } + + // Create the target socket but don't set up piping immediately + const targetSocket = plugins.net.connect(connectionOptions); + record.outgoing = targetSocket; + record.outgoingStartTime = Date.now(); + + // Apply socket optimizations + targetSocket.setNoDelay(this.settings.noDelay); + + // Apply keep-alive settings to the outgoing connection as well + if (this.settings.keepAlive) { + targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay); + + // Apply enhanced TCP keep-alive options if enabled + if (this.settings.enableKeepAliveProbes) { + try { + if ('setKeepAliveProbes' in targetSocket) { + (targetSocket as any).setKeepAliveProbes(10); + } + if ('setKeepAliveInterval' in targetSocket) { + (targetSocket as any).setKeepAliveInterval(1000); + } + } catch (err) { + // Ignore errors - these are optional enhancements + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}` + ); + } + } + } + } + + // Setup specific error handler for connection phase + targetSocket.once('error', (err) => { + // This handler runs only once during the initial connection phase + const code = (err as any).code; + console.log( + `[${connectionId}] Connection setup error to ${finalTargetHost}:${connectionOptions.port}: ${err.message} (${code})` + ); + + // Resume the incoming socket to prevent it from hanging + socket.resume(); + + if (code === 'ECONNREFUSED') { + console.log( + `[${connectionId}] Target ${finalTargetHost}:${connectionOptions.port} refused connection` + ); + } else if (code === 'ETIMEDOUT') { + console.log( + `[${connectionId}] Connection to ${finalTargetHost}:${connectionOptions.port} timed out` + ); + } else if (code === 'ECONNRESET') { + console.log( + `[${connectionId}] Connection to ${finalTargetHost}:${connectionOptions.port} was reset` + ); + } else if (code === 'EHOSTUNREACH') { + console.log(`[${connectionId}] Host ${finalTargetHost} is unreachable`); + } + + // Clear any existing error handler after connection phase + targetSocket.removeAllListeners('error'); + + // Re-add the normal error handler for established connections + targetSocket.on('error', this.connectionManager.handleError('outgoing', record)); + + if (record.outgoingTerminationReason === null) { + record.outgoingTerminationReason = 'connection_failed'; + this.connectionManager.incrementTerminationStat('outgoing', 'connection_failed'); + } + + // If we have a forwarding handler for this domain, let it handle the error + if (domainConfig) { + try { + const forwardingHandler = this.domainConfigManager.getForwardingHandler(domainConfig); + forwardingHandler.emit('connection_error', { + socket, + error: err, + connectionId + }); + } catch (handlerErr) { + // If getting the handler fails, just log and continue with normal cleanup + console.log(`Error getting forwarding handler for error handling: ${handlerErr}`); + } + } + + // Clean up the connection + this.connectionManager.initiateCleanupOnce(record, `connection_failed_${code}`); + }); + + // Setup close handler + targetSocket.on('close', this.connectionManager.handleClose('outgoing', record)); + socket.on('close', this.connectionManager.handleClose('incoming', record)); + + // Handle timeouts with keep-alive awareness + socket.on('timeout', () => { + // For keep-alive connections, just log a warning instead of closing + if (record.hasKeepAlive) { + console.log( + `[${connectionId}] Timeout event on incoming keep-alive connection from ${ + record.remoteIP + } after ${plugins.prettyMs( + this.settings.socketTimeout || 3600000 + )}. Connection preserved.` + ); + return; + } + + // For non-keep-alive connections, proceed with normal cleanup + console.log( + `[${connectionId}] Timeout on incoming side from ${ + record.remoteIP + } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` + ); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'timeout'; + this.connectionManager.incrementTerminationStat('incoming', 'timeout'); + } + this.connectionManager.initiateCleanupOnce(record, 'timeout_incoming'); + }); + + targetSocket.on('timeout', () => { + // For keep-alive connections, just log a warning instead of closing + if (record.hasKeepAlive) { + console.log( + `[${connectionId}] Timeout event on outgoing keep-alive connection from ${ + record.remoteIP + } after ${plugins.prettyMs( + this.settings.socketTimeout || 3600000 + )}. Connection preserved.` + ); + return; + } + + // For non-keep-alive connections, proceed with normal cleanup + console.log( + `[${connectionId}] Timeout on outgoing side from ${ + record.remoteIP + } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` + ); + if (record.outgoingTerminationReason === null) { + record.outgoingTerminationReason = 'timeout'; + this.connectionManager.incrementTerminationStat('outgoing', 'timeout'); + } + this.connectionManager.initiateCleanupOnce(record, 'timeout_outgoing'); + }); + + // Apply socket timeouts + this.timeoutManager.applySocketTimeouts(record); + + // Track outgoing data for bytes counting + targetSocket.on('data', (chunk: Buffer) => { + record.bytesSent += chunk.length; + this.timeoutManager.updateActivity(record); + }); + + // Wait for the outgoing connection to be ready before setting up piping + targetSocket.once('connect', () => { + // Clear the initial connection error handler + targetSocket.removeAllListeners('error'); + + // Add the normal error handler for established connections + targetSocket.on('error', this.connectionManager.handleError('outgoing', record)); + + // Process any remaining data in the queue before switching to piping + processDataQueue(); + + // Set up piping immediately + pipingEstablished = true; + + // Flush all pending data to target + if (record.pendingData.length > 0) { + const combinedData = Buffer.concat(record.pendingData); + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target` + ); + } + + // Write pending data immediately + targetSocket.write(combinedData, (err) => { + if (err) { + console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`); + return this.connectionManager.initiateCleanupOnce(record, 'write_error'); + } + }); + + // Clear the buffer now that we've processed it + record.pendingData = []; + record.pendingDataSize = 0; + } + + // Setup piping in both directions without any delays + socket.pipe(targetSocket); + targetSocket.pipe(socket); + + // Resume the socket to ensure data flows + socket.resume(); + + // Process any data that might be queued in the interim + if (dataQueue.length > 0) { + // Write any remaining queued data directly to the target socket + for (const chunk of dataQueue) { + targetSocket.write(chunk); + } + // Clear the queue + dataQueue.length = 0; + queueSize = 0; + } + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Connection established: ${record.remoteIP} -> ${finalTargetHost}:${connectionOptions.port}` + + `${ + serverName + ? ` (SNI: ${serverName})` + : domainConfig + ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` + : '' + }` + + ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ + record.hasKeepAlive ? 'Yes' : 'No' + }` + ); + } else { + console.log( + `Connection established: ${record.remoteIP} -> ${finalTargetHost}:${connectionOptions.port}` + + `${ + serverName + ? ` (SNI: ${serverName})` + : domainConfig + ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` + : '' + }` + ); + } + + // Add the renegotiation handler for SNI validation + if (serverName) { + // Create connection info object for the existing connection + const connInfo = { + sourceIp: record.remoteIP, + sourcePort: record.incoming.remotePort || 0, + destIp: record.incoming.localAddress || '', + destPort: record.incoming.localPort || 0, + }; + + // Create a renegotiation handler function + const renegotiationHandler = this.tlsManager.createRenegotiationHandler( + connectionId, + serverName, + connInfo, + (connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason) + ); + + // Store the handler in the connection record so we can remove it during cleanup + record.renegotiationHandler = renegotiationHandler; + + // Add the handler to the socket + socket.on('data', renegotiationHandler); + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] TLS renegotiation handler installed for SNI domain: ${serverName}` + ); + if (this.settings.allowSessionTicket === false) { + console.log( + `[${connectionId}] Session ticket usage is disabled. Connection will be reset on reconnection attempts.` + ); + } + } + } + + // Set connection timeout + record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => { + console.log( + `[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.` + ); + this.connectionManager.initiateCleanupOnce(record, reason); + }); + + // Mark TLS handshake as complete for TLS connections + if (record.isTLS) { + record.tlsHandshakeComplete = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] TLS handshake complete for connection from ${record.remoteIP}` + ); + } + } + }); + } +} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/route-helpers.ts b/ts/proxies/smart-proxy/route-helpers.ts new file mode 100644 index 0000000..604578a --- /dev/null +++ b/ts/proxies/smart-proxy/route-helpers.ts @@ -0,0 +1,344 @@ +import type { + IRouteConfig, + IRouteMatch, + IRouteAction, + IRouteTarget, + IRouteTls, + IRouteRedirect, + IRouteSecurity, + IRouteAdvanced +} from './models/route-types.js'; + +/** + * Basic helper function to create a route configuration + */ +export function createRoute( + match: IRouteMatch, + action: IRouteAction, + metadata?: { + name?: string; + description?: string; + priority?: number; + tags?: string[]; + } +): IRouteConfig { + return { + match, + action, + ...metadata + }; +} + +/** + * Create a basic HTTP route configuration + */ +export function createHttpRoute( + options: { + ports?: number | number[]; // Default: 80 + domains?: string | string[]; + path?: string; + target: IRouteTarget; + headers?: Record; + security?: IRouteSecurity; + name?: string; + description?: string; + priority?: number; + tags?: string[]; + } +): IRouteConfig { + return createRoute( + { + ports: options.ports || 80, + ...(options.domains ? { domains: options.domains } : {}), + ...(options.path ? { path: options.path } : {}) + }, + { + type: 'forward', + target: options.target, + ...(options.headers || options.security ? { + advanced: { + ...(options.headers ? { headers: options.headers } : {}) + }, + ...(options.security ? { security: options.security } : {}) + } : {}) + }, + { + name: options.name || 'HTTP Route', + description: options.description, + priority: options.priority, + tags: options.tags + } + ); +} + +/** + * Create an HTTPS route configuration with TLS termination + */ +export function createHttpsRoute( + options: { + ports?: number | number[]; // Default: 443 + domains: string | string[]; + path?: string; + target: IRouteTarget; + tlsMode?: 'terminate' | 'terminate-and-reencrypt'; + certificate?: 'auto' | { key: string; cert: string }; + headers?: Record; + security?: IRouteSecurity; + name?: string; + description?: string; + priority?: number; + tags?: string[]; + } +): IRouteConfig { + return createRoute( + { + ports: options.ports || 443, + domains: options.domains, + ...(options.path ? { path: options.path } : {}) + }, + { + type: 'forward', + target: options.target, + tls: { + mode: options.tlsMode || 'terminate', + certificate: options.certificate || 'auto' + }, + ...(options.headers || options.security ? { + advanced: { + ...(options.headers ? { headers: options.headers } : {}) + }, + ...(options.security ? { security: options.security } : {}) + } : {}) + }, + { + name: options.name || 'HTTPS Route', + description: options.description, + priority: options.priority, + tags: options.tags + } + ); +} + +/** + * Create an HTTPS passthrough route configuration + */ +export function createPassthroughRoute( + options: { + ports?: number | number[]; // Default: 443 + domains?: string | string[]; + target: IRouteTarget; + security?: IRouteSecurity; + name?: string; + description?: string; + priority?: number; + tags?: string[]; + } +): IRouteConfig { + return createRoute( + { + ports: options.ports || 443, + ...(options.domains ? { domains: options.domains } : {}) + }, + { + type: 'forward', + target: options.target, + tls: { + mode: 'passthrough' + }, + ...(options.security ? { security: options.security } : {}) + }, + { + name: options.name || 'HTTPS Passthrough Route', + description: options.description, + priority: options.priority, + tags: options.tags + } + ); +} + +/** + * Create a redirect route configuration + */ +export function createRedirectRoute( + options: { + ports?: number | number[]; // Default: 80 + domains?: string | string[]; + path?: string; + redirectTo: string; + statusCode?: 301 | 302 | 307 | 308; + name?: string; + description?: string; + priority?: number; + tags?: string[]; + } +): IRouteConfig { + return createRoute( + { + ports: options.ports || 80, + ...(options.domains ? { domains: options.domains } : {}), + ...(options.path ? { path: options.path } : {}) + }, + { + type: 'redirect', + redirect: { + to: options.redirectTo, + status: options.statusCode || 301 + } + }, + { + name: options.name || 'Redirect Route', + description: options.description, + priority: options.priority, + tags: options.tags + } + ); +} + +/** + * Create an HTTP to HTTPS redirect route configuration + */ +export function createHttpToHttpsRedirect( + options: { + domains: string | string[]; + statusCode?: 301 | 302 | 307 | 308; + name?: string; + priority?: number; + } +): IRouteConfig { + const domainArray = Array.isArray(options.domains) ? options.domains : [options.domains]; + + return createRedirectRoute({ + ports: 80, + domains: options.domains, + redirectTo: 'https://{domain}{path}', + statusCode: options.statusCode || 301, + name: options.name || `HTTP to HTTPS Redirect for ${domainArray.join(', ')}`, + priority: options.priority || 100 // High priority for redirects + }); +} + +/** + * Create a block route configuration + */ +export function createBlockRoute( + options: { + ports: number | number[]; + domains?: string | string[]; + clientIp?: string[]; + name?: string; + description?: string; + priority?: number; + tags?: string[]; + } +): IRouteConfig { + return createRoute( + { + ports: options.ports, + ...(options.domains ? { domains: options.domains } : {}), + ...(options.clientIp ? { clientIp: options.clientIp } : {}) + }, + { + type: 'block' + }, + { + name: options.name || 'Block Route', + description: options.description, + priority: options.priority || 1000, // Very high priority for blocks + tags: options.tags + } + ); +} + +/** + * Create a load balancer route configuration + */ +export function createLoadBalancerRoute( + options: { + ports?: number | number[]; // Default: 443 + domains: string | string[]; + path?: string; + targets: string[]; // Array of host names/IPs for load balancing + targetPort: number; + tlsMode?: 'passthrough' | 'terminate' | 'terminate-and-reencrypt'; + certificate?: 'auto' | { key: string; cert: string }; + headers?: Record; + security?: IRouteSecurity; + name?: string; + description?: string; + tags?: string[]; + } +): IRouteConfig { + const useTls = options.tlsMode !== undefined; + const defaultPort = useTls ? 443 : 80; + + return createRoute( + { + ports: options.ports || defaultPort, + domains: options.domains, + ...(options.path ? { path: options.path } : {}) + }, + { + type: 'forward', + target: { + host: options.targets, + port: options.targetPort + }, + ...(useTls ? { + tls: { + mode: options.tlsMode!, + ...(options.tlsMode !== 'passthrough' && options.certificate ? { + certificate: options.certificate + } : {}) + } + } : {}), + ...(options.headers || options.security ? { + advanced: { + ...(options.headers ? { headers: options.headers } : {}) + }, + ...(options.security ? { security: options.security } : {}) + } : {}) + }, + { + name: options.name || 'Load Balanced Route', + description: options.description || `Load balancing across ${options.targets.length} backends`, + tags: options.tags + } + ); +} + +/** + * Create a complete HTTPS server configuration with HTTP redirect + */ +export function createHttpsServer( + options: { + domains: string | string[]; + target: IRouteTarget; + certificate?: 'auto' | { key: string; cert: string }; + security?: IRouteSecurity; + addHttpRedirect?: boolean; + name?: string; + } +): IRouteConfig[] { + const routes: IRouteConfig[] = []; + const domainArray = Array.isArray(options.domains) ? options.domains : [options.domains]; + + // Add HTTPS route + routes.push(createHttpsRoute({ + domains: options.domains, + target: options.target, + certificate: options.certificate || 'auto', + security: options.security, + name: options.name || `HTTPS Server for ${domainArray.join(', ')}` + })); + + // Add HTTP to HTTPS redirect if requested + if (options.addHttpRedirect !== false) { + routes.push(createHttpToHttpsRedirect({ + domains: options.domains, + name: `HTTP to HTTPS Redirect for ${domainArray.join(', ')}`, + priority: 100 + })); + } + + return routes; +} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/route-manager.ts b/ts/proxies/smart-proxy/route-manager.ts new file mode 100644 index 0000000..629e0ee --- /dev/null +++ b/ts/proxies/smart-proxy/route-manager.ts @@ -0,0 +1,587 @@ +import * as plugins from '../../plugins.js'; +import type { + IRouteConfig, + IRouteMatch, + IRouteAction, + TPortRange +} from './models/route-types.js'; +import type { + ISmartProxyOptions, + IRoutedSmartProxyOptions, + IDomainConfig +} from './models/interfaces.js'; +import { + isRoutedOptions, + isLegacyOptions +} from './models/interfaces.js'; + +/** + * Result of route matching + */ +export interface IRouteMatchResult { + route: IRouteConfig; + // Additional match parameters (path, query, etc.) + params?: Record; +} + +/** + * The RouteManager handles all routing decisions based on connections and attributes + */ +export class RouteManager extends plugins.EventEmitter { + private routes: IRouteConfig[] = []; + private portMap: Map = new Map(); + private options: IRoutedSmartProxyOptions; + + constructor(options: ISmartProxyOptions) { + super(); + + // We no longer support legacy options, always use provided options + this.options = options; + + // Initialize routes from either source + this.updateRoutes(this.options.routes); + } + + /** + * Update routes with new configuration + */ + public updateRoutes(routes: IRouteConfig[] = []): void { + // Sort routes by priority (higher first) + this.routes = [...(routes || [])].sort((a, b) => { + const priorityA = a.priority ?? 0; + const priorityB = b.priority ?? 0; + return priorityB - priorityA; + }); + + // Rebuild port mapping for fast lookups + this.rebuildPortMap(); + } + + /** + * Rebuild the port mapping for fast lookups + */ + private rebuildPortMap(): void { + this.portMap.clear(); + + for (const route of this.routes) { + const ports = this.expandPortRange(route.match.ports); + + for (const port of ports) { + if (!this.portMap.has(port)) { + this.portMap.set(port, []); + } + this.portMap.get(port)!.push(route); + } + } + } + + /** + * Expand a port range specification into an array of individual ports + */ + private expandPortRange(portRange: TPortRange): number[] { + if (typeof portRange === 'number') { + return [portRange]; + } + + if (Array.isArray(portRange)) { + // Handle array of port objects or numbers + return portRange.flatMap(item => { + if (typeof item === 'number') { + return [item]; + } else if (typeof item === 'object' && 'from' in item && 'to' in item) { + // Handle port range object + const ports: number[] = []; + for (let p = item.from; p <= item.to; p++) { + ports.push(p); + } + return ports; + } + return []; + }); + } + + return []; + } + + /** + * Get all ports that should be listened on + */ + public getListeningPorts(): number[] { + return Array.from(this.portMap.keys()); + } + + /** + * Get all routes for a given port + */ + public getRoutesForPort(port: number): IRouteConfig[] { + return this.portMap.get(port) || []; + } + + /** + * Test if a pattern matches a domain using glob matching + */ + private matchDomain(pattern: string, domain: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, '\\.') // Escape dots + .replace(/\*/g, '.*'); // Convert * to .* + + const regex = new RegExp(`^${regexPattern}$`, 'i'); + return regex.test(domain); + } + + /** + * Match a domain against all patterns in a route + */ + private matchRouteDomain(route: IRouteConfig, domain: string): boolean { + if (!route.match.domains) { + // If no domains specified, match all domains + return true; + } + + const patterns = Array.isArray(route.match.domains) + ? route.match.domains + : [route.match.domains]; + + return patterns.some(pattern => this.matchDomain(pattern, domain)); + } + + /** + * Check if a client IP is allowed by a route's security settings + */ + private isClientIpAllowed(route: IRouteConfig, clientIp: string): boolean { + const security = route.action.security; + + if (!security) { + return true; // No security settings means allowed + } + + // Check blocked IPs first + if (security.blockedIps && security.blockedIps.length > 0) { + for (const pattern of security.blockedIps) { + if (this.matchIpPattern(pattern, clientIp)) { + return false; // IP is blocked + } + } + } + + // If there are allowed IPs, check them + if (security.allowedIps && security.allowedIps.length > 0) { + for (const pattern of security.allowedIps) { + if (this.matchIpPattern(pattern, clientIp)) { + return true; // IP is allowed + } + } + return false; // IP not in allowed list + } + + // No allowed IPs specified, so IP is allowed + return true; + } + + /** + * Match an IP against a pattern + */ + private matchIpPattern(pattern: string, ip: string): boolean { + // Handle exact match + if (pattern === ip) { + return true; + } + + // Handle CIDR notation (e.g., 192.168.1.0/24) + if (pattern.includes('/')) { + return this.matchIpCidr(pattern, ip); + } + + // Handle glob pattern (e.g., 192.168.1.*) + if (pattern.includes('*')) { + const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(ip); + } + + return false; + } + + /** + * Match an IP against a CIDR pattern + */ + private matchIpCidr(cidr: string, ip: string): boolean { + try { + // In a real implementation, you'd use a proper IP library + // This is a simplified implementation + const [subnet, bits] = cidr.split('/'); + const mask = parseInt(bits, 10); + + // Convert IP addresses to numeric values + const ipNum = this.ipToNumber(ip); + const subnetNum = this.ipToNumber(subnet); + + // Calculate subnet mask + const maskNum = ~(2 ** (32 - mask) - 1); + + // Check if IP is in subnet + return (ipNum & maskNum) === (subnetNum & maskNum); + } catch (e) { + console.error(`Error matching IP ${ip} against CIDR ${cidr}:`, e); + return false; + } + } + + /** + * Convert an IP address to a numeric value + */ + private ipToNumber(ip: string): number { + const parts = ip.split('.').map(part => parseInt(part, 10)); + return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]; + } + + /** + * Find the matching route for a connection + */ + public findMatchingRoute(options: { + port: number; + domain?: string; + clientIp: string; + path?: string; + tlsVersion?: string; + }): IRouteMatchResult | null { + const { port, domain, clientIp, path, tlsVersion } = options; + + // Get all routes for this port + const routesForPort = this.getRoutesForPort(port); + + // Find the first matching route based on priority order + for (const route of routesForPort) { + // Check domain match if specified + if (domain && !this.matchRouteDomain(route, domain)) { + continue; + } + + // Check path match if specified in both route and request + if (path && route.match.path) { + if (!this.matchPath(route.match.path, path)) { + continue; + } + } + + // Check client IP match + if (route.match.clientIp && !route.match.clientIp.some(pattern => + this.matchIpPattern(pattern, clientIp))) { + continue; + } + + // Check TLS version match + if (tlsVersion && route.match.tlsVersion && + !route.match.tlsVersion.includes(tlsVersion)) { + continue; + } + + // Check security settings + if (!this.isClientIpAllowed(route, clientIp)) { + continue; + } + + // All checks passed, this route matches + return { route }; + } + + return null; + } + + /** + * Match a path against a pattern + */ + private matchPath(pattern: string, path: string): boolean { + // Convert the glob pattern to a regex + const regexPattern = pattern + .replace(/\./g, '\\.') // Escape dots + .replace(/\*/g, '.*') // Convert * to .* + .replace(/\//g, '\\/'); // Escape slashes + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(path); + } + + /** + * Convert a domain config to routes + * (For backward compatibility with code that still uses domainConfigs) + */ + public domainConfigToRoutes(domainConfig: IDomainConfig): IRouteConfig[] { + const routes: IRouteConfig[] = []; + const { domains, forwarding } = domainConfig; + + // Determine the action based on forwarding type + let action: IRouteAction = { + type: 'forward', + target: { + host: forwarding.target.host, + port: forwarding.target.port + } + }; + + // Set TLS mode based on forwarding type + switch (forwarding.type) { + case 'http-only': + // No TLS settings needed + break; + case 'https-passthrough': + action.tls = { mode: 'passthrough' }; + break; + case 'https-terminate-to-http': + action.tls = { + mode: 'terminate', + certificate: forwarding.https?.customCert ? { + key: forwarding.https.customCert.key, + cert: forwarding.https.customCert.cert + } : 'auto' + }; + break; + case 'https-terminate-to-https': + action.tls = { + mode: 'terminate-and-reencrypt', + certificate: forwarding.https?.customCert ? { + key: forwarding.https.customCert.key, + cert: forwarding.https.customCert.cert + } : 'auto' + }; + break; + } + + // Add security settings if present + if (forwarding.security) { + action.security = { + allowedIps: forwarding.security.allowedIps, + blockedIps: forwarding.security.blockedIps, + maxConnections: forwarding.security.maxConnections + }; + } + + // Add advanced settings if present + if (forwarding.advanced) { + action.advanced = { + timeout: forwarding.advanced.timeout, + headers: forwarding.advanced.headers, + keepAlive: forwarding.advanced.keepAlive + }; + } + + // Determine which port to use based on forwarding type + const defaultPort = forwarding.type.startsWith('https') ? 443 : 80; + + // Add the main route + routes.push({ + match: { + ports: defaultPort, + domains + }, + action, + name: `Route for ${domains.join(', ')}` + }); + + // Add HTTP redirect if needed + if (forwarding.http?.redirectToHttps) { + routes.push({ + match: { + ports: 80, + domains + }, + action: { + type: 'redirect', + redirect: { + to: 'https://{domain}{path}', + status: 301 + } + }, + name: `HTTP Redirect for ${domains.join(', ')}`, + priority: 100 // Higher priority for redirects + }); + } + + // Add port ranges if specified + if (forwarding.advanced?.portRanges) { + for (const range of forwarding.advanced.portRanges) { + routes.push({ + match: { + ports: [{ from: range.from, to: range.to }], + domains + }, + action, + name: `Port Range ${range.from}-${range.to} for ${domains.join(', ')}` + }); + } + } + + return routes; + } + + /** + * Update routes based on domain configs + * (For backward compatibility with code that still uses domainConfigs) + */ + public updateFromDomainConfigs(domainConfigs: IDomainConfig[]): void { + const routes: IRouteConfig[] = []; + + // Convert each domain config to routes + for (const config of domainConfigs) { + routes.push(...this.domainConfigToRoutes(config)); + } + + // Merge with existing routes that aren't derived from domain configs + const nonDomainRoutes = this.routes.filter(r => + !r.name || !r.name.includes('for ')); + + this.updateRoutes([...nonDomainRoutes, ...routes]); + } + + /** + * Validate the route configuration and return any warnings + */ + public validateConfiguration(): string[] { + const warnings: string[] = []; + const duplicatePorts = new Map(); + + // Check for routes with the same exact match criteria + for (let i = 0; i < this.routes.length; i++) { + for (let j = i + 1; j < this.routes.length; j++) { + const route1 = this.routes[i]; + const route2 = this.routes[j]; + + // Check if route match criteria are the same + if (this.areMatchesSimilar(route1.match, route2.match)) { + warnings.push( + `Routes "${route1.name || i}" and "${route2.name || j}" have similar match criteria. ` + + `The route with higher priority (${Math.max(route1.priority || 0, route2.priority || 0)}) will be used.` + ); + } + } + } + + // Check for routes that may never be matched due to priority + for (let i = 0; i < this.routes.length; i++) { + const route = this.routes[i]; + const higherPriorityRoutes = this.routes.filter(r => + (r.priority || 0) > (route.priority || 0)); + + for (const higherRoute of higherPriorityRoutes) { + if (this.isRouteShadowed(route, higherRoute)) { + warnings.push( + `Route "${route.name || i}" may never be matched because it is shadowed by ` + + `higher priority route "${higherRoute.name || 'unnamed'}"` + ); + break; + } + } + } + + return warnings; + } + + /** + * Check if two route matches are similar (potential conflict) + */ + private areMatchesSimilar(match1: IRouteMatch, match2: IRouteMatch): boolean { + // Check port overlap + const ports1 = new Set(this.expandPortRange(match1.ports)); + const ports2 = new Set(this.expandPortRange(match2.ports)); + + let havePortOverlap = false; + for (const port of ports1) { + if (ports2.has(port)) { + havePortOverlap = true; + break; + } + } + + if (!havePortOverlap) { + return false; + } + + // Check domain overlap + if (match1.domains && match2.domains) { + const domains1 = Array.isArray(match1.domains) ? match1.domains : [match1.domains]; + const domains2 = Array.isArray(match2.domains) ? match2.domains : [match2.domains]; + + // Check if any domain pattern from match1 could match any from match2 + let haveDomainOverlap = false; + for (const domain1 of domains1) { + for (const domain2 of domains2) { + if (domain1 === domain2 || + (domain1.includes('*') || domain2.includes('*'))) { + haveDomainOverlap = true; + break; + } + } + if (haveDomainOverlap) break; + } + + if (!haveDomainOverlap) { + return false; + } + } else if (match1.domains || match2.domains) { + // One has domains, the other doesn't - they could overlap + // The one with domains is more specific, so it's not exactly a conflict + return false; + } + + // Check path overlap + if (match1.path && match2.path) { + // This is a simplified check - in a real implementation, + // you'd need to check if the path patterns could match the same paths + return match1.path === match2.path || + match1.path.includes('*') || + match2.path.includes('*'); + } else if (match1.path || match2.path) { + // One has a path, the other doesn't + return false; + } + + // If we get here, the matches have significant overlap + return true; + } + + /** + * Check if a route is completely shadowed by a higher priority route + */ + private isRouteShadowed(route: IRouteConfig, higherPriorityRoute: IRouteConfig): boolean { + // If they don't have similar match criteria, no shadowing occurs + if (!this.areMatchesSimilar(route.match, higherPriorityRoute.match)) { + return false; + } + + // If higher priority route has more specific criteria, no shadowing + if (this.isRouteMoreSpecific(higherPriorityRoute.match, route.match)) { + return false; + } + + // If higher priority route is equally or less specific but has higher priority, + // it shadows the lower priority route + return true; + } + + /** + * Check if route1 is more specific than route2 + */ + private isRouteMoreSpecific(match1: IRouteMatch, match2: IRouteMatch): boolean { + // Check if match1 has more specific criteria + let match1Points = 0; + let match2Points = 0; + + // Path is the most specific + if (match1.path) match1Points += 3; + if (match2.path) match2Points += 3; + + // Domain is next most specific + if (match1.domains) match1Points += 2; + if (match2.domains) match2Points += 2; + + // Client IP and TLS version are least specific + if (match1.clientIp) match1Points += 1; + if (match2.clientIp) match2Points += 1; + + if (match1.tlsVersion) match1Points += 1; + if (match2.tlsVersion) match2Points += 1; + + return match1Points > match2Points; + } +} \ 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 d258daf..f05f1b7 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -1,6 +1,6 @@ import * as plugins from '../../plugins.js'; -// Importing from the new structure +// Importing required components import { ConnectionManager } from './connection-manager.js'; import { SecurityManager } from './security-manager.js'; import { DomainConfigManager } from './domain-config-manager.js'; @@ -8,23 +8,27 @@ import { TlsManager } from './tls-manager.js'; import { NetworkProxyBridge } from './network-proxy-bridge.js'; import { TimeoutManager } from './timeout-manager.js'; import { PortRangeManager } from './port-range-manager.js'; -import { ConnectionHandler } from './connection-handler.js'; +import { RouteManager } from './route-manager.js'; +import { RouteConnectionHandler } from './route-connection-handler.js'; -// External dependencies from migrated modules +// External dependencies import { Port80Handler } from '../../http/port80/port80-handler.js'; import { CertProvisioner } from '../../certificate/providers/cert-provisioner.js'; import type { ICertificateData } from '../../certificate/models/certificate-types.js'; import { buildPort80Handler } from '../../certificate/acme/acme-factory.js'; -import type { TForwardingType } from '../../forwarding/config/forwarding-types.js'; import { createPort80HandlerOptions } from '../../common/port80-adapter.js'; -// Import types from models -import type { ISmartProxyOptions, IDomainConfig } from './models/interfaces.js'; -// Provide backward compatibility types -export type { ISmartProxyOptions as IPortProxySettings, IDomainConfig }; +// Import types and utilities +import type { + ISmartProxyOptions, + IRoutedSmartProxyOptions, + IDomainConfig +} from './models/interfaces.js'; +import { isRoutedOptions, isLegacyOptions } from './models/interfaces.js'; +import type { IRouteConfig } from './models/route-types.js'; /** - * SmartProxy - Main class that coordinates all components + * SmartProxy - Unified route-based API */ export class SmartProxy extends plugins.EventEmitter { private netServers: plugins.net.Server[] = []; @@ -34,24 +38,28 @@ export class SmartProxy extends plugins.EventEmitter { // Component managers private connectionManager: ConnectionManager; private securityManager: SecurityManager; - public domainConfigManager: DomainConfigManager; + private domainConfigManager: DomainConfigManager; private tlsManager: TlsManager; private networkProxyBridge: NetworkProxyBridge; private timeoutManager: TimeoutManager; private portRangeManager: PortRangeManager; - private connectionHandler: ConnectionHandler; + private routeManager: RouteManager; + private routeConnectionHandler: RouteConnectionHandler; // Port80Handler for ACME certificate management private port80Handler: Port80Handler | null = null; // CertProvisioner for unified certificate workflows private certProvisioner?: CertProvisioner; + /** + * Constructor that supports both legacy and route-based configuration + */ constructor(settingsArg: ISmartProxyOptions) { super(); + // Set reasonable defaults for all settings this.settings = { ...settingsArg, - targetIP: settingsArg.targetIP || 'localhost', initialDataTimeout: settingsArg.initialDataTimeout || 120000, socketTimeout: settingsArg.socketTimeout || 3600000, inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, @@ -76,12 +84,11 @@ export class SmartProxy extends plugins.EventEmitter { keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, networkProxyPort: settingsArg.networkProxyPort || 8443, - acme: settingsArg.acme || {}, - globalPortRanges: settingsArg.globalPortRanges || [], }; // Set default ACME options if not provided - if (!this.settings.acme || Object.keys(this.settings.acme).length === 0) { + this.settings.acme = this.settings.acme || {}; + if (Object.keys(this.settings.acme).length === 0) { this.settings.acme = { enabled: false, port: 80, @@ -91,7 +98,7 @@ export class SmartProxy extends plugins.EventEmitter { autoRenew: true, certificateStore: './certs', skipConfiguredCerts: false, - httpsRedirectPort: this.settings.fromPort, + httpsRedirectPort: this.settings.fromPort || 443, renewCheckIntervalHours: 24, domainForwards: [] }; @@ -105,13 +112,20 @@ export class SmartProxy extends plugins.EventEmitter { this.securityManager, this.timeoutManager ); + + // Create domain config manager and port range manager (for backward compatibility) this.domainConfigManager = new DomainConfigManager(this.settings); - this.tlsManager = new TlsManager(this.settings); - this.networkProxyBridge = new NetworkProxyBridge(this.settings); this.portRangeManager = new PortRangeManager(this.settings); - // Initialize connection handler - this.connectionHandler = new ConnectionHandler( + // Create the new route manager + this.routeManager = new RouteManager(this.settings); + + // Create other required components + this.tlsManager = new TlsManager(this.settings); + this.networkProxyBridge = new NetworkProxyBridge(this.settings); + + // Initialize connection handler with route support + this.routeConnectionHandler = new RouteConnectionHandler( this.settings, this.connectionManager, this.securityManager, @@ -119,12 +133,12 @@ export class SmartProxy extends plugins.EventEmitter { this.tlsManager, this.networkProxyBridge, this.timeoutManager, - this.portRangeManager + this.routeManager ); } /** - * The settings for the port proxy + * The settings for the SmartProxy */ public settings: ISmartProxyOptions; @@ -142,8 +156,9 @@ export class SmartProxy extends plugins.EventEmitter { // Build and start the Port80Handler this.port80Handler = buildPort80Handler({ ...config, - httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort + httpsRedirectPort: config.httpsRedirectPort || (isLegacyOptions(this.settings) ? this.settings.fromPort : 443) }); + // Share Port80Handler with NetworkProxyBridge before start this.networkProxyBridge.setPort80Handler(this.port80Handler); await this.port80Handler.start(); @@ -154,7 +169,7 @@ export class SmartProxy extends plugins.EventEmitter { } /** - * Start the proxy server + * Start the proxy server with support for both configuration types */ public async start() { // Don't start if already shutting down @@ -163,11 +178,11 @@ export class SmartProxy extends plugins.EventEmitter { return; } - // Process domain configs - // Note: ensureForwardingConfig is no longer needed since forwarding is now required - - // Initialize domain config manager with the processed configs - this.domainConfigManager.updateDomainConfigs(this.settings.domainConfigs); + // If using legacy format, make sure domainConfigs are initialized + if (isLegacyOptions(this.settings)) { + // Initialize domain config manager with the processed configs + this.domainConfigManager.updateDomainConfigs(this.settings.domainConfigs); + } // Initialize Port80Handler if enabled await this.initializePort80Handler(); @@ -176,20 +191,39 @@ export class SmartProxy extends plugins.EventEmitter { if (this.port80Handler) { const acme = this.settings.acme!; - // Convert domain forwards to use the new forwarding system if possible + // Setup domain forwards based on configuration type const domainForwards = acme.domainForwards?.map(f => { - // If the domain has a forwarding config in domainConfigs, use that - const domainConfig = this.settings.domainConfigs.find( - dc => dc.domains.some(d => d === f.domain) - ); + if (isLegacyOptions(this.settings)) { + // If using legacy mode, check if domain config exists + const domainConfig = this.settings.domainConfigs.find( + dc => dc.domains.some(d => d === f.domain) + ); - if (domainConfig?.forwarding) { - return { + if (domainConfig?.forwarding) { + return { + domain: f.domain, + forwardConfig: f.forwardConfig, + acmeForwardConfig: f.acmeForwardConfig, + sslRedirect: f.sslRedirect || domainConfig.forwarding.http?.redirectToHttps || false + }; + } + } else { + // In route mode, look for matching route + const route = this.routeManager.findMatchingRoute({ + port: 443, domain: f.domain, - forwardConfig: f.forwardConfig, - acmeForwardConfig: f.acmeForwardConfig, - sslRedirect: f.sslRedirect || domainConfig.forwarding.http?.redirectToHttps || false - }; + clientIp: '127.0.0.1' // Dummy IP for finding routes + })?.route; + + if (route && route.action.type === 'forward' && route.action.tls) { + // If we found a matching route with TLS settings + return { + domain: f.domain, + forwardConfig: f.forwardConfig, + acmeForwardConfig: f.acmeForwardConfig, + sslRedirect: f.sslRedirect || false + }; + } } // Otherwise use the existing configuration @@ -201,17 +235,38 @@ export class SmartProxy extends plugins.EventEmitter { }; }) || []; - this.certProvisioner = new CertProvisioner( - this.settings.domainConfigs, - this.port80Handler, - this.networkProxyBridge, - this.settings.certProvisionFunction, - acme.renewThresholdDays!, - acme.renewCheckIntervalHours!, - acme.autoRenew!, - domainForwards - ); + // Create CertProvisioner with appropriate parameters + if (isLegacyOptions(this.settings)) { + this.certProvisioner = new CertProvisioner( + this.settings.domainConfigs, + this.port80Handler, + this.networkProxyBridge, + this.settings.certProvisionFunction, + acme.renewThresholdDays!, + acme.renewCheckIntervalHours!, + acme.autoRenew!, + domainForwards + ); + } else { + // For route-based configuration, we need to adapt the interface + // Convert routes to domain configs for CertProvisioner + const domainConfigs: IDomainConfig[] = this.extractDomainConfigsFromRoutes( + (this.settings as IRoutedSmartProxyOptions).routes + ); + this.certProvisioner = new CertProvisioner( + domainConfigs, + this.port80Handler, + this.networkProxyBridge, + this.settings.certProvisionFunction, + acme.renewThresholdDays!, + acme.renewCheckIntervalHours!, + acme.autoRenew!, + domainForwards + ); + } + + // Register certificate event handler this.certProvisioner.on('certificate', (certData) => { this.emit('certificate', { domain: certData.domain, @@ -228,25 +283,22 @@ export class SmartProxy extends plugins.EventEmitter { } // Initialize and start NetworkProxy if needed - if ( - this.settings.useNetworkProxy && - this.settings.useNetworkProxy.length > 0 - ) { + if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { await this.networkProxyBridge.initialize(); await this.networkProxyBridge.start(); } - // Validate port configuration - const configWarnings = this.portRangeManager.validateConfiguration(); + // Validate the route configuration + const configWarnings = this.routeManager.validateConfiguration(); if (configWarnings.length > 0) { - console.log("Port configuration warnings:"); + console.log("Route configuration warnings:"); for (const warning of configWarnings) { console.log(` - ${warning}`); } } - // Get listening ports from PortRangeManager - const listeningPorts = this.portRangeManager.getListeningPorts(); + // Get listening ports from RouteManager + const listeningPorts = this.routeManager.getListeningPorts(); // Create servers for each port for (const port of listeningPorts) { @@ -258,8 +310,8 @@ export class SmartProxy extends plugins.EventEmitter { return; } - // Delegate to connection handler - this.connectionHandler.handleConnection(socket); + // Delegate to route connection handler + this.routeConnectionHandler.handleConnection(socket); }).on('error', (err: Error) => { console.log(`Server Error on port ${port}: ${err.message}`); }); @@ -268,7 +320,9 @@ export class SmartProxy extends plugins.EventEmitter { const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port); console.log( `SmartProxy -> OK: Now listening on port ${port}${ - this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : '' + isLegacyOptions(this.settings) && this.settings.sniEnabled && !isNetworkProxyPort ? + ' (SNI passthrough enabled)' : + '' }${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}` ); }); @@ -348,12 +402,70 @@ export class SmartProxy extends plugins.EventEmitter { } } + /** + * Extract domain configurations from routes for certificate provisioning + */ + private extractDomainConfigsFromRoutes(routes: IRouteConfig[]): IDomainConfig[] { + const domainConfigs: IDomainConfig[] = []; + + for (const route of routes) { + // Skip routes without domain specs + if (!route.match.domains) continue; + + // Skip non-forward routes + if (route.action.type !== 'forward') continue; + + // Only process routes that need TLS termination (those with certificates) + if (!route.action.tls || + route.action.tls.mode === 'passthrough' || + !route.action.target) continue; + + const domains = Array.isArray(route.match.domains) + ? route.match.domains + : [route.match.domains]; + + // Determine forwarding type based on TLS mode + const forwardingType = route.action.tls.mode === 'terminate' + ? 'https-terminate-to-http' + : 'https-terminate-to-https'; + + // Create a forwarding config + const forwarding = { + type: forwardingType as any, + target: { + host: Array.isArray(route.action.target.host) + ? route.action.target.host[0] + : route.action.target.host, + port: route.action.target.port + }, + // Add TLS settings + https: { + customCert: route.action.tls.certificate !== 'auto' + ? route.action.tls.certificate + : undefined + }, + // Add security settings if present + security: route.action.security, + // Add advanced settings if present + advanced: route.action.advanced + }; + + domainConfigs.push({ + domains, + forwarding + }); + } + + return domainConfigs; + } + /** * Stop the proxy server */ public async stop() { console.log('SmartProxy shutting down...'); this.isShuttingDown = true; + // Stop CertProvisioner if active if (this.certProvisioner) { await this.certProvisioner.stop(); @@ -411,14 +523,17 @@ export class SmartProxy extends plugins.EventEmitter { } /** - * Updates the domain configurations for the proxy + * Updates the domain configurations for the proxy (legacy support) */ public async updateDomainConfigs(newDomainConfigs: IDomainConfig[]): Promise { console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`); - // Update domain configs in DomainConfigManager + // Update domain configs in DomainConfigManager (legacy) this.domainConfigManager.updateDomainConfigs(newDomainConfigs); + // Also update the RouteManager with these domain configs + this.routeManager.updateFromDomainConfigs(newDomainConfigs); + // If NetworkProxy is initialized, resync the configurations if (this.networkProxyBridge.getNetworkProxy()) { await this.networkProxyBridge.syncDomainConfigsToNetworkProxy(); @@ -428,7 +543,7 @@ export class SmartProxy extends plugins.EventEmitter { if (this.port80Handler && this.settings.acme?.enabled) { for (const domainConfig of newDomainConfigs) { // Skip certificate provisioning for http-only or passthrough configs that don't need certs - const forwardingType = domainConfig.forwarding.type; + const forwardingType = this.domainConfigManager.getForwardingType(domainConfig); const needsCertificate = forwardingType === 'https-terminate-to-http' || forwardingType === 'https-terminate-to-https'; @@ -490,6 +605,95 @@ export class SmartProxy extends plugins.EventEmitter { } } + /** + * Update routes with new configuration (new API) + */ + public async updateRoutes(newRoutes: IRouteConfig[]): Promise { + console.log(`Updating routes (${newRoutes.length} routes)`); + + // Update routes in RouteManager + this.routeManager.updateRoutes(newRoutes); + + // If NetworkProxy is initialized, resync the configurations + if (this.networkProxyBridge.getNetworkProxy()) { + // Create equivalent domain configs for NetworkProxy + const domainConfigs = this.extractDomainConfigsFromRoutes(newRoutes); + + // Update domain configs in DomainConfigManager for sync + this.domainConfigManager.updateDomainConfigs(domainConfigs); + + // Sync with NetworkProxy + await this.networkProxyBridge.syncDomainConfigsToNetworkProxy(); + } + + // If Port80Handler is running, provision certificates based on routes + if (this.port80Handler && this.settings.acme?.enabled) { + for (const route of newRoutes) { + // Skip routes without domains + if (!route.match.domains) continue; + + // Skip non-forward routes + if (route.action.type !== 'forward') continue; + + // Skip routes without TLS termination + if (!route.action.tls || + route.action.tls.mode === 'passthrough' || + !route.action.target) continue; + + // Skip certificate provisioning if certificate is not auto + if (route.action.tls.certificate !== 'auto') continue; + + const domains = Array.isArray(route.match.domains) + ? route.match.domains + : [route.match.domains]; + + for (const domain of domains) { + const isWildcard = domain.includes('*'); + let provision: string | plugins.tsclass.network.ICert = 'http01'; + + if (this.settings.certProvisionFunction) { + try { + provision = await this.settings.certProvisionFunction(domain); + } catch (err) { + console.log(`certProvider error for ${domain}: ${err}`); + } + } else if (isWildcard) { + console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`); + continue; + } + + if (provision === 'http01') { + if (isWildcard) { + console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); + continue; + } + + // Register domain with Port80Handler + this.port80Handler.addDomain({ + domainName: domain, + sslRedirect: true, + acmeMaintenance: true + }); + + console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`); + } else { + // Handle static certificate (e.g., DNS-01 provisioned) + const certObj = provision as plugins.tsclass.network.ICert; + const certData: ICertificateData = { + domain: certObj.domainName, + certificate: certObj.publicKey, + privateKey: certObj.privateKey, + expiryDate: new Date(certObj.validUntil) + }; + this.networkProxyBridge.applyExternalCertificate(certData); + console.log(`Applied static certificate for ${domain} from certProvider`); + } + } + } + + console.log('Provisioned certificates for new routes'); + } + } /** * Request a certificate for a specific domain @@ -583,7 +787,8 @@ export class SmartProxy extends plugins.EventEmitter { networkProxyConnections, terminationStats, acmeEnabled: !!this.port80Handler, - port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null + port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null, + routes: this.routeManager.getListeningPorts().length }; } @@ -591,18 +796,44 @@ export class SmartProxy extends plugins.EventEmitter { * Get a list of eligible domains for ACME certificates */ public getEligibleDomainsForCertificates(): string[] { - // Collect all non-wildcard domains from domain configs const domains: string[] = []; - for (const config of this.settings.domainConfigs) { + // Get domains from routes + const routes = isRoutedOptions(this.settings) ? this.settings.routes : []; + + for (const route of routes) { + if (!route.match.domains) continue; + + // Skip routes without TLS termination or auto certificates + if (route.action.type !== 'forward' || + !route.action.tls || + route.action.tls.mode === 'passthrough' || + route.action.tls.certificate !== 'auto') continue; + + const routeDomains = Array.isArray(route.match.domains) + ? route.match.domains + : [route.match.domains]; + // Skip domains that can't be used with ACME - const eligibleDomains = config.domains.filter(domain => + const eligibleDomains = routeDomains.filter(domain => !domain.includes('*') && this.isValidDomain(domain) ); domains.push(...eligibleDomains); } + // For legacy mode, also get domains from domain configs + if (isLegacyOptions(this.settings)) { + for (const config of this.settings.domainConfigs) { + // Skip domains that can't be used with ACME + const eligibleDomains = config.domains.filter(domain => + !domain.includes('*') && this.isValidDomain(domain) + ); + + domains.push(...eligibleDomains); + } + } + return domains; }