From 9697ab307812660665968fe6e2627ab602b159a7 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 30 Jan 2026 04:06:32 +0000 Subject: [PATCH] feat(proxies): introduce nftables command executor and utilities, default certificate provider, expanded route/socket helper modules, and security improvements --- certs/static-route/meta.json | 6 +- changelog.md | 10 + ts/00_commitinfo_data.ts | 2 +- ts/core/utils/shared-security-manager.ts | 111 +- ts/proxies/http-proxy/certificate-manager.ts | 244 --- ts/proxies/http-proxy/default-certificates.ts | 150 ++ ts/proxies/http-proxy/http-proxy.ts | 22 +- ts/proxies/http-proxy/index.ts | 7 +- ts/proxies/http-proxy/security-manager.ts | 302 ++-- ts/proxies/nftables-proxy/index.ts | 1 + ts/proxies/nftables-proxy/nftables-proxy.ts | 293 +--- ts/proxies/nftables-proxy/utils/index.ts | 38 + .../utils/nft-command-executor.ts | 162 ++ .../utils/nft-port-spec-normalizer.ts | 125 ++ .../utils/nft-rule-validator.ts | 156 ++ ts/proxies/smart-proxy/security-manager.ts | 145 +- ts/proxies/smart-proxy/utils/route-helpers.ts | 1307 +---------------- .../utils/route-helpers/api-helpers.ts | 144 ++ .../utils/route-helpers/dynamic-helpers.ts | 124 ++ .../utils/route-helpers/http-helpers.ts | 40 + .../utils/route-helpers/https-helpers.ts | 163 ++ .../smart-proxy/utils/route-helpers/index.ts | 62 + .../route-helpers/load-balancer-helpers.ts | 154 ++ .../utils/route-helpers/nftables-helpers.ts | 202 +++ .../utils/route-helpers/security-helpers.ts | 96 ++ .../utils/route-helpers/socket-handlers.ts | 337 +++++ .../utils/route-helpers/websocket-helpers.ts | 98 ++ 27 files changed, 2453 insertions(+), 2048 deletions(-) delete mode 100644 ts/proxies/http-proxy/certificate-manager.ts create mode 100644 ts/proxies/http-proxy/default-certificates.ts create mode 100644 ts/proxies/nftables-proxy/utils/index.ts create mode 100644 ts/proxies/nftables-proxy/utils/nft-command-executor.ts create mode 100644 ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts create mode 100644 ts/proxies/nftables-proxy/utils/nft-rule-validator.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/api-helpers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/dynamic-helpers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/http-helpers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/https-helpers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/index.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/load-balancer-helpers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/nftables-helpers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/security-helpers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts create mode 100644 ts/proxies/smart-proxy/utils/route-helpers/websocket-helpers.ts diff --git a/certs/static-route/meta.json b/certs/static-route/meta.json index 8dd6dca..9f0134f 100644 --- a/certs/static-route/meta.json +++ b/certs/static-route/meta.json @@ -1,5 +1,5 @@ { - "expiryDate": "2026-03-09T14:50:10.005Z", - "issueDate": "2025-12-09T14:50:10.005Z", - "savedAt": "2025-12-09T14:50:10.006Z" + "expiryDate": "2026-04-30T03:50:41.276Z", + "issueDate": "2026-01-30T03:50:41.276Z", + "savedAt": "2026-01-30T03:50:41.276Z" } \ No newline at end of file diff --git a/changelog.md b/changelog.md index 9609672..e6b031b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-01-30 - 22.2.0 - feat(proxies) +introduce nftables command executor and utilities, default certificate provider, expanded route/socket helper modules, and security improvements + +- Added NftCommandExecutor with retry, temp-file support, sync execution, availability and conntrack checks. +- Refactored NfTablesProxy to use executor/utils (normalizePortSpec, validators, port normalizer, IP family filtering) and removed inline command/validation code. +- Introduced DefaultCertificateProvider to replace the deprecated CertificateManager; HttpProxy now uses DefaultCertificateProvider (CertificateManager exported as deprecated alias for compatibility). +- Added extensive route helper modules (http, https, api, load-balancer, nftables, dynamic, websocket, security, socket handlers) to simplify route creation and provide reusable patterns. +- Enhanced SecurityManagers: centralized security utilities (normalizeIP, isIPAuthorized, parseBasicAuthHeader, cleanup helpers), added validateAndTrackIP and JWT token verification, better IP normalization and rate tracking. +- Added many utility modules under ts/proxies/nftables-proxy/utils (command executor, port spec normalizer, rule validator) and exposed them via barrel export. + ## 2025-12-09 - 22.1.1 - fix(tests) Normalize route configurations in tests to use name (remove id) and standardize route names diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 7ca59b1..e5f4fae 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '22.1.1', + version: '22.2.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' } diff --git a/ts/core/utils/shared-security-manager.ts b/ts/core/utils/shared-security-manager.ts index 5f7573c..6a77809 100644 --- a/ts/core/utils/shared-security-manager.ts +++ b/ts/core/utils/shared-security-manager.ts @@ -148,31 +148,66 @@ export class SharedSecurityManager { /** * Validate IP against rate limits and connection limits - * + * * @param ip - The IP address to validate * @returns Result with allowed status and reason if blocked */ public validateIP(ip: string): IIpValidationResult { // Check connection count limit const connectionResult = checkMaxConnections( - ip, - this.connectionsByIP, + ip, + this.connectionsByIP, this.maxConnectionsPerIP ); if (!connectionResult.allowed) { return connectionResult; } - + // Check connection rate limit const rateResult = checkConnectionRate( - ip, - this.connectionsByIP, + ip, + this.connectionsByIP, this.connectionRateLimitPerMinute ); if (!rateResult.allowed) { return rateResult; } - + + return { allowed: true }; + } + + /** + * Atomically validate an IP and track the connection if allowed. + * This prevents race conditions where concurrent connections could bypass per-IP limits. + * + * @param ip - The IP address to validate + * @param connectionId - The connection ID to track if validation passes + * @returns Object with validation result and reason + */ + public validateAndTrackIP(ip: string, connectionId: string): IIpValidationResult { + // Check connection count limit BEFORE tracking + const connectionResult = checkMaxConnections( + ip, + this.connectionsByIP, + this.maxConnectionsPerIP + ); + if (!connectionResult.allowed) { + return connectionResult; + } + + // Check connection rate limit + const rateResult = checkConnectionRate( + ip, + this.connectionsByIP, + this.connectionRateLimitPerMinute + ); + if (!rateResult.allowed) { + return rateResult; + } + + // Validation passed - immediately track to prevent race conditions + this.trackConnectionByIP(ip, connectionId); + return { allowed: true }; } @@ -304,7 +339,7 @@ export class SharedSecurityManager { /** * Validate HTTP Basic Authentication - * + * * @param route - The route to check * @param authHeader - The Authorization header * @returns Whether authentication is valid @@ -314,26 +349,76 @@ export class SharedSecurityManager { if (!route.security?.basicAuth?.enabled) { return true; } - + // No auth header means auth failed if (!authHeader) { return false; } - + // Parse auth header const credentials = parseBasicAuthHeader(authHeader); if (!credentials) { return false; } - + // Check credentials against configured users const { username, password } = credentials; const users = route.security.basicAuth.users; - - return users.some(user => + + return users.some(user => user.username === username && user.password === password ); } + + /** + * Verify a JWT token against route configuration + * + * @param route - The route to verify the token for + * @param token - The JWT token to verify + * @returns True if the token is valid, false otherwise + */ + public verifyJwtToken(route: IRouteConfig, token: string): boolean { + if (!route.security?.jwtAuth?.enabled) { + return true; + } + + try { + const jwtAuth = route.security.jwtAuth; + + // Verify structure (header.payload.signature) + const parts = token.split('.'); + if (parts.length !== 3) { + return false; + } + + // Decode payload + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + // Check expiration + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + return false; + } + + // Check issuer + if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) { + return false; + } + + // Check audience + if (jwtAuth.audience && payload.aud !== jwtAuth.audience) { + return false; + } + + // Note: In a real implementation, you'd also verify the signature + // using the secret and algorithm specified in jwtAuth. + // This requires a proper JWT library for cryptographic verification. + + return true; + } catch (err) { + this.logger?.error?.(`Error verifying JWT: ${err}`); + return false; + } + } /** * Clean up caches to prevent memory leaks diff --git a/ts/proxies/http-proxy/certificate-manager.ts b/ts/proxies/http-proxy/certificate-manager.ts deleted file mode 100644 index 8764d82..0000000 --- a/ts/proxies/http-proxy/certificate-manager.ts +++ /dev/null @@ -1,244 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; -import { AsyncFileSystem } from '../../core/utils/fs-utils.js'; -import { type IHttpProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './models/types.js'; -import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; - -/** - * @deprecated This class is deprecated. Use SmartCertManager instead. - * - * This is a stub implementation that maintains backward compatibility - * while the functionality has been moved to SmartCertManager. - */ -export class CertificateManager { - private defaultCertificates: { key: string; cert: string }; - private certificateCache: Map = new Map(); - private certificateStoreDir: string; - private logger: ILogger; - private httpsServer: plugins.https.Server | null = null; - private initialized = false; - - constructor(private options: IHttpProxyOptions) { - this.certificateStoreDir = path.resolve(options.acme?.certificateStore || './certs'); - this.logger = createLogger(options.logLevel || 'info'); - - this.logger.warn('CertificateManager is deprecated - use SmartCertManager instead'); - - // Initialize synchronously for backward compatibility but log warning - this.initializeSync(); - } - - /** - * Synchronous initialization for backward compatibility - * @deprecated This uses sync filesystem operations which block the event loop - */ - private initializeSync(): void { - // Ensure certificate store directory exists - try { - if (!fs.existsSync(this.certificateStoreDir)) { - fs.mkdirSync(this.certificateStoreDir, { recursive: true }); - this.logger.info(`Created certificate store directory: ${this.certificateStoreDir}`); - } - } catch (error) { - this.logger.warn(`Failed to create certificate store directory: ${error}`); - } - - this.loadDefaultCertificates(); - } - - /** - * Async initialization - preferred method - */ - public async initialize(): Promise { - if (this.initialized) return; - - // Ensure certificate store directory exists - try { - await AsyncFileSystem.ensureDir(this.certificateStoreDir); - this.logger.info(`Ensured certificate store directory: ${this.certificateStoreDir}`); - } catch (error) { - this.logger.warn(`Failed to create certificate store directory: ${error}`); - } - - await this.loadDefaultCertificatesAsync(); - this.initialized = true; - } - - /** - * Loads default certificates from the filesystem - * @deprecated This uses sync filesystem operations which block the event loop - */ - public loadDefaultCertificates(): void { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs'); - - try { - this.defaultCertificates = { - key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'), - cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8') - }; - this.logger.info('Loaded default certificates from filesystem (sync - deprecated)'); - } catch (error) { - this.logger.error(`Failed to load default certificates: ${error}`); - this.generateSelfSignedCertificate(); - } - } - - /** - * Loads default certificates from the filesystem asynchronously - */ - public async loadDefaultCertificatesAsync(): Promise { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs'); - - try { - const [key, cert] = await Promise.all([ - AsyncFileSystem.readFile(path.join(certPath, 'key.pem')), - AsyncFileSystem.readFile(path.join(certPath, 'cert.pem')) - ]); - - this.defaultCertificates = { key, cert }; - this.logger.info('Loaded default certificates from filesystem (async)'); - } catch (error) { - this.logger.error(`Failed to load default certificates: ${error}`); - this.generateSelfSignedCertificate(); - } - } - - /** - * Generates self-signed certificates as fallback - */ - private generateSelfSignedCertificate(): void { - // Generate a self-signed certificate using forge or similar - // For now, just use a placeholder - const selfSignedCert = `-----BEGIN CERTIFICATE----- -MIIBkTCB+wIJAKHHIgIIA0/cMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT -MB4XDTE0MDEwMTAwMDAwMFoXDTI0MDEwMTAwMDAwMFowDTELMAkGA1UEBhMCVVMw -gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMRiH0VwnOH3jCV7c6JFZWYrvuqy ------END CERTIFICATE-----`; - - const selfSignedKey = `-----BEGIN PRIVATE KEY----- -MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMRiH0VwnOH3jCV7 -c6JFZWYrvuqyALCLXj0pcr1iqNdHjegNXnkl5zjdaUjq4edNOKl7M1AlFiYjG2xk ------END PRIVATE KEY-----`; - - this.defaultCertificates = { - key: selfSignedKey, - cert: selfSignedCert - }; - - this.logger.warn('Using self-signed certificate as fallback'); - } - - /** - * Gets the default certificates - */ - public getDefaultCertificates(): { key: string; cert: string } { - return this.defaultCertificates; - } - - /** - * @deprecated Use SmartCertManager instead - */ - public setExternalPort80Handler(handler: any): void { - this.logger.warn('setExternalPort80Handler is deprecated - use SmartCertManager instead'); - } - - /** - * @deprecated Use SmartCertManager instead - */ - public async updateRoutes(routes: IRouteConfig[]): Promise { - this.logger.warn('updateRoutes is deprecated - use SmartCertManager instead'); - } - - /** - * Handles SNI callback to provide appropriate certificate - */ - public handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void { - const certificate = this.getCachedCertificate(domain); - - if (certificate) { - const context = plugins.tls.createSecureContext({ - key: certificate.key, - cert: certificate.cert - }); - cb(null, context); - return; - } - - // Use default certificate if no domain-specific certificate found - const defaultContext = plugins.tls.createSecureContext({ - key: this.defaultCertificates.key, - cert: this.defaultCertificates.cert - }); - cb(null, defaultContext); - } - - /** - * Updates a certificate in the cache - */ - public updateCertificate(domain: string, cert: string, key: string): void { - this.certificateCache.set(domain, { - cert, - key, - expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days - }); - - this.logger.info(`Certificate updated for ${domain}`); - } - - /** - * Gets a cached certificate - */ - private getCachedCertificate(domain: string): ICertificateEntry | null { - return this.certificateCache.get(domain) || null; - } - - /** - * @deprecated Use SmartCertManager instead - */ - public async initializePort80Handler(): Promise { - this.logger.warn('initializePort80Handler is deprecated - use SmartCertManager instead'); - return null; - } - - /** - * @deprecated Use SmartCertManager instead - */ - public async stopPort80Handler(): Promise { - this.logger.warn('stopPort80Handler is deprecated - use SmartCertManager instead'); - } - - /** - * @deprecated Use SmartCertManager instead - */ - public registerDomainsWithPort80Handler(domains: string[]): void { - this.logger.warn('registerDomainsWithPort80Handler is deprecated - use SmartCertManager instead'); - } - - /** - * @deprecated Use SmartCertManager instead - */ - public registerRoutesWithPort80Handler(routes: IRouteConfig[]): void { - this.logger.warn('registerRoutesWithPort80Handler is deprecated - use SmartCertManager instead'); - } - - /** - * Sets the HTTPS server for certificate updates - */ - public setHttpsServer(server: plugins.https.Server): void { - this.httpsServer = server; - } - - /** - * Gets statistics for metrics - */ - public getStats() { - return { - cachedCertificates: this.certificateCache.size, - defaultCertEnabled: true - }; - } -} \ No newline at end of file diff --git a/ts/proxies/http-proxy/default-certificates.ts b/ts/proxies/http-proxy/default-certificates.ts new file mode 100644 index 0000000..3fe9812 --- /dev/null +++ b/ts/proxies/http-proxy/default-certificates.ts @@ -0,0 +1,150 @@ +import * as plugins from '../../plugins.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { AsyncFileSystem } from '../../core/utils/fs-utils.js'; +import type { ILogger, ICertificateEntry } from './models/types.js'; + +/** + * Interface for default certificate data + */ +export interface IDefaultCertificates { + key: string; + cert: string; +} + +/** + * Provides default SSL certificates for HttpProxy. + * This is a minimal replacement for the deprecated CertificateManager. + * + * For production certificate management, use SmartCertManager instead. + */ +export class DefaultCertificateProvider { + private defaultCertificates: IDefaultCertificates | null = null; + private certificateCache: Map = new Map(); + private initialized = false; + + constructor(private logger?: ILogger) {} + + /** + * Load default certificates asynchronously (preferred) + */ + public async loadDefaultCertificatesAsync(): Promise { + if (this.defaultCertificates) { + return this.defaultCertificates; + } + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs'); + + try { + const [key, cert] = await Promise.all([ + AsyncFileSystem.readFile(path.join(certPath, 'key.pem')), + AsyncFileSystem.readFile(path.join(certPath, 'cert.pem')) + ]); + + this.defaultCertificates = { key, cert }; + this.logger?.info?.('Loaded default certificates from filesystem'); + this.initialized = true; + return this.defaultCertificates; + } catch (error) { + this.logger?.warn?.(`Failed to load default certificates: ${error}`); + this.defaultCertificates = this.generateFallbackCertificate(); + this.initialized = true; + return this.defaultCertificates; + } + } + + /** + * Load default certificates synchronously (for backward compatibility) + * @deprecated Use loadDefaultCertificatesAsync instead + */ + public loadDefaultCertificatesSync(): IDefaultCertificates { + if (this.defaultCertificates) { + return this.defaultCertificates; + } + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs'); + + try { + this.defaultCertificates = { + key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'), + cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8') + }; + this.logger?.info?.('Loaded default certificates from filesystem (sync)'); + } catch (error) { + this.logger?.warn?.(`Failed to load default certificates: ${error}`); + this.defaultCertificates = this.generateFallbackCertificate(); + } + + this.initialized = true; + return this.defaultCertificates; + } + + /** + * Gets the default certificates (loads synchronously if not already loaded) + */ + public getDefaultCertificates(): IDefaultCertificates { + if (!this.defaultCertificates) { + return this.loadDefaultCertificatesSync(); + } + return this.defaultCertificates; + } + + /** + * Updates a certificate in the cache + */ + public updateCertificate(domain: string, cert: string, key: string): void { + this.certificateCache.set(domain, { + cert, + key, + expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days + }); + + this.logger?.info?.(`Certificate updated for ${domain}`); + } + + /** + * Gets a cached certificate + */ + public getCachedCertificate(domain: string): ICertificateEntry | null { + return this.certificateCache.get(domain) || null; + } + + /** + * Gets statistics for metrics + */ + public getStats(): { cachedCertificates: number; defaultCertEnabled: boolean } { + return { + cachedCertificates: this.certificateCache.size, + defaultCertEnabled: this.defaultCertificates !== null + }; + } + + /** + * Generate a fallback self-signed certificate placeholder + * Note: This is just a placeholder - real apps should provide proper certificates + */ + private generateFallbackCertificate(): IDefaultCertificates { + this.logger?.warn?.('Using fallback self-signed certificate placeholder'); + + // Minimal self-signed certificate for fallback only + // In production, proper certificates should be provided via SmartCertManager + const selfSignedCert = `-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHHIgIIA0/cMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT +MB4XDTE0MDEwMTAwMDAwMFoXDTI0MDEwMTAwMDAwMFowDTELMAkGA1UEBhMCVVMw +gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMRiH0VwnOH3jCV7c6JFZWYrvuqy +-----END CERTIFICATE-----`; + + const selfSignedKey = `-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMRiH0VwnOH3jCV7 +c6JFZWYrvuqyALCLXj0pcr1iqNdHjegNXnkl5zjdaUjq4edNOKl7M1AlFiYjG2xk +-----END PRIVATE KEY-----`; + + return { + key: selfSignedKey, + cert: selfSignedCert + }; + } +} diff --git a/ts/proxies/http-proxy/http-proxy.ts b/ts/proxies/http-proxy/http-proxy.ts index f513422..11e80b3 100644 --- a/ts/proxies/http-proxy/http-proxy.ts +++ b/ts/proxies/http-proxy/http-proxy.ts @@ -10,7 +10,7 @@ import type { import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js'; import { createBaseRouteContext } from '../../core/models/route-context.js'; -import { CertificateManager } from './certificate-manager.js'; +import { DefaultCertificateProvider } from './default-certificates.js'; import { ConnectionPool } from './connection-pool.js'; import { RequestHandler, type IMetricsTracker } from './request-handler.js'; import { WebSocketHandler } from './websocket-handler.js'; @@ -38,7 +38,7 @@ export class HttpProxy implements IMetricsTracker { public httpsServer: plugins.http2.Http2SecureServer; // Core components - private certificateManager: CertificateManager; + private defaultCertProvider: DefaultCertificateProvider; private connectionPool: ConnectionPool; private requestHandler: RequestHandler; private webSocketHandler: WebSocketHandler; @@ -126,7 +126,7 @@ export class HttpProxy implements IMetricsTracker { ); // Initialize other components - this.certificateManager = new CertificateManager(this.options); + this.defaultCertProvider = new DefaultCertificateProvider(this.logger); this.connectionPool = new ConnectionPool(this.options); this.requestHandler = new RequestHandler( this.options, @@ -237,10 +237,11 @@ export class HttpProxy implements IMetricsTracker { this.startTime = Date.now(); // Create HTTP/2 server with HTTP/1 fallback + const defaultCerts = this.defaultCertProvider.getDefaultCertificates(); this.httpsServer = plugins.http2.createSecureServer( { - key: this.certificateManager.getDefaultCertificates().key, - cert: this.certificateManager.getDefaultCertificates().cert, + key: defaultCerts.key, + cert: defaultCerts.cert, allowHTTP1: true, ALPNProtocols: ['h2', 'http/1.1'] } @@ -258,9 +259,6 @@ export class HttpProxy implements IMetricsTracker { this.requestHandler.handleRequest(req, res); }); - // Share server with certificate manager for dynamic contexts - // Cast to https.Server as Http2SecureServer is compatible for certificate contexts - this.certificateManager.setHttpsServer(this.httpsServer as any); // Setup WebSocket support on HTTP/1 fallback this.webSocketHandler.initialize(this.httpsServer as any); // Start metrics logging @@ -506,10 +504,6 @@ export class HttpProxy implements IMetricsTracker { this.requestHandler.securityManager.setRoutes(routes); this.routes = routes; - // Directly update the certificate manager with the new routes - // This will extract domains and handle certificate provisioning - this.certificateManager.updateRoutes(routes); - // Collect all domains and certificates for configuration const currentHostnames = new Set(); const certificateUpdates = new Map(); @@ -548,7 +542,7 @@ export class HttpProxy implements IMetricsTracker { // Update certificate cache with any static certificates for (const [domain, certData] of certificateUpdates.entries()) { try { - this.certificateManager.updateCertificate( + this.defaultCertProvider.updateCertificate( domain, certData.cert, certData.key @@ -663,7 +657,7 @@ export class HttpProxy implements IMetricsTracker { expiryDate?: Date ): void { this.logger.info(`Updating certificate for ${domain}`); - this.certificateManager.updateCertificate(domain, certificate, privateKey); + this.defaultCertProvider.updateCertificate(domain, certificate, privateKey); } /** diff --git a/ts/proxies/http-proxy/index.ts b/ts/proxies/http-proxy/index.ts index ec9db9a..5d4f51c 100644 --- a/ts/proxies/http-proxy/index.ts +++ b/ts/proxies/http-proxy/index.ts @@ -6,8 +6,13 @@ export * from './models/index.js'; // Export HttpProxy and supporting classes export { HttpProxy } from './http-proxy.js'; -export { CertificateManager } from './certificate-manager.js'; +export { DefaultCertificateProvider } from './default-certificates.js'; export { ConnectionPool } from './connection-pool.js'; export { RequestHandler } from './request-handler.js'; export type { IMetricsTracker, MetricsTracker } from './request-handler.js'; export { WebSocketHandler } from './websocket-handler.js'; + +/** + * @deprecated Use DefaultCertificateProvider instead. This alias is for backward compatibility. + */ +export { DefaultCertificateProvider as CertificateManager } from './default-certificates.js'; diff --git a/ts/proxies/http-proxy/security-manager.ts b/ts/proxies/http-proxy/security-manager.ts index e17e561..858e1f4 100644 --- a/ts/proxies/http-proxy/security-manager.ts +++ b/ts/proxies/http-proxy/security-manager.ts @@ -1,28 +1,40 @@ -import * as plugins from '../../plugins.js'; import type { ILogger } from './models/types.js'; import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; import type { IRouteContext } from '../../core/models/route-context.js'; +import { + isIPAuthorized, + normalizeIP, + parseBasicAuthHeader, + cleanupExpiredRateLimits, + type IRateLimitInfo +} from '../../core/utils/security-utils.js'; /** - * Manages security features for the NetworkProxy - * Implements Phase 5.4: Security features like IP filtering and rate limiting + * Manages security features for the HttpProxy + * Implements IP filtering, rate limiting, and authentication. + * Uses shared utilities from security-utils.ts. */ export class SecurityManager { // Cache IP filtering results to avoid constant regex matching private ipFilterCache: Map> = new Map(); - + // Store rate limits per route and key - private rateLimits: Map> = new Map(); - + private rateLimits: Map> = new Map(); + // Connection tracking by IP private connectionsByIP: Map> = new Map(); private connectionRateByIP: Map = new Map(); - - constructor(private logger: ILogger, private routes: IRouteConfig[] = [], private maxConnectionsPerIP: number = 100, private connectionRateLimitPerMinute: number = 300) { + + constructor( + private logger: ILogger, + private routes: IRouteConfig[] = [], + private maxConnectionsPerIP: number = 100, + private connectionRateLimitPerMinute: number = 300 + ) { // Start periodic cleanup for connection tracking this.startPeriodicIpCleanup(); } - + /** * Update the routes configuration */ @@ -31,10 +43,10 @@ export class SecurityManager { // Reset caches when routes change this.ipFilterCache.clear(); } - + /** * Check if a client is allowed to access a specific route - * + * * @param route The route to check access for * @param context The route context with client information * @returns True if access is allowed, false otherwise @@ -43,26 +55,26 @@ export class SecurityManager { if (!route.security) { return true; // No security restrictions } - + // --- IP filtering --- if (!this.isIpAllowed(route, context.clientIp)) { - this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || route.id || 'unnamed'}`); + this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || 'unnamed'}`); return false; } - + // --- Rate limiting --- if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) { - this.logger.debug(`Rate limit exceeded for route ${route.name || route.id || 'unnamed'}`); + this.logger.debug(`Rate limit exceeded for route ${route.name || 'unnamed'}`); return false; } - + // --- Basic Auth (handled at HTTP level) --- // Basic auth is not checked here as it requires HTTP headers // and is handled in the RequestHandler - + return true; } - + /** * Check if an IP is allowed based on route security settings */ @@ -70,94 +82,32 @@ export class SecurityManager { if (!route.security) { return true; // No security restrictions } - - const routeId = route.id || route.name || 'unnamed'; - + + const routeId = route.name || 'unnamed'; + // Check cache first if (!this.ipFilterCache.has(routeId)) { this.ipFilterCache.set(routeId, new Map()); } - + const routeCache = this.ipFilterCache.get(routeId)!; if (routeCache.has(clientIp)) { return routeCache.get(clientIp)!; } - - let allowed = true; - - // Check block list first (deny has priority over allow) - if (route.security.ipBlockList && route.security.ipBlockList.length > 0) { - if (this.ipMatchesPattern(clientIp, route.security.ipBlockList)) { - allowed = false; - } - } - - // Then check allow list (overrides block list if specified) - if (route.security.ipAllowList && route.security.ipAllowList.length > 0) { - // If allow list is specified, IP must match an entry to be allowed - allowed = this.ipMatchesPattern(clientIp, route.security.ipAllowList); - } - + + // Use shared utility for IP authorization + const allowed = isIPAuthorized( + clientIp, + route.security.ipAllowList, + route.security.ipBlockList + ); + // Cache the result routeCache.set(clientIp, allowed); - + return allowed; } - - /** - * Check if IP matches any pattern in the list - */ - private ipMatchesPattern(ip: string, patterns: string[]): boolean { - for (const pattern of patterns) { - // CIDR notation - if (pattern.includes('/')) { - if (this.ipMatchesCidr(ip, pattern)) { - return true; - } - } - // Wildcard notation - else if (pattern.includes('*')) { - const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); - if (regex.test(ip)) { - return true; - } - } - // Exact match - else if (pattern === ip) { - return true; - } - } - return false; - } - - /** - * Check if IP matches CIDR notation - * Very basic implementation - for production use, consider a dedicated IP library - */ - private ipMatchesCidr(ip: string, cidr: string): boolean { - try { - const [subnet, bits] = cidr.split('/'); - const mask = parseInt(bits, 10); - - // Convert IP to numeric format - const ipParts = ip.split('.').map(part => parseInt(part, 10)); - const subnetParts = subnet.split('.').map(part => parseInt(part, 10)); - - // Calculate the numeric IP and subnet - const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; - const subnetNum = (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3]; - - // Calculate the mask - const maskNum = ~((1 << (32 - mask)) - 1); - - // Check if IP is in subnet - return (ipNum & maskNum) === (subnetNum & maskNum); - } catch (e) { - this.logger.error(`Invalid CIDR notation: ${cidr}`); - return false; - } - } - + /** * Check if request is within rate limit */ @@ -165,13 +115,13 @@ export class SecurityManager { if (!route.security?.rateLimit?.enabled) { return true; } - + const rateLimit = route.security.rateLimit; - const routeId = route.id || route.name || 'unnamed'; - + const routeId = route.name || 'unnamed'; + // Determine rate limit key (by IP, path, or header) let key = context.clientIp; // Default to IP - + if (rateLimit.keyBy === 'path' && context.path) { key = `${context.clientIp}:${context.path}`; } else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) { @@ -180,15 +130,15 @@ export class SecurityManager { key = `${context.clientIp}:${headerValue}`; } } - + // Get or create rate limit tracking for this route if (!this.rateLimits.has(routeId)) { this.rateLimits.set(routeId, new Map()); } - + const routeLimits = this.rateLimits.get(routeId)!; const now = Date.now(); - + // Get or create rate limit tracking for this key let limit = routeLimits.get(key); if (!limit || limit.expiry < now) { @@ -200,37 +150,30 @@ export class SecurityManager { routeLimits.set(key, limit); return true; } - + // Increment the counter limit.count++; - + // Check if rate limit is exceeded return limit.count <= rateLimit.maxRequests; } - + /** * Clean up expired rate limits * Should be called periodically to prevent memory leaks */ public cleanupExpiredRateLimits(): void { - const now = Date.now(); - for (const [routeId, routeLimits] of this.rateLimits.entries()) { - let removed = 0; - for (const [key, limit] of routeLimits.entries()) { - if (limit.expiry < now) { - routeLimits.delete(key); - removed++; - } - } - if (removed > 0) { - this.logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`); - } - } + cleanupExpiredRateLimits(this.rateLimits, { + info: this.logger.info.bind(this.logger), + warn: this.logger.warn.bind(this.logger), + error: this.logger.error.bind(this.logger), + debug: this.logger.debug?.bind(this.logger) + }); } - + /** * Check basic auth credentials - * + * * @param route The route to check auth for * @param username The provided username * @param password The provided password @@ -240,22 +183,22 @@ export class SecurityManager { if (!route.security?.basicAuth?.enabled) { return true; } - + const basicAuth = route.security.basicAuth; - + // Check credentials against configured users for (const user of basicAuth.users) { if (user.username === username && user.password === password) { return true; } } - + return false; } - + /** * Verify a JWT token - * + * * @param route The route to verify the token for * @param token The JWT token to verify * @returns True if the token is valid, false otherwise @@ -264,38 +207,37 @@ export class SecurityManager { if (!route.security?.jwtAuth?.enabled) { return true; } - + try { - // This is a simplified version - in production you'd use a proper JWT library const jwtAuth = route.security.jwtAuth; - + // Verify structure const parts = token.split('.'); if (parts.length !== 3) { return false; } - + // Decode payload const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); - + // Check expiration if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { return false; } - + // Check issuer if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) { return false; } - + // Check audience if (jwtAuth.audience && payload.aud !== jwtAuth.audience) { return false; } - - // In a real implementation, you'd also verify the signature + + // Note: In a real implementation, you'd also verify the signature // using the secret and algorithm specified in jwtAuth - + return true; } catch (err) { this.logger.error(`Error verifying JWT: ${err}`); @@ -304,12 +246,20 @@ export class SecurityManager { } /** - * Get connections count by IP + * Get connections count by IP (checks normalized variants) */ public getConnectionCountByIP(ip: string): number { - return this.connectionsByIP.get(ip)?.size || 0; + // Check all normalized variants of the IP + const variants = normalizeIP(ip); + for (const variant of variants) { + const connections = this.connectionsByIP.get(variant); + if (connections) { + return connections.size; + } + } + return 0; } - + /** * Check and update connection rate for an IP * @returns true if within rate limit, false if exceeding limit @@ -318,43 +268,73 @@ export class SecurityManager { const now = Date.now(); const minute = 60 * 1000; - if (!this.connectionRateByIP.has(ip)) { - this.connectionRateByIP.set(ip, [now]); + // Find existing rate tracking (check normalized variants) + const variants = normalizeIP(ip); + let existingKey: string | null = null; + for (const variant of variants) { + if (this.connectionRateByIP.has(variant)) { + existingKey = variant; + break; + } + } + + const key = existingKey || ip; + + if (!this.connectionRateByIP.has(key)) { + this.connectionRateByIP.set(key, [now]); return true; } // Get timestamps and filter out entries older than 1 minute - const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute); + const timestamps = this.connectionRateByIP.get(key)!.filter((time) => now - time < minute); timestamps.push(now); - this.connectionRateByIP.set(ip, timestamps); + this.connectionRateByIP.set(key, timestamps); // Check if rate exceeds limit return timestamps.length <= this.connectionRateLimitPerMinute; } - + /** * Track connection by IP */ public trackConnectionByIP(ip: string, connectionId: string): void { - if (!this.connectionsByIP.has(ip)) { - this.connectionsByIP.set(ip, new Set()); + // Check if any variant already exists + const variants = normalizeIP(ip); + let existingKey: string | null = null; + + for (const variant of variants) { + if (this.connectionsByIP.has(variant)) { + existingKey = variant; + break; + } } - this.connectionsByIP.get(ip)!.add(connectionId); + + const key = existingKey || ip; + if (!this.connectionsByIP.has(key)) { + this.connectionsByIP.set(key, new Set()); + } + this.connectionsByIP.get(key)!.add(connectionId); } - + /** * Remove connection tracking for an IP */ public removeConnectionByIP(ip: string, connectionId: string): void { - if (this.connectionsByIP.has(ip)) { - const connections = this.connectionsByIP.get(ip)!; - connections.delete(connectionId); - if (connections.size === 0) { - this.connectionsByIP.delete(ip); + // Check all variants to find where the connection is tracked + const variants = normalizeIP(ip); + + for (const variant of variants) { + if (this.connectionsByIP.has(variant)) { + const connections = this.connectionsByIP.get(variant)!; + connections.delete(connectionId); + if (connections.size === 0) { + this.connectionsByIP.delete(variant); + } + break; } } } - + /** * Check if IP should be allowed considering connection rate and max connections * @returns Object with result and reason @@ -375,10 +355,10 @@ export class SecurityManager { reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded` }; } - + return { allowed: true }; } - + /** * Clears all IP tracking data (for shutdown) */ @@ -386,7 +366,7 @@ export class SecurityManager { this.connectionsByIP.clear(); this.connectionRateByIP.clear(); } - + /** * Start periodic cleanup of IP tracking data */ @@ -396,7 +376,7 @@ export class SecurityManager { this.performIpCleanup(); }, 60000).unref(); } - + /** * Perform cleanup of expired IP data */ @@ -405,11 +385,11 @@ export class SecurityManager { const minute = 60 * 1000; let cleanedRateLimits = 0; let cleanedIPs = 0; - + // Clean up expired rate limit timestamps for (const [ip, timestamps] of this.connectionRateByIP.entries()) { - const validTimestamps = timestamps.filter(time => now - time < minute); - + const validTimestamps = timestamps.filter((time) => now - time < minute); + if (validTimestamps.length === 0) { this.connectionRateByIP.delete(ip); cleanedRateLimits++; @@ -417,7 +397,7 @@ export class SecurityManager { this.connectionRateByIP.set(ip, validTimestamps); } } - + // Clean up IPs with no active connections for (const [ip, connections] of this.connectionsByIP.entries()) { if (connections.size === 0) { @@ -425,7 +405,7 @@ export class SecurityManager { cleanedIPs++; } } - + if (cleanedRateLimits > 0 || cleanedIPs > 0) { this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`); } diff --git a/ts/proxies/nftables-proxy/index.ts b/ts/proxies/nftables-proxy/index.ts index 5f5cedd..b7eecea 100644 --- a/ts/proxies/nftables-proxy/index.ts +++ b/ts/proxies/nftables-proxy/index.ts @@ -3,3 +3,4 @@ */ export * from './nftables-proxy.js'; export * from './models/index.js'; +export * from './utils/index.js'; diff --git a/ts/proxies/nftables-proxy/nftables-proxy.ts b/ts/proxies/nftables-proxy/nftables-proxy.ts index ee87232..5fd75c9 100644 --- a/ts/proxies/nftables-proxy/nftables-proxy.ts +++ b/ts/proxies/nftables-proxy/nftables-proxy.ts @@ -3,10 +3,8 @@ import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { delay } from '../../core/utils/async-utils.js'; import { AsyncFileSystem } from '../../core/utils/fs-utils.js'; import { - NftBaseError, NftValidationError, NftExecutionError, NftResourceError @@ -16,6 +14,12 @@ import type { NfTableProxyOptions, NfTablesStatus } from './models/index.js'; +import { + NftCommandExecutor, + normalizePortSpec, + validateSettings, + filterIPsByFamily +} from './utils/index.js'; const execAsync = promisify(exec); @@ -44,11 +48,12 @@ export class NfTablesProxy { private ruleTag: string; private tableName: string; private tempFilePath: string; + private executor: NftCommandExecutor; private static NFT_CMD = 'nft'; constructor(settings: NfTableProxyOptions) { // Validate inputs to prevent command injection - this.validateSettings(settings); + validateSettings(settings); // Set default settings this.settings = { @@ -74,6 +79,16 @@ export class NfTablesProxy { // Create a temp file path for batch operations this.tempFilePath = path.join(os.tmpdir(), `nft-rules-${Date.now()}.nft`); + // Create the command executor + this.executor = new NftCommandExecutor( + (level, message, data) => this.log(level, message, data), + { + maxRetries: this.settings.maxRetries, + retryDelayMs: this.settings.retryDelayMs, + tempFilePath: this.tempFilePath + } + ); + // Register cleanup handlers if deleteOnExit is true if (this.settings.deleteOnExit) { // Synchronous cleanup for 'exit' event (only sync code runs here) @@ -104,183 +119,17 @@ export class NfTablesProxy { } } - /** - * Validates settings to prevent command injection and ensure valid values - */ - private validateSettings(settings: NfTableProxyOptions): void { - // Validate port numbers - const validatePorts = (port: number | PortRange | Array) => { - if (Array.isArray(port)) { - port.forEach(p => validatePorts(p)); - return; - } - - if (typeof port === 'number') { - if (port < 1 || port > 65535) { - throw new NftValidationError(`Invalid port number: ${port}`); - } - } else if (typeof port === 'object') { - if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) { - throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`); - } - } - }; - - validatePorts(settings.fromPort); - validatePorts(settings.toPort); - - // Define regex patterns for validation - const ipRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/; - const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/; - - // Validate IP addresses - const validateIPs = (ips?: string[]) => { - if (!ips) return; - - for (const ip of ips) { - if (!ipRegex.test(ip) && !ipv6Regex.test(ip)) { - throw new NftValidationError(`Invalid IP address format: ${ip}`); - } - } - }; - - validateIPs(settings.ipAllowList); - validateIPs(settings.ipBlockList); - - // Validate toHost - only allow hostnames or IPs - if (settings.toHost) { - const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; - if (!hostRegex.test(settings.toHost) && !ipRegex.test(settings.toHost) && !ipv6Regex.test(settings.toHost)) { - throw new NftValidationError(`Invalid host format: ${settings.toHost}`); - } - } - - // Validate table name to prevent command injection - if (settings.tableName) { - const tableNameRegex = /^[a-zA-Z0-9_]+$/; - if (!tableNameRegex.test(settings.tableName)) { - throw new NftValidationError(`Invalid table name: ${settings.tableName}. Only alphanumeric characters and underscores are allowed.`); - } - } - - // Validate QoS settings if enabled - if (settings.qos?.enabled) { - if (settings.qos.maxRate) { - const rateRegex = /^[0-9]+[kKmMgG]?bps$/; - if (!rateRegex.test(settings.qos.maxRate)) { - throw new NftValidationError(`Invalid rate format: ${settings.qos.maxRate}. Use format like "10mbps", "1gbps", etc.`); - } - } - - if (settings.qos.priority !== undefined) { - if (settings.qos.priority < 1 || settings.qos.priority > 10 || !Number.isInteger(settings.qos.priority)) { - throw new NftValidationError(`Invalid priority: ${settings.qos.priority}. Must be an integer between 1 and 10.`); - } - } - } - } - - /** - * Normalizes port specifications into an array of port ranges - */ - private normalizePortSpec(portSpec: number | PortRange | Array): PortRange[] { - const result: PortRange[] = []; - - if (Array.isArray(portSpec)) { - // If it's an array, process each element - for (const spec of portSpec) { - result.push(...this.normalizePortSpec(spec)); - } - } else if (typeof portSpec === 'number') { - // Single port becomes a range with the same start and end - result.push({ from: portSpec, to: portSpec }); - } else { - // Already a range - result.push(portSpec); - } - - return result; - } - - /** - * Execute a command with retry capability - */ - private async executeWithRetry(command: string, maxRetries = 3, retryDelayMs = 1000): Promise { - let lastError: Error | undefined; - - for (let i = 0; i < maxRetries; i++) { - try { - const { stdout } = await execAsync(command); - return stdout; - } catch (err) { - lastError = err; - this.log('warn', `Command failed (attempt ${i+1}/${maxRetries}): ${command}`, { error: err.message }); - - // Wait before retry, unless it's the last attempt - if (i < maxRetries - 1) { - await delay(retryDelayMs); - } - } - } - - throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`); - } - - /** - * Execute system command synchronously (single attempt, no retry) - * Used only for exit handlers where the process is terminating anyway. - * For normal operations, use the async executeWithRetry method. - */ - private executeSync(command: string): string { - try { - return execSync(command, { timeout: 5000 }).toString(); - } catch (err) { - this.log('warn', `Sync command failed: ${command}`, { error: err.message }); - throw err; - } - } - - /** - * Execute nftables commands with a temporary file - * This helper handles the common pattern of writing rules to a temp file, - * executing nftables with the file, and cleaning up - */ - private async executeWithTempFile(rulesetContent: string): Promise { - await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent); - - try { - await this.executeWithRetry( - `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, - this.settings.maxRetries, - this.settings.retryDelayMs - ); - } finally { - // Always clean up the temp file - await AsyncFileSystem.remove(this.tempFilePath); - } - } - /** * Checks if nftables is available and the required modules are loaded */ private async checkNftablesAvailability(): Promise { - try { - await this.executeWithRetry(`${NfTablesProxy.NFT_CMD} --version`, this.settings.maxRetries, this.settings.retryDelayMs); - - // Check for conntrack support if we're using advanced NAT - if (this.settings.useAdvancedNAT) { - try { - await this.executeWithRetry('lsmod | grep nf_conntrack', this.settings.maxRetries, this.settings.retryDelayMs); - } catch (err) { - this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work'); - } - } - - return true; - } catch (err) { - this.log('error', `nftables is not available: ${err.message}`); - return false; + const available = await this.executor.checkAvailability(); + + if (available && this.settings.useAdvancedNAT) { + await this.executor.checkConntrackModules(); } + + return available; } /** @@ -291,7 +140,7 @@ export class NfTablesProxy { try { // Check if the table already exists - const stdout = await this.executeWithRetry( + const stdout = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list tables ${family}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -301,7 +150,7 @@ export class NfTablesProxy { if (!tableExists) { // Create the table - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -310,7 +159,7 @@ export class NfTablesProxy { this.log('info', `Created table ${family} ${this.tableName}`); // Create the nat chain for the prerouting hook - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_prerouting { type nat hook prerouting priority -100 ; }`, this.settings.maxRetries, this.settings.retryDelayMs @@ -320,7 +169,7 @@ export class NfTablesProxy { // Create the nat chain for the postrouting hook if not preserving source IP if (!this.settings.preserveSourceIP) { - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_postrouting { type nat hook postrouting priority 100 ; }`, this.settings.maxRetries, this.settings.retryDelayMs @@ -331,7 +180,7 @@ export class NfTablesProxy { // Create the chain for NetworkProxy integration if needed if (this.settings.netProxyIntegration?.enabled && this.settings.netProxyIntegration.redirectLocalhost) { - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} nat_output { type nat hook output priority 0 ; }`, this.settings.maxRetries, this.settings.retryDelayMs @@ -342,7 +191,7 @@ export class NfTablesProxy { // Create the QoS chain if needed if (this.settings.qos?.enabled) { - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add chain ${family} ${this.tableName} qos_forward { type filter hook forward priority 0 ; }`, this.settings.maxRetries, this.settings.retryDelayMs @@ -372,11 +221,7 @@ export class NfTablesProxy { ): Promise { try { // Filter IPs based on family - const filteredIPs = ips.filter(ip => { - if (family === 'ip6' && ip.includes(':')) return true; - if (family === 'ip' && ip.includes('.')) return true; - return false; - }); + const filteredIPs = filterIPsByFamily(ips, family as 'ip' | 'ip6'); if (filteredIPs.length === 0) { this.log('info', `No IP addresses of type ${setType} to add to set ${setName}`); @@ -385,7 +230,7 @@ export class NfTablesProxy { // Check if set already exists try { - const sets = await this.executeWithRetry( + const sets = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -395,7 +240,7 @@ export class NfTablesProxy { this.log('info', `IP set ${setName} already exists, will add elements`); } else { // Create the set - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`, this.settings.maxRetries, this.settings.retryDelayMs @@ -405,7 +250,7 @@ export class NfTablesProxy { } } catch (err) { // Set might not exist yet, create it - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add set ${family} ${this.tableName} ${setName} { type ${setType}; }`, this.settings.maxRetries, this.settings.retryDelayMs @@ -420,7 +265,7 @@ export class NfTablesProxy { const batch = filteredIPs.slice(i, i + batchSize); const elements = batch.join(', '); - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} add element ${family} ${this.tableName} ${setName} { ${elements} }`, this.settings.maxRetries, this.settings.retryDelayMs @@ -563,7 +408,7 @@ export class NfTablesProxy { // Only write and apply if we have rules to add if (rulesetContent) { // Apply the ruleset using the helper - await this.executeWithTempFile(rulesetContent); + await this.executor.executeWithTempFile(rulesetContent); this.log('info', `Added source IP filter rules for ${family}`); @@ -593,7 +438,7 @@ export class NfTablesProxy { * Gets a comma-separated list of all ports from a port specification */ private getAllPorts(portSpec: number | PortRange | Array): string { - const portRanges = this.normalizePortSpec(portSpec); + const portRanges = normalizePortSpec(portSpec); const ports: string[] = []; for (const range of portRanges) { @@ -620,8 +465,8 @@ export class NfTablesProxy { try { // Get the port ranges - const fromPortRanges = this.normalizePortSpec(this.settings.fromPort); - const toPortRanges = this.normalizePortSpec(this.settings.toPort); + const fromPortRanges = normalizePortSpec(this.settings.fromPort); + const toPortRanges = normalizePortSpec(this.settings.toPort); let rulesetContent = ''; @@ -670,7 +515,7 @@ export class NfTablesProxy { // Apply the rules if we have any if (rulesetContent) { - await this.executeWithTempFile(rulesetContent); + await this.executor.executeWithTempFile(rulesetContent); this.log('info', `Added advanced NAT rules for ${family}`); @@ -708,8 +553,8 @@ export class NfTablesProxy { try { // Normalize port specifications - const fromPortRanges = this.normalizePortSpec(this.settings.fromPort); - const toPortRanges = this.normalizePortSpec(this.settings.toPort); + const fromPortRanges = normalizePortSpec(this.settings.fromPort); + const toPortRanges = normalizePortSpec(this.settings.toPort); // Handle the case where fromPort and toPort counts don't match if (fromPortRanges.length !== toPortRanges.length) { @@ -815,7 +660,7 @@ export class NfTablesProxy { // Apply the ruleset if we have any rules if (rulesetContent) { // Apply the ruleset using the helper - await this.executeWithTempFile(rulesetContent); + await this.executor.executeWithTempFile(rulesetContent); this.log('info', `Added port forwarding rules for ${family}`); @@ -919,7 +764,7 @@ export class NfTablesProxy { // Apply the ruleset if we have any rules if (rulesetContent) { - await this.executeWithTempFile(rulesetContent); + await this.executor.executeWithTempFile(rulesetContent); this.log('info', `Added port forwarding rules for ${family}`); @@ -972,7 +817,7 @@ export class NfTablesProxy { // Add priority marking if specified if (this.settings.qos.priority !== undefined) { // Check if the chain exists - const chainsOutput = await this.executeWithRetry( + const chainsOutput = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list chains ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -988,7 +833,7 @@ export class NfTablesProxy { } // Add the rules to mark packets with this priority - for (const range of this.normalizePortSpec(this.settings.toPort)) { + for (const range of normalizePortSpec(this.settings.toPort)) { const markRule = `add rule ${family} ${this.tableName} ${qosChain} ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`; rulesetContent += `${markRule}\n`; @@ -1005,7 +850,7 @@ export class NfTablesProxy { // Apply the ruleset if we have any rules if (rulesetContent) { // Apply the ruleset using the helper - await this.executeWithTempFile(rulesetContent); + await this.executor.executeWithTempFile(rulesetContent); this.log('info', `Added QoS rules for ${family}`); @@ -1048,7 +893,7 @@ export class NfTablesProxy { const rule = `add rule ${family} ${this.tableName} ${outputChain} ${this.settings.protocol} daddr ${localhost} redirect to :${netProxyConfig.sslTerminationPort} comment "${this.ruleTag}:NETPROXY_REDIRECT"`; // Apply the rule - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} ${rule}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1091,7 +936,7 @@ export class NfTablesProxy { const commentTag = commentMatch[1]; // List the chain to check if our rule is there - const stdout = await this.executeWithRetry( + const stdout = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list chain ${tableFamily} ${tableName} ${chainName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1127,7 +972,7 @@ export class NfTablesProxy { try { // For nftables, create a delete rule by replacing 'add' with 'delete' const deleteRule = rule.ruleContents.replace('add rule', 'delete rule'); - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} ${deleteRule}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1149,7 +994,7 @@ export class NfTablesProxy { */ private async tableExists(family: string, tableName: string): Promise { try { - const stdout = await this.executeWithRetry( + const stdout = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list tables ${family}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1178,7 +1023,7 @@ export class NfTablesProxy { try { // Try to get connection metrics if conntrack is available try { - const stdout = await this.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs); + const stdout = await this.executor.executeWithRetry('conntrack -C', this.settings.maxRetries, this.settings.retryDelayMs); metrics.activeConnections = parseInt(stdout.trim(), 10); } catch (err) { // conntrack not available, skip this metric @@ -1187,7 +1032,7 @@ export class NfTablesProxy { // Try to get forwarded connections count from nftables counters try { // Look for counters in our rules - const stdout = await this.executeWithRetry( + const stdout = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list table ip ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1238,7 +1083,7 @@ export class NfTablesProxy { try { for (const family of ['ip', 'ip6']) { try { - const stdout = await this.executeWithRetry( + const stdout = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list sets ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1290,7 +1135,7 @@ export class NfTablesProxy { try { // Get list of configured tables - const stdout = await this.executeWithRetry( + const stdout = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list tables`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1396,8 +1241,8 @@ export class NfTablesProxy { // Port forwarding rules if (this.settings.useAdvancedNAT) { // Advanced NAT with connection tracking - const fromPortRanges = this.normalizePortSpec(this.settings.fromPort); - const toPortRanges = this.normalizePortSpec(this.settings.toPort); + const fromPortRanges = normalizePortSpec(this.settings.fromPort); + const toPortRanges = normalizePortSpec(this.settings.toPort); if (fromPortRanges.length === 1 && toPortRanges.length === 1) { const fromRange = fromPortRanges[0]; @@ -1413,8 +1258,8 @@ export class NfTablesProxy { } } else { // Standard NAT rules - const fromRanges = this.normalizePortSpec(this.settings.fromPort); - const toRanges = this.normalizePortSpec(this.settings.toPort); + const fromRanges = normalizePortSpec(this.settings.fromPort); + const toRanges = normalizePortSpec(this.settings.toPort); if (fromRanges.length === 1 && toRanges.length === 1) { const fromRange = fromRanges[0]; @@ -1460,7 +1305,7 @@ export class NfTablesProxy { if (this.settings.qos.priority !== undefined) { commands.push(`add chain ip ${this.tableName} prio${this.settings.qos.priority} { type filter hook forward priority ${this.settings.qos.priority * 10}; }`); - for (const range of this.normalizePortSpec(this.settings.toPort)) { + for (const range of normalizePortSpec(this.settings.toPort)) { commands.push(`add rule ip ${this.tableName} qos_forward ${this.settings.protocol} dport ${range.from}-${range.to} counter goto prio${this.settings.qos.priority} comment "${this.ruleTag}:QOS_PRIORITY"`); } } @@ -1586,7 +1431,7 @@ export class NfTablesProxy { try { // Apply the ruleset - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1611,7 +1456,7 @@ export class NfTablesProxy { const [family, setName] = key.split(':'); try { - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1661,7 +1506,7 @@ export class NfTablesProxy { fs.writeFileSync(this.tempFilePath, rulesetContent); // Apply the ruleset (single attempt, no retry - process is exiting) - this.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`); + this.executor.executeSync(`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`); this.log('info', 'Removed all added rules'); @@ -1685,7 +1530,7 @@ export class NfTablesProxy { const [family, setName] = key.split(':'); try { - this.executeSync( + this.executor.executeSync( `${NfTablesProxy.NFT_CMD} delete set ${family} ${this.tableName} ${setName}` ); } catch { @@ -1722,7 +1567,7 @@ export class NfTablesProxy { } // Check if the table has any rules - const stdout = await this.executeWithRetry( + const stdout = await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1732,7 +1577,7 @@ export class NfTablesProxy { if (!hasRules) { // Table is empty, delete it - await this.executeWithRetry( + await this.executor.executeWithRetry( `${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}`, this.settings.maxRetries, this.settings.retryDelayMs @@ -1759,7 +1604,7 @@ export class NfTablesProxy { try { // Check if table exists - const tableExistsOutput = this.executeSync( + const tableExistsOutput = this.executor.executeSync( `${NfTablesProxy.NFT_CMD} list tables ${family}` ); @@ -1770,7 +1615,7 @@ export class NfTablesProxy { } // Check if the table has any rules - const stdout = this.executeSync( + const stdout = this.executor.executeSync( `${NfTablesProxy.NFT_CMD} list table ${family} ${this.tableName}` ); @@ -1778,7 +1623,7 @@ export class NfTablesProxy { if (!hasRules) { // Table is empty, delete it - this.executeSync( + this.executor.executeSync( `${NfTablesProxy.NFT_CMD} delete table ${family} ${this.tableName}` ); diff --git a/ts/proxies/nftables-proxy/utils/index.ts b/ts/proxies/nftables-proxy/utils/index.ts new file mode 100644 index 0000000..86f8d62 --- /dev/null +++ b/ts/proxies/nftables-proxy/utils/index.ts @@ -0,0 +1,38 @@ +/** + * NFTables Proxy Utilities + * + * This module exports utility functions and classes for NFTables operations. + */ + +// Command execution +export { NftCommandExecutor } from './nft-command-executor.js'; +export type { INftLoggerFn, INftExecutorOptions } from './nft-command-executor.js'; + +// Port specification normalization +export { + normalizePortSpec, + validatePorts, + formatPortRange, + portSpecToNftExpr, + rangesOverlap, + mergeOverlappingRanges, + countPorts, + isPortInSpec +} from './nft-port-spec-normalizer.js'; + +// Rule validation +export { + isValidIP, + isValidIPv4, + isValidIPv6, + isValidHostname, + isValidTableName, + isValidRate, + validateIPs, + validateHost, + validateTableName, + validateQosSettings, + validateSettings, + isIPForFamily, + filterIPsByFamily +} from './nft-rule-validator.js'; diff --git a/ts/proxies/nftables-proxy/utils/nft-command-executor.ts b/ts/proxies/nftables-proxy/utils/nft-command-executor.ts new file mode 100644 index 0000000..caf8a03 --- /dev/null +++ b/ts/proxies/nftables-proxy/utils/nft-command-executor.ts @@ -0,0 +1,162 @@ +/** + * NFTables Command Executor + * + * Handles command execution with retry logic, temp file management, + * and error handling for nftables operations. + */ + +import { exec, execSync } from 'child_process'; +import { promisify } from 'util'; +import { delay } from '../../../core/utils/async-utils.js'; +import { AsyncFileSystem } from '../../../core/utils/fs-utils.js'; +import { NftExecutionError } from '../models/index.js'; + +const execAsync = promisify(exec); + +export interface INftLoggerFn { + (level: 'info' | 'warn' | 'error' | 'debug', message: string, data?: Record): void; +} + +export interface INftExecutorOptions { + maxRetries?: number; + retryDelayMs?: number; + tempFilePath?: string; +} + +/** + * NFTables command executor with retry logic and temp file support + */ +export class NftCommandExecutor { + private static readonly NFT_CMD = 'nft'; + private maxRetries: number; + private retryDelayMs: number; + private tempFilePath: string; + + constructor( + private log: INftLoggerFn, + options: INftExecutorOptions = {} + ) { + this.maxRetries = options.maxRetries || 3; + this.retryDelayMs = options.retryDelayMs || 1000; + this.tempFilePath = options.tempFilePath || `/tmp/nft-rules-${Date.now()}.nft`; + } + + /** + * Execute a command with retry capability + */ + async executeWithRetry(command: string, maxRetries?: number, retryDelayMs?: number): Promise { + const retries = maxRetries ?? this.maxRetries; + const delayMs = retryDelayMs ?? this.retryDelayMs; + let lastError: Error | undefined; + + for (let i = 0; i < retries; i++) { + try { + const { stdout } = await execAsync(command); + return stdout; + } catch (err) { + lastError = err as Error; + this.log('warn', `Command failed (attempt ${i+1}/${retries}): ${command}`, { error: lastError.message }); + + // Wait before retry, unless it's the last attempt + if (i < retries - 1) { + await delay(delayMs); + } + } + } + + throw new NftExecutionError(`Failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`); + } + + /** + * Execute system command synchronously (single attempt, no retry) + * Used only for exit handlers where the process is terminating anyway. + */ + executeSync(command: string): string { + try { + return execSync(command, { timeout: 5000 }).toString(); + } catch (err) { + this.log('warn', `Sync command failed: ${command}`, { error: (err as Error).message }); + throw err; + } + } + + /** + * Execute nftables commands with a temporary file + */ + async executeWithTempFile(rulesetContent: string): Promise { + await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent); + + try { + await this.executeWithRetry( + `${NftCommandExecutor.NFT_CMD} -f ${this.tempFilePath}`, + this.maxRetries, + this.retryDelayMs + ); + } finally { + // Always clean up the temp file + await AsyncFileSystem.remove(this.tempFilePath); + } + } + + /** + * Check if nftables is available + */ + async checkAvailability(): Promise { + try { + await this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} --version`, this.maxRetries, this.retryDelayMs); + return true; + } catch (err) { + this.log('error', `nftables is not available: ${(err as Error).message}`); + return false; + } + } + + /** + * Check if connection tracking modules are loaded + */ + async checkConntrackModules(): Promise { + try { + await this.executeWithRetry('lsmod | grep nf_conntrack', this.maxRetries, this.retryDelayMs); + return true; + } catch (err) { + this.log('warn', 'Connection tracking modules might not be loaded, advanced NAT features may not work'); + return false; + } + } + + /** + * Run an nft command directly + */ + async nft(args: string): Promise { + return this.executeWithRetry(`${NftCommandExecutor.NFT_CMD} ${args}`, this.maxRetries, this.retryDelayMs); + } + + /** + * Run an nft command synchronously (for cleanup on exit) + */ + nftSync(args: string): string { + return this.executeSync(`${NftCommandExecutor.NFT_CMD} ${args}`); + } + + /** + * Get the NFT command path + */ + static get nftCmd(): string { + return NftCommandExecutor.NFT_CMD; + } + + /** + * Update the temp file path + */ + setTempFilePath(path: string): void { + this.tempFilePath = path; + } + + /** + * Update retry settings + */ + setRetryOptions(maxRetries: number, retryDelayMs: number): void { + this.maxRetries = maxRetries; + this.retryDelayMs = retryDelayMs; + } +} diff --git a/ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts b/ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts new file mode 100644 index 0000000..f52aa83 --- /dev/null +++ b/ts/proxies/nftables-proxy/utils/nft-port-spec-normalizer.ts @@ -0,0 +1,125 @@ +/** + * NFTables Port Specification Normalizer + * + * Handles normalization and validation of port specifications + * for nftables rules. + */ + +import type { PortRange } from '../models/index.js'; +import { NftValidationError } from '../models/index.js'; + +/** + * Normalizes port specifications into an array of port ranges + */ +export function normalizePortSpec(portSpec: number | PortRange | Array): PortRange[] { + const result: PortRange[] = []; + + if (Array.isArray(portSpec)) { + // If it's an array, process each element + for (const spec of portSpec) { + result.push(...normalizePortSpec(spec)); + } + } else if (typeof portSpec === 'number') { + // Single port becomes a range with the same start and end + result.push({ from: portSpec, to: portSpec }); + } else { + // Already a range + result.push(portSpec); + } + + return result; +} + +/** + * Validates port numbers or ranges + */ +export function validatePorts(port: number | PortRange | Array): void { + if (Array.isArray(port)) { + port.forEach(p => validatePorts(p)); + return; + } + + if (typeof port === 'number') { + if (port < 1 || port > 65535) { + throw new NftValidationError(`Invalid port number: ${port}`); + } + } else if (typeof port === 'object') { + if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) { + throw new NftValidationError(`Invalid port range: ${port.from}-${port.to}`); + } + } +} + +/** + * Format port range for nftables rule + */ +export function formatPortRange(range: PortRange): string { + if (range.from === range.to) { + return String(range.from); + } + return `${range.from}-${range.to}`; +} + +/** + * Convert port spec to nftables expression + */ +export function portSpecToNftExpr(portSpec: number | PortRange | Array): string { + const ranges = normalizePortSpec(portSpec); + + if (ranges.length === 1) { + return formatPortRange(ranges[0]); + } + + // Multiple ports/ranges need to use a set + const ports = ranges.map(formatPortRange); + return `{ ${ports.join(', ')} }`; +} + +/** + * Check if two port ranges overlap + */ +export function rangesOverlap(range1: PortRange, range2: PortRange): boolean { + return range1.from <= range2.to && range2.from <= range1.to; +} + +/** + * Merge overlapping port ranges + */ +export function mergeOverlappingRanges(ranges: PortRange[]): PortRange[] { + if (ranges.length <= 1) return ranges; + + // Sort by start port + const sorted = [...ranges].sort((a, b) => a.from - b.from); + const merged: PortRange[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const current = sorted[i]; + const lastMerged = merged[merged.length - 1]; + + if (current.from <= lastMerged.to + 1) { + // Ranges overlap or are adjacent, merge them + lastMerged.to = Math.max(lastMerged.to, current.to); + } else { + // No overlap, add as new range + merged.push(current); + } + } + + return merged; +} + +/** + * Calculate the total number of ports in a port specification + */ +export function countPorts(portSpec: number | PortRange | Array): number { + const ranges = normalizePortSpec(portSpec); + return ranges.reduce((total, range) => total + (range.to - range.from + 1), 0); +} + +/** + * Check if a port is within the given specification + */ +export function isPortInSpec(port: number, portSpec: number | PortRange | Array): boolean { + const ranges = normalizePortSpec(portSpec); + return ranges.some(range => port >= range.from && port <= range.to); +} diff --git a/ts/proxies/nftables-proxy/utils/nft-rule-validator.ts b/ts/proxies/nftables-proxy/utils/nft-rule-validator.ts new file mode 100644 index 0000000..d35b58c --- /dev/null +++ b/ts/proxies/nftables-proxy/utils/nft-rule-validator.ts @@ -0,0 +1,156 @@ +/** + * NFTables Rule Validator + * + * Handles validation of settings and inputs for nftables operations. + * Prevents command injection and ensures valid values. + */ + +import type { PortRange, NfTableProxyOptions } from '../models/index.js'; +import { NftValidationError } from '../models/index.js'; +import { validatePorts } from './nft-port-spec-normalizer.js'; + +// IP address validation patterns +const IPV4_REGEX = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/; +const IPV6_REGEX = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/; +const HOSTNAME_REGEX = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; +const TABLE_NAME_REGEX = /^[a-zA-Z0-9_]+$/; +const RATE_REGEX = /^[0-9]+[kKmMgG]?bps$/; + +/** + * Validates an IP address (IPv4 or IPv6) + */ +export function isValidIP(ip: string): boolean { + return IPV4_REGEX.test(ip) || IPV6_REGEX.test(ip); +} + +/** + * Validates an IPv4 address + */ +export function isValidIPv4(ip: string): boolean { + return IPV4_REGEX.test(ip); +} + +/** + * Validates an IPv6 address + */ +export function isValidIPv6(ip: string): boolean { + return IPV6_REGEX.test(ip); +} + +/** + * Validates a hostname + */ +export function isValidHostname(hostname: string): boolean { + return HOSTNAME_REGEX.test(hostname); +} + +/** + * Validates a table name for nftables + */ +export function isValidTableName(tableName: string): boolean { + return TABLE_NAME_REGEX.test(tableName); +} + +/** + * Validates a rate specification (e.g., "10mbps") + */ +export function isValidRate(rate: string): boolean { + return RATE_REGEX.test(rate); +} + +/** + * Validates an array of IP addresses + */ +export function validateIPs(ips?: string[]): void { + if (!ips) return; + + for (const ip of ips) { + if (!isValidIP(ip)) { + throw new NftValidationError(`Invalid IP address format: ${ip}`); + } + } +} + +/** + * Validates a host (can be hostname or IP) + */ +export function validateHost(host?: string): void { + if (!host) return; + + if (!isValidHostname(host) && !isValidIP(host)) { + throw new NftValidationError(`Invalid host format: ${host}`); + } +} + +/** + * Validates a table name + */ +export function validateTableName(tableName?: string): void { + if (!tableName) return; + + if (!isValidTableName(tableName)) { + throw new NftValidationError( + `Invalid table name: ${tableName}. Only alphanumeric characters and underscores are allowed.` + ); + } +} + +/** + * Validates QoS settings + */ +export function validateQosSettings(qos?: NfTableProxyOptions['qos']): void { + if (!qos?.enabled) return; + + if (qos.maxRate && !isValidRate(qos.maxRate)) { + throw new NftValidationError( + `Invalid rate format: ${qos.maxRate}. Use format like "10mbps", "1gbps", etc.` + ); + } + + if (qos.priority !== undefined) { + if (qos.priority < 1 || qos.priority > 10 || !Number.isInteger(qos.priority)) { + throw new NftValidationError( + `Invalid priority: ${qos.priority}. Must be an integer between 1 and 10.` + ); + } + } +} + +/** + * Validates all NfTablesProxy settings + */ +export function validateSettings(settings: NfTableProxyOptions): void { + // Validate port numbers + validatePorts(settings.fromPort); + validatePorts(settings.toPort); + + // Validate IP addresses + validateIPs(settings.ipAllowList); + validateIPs(settings.ipBlockList); + + // Validate target host + validateHost(settings.toHost); + + // Validate table name + validateTableName(settings.tableName); + + // Validate QoS settings + validateQosSettings(settings.qos); +} + +/** + * Check if an IP matches the given family (ip or ip6) + */ +export function isIPForFamily(ip: string, family: 'ip' | 'ip6'): boolean { + if (family === 'ip6') { + return ip.includes(':'); + } + return ip.includes('.'); +} + +/** + * Filter IPs by family + */ +export function filterIPsByFamily(ips: string[], family: 'ip' | 'ip6'): string[] { + return ips.filter(ip => isIPForFamily(ip, family)); +} diff --git a/ts/proxies/smart-proxy/security-manager.ts b/ts/proxies/smart-proxy/security-manager.ts index c16a3b6..a3c54d7 100644 --- a/ts/proxies/smart-proxy/security-manager.ts +++ b/ts/proxies/smart-proxy/security-manager.ts @@ -1,10 +1,11 @@ import * as plugins from '../../plugins.js'; import type { SmartProxy } from './smart-proxy.js'; -import { logger } from '../../core/utils/logger.js'; import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js'; +import { isIPAuthorized, normalizeIP } from '../../core/utils/security-utils.js'; /** * Handles security aspects like IP tracking, rate limiting, and authorization + * for SmartProxy. This is a lightweight wrapper that uses shared utilities. */ export class SecurityManager { private connectionsByIP: Map> = new Map(); @@ -15,14 +16,22 @@ export class SecurityManager { // Start periodic cleanup every 60 seconds this.startPeriodicCleanup(); } - + /** - * Get connections count by IP + * Get connections count by IP (checks normalized variants) */ public getConnectionCountByIP(ip: string): number { - return this.connectionsByIP.get(ip)?.size || 0; + // Check all normalized variants of the IP + const variants = normalizeIP(ip); + for (const variant of variants) { + const connections = this.connectionsByIP.get(variant); + if (connections) { + return connections.size; + } + } + return 0; } - + /** * Check and update connection rate for an IP * @returns true if within rate limit, false if exceeding limit @@ -31,43 +40,73 @@ export class SecurityManager { const now = Date.now(); const minute = 60 * 1000; - if (!this.connectionRateByIP.has(ip)) { - this.connectionRateByIP.set(ip, [now]); + // Find existing rate tracking (check normalized variants) + const variants = normalizeIP(ip); + let existingKey: string | null = null; + for (const variant of variants) { + if (this.connectionRateByIP.has(variant)) { + existingKey = variant; + break; + } + } + + const key = existingKey || ip; + + if (!this.connectionRateByIP.has(key)) { + this.connectionRateByIP.set(key, [now]); return true; } // Get timestamps and filter out entries older than 1 minute - const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute); + const timestamps = this.connectionRateByIP.get(key)!.filter((time) => now - time < minute); timestamps.push(now); - this.connectionRateByIP.set(ip, timestamps); + this.connectionRateByIP.set(key, timestamps); // Check if rate exceeds limit return timestamps.length <= this.smartProxy.settings.connectionRateLimitPerMinute!; } - + /** * Track connection by IP */ public trackConnectionByIP(ip: string, connectionId: string): void { - if (!this.connectionsByIP.has(ip)) { - this.connectionsByIP.set(ip, new Set()); + // Check if any variant already exists + const variants = normalizeIP(ip); + let existingKey: string | null = null; + + for (const variant of variants) { + if (this.connectionsByIP.has(variant)) { + existingKey = variant; + break; + } } - this.connectionsByIP.get(ip)!.add(connectionId); + + const key = existingKey || ip; + if (!this.connectionsByIP.has(key)) { + this.connectionsByIP.set(key, new Set()); + } + this.connectionsByIP.get(key)!.add(connectionId); } - + /** * Remove connection tracking for an IP */ public removeConnectionByIP(ip: string, connectionId: string): void { - if (this.connectionsByIP.has(ip)) { - const connections = this.connectionsByIP.get(ip)!; - connections.delete(connectionId); - if (connections.size === 0) { - this.connectionsByIP.delete(ip); + // Check all variants to find where the connection is tracked + const variants = normalizeIP(ip); + + for (const variant of variants) { + if (this.connectionsByIP.has(variant)) { + const connections = this.connectionsByIP.get(variant)!; + connections.delete(connectionId); + if (connections.size === 0) { + this.connectionsByIP.delete(variant); + } + break; } } } - + /** * Check if an IP is authorized using security rules * @@ -81,71 +120,7 @@ export class SecurityManager { * @returns true if IP is authorized, false if blocked */ public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean { - // Skip IP validation if allowedIPs is empty - if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) { - return true; - } - - // First check if IP is blocked - blocked IPs take precedence - if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) { - return false; - } - - // Then check if IP is allowed - return this.isGlobIPMatch(ip, allowedIPs); - } - - /** - * Check if the IP matches any of the glob patterns from security configuration - * - * This method checks IP addresses against glob patterns and handles IPv4/IPv6 normalization. - * It's used to implement IP filtering based on the route.security configuration. - * - * @param ip - The IP address to check - * @param patterns - Array of glob patterns from security.ipAllowList or ipBlockList - * @returns true if IP matches any pattern, false otherwise - */ - private isGlobIPMatch(ip: string, patterns: string[]): boolean { - if (!ip || !patterns || patterns.length === 0) return false; - - // Handle IPv4/IPv6 normalization for proper matching - const normalizeIP = (ip: string): string[] => { - if (!ip) return []; - // Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) - if (ip.startsWith('::ffff:')) { - const ipv4 = ip.slice(7); - return [ip, ipv4]; - } - // Handle IPv4 addresses by also checking IPv4-mapped form - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { - return [ip, `::ffff:${ip}`]; - } - return [ip]; - }; - - // Normalize the IP being checked - const normalizedIPVariants = normalizeIP(ip); - if (normalizedIPVariants.length === 0) return false; - - // Expand shorthand patterns and normalize IPs for consistent comparison - const expandShorthand = (pattern: string): string => { - // Expand shorthand IP patterns like '192.168.*' to '192.168.*.*' - if (pattern.includes('*') && !pattern.includes(':')) { - const parts = pattern.split('.'); - while (parts.length < 4) { - parts.push('*'); - } - return parts.join('.'); - } - return pattern; - }; - - const expandedPatterns = patterns.map(expandShorthand).flatMap(normalizeIP); - - // Check for any match between normalized IP variants and patterns - return normalizedIPVariants.some((ipVariant) => - expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern)) - ); + return isIPAuthorized(ip, allowedIPs, blockedIPs); } /** diff --git a/ts/proxies/smart-proxy/utils/route-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers.ts index fdd6151..fd9342d 100644 --- a/ts/proxies/smart-proxy/utils/route-helpers.ts +++ b/ts/proxies/smart-proxy/utils/route-helpers.ts @@ -1,1308 +1,11 @@ /** * Route Helper Functions * - * This file provides utility functions for creating route configurations for common scenarios. - * These functions aim to simplify the creation of route configurations for typical use cases. + * This file re-exports all route helper functions for backwards compatibility. + * The actual implementations have been split into focused modules in the route-helpers/ directory. * - * This module includes helper functions for creating: - * - HTTP routes (createHttpRoute) - * - HTTPS routes with TLS termination (createHttpsTerminateRoute) - * - HTTP to HTTPS redirects (createHttpToHttpsRedirect) - * - HTTPS passthrough routes (createHttpsPassthroughRoute) - * - Complete HTTPS servers with redirects (createCompleteHttpsServer) - * - Load balancer routes (createLoadBalancerRoute) - * - API routes (createApiRoute) - * - WebSocket routes (createWebSocketRoute) - * - Port mapping routes (createPortMappingRoute, createOffsetPortMappingRoute) - * - Dynamic routing (createDynamicRoute, createSmartLoadBalancer) - * - NFTables routes (createNfTablesRoute, createNfTablesTerminateRoute) + * @see ./route-helpers/index.ts for the modular exports */ -import * as plugins from '../../../plugins.js'; -import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js'; -import { mergeRouteConfigs } from './route-utils.js'; -import { ProtocolDetector, HttpDetector } from '../../../detection/index.js'; -import { createSocketTracker } from '../../../core/utils/socket-tracker.js'; - -/** - * Create an HTTP-only route configuration - * @param domains Domain(s) to match - * @param target Target host and port - * @param options Additional route options - * @returns Route configuration object - */ -export function createHttpRoute( - domains: string | string[], - target: { host: string | string[]; port: number }, - options: Partial = {} -): IRouteConfig { - // Create route match - const match: IRouteMatch = { - ports: options.match?.ports || 80, - domains - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [target] - }; - - // Create the route config - return { - match, - action, - name: options.name || `HTTP Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`, - ...options - }; -} - -/** - * Create an HTTPS route with TLS termination (including HTTP redirect to HTTPS) - * @param domains Domain(s) to match - * @param target Target host and port - * @param options Additional route options - * @returns Route configuration object - */ -export function createHttpsTerminateRoute( - domains: string | string[], - target: { host: string | string[]; port: number }, - options: { - certificate?: 'auto' | { key: string; cert: string }; - httpPort?: number | number[]; - httpsPort?: number | number[]; - reencrypt?: boolean; - name?: string; - [key: string]: any; - } = {} -): IRouteConfig { - // Create route match - const match: IRouteMatch = { - ports: options.httpsPort || 443, - domains - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [target], - tls: { - mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate', - certificate: options.certificate || 'auto' - } - }; - - // Create the route config - return { - match, - action, - name: options.name || `HTTPS Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`, - ...options - }; -} - -/** - * Create an HTTP to HTTPS redirect route - * @param domains Domain(s) to match - * @param httpsPort HTTPS port to redirect to (default: 443) - * @param options Additional route options - * @returns Route configuration object - */ -export function createHttpToHttpsRedirect( - domains: string | string[], - httpsPort: number = 443, - options: Partial = {} -): IRouteConfig { - // Create route match - const match: IRouteMatch = { - ports: options.match?.ports || 80, - domains - }; - - // Create route action - const action: IRouteAction = { - type: 'socket-handler', - socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301) - }; - - // Create the route config - return { - match, - action, - name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`, - ...options - }; -} - -/** - * Create an HTTPS passthrough route (SNI-based forwarding without TLS termination) - * @param domains Domain(s) to match - * @param target Target host and port - * @param options Additional route options - * @returns Route configuration object - */ -export function createHttpsPassthroughRoute( - domains: string | string[], - target: { host: string | string[]; port: number }, - options: Partial = {} -): IRouteConfig { - // Create route match - const match: IRouteMatch = { - ports: options.match?.ports || 443, - domains - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [target], - tls: { - mode: 'passthrough' - } - }; - - // Create the route config - return { - match, - action, - name: options.name || `HTTPS Passthrough for ${Array.isArray(domains) ? domains.join(', ') : domains}`, - ...options - }; -} - -/** - * Create a complete HTTPS server with HTTP to HTTPS redirects - * @param domains Domain(s) to match - * @param target Target host and port - * @param options Additional configuration options - * @returns Array of two route configurations (HTTPS and HTTP redirect) - */ -export function createCompleteHttpsServer( - domains: string | string[], - target: { host: string | string[]; port: number }, - options: { - certificate?: 'auto' | { key: string; cert: string }; - httpPort?: number | number[]; - httpsPort?: number | number[]; - reencrypt?: boolean; - name?: string; - [key: string]: any; - } = {} -): IRouteConfig[] { - // Create the HTTPS route - const httpsRoute = createHttpsTerminateRoute(domains, target, options); - - // Create the HTTP redirect route - const httpRedirectRoute = createHttpToHttpsRedirect( - domains, - // Extract the HTTPS port from the HTTPS route - ensure it's a number - typeof options.httpsPort === 'number' ? options.httpsPort : - Array.isArray(options.httpsPort) ? options.httpsPort[0] : 443, - { - // Set the HTTP port - match: { - ports: options.httpPort || 80, - domains - }, - name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}` - } - ); - - return [httpsRoute, httpRedirectRoute]; -} - -/** - * Create a load balancer route (round-robin between multiple backend hosts) - * @param domains Domain(s) to match - * @param backendsOrHosts Array of backend servers OR array of host strings (legacy) - * @param portOrOptions Port number (legacy) OR options object - * @param options Additional route options (legacy) - * @returns Route configuration object - */ -export function createLoadBalancerRoute( - domains: string | string[], - backendsOrHosts: Array<{ host: string; port: number }> | string[], - portOrOptions?: number | { - tls?: { - mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt'; - certificate?: 'auto' | { key: string; cert: string }; - }; - useTls?: boolean; - certificate?: 'auto' | { key: string; cert: string }; - algorithm?: 'round-robin' | 'least-connections' | 'ip-hash'; - healthCheck?: { - path: string; - interval: number; - timeout: number; - unhealthyThreshold: number; - healthyThreshold: number; - }; - [key: string]: any; - }, - options?: { - tls?: { - mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt'; - certificate?: 'auto' | { key: string; cert: string }; - }; - [key: string]: any; - } -): IRouteConfig { - // Handle legacy signature: (domains, hosts[], port, options) - let backends: Array<{ host: string; port: number }>; - let finalOptions: any; - - if (Array.isArray(backendsOrHosts) && backendsOrHosts.length > 0 && typeof backendsOrHosts[0] === 'string') { - // Legacy signature - const hosts = backendsOrHosts as string[]; - const port = portOrOptions as number; - backends = hosts.map(host => ({ host, port })); - finalOptions = options || {}; - } else { - // New signature - backends = backendsOrHosts as Array<{ host: string; port: number }>; - finalOptions = (portOrOptions as any) || {}; - } - - // Extract hosts and ensure all backends use the same port - const port = backends[0].port; - const hosts = backends.map(backend => backend.host); - - // Create route match - const match: IRouteMatch = { - ports: finalOptions.match?.ports || (finalOptions.tls || finalOptions.useTls ? 443 : 80), - domains - }; - - // Create route target - const target: IRouteTarget = { - host: hosts, - port - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [target] - }; - - // Add TLS configuration if provided - if (finalOptions.tls || finalOptions.useTls) { - action.tls = { - mode: finalOptions.tls?.mode || 'terminate', - certificate: finalOptions.tls?.certificate || finalOptions.certificate || 'auto' - }; - } - - // Add load balancing options - if (finalOptions.algorithm || finalOptions.healthCheck) { - action.loadBalancing = { - algorithm: finalOptions.algorithm || 'round-robin', - healthCheck: finalOptions.healthCheck - }; - } - - // Create the route config - return { - match, - action, - name: finalOptions.name || `Load Balancer for ${Array.isArray(domains) ? domains.join(', ') : domains}`, - ...finalOptions - }; -} - -/** - * Create an API route configuration - * @param domains Domain(s) to match - * @param apiPath API base path (e.g., "/api") - * @param target Target host and port - * @param options Additional route options - * @returns Route configuration object - */ -export function createApiRoute( - domains: string | string[], - apiPath: string, - target: { host: string | string[]; port: number }, - options: { - useTls?: boolean; - certificate?: 'auto' | { key: string; cert: string }; - addCorsHeaders?: boolean; - httpPort?: number | number[]; - httpsPort?: number | number[]; - name?: string; - [key: string]: any; - } = {} -): IRouteConfig { - // Normalize API path - const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; - const pathWithWildcard = normalizedPath.endsWith('/') - ? `${normalizedPath}*` - : `${normalizedPath}/*`; - - // Create route match - const match: IRouteMatch = { - ports: options.useTls - ? (options.httpsPort || 443) - : (options.httpPort || 80), - domains, - path: pathWithWildcard - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [target] - }; - - // Add TLS configuration if using HTTPS - if (options.useTls) { - action.tls = { - mode: 'terminate', - certificate: options.certificate || 'auto' - }; - } - - // Add CORS headers if requested - const headers: Record> = {}; - if (options.addCorsHeaders) { - headers.response = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Max-Age': '86400' - }; - } - - // Create the route config - return { - match, - action, - headers: Object.keys(headers).length > 0 ? headers : undefined, - name: options.name || `API Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`, - priority: options.priority || 100, // Higher priority for specific path matches - ...options - }; -} - -/** - * Create a WebSocket route configuration - * @param domains Domain(s) to match - * @param targetOrPath Target server OR WebSocket path (legacy) - * @param targetOrOptions Target server (legacy) OR options - * @param options Additional route options (legacy) - * @returns Route configuration object - */ -export function createWebSocketRoute( - domains: string | string[], - targetOrPath: { host: string | string[]; port: number } | string, - targetOrOptions?: { host: string | string[]; port: number } | { - useTls?: boolean; - certificate?: 'auto' | { key: string; cert: string }; - path?: string; - httpPort?: number | number[]; - httpsPort?: number | number[]; - pingInterval?: number; - pingTimeout?: number; - name?: string; - [key: string]: any; - }, - options?: { - useTls?: boolean; - certificate?: 'auto' | { key: string; cert: string }; - httpPort?: number | number[]; - httpsPort?: number | number[]; - pingInterval?: number; - pingTimeout?: number; - name?: string; - [key: string]: any; - } -): IRouteConfig { - // Handle different signatures - let target: { host: string | string[]; port: number }; - let wsPath: string; - let finalOptions: any; - - if (typeof targetOrPath === 'string') { - // Legacy signature: (domains, path, target, options) - wsPath = targetOrPath; - target = targetOrOptions as { host: string | string[]; port: number }; - finalOptions = options || {}; - } else { - // New signature: (domains, target, options) - target = targetOrPath; - finalOptions = (targetOrOptions as any) || {}; - wsPath = finalOptions.path || '/ws'; - } - - // Normalize WebSocket path - const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`; - - // Create route match - const match: IRouteMatch = { - ports: finalOptions.useTls - ? (finalOptions.httpsPort || 443) - : (finalOptions.httpPort || 80), - domains, - path: normalizedPath - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [target], - websocket: { - enabled: true, - pingInterval: finalOptions.pingInterval || 30000, // 30 seconds - pingTimeout: finalOptions.pingTimeout || 5000 // 5 seconds - } - }; - - // Add TLS configuration if using HTTPS - if (finalOptions.useTls) { - action.tls = { - mode: 'terminate', - certificate: finalOptions.certificate || 'auto' - }; - } - - // Create the route config - return { - match, - action, - name: finalOptions.name || `WebSocket Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`, - priority: finalOptions.priority || 100, // Higher priority for WebSocket routes - ...finalOptions - }; -} - -/** - * Create a helper function that applies a port offset - * @param offset The offset to apply to the matched port - * @returns A function that adds the offset to the matched port - */ -export function createPortOffset(offset: number): (context: IRouteContext) => number { - return (context: IRouteContext) => context.port + offset; -} - -/** - * Create a port mapping route with context-based port function - * @param options Port mapping route options - * @returns Route configuration object - */ -export function createPortMappingRoute(options: { - sourcePortRange: TPortRange; - targetHost: string | string[] | ((context: IRouteContext) => string | string[]); - portMapper: (context: IRouteContext) => number; - name?: string; - domains?: string | string[]; - priority?: number; - [key: string]: any; -}): IRouteConfig { - // Create route match - const match: IRouteMatch = { - ports: options.sourcePortRange, - domains: options.domains - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [{ - host: options.targetHost, - port: options.portMapper - }] - }; - - // Create the route config - return { - match, - action, - name: options.name || `Port Mapping Route for ${options.domains || 'all domains'}`, - priority: options.priority, - ...options - }; -} - -/** - * Create a simple offset port mapping route - * @param options Offset port mapping route options - * @returns Route configuration object - */ -export function createOffsetPortMappingRoute(options: { - ports: TPortRange; - targetHost: string | string[]; - offset: number; - name?: string; - domains?: string | string[]; - priority?: number; - [key: string]: any; -}): IRouteConfig { - return createPortMappingRoute({ - sourcePortRange: options.ports, - targetHost: options.targetHost, - portMapper: (context) => context.port + options.offset, - name: options.name || `Offset Mapping (${options.offset > 0 ? '+' : ''}${options.offset}) for ${options.domains || 'all domains'}`, - domains: options.domains, - priority: options.priority, - ...options - }); -} - -/** - * Create a dynamic route with context-based host and port mapping - * @param options Dynamic route options - * @returns Route configuration object - */ -export function createDynamicRoute(options: { - ports: TPortRange; - targetHost: (context: IRouteContext) => string | string[]; - portMapper: (context: IRouteContext) => number; - name?: string; - domains?: string | string[]; - path?: string; - clientIp?: string[]; - priority?: number; - [key: string]: any; -}): IRouteConfig { - // Create route match - const match: IRouteMatch = { - ports: options.ports, - domains: options.domains, - path: options.path, - clientIp: options.clientIp - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [{ - host: options.targetHost, - port: options.portMapper - }] - }; - - // Create the route config - return { - match, - action, - name: options.name || `Dynamic Route for ${options.domains || 'all domains'}`, - priority: options.priority, - ...options - }; -} - -/** - * Create a smart load balancer with dynamic domain-based backend selection - * @param options Smart load balancer options - * @returns Route configuration object - */ -export function createSmartLoadBalancer(options: { - ports: TPortRange; - domainTargets: Record; - portMapper: (context: IRouteContext) => number; - name?: string; - defaultTarget?: string | string[]; - priority?: number; - [key: string]: any; -}): IRouteConfig { - // Extract all domain keys to create the match criteria - const domains = Object.keys(options.domainTargets); - - // Create the smart host selector function - const hostSelector = (context: IRouteContext) => { - const domain = context.domain || ''; - return options.domainTargets[domain] || options.defaultTarget || 'localhost'; - }; - - // Create route match - const match: IRouteMatch = { - ports: options.ports, - domains - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [{ - host: hostSelector, - port: options.portMapper - }] - }; - - // Create the route config - return { - match, - action, - name: options.name || `Smart Load Balancer for ${domains.join(', ')}`, - priority: options.priority, - ...options - }; -} - -/** - * Create an NFTables-based route for high-performance packet forwarding - * @param nameOrDomains Name or domain(s) to match - * @param target Target host and port - * @param options Additional route options - * @returns Route configuration object - */ -export function createNfTablesRoute( - nameOrDomains: string | string[], - target: { host: string; port: number | 'preserve' }, - options: { - ports?: TPortRange; - protocol?: 'tcp' | 'udp' | 'all'; - preserveSourceIP?: boolean; - ipAllowList?: string[]; - ipBlockList?: string[]; - maxRate?: string; - priority?: number; - useTls?: boolean; - tableName?: string; - useIPSets?: boolean; - useAdvancedNAT?: boolean; - } = {} -): IRouteConfig { - // Determine if this is a name or domain - let name: string; - let domains: string | string[] | undefined; - - if (Array.isArray(nameOrDomains) || (typeof nameOrDomains === 'string' && nameOrDomains.includes('.'))) { - domains = nameOrDomains; - name = Array.isArray(nameOrDomains) ? nameOrDomains[0] : nameOrDomains; - } else { - name = nameOrDomains; - domains = undefined; // No domains - } - - // Create route match - const match: IRouteMatch = { - domains, - ports: options.ports || 80 - }; - - // Create route action - const action: IRouteAction = { - type: 'forward', - targets: [{ - host: target.host, - port: target.port - }], - forwardingEngine: 'nftables', - nftables: { - protocol: options.protocol || 'tcp', - preserveSourceIP: options.preserveSourceIP, - maxRate: options.maxRate, - priority: options.priority, - tableName: options.tableName, - useIPSets: options.useIPSets, - useAdvancedNAT: options.useAdvancedNAT - } - }; - - // Add TLS options if needed - if (options.useTls) { - action.tls = { - mode: 'passthrough' - }; - } - - // Create the route config - const routeConfig: IRouteConfig = { - name, - match, - action - }; - - // Add security if allowed or blocked IPs are specified - if (options.ipAllowList?.length || options.ipBlockList?.length) { - routeConfig.security = { - ipAllowList: options.ipAllowList, - ipBlockList: options.ipBlockList - }; - } - - return routeConfig; -} - -/** - * Create an NFTables-based TLS termination route - * @param nameOrDomains Name or domain(s) to match - * @param target Target host and port - * @param options Additional route options - * @returns Route configuration object - */ -export function createNfTablesTerminateRoute( - nameOrDomains: string | string[], - target: { host: string; port: number | 'preserve' }, - options: { - ports?: TPortRange; - protocol?: 'tcp' | 'udp' | 'all'; - preserveSourceIP?: boolean; - ipAllowList?: string[]; - ipBlockList?: string[]; - maxRate?: string; - priority?: number; - tableName?: string; - useIPSets?: boolean; - useAdvancedNAT?: boolean; - certificate?: 'auto' | { key: string; cert: string }; - } = {} -): IRouteConfig { - // Create basic NFTables route - const route = createNfTablesRoute( - nameOrDomains, - target, - { - ...options, - ports: options.ports || 443, - useTls: false - } - ); - - // Set TLS termination - route.action.tls = { - mode: 'terminate', - certificate: options.certificate || 'auto' - }; - - return route; -} - -/** - * Create a complete NFTables-based HTTPS setup with HTTP redirect - * @param nameOrDomains Name or domain(s) to match - * @param target Target host and port - * @param options Additional route options - * @returns Array of two route configurations (HTTPS and HTTP redirect) - */ -export function createCompleteNfTablesHttpsServer( - nameOrDomains: string | string[], - target: { host: string; port: number | 'preserve' }, - options: { - httpPort?: TPortRange; - httpsPort?: TPortRange; - protocol?: 'tcp' | 'udp' | 'all'; - preserveSourceIP?: boolean; - ipAllowList?: string[]; - ipBlockList?: string[]; - maxRate?: string; - priority?: number; - tableName?: string; - useIPSets?: boolean; - useAdvancedNAT?: boolean; - certificate?: 'auto' | { key: string; cert: string }; - } = {} -): IRouteConfig[] { - // Create the HTTPS route using NFTables - const httpsRoute = createNfTablesTerminateRoute( - nameOrDomains, - target, - { - ...options, - ports: options.httpsPort || 443 - } - ); - - // Determine the domain(s) for HTTP redirect - const domains = typeof nameOrDomains === 'string' && !nameOrDomains.includes('.') - ? undefined - : nameOrDomains; - - // Extract the HTTPS port for the redirect destination - const httpsPort = typeof options.httpsPort === 'number' - ? options.httpsPort - : Array.isArray(options.httpsPort) && typeof options.httpsPort[0] === 'number' - ? options.httpsPort[0] - : 443; - - // Create the HTTP redirect route (this uses standard forwarding, not NFTables) - const httpRedirectRoute = createHttpToHttpsRedirect( - domains as any, // Type cast needed since domains can be undefined now - httpsPort, - { - match: { - ports: options.httpPort || 80, - domains: domains as any // Type cast needed since domains can be undefined now - }, - name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains || 'all domains'}` - } - ); - - return [httpsRoute, httpRedirectRoute]; -} - -/** - * Create a socket handler route configuration - * @param domains Domain(s) to match - * @param ports Port(s) to listen on - * @param handler Socket handler function - * @param options Additional route options - * @returns Route configuration object - */ -export function createSocketHandlerRoute( - domains: string | string[], - ports: TPortRange, - handler: (socket: plugins.net.Socket) => void | Promise, - options: { - name?: string; - priority?: number; - path?: string; - } = {} -): IRouteConfig { - return { - name: options.name || 'socket-handler-route', - priority: options.priority !== undefined ? options.priority : 50, - match: { - domains, - ports, - ...(options.path && { path: options.path }) - }, - action: { - type: 'socket-handler', - socketHandler: handler - } - }; -} - -/** - * Pre-built socket handlers for common use cases - */ -export const SocketHandlers = { - /** - * Simple echo server handler - */ - echo: (socket: plugins.net.Socket, context: IRouteContext) => { - socket.write('ECHO SERVER READY\n'); - socket.on('data', data => socket.write(data)); - }, - - /** - * TCP proxy handler - */ - proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket, context: IRouteContext) => { - const target = plugins.net.connect(targetPort, targetHost); - socket.pipe(target); - target.pipe(socket); - socket.on('close', () => target.destroy()); - target.on('close', () => socket.destroy()); - target.on('error', (err) => { - console.error('Proxy target error:', err); - socket.destroy(); - }); - }, - - /** - * Line-based protocol handler - */ - lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket, context: IRouteContext) => { - let buffer = ''; - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - lines.forEach(line => { - if (line.trim()) { - handler(line.trim(), socket); - } - }); - }); - }, - - /** - * Simple HTTP response handler (for testing) - */ - httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket, context: IRouteContext) => { - const response = [ - `HTTP/1.1 ${statusCode} ${statusCode === 200 ? 'OK' : 'Error'}`, - 'Content-Type: text/plain', - `Content-Length: ${body.length}`, - 'Connection: close', - '', - body - ].join('\r\n'); - - socket.write(response); - socket.end(); - }, - - /** - * Block connection immediately - */ - block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => { - const finalMessage = message || `Connection blocked from ${context.clientIp}`; - if (finalMessage) { - socket.write(finalMessage); - } - socket.end(); - }, - - /** - * HTTP block response - */ - httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => { - const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`; - const finalMessage = message || defaultMessage; - - const response = [ - `HTTP/1.1 ${statusCode} ${finalMessage}`, - 'Content-Type: text/plain', - `Content-Length: ${finalMessage.length}`, - 'Connection: close', - '', - finalMessage - ].join('\r\n'); - - socket.write(response); - socket.end(); - }, - - /** - * HTTP redirect handler - * Now uses the centralized detection module for HTTP parsing - */ - httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => { - const tracker = createSocketTracker(socket); - const connectionId = ProtocolDetector.createConnectionId({ - socketId: context.connectionId || `${Date.now()}-${Math.random()}` - }); - - const handleData = async (data: Buffer) => { - // Use detection module for parsing - const detectionResult = await ProtocolDetector.detectWithConnectionTracking( - data, - connectionId, - { extractFullHeaders: false } // We only need method and path - ); - - if (detectionResult.protocol === 'http' && detectionResult.connectionInfo.path) { - const method = detectionResult.connectionInfo.method || 'GET'; - const path = detectionResult.connectionInfo.path || '/'; - - const domain = context.domain || 'localhost'; - const port = context.port; - - let finalLocation = locationTemplate - .replace('{domain}', domain) - .replace('{port}', String(port)) - .replace('{path}', path) - .replace('{clientIp}', context.clientIp); - - const message = `Redirecting to ${finalLocation}`; - const response = [ - `HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`, - `Location: ${finalLocation}`, - 'Content-Type: text/plain', - `Content-Length: ${message.length}`, - 'Connection: close', - '', - message - ].join('\r\n'); - - socket.write(response); - } else { - // Not a valid HTTP request, close connection - socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); - } - - socket.end(); - // Clean up detection state - ProtocolDetector.cleanupConnections(); - // Clean up all tracked resources - tracker.cleanup(); - }; - - // Use tracker to manage the listener - socket.once('data', handleData); - - tracker.addListener('error', (err) => { - tracker.safeDestroy(err); - }); - - tracker.addListener('close', () => { - tracker.cleanup(); - }); - }, - - /** - * HTTP server handler for ACME challenges and other HTTP needs - * Now uses the centralized detection module for HTTP parsing - */ - httpServer: (handler: (req: { method: string; url: string; headers: Record; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => { - const tracker = createSocketTracker(socket); - let requestParsed = false; - let responseTimer: NodeJS.Timeout | null = null; - const connectionId = ProtocolDetector.createConnectionId({ - socketId: context.connectionId || `${Date.now()}-${Math.random()}` - }); - - const processData = async (data: Buffer) => { - if (requestParsed) return; // Only handle the first request - - // Use HttpDetector for parsing - const detectionResult = await ProtocolDetector.detectWithConnectionTracking( - data, - connectionId, - { extractFullHeaders: true } - ); - - if (detectionResult.protocol !== 'http' || !detectionResult.isComplete) { - // Not a complete HTTP request yet - return; - } - - requestParsed = true; - // Remove data listener after parsing request - socket.removeListener('data', processData); - const connInfo = detectionResult.connectionInfo; - - // Create request object from detection result - const req = { - method: connInfo.method || 'GET', - url: connInfo.path || '/', - headers: connInfo.headers || {}, - body: detectionResult.remainingBuffer?.toString() || '' - }; - - // Create response object - let statusCode = 200; - const responseHeaders: Record = {}; - let ended = false; - - const res = { - status: (code: number) => { - statusCode = code; - }, - header: (name: string, value: string) => { - responseHeaders[name] = value; - }, - send: (data: string) => { - if (ended) return; - ended = true; - - // Clear response timer since we're sending now - if (responseTimer) { - clearTimeout(responseTimer); - responseTimer = null; - } - - if (!responseHeaders['content-type']) { - responseHeaders['content-type'] = 'text/plain'; - } - responseHeaders['content-length'] = String(data.length); - responseHeaders['connection'] = 'close'; - - const statusText = statusCode === 200 ? 'OK' : - statusCode === 404 ? 'Not Found' : - statusCode === 500 ? 'Internal Server Error' : 'Response'; - - let response = `HTTP/1.1 ${statusCode} ${statusText}\r\n`; - for (const [name, value] of Object.entries(responseHeaders)) { - response += `${name}: ${value}\r\n`; - } - response += '\r\n'; - response += data; - - socket.write(response); - socket.end(); - }, - end: () => { - if (ended) return; - ended = true; - socket.write('HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'); - socket.end(); - } - }; - - try { - handler(req, res); - // Ensure response is sent even if handler doesn't call send() - responseTimer = setTimeout(() => { - if (!ended) { - res.send(''); - } - responseTimer = null; - }, 1000); - // Track and unref the timer - tracker.addTimer(responseTimer); - } catch (error) { - if (!ended) { - res.status(500); - res.send('Internal Server Error'); - } - // Use safeDestroy for error cases - tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error')); - } - }; - - // Use tracker to manage listeners - tracker.addListener('data', processData); - - tracker.addListener('error', (err) => { - if (!requestParsed) { - tracker.safeDestroy(err); - } - }); - - tracker.addListener('close', () => { - // Cleanup is handled by tracker - // Clear any pending response timer - if (responseTimer) { - clearTimeout(responseTimer); - responseTimer = null; - } - // Clean up detection state - ProtocolDetector.cleanupConnections(); - // Clean up all tracked resources - tracker.cleanup(); - }); - } -}; - -/** - * Create an API Gateway route pattern - * @param domains Domain(s) to match - * @param apiBasePath Base path for API endpoints (e.g., '/api') - * @param target Target host and port - * @param options Additional route options - * @returns API route configuration - */ -export function createApiGatewayRoute( - domains: string | string[], - apiBasePath: string, - target: { host: string | string[]; port: number }, - options: { - useTls?: boolean; - certificate?: 'auto' | { key: string; cert: string }; - addCorsHeaders?: boolean; - [key: string]: any; - } = {} -): IRouteConfig { - // Normalize apiBasePath to ensure it starts with / and doesn't end with / - const normalizedPath = apiBasePath.startsWith('/') - ? apiBasePath - : `/${apiBasePath}`; - - // Add wildcard to path to match all API endpoints - const apiPath = normalizedPath.endsWith('/') - ? `${normalizedPath}*` - : `${normalizedPath}/*`; - - // Create base route - const baseRoute = options.useTls - ? createHttpsTerminateRoute(domains, target, { - certificate: options.certificate || 'auto' - }) - : createHttpRoute(domains, target); - - // Add API-specific configurations - const apiRoute: Partial = { - match: { - ...baseRoute.match, - path: apiPath - }, - name: options.name || `API Gateway: ${apiPath} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`, - priority: options.priority || 100 // Higher priority for specific path matching - }; - - // Add CORS headers if requested - if (options.addCorsHeaders) { - apiRoute.headers = { - response: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Max-Age': '86400' - } - }; - } - - return mergeRouteConfigs(baseRoute, apiRoute); -} - -/** - * Create a rate limiting route pattern - * @param baseRoute Base route to add rate limiting to - * @param rateLimit Rate limiting configuration - * @returns Route with rate limiting - */ -export function addRateLimiting( - baseRoute: IRouteConfig, - rateLimit: { - maxRequests: number; - window: number; // Time window in seconds - keyBy?: 'ip' | 'path' | 'header'; - headerName?: string; // Required if keyBy is 'header' - errorMessage?: string; - } -): IRouteConfig { - return mergeRouteConfigs(baseRoute, { - security: { - rateLimit: { - enabled: true, - maxRequests: rateLimit.maxRequests, - window: rateLimit.window, - keyBy: rateLimit.keyBy || 'ip', - headerName: rateLimit.headerName, - errorMessage: rateLimit.errorMessage || 'Rate limit exceeded. Please try again later.' - } - } - }); -} - -/** - * Create a basic authentication route pattern - * @param baseRoute Base route to add authentication to - * @param auth Authentication configuration - * @returns Route with basic authentication - */ -export function addBasicAuth( - baseRoute: IRouteConfig, - auth: { - users: Array<{ username: string; password: string }>; - realm?: string; - excludePaths?: string[]; - } -): IRouteConfig { - return mergeRouteConfigs(baseRoute, { - security: { - basicAuth: { - enabled: true, - users: auth.users, - realm: auth.realm || 'Restricted Area', - excludePaths: auth.excludePaths || [] - } - } - }); -} - -/** - * Create a JWT authentication route pattern - * @param baseRoute Base route to add JWT authentication to - * @param jwt JWT authentication configuration - * @returns Route with JWT authentication - */ -export function addJwtAuth( - baseRoute: IRouteConfig, - jwt: { - secret: string; - algorithm?: string; - issuer?: string; - audience?: string; - expiresIn?: number; // Time in seconds - excludePaths?: string[]; - } -): IRouteConfig { - return mergeRouteConfigs(baseRoute, { - security: { - jwtAuth: { - enabled: true, - secret: jwt.secret, - algorithm: jwt.algorithm || 'HS256', - issuer: jwt.issuer, - audience: jwt.audience, - expiresIn: jwt.expiresIn, - excludePaths: jwt.excludePaths || [] - } - } - }); -} +// Re-export everything from the modular helpers +export * from './route-helpers/index.js'; diff --git a/ts/proxies/smart-proxy/utils/route-helpers/api-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/api-helpers.ts new file mode 100644 index 0000000..8ecddfe --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/api-helpers.ts @@ -0,0 +1,144 @@ +/** + * API Route Helper Functions + * + * This module provides utility functions for creating API route configurations. + */ + +import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js'; +import { mergeRouteConfigs } from '../route-utils.js'; +import { createHttpRoute } from './http-helpers.js'; +import { createHttpsTerminateRoute } from './https-helpers.js'; + +/** + * Create an API route configuration + * @param domains Domain(s) to match + * @param apiPath API base path (e.g., "/api") + * @param target Target host and port + * @param options Additional route options + * @returns Route configuration object + */ +export function createApiRoute( + domains: string | string[], + apiPath: string, + target: { host: string | string[]; port: number }, + options: { + useTls?: boolean; + certificate?: 'auto' | { key: string; cert: string }; + addCorsHeaders?: boolean; + httpPort?: number | number[]; + httpsPort?: number | number[]; + name?: string; + [key: string]: any; + } = {} +): IRouteConfig { + // Normalize API path + const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; + const pathWithWildcard = normalizedPath.endsWith('/') + ? `${normalizedPath}*` + : `${normalizedPath}/*`; + + // Create route match + const match: IRouteMatch = { + ports: options.useTls + ? (options.httpsPort || 443) + : (options.httpPort || 80), + domains, + path: pathWithWildcard + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [target] + }; + + // Add TLS configuration if using HTTPS + if (options.useTls) { + action.tls = { + mode: 'terminate', + certificate: options.certificate || 'auto' + }; + } + + // Add CORS headers if requested + const headers: Record> = {}; + if (options.addCorsHeaders) { + headers.response = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400' + }; + } + + // Create the route config + return { + match, + action, + headers: Object.keys(headers).length > 0 ? headers : undefined, + name: options.name || `API Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`, + priority: options.priority || 100, // Higher priority for specific path matches + ...options + }; +} + +/** + * Create an API Gateway route pattern + * @param domains Domain(s) to match + * @param apiBasePath Base path for API endpoints (e.g., '/api') + * @param target Target host and port + * @param options Additional route options + * @returns API route configuration + */ +export function createApiGatewayRoute( + domains: string | string[], + apiBasePath: string, + target: { host: string | string[]; port: number }, + options: { + useTls?: boolean; + certificate?: 'auto' | { key: string; cert: string }; + addCorsHeaders?: boolean; + [key: string]: any; + } = {} +): IRouteConfig { + // Normalize apiBasePath to ensure it starts with / and doesn't end with / + const normalizedPath = apiBasePath.startsWith('/') + ? apiBasePath + : `/${apiBasePath}`; + + // Add wildcard to path to match all API endpoints + const apiPath = normalizedPath.endsWith('/') + ? `${normalizedPath}*` + : `${normalizedPath}/*`; + + // Create base route + const baseRoute = options.useTls + ? createHttpsTerminateRoute(domains, target, { + certificate: options.certificate || 'auto' + }) + : createHttpRoute(domains, target); + + // Add API-specific configurations + const apiRoute: Partial = { + match: { + ...baseRoute.match, + path: apiPath + }, + name: options.name || `API Gateway: ${apiPath} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`, + priority: options.priority || 100 // Higher priority for specific path matching + }; + + // Add CORS headers if requested + if (options.addCorsHeaders) { + apiRoute.headers = { + response: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400' + } + }; + } + + return mergeRouteConfigs(baseRoute, apiRoute); +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/dynamic-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/dynamic-helpers.ts new file mode 100644 index 0000000..8d7c56c --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/dynamic-helpers.ts @@ -0,0 +1,124 @@ +/** + * Dynamic Route Helper Functions + * + * This module provides utility functions for creating dynamic routes + * with context-based host and port mapping. + */ + +import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange, IRouteContext } from '../../models/route-types.js'; + +/** + * Create a helper function that applies a port offset + * @param offset The offset to apply to the matched port + * @returns A function that adds the offset to the matched port + */ +export function createPortOffset(offset: number): (context: IRouteContext) => number { + return (context: IRouteContext) => context.port + offset; +} + +/** + * Create a port mapping route with context-based port function + * @param options Port mapping route options + * @returns Route configuration object + */ +export function createPortMappingRoute(options: { + sourcePortRange: TPortRange; + targetHost: string | string[] | ((context: IRouteContext) => string | string[]); + portMapper: (context: IRouteContext) => number; + name?: string; + domains?: string | string[]; + priority?: number; + [key: string]: any; +}): IRouteConfig { + // Create route match + const match: IRouteMatch = { + ports: options.sourcePortRange, + domains: options.domains + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [{ + host: options.targetHost, + port: options.portMapper + }] + }; + + // Create the route config + return { + match, + action, + name: options.name || `Port Mapping Route for ${options.domains || 'all domains'}`, + priority: options.priority, + ...options + }; +} + +/** + * Create a simple offset port mapping route + * @param options Offset port mapping route options + * @returns Route configuration object + */ +export function createOffsetPortMappingRoute(options: { + ports: TPortRange; + targetHost: string | string[]; + offset: number; + name?: string; + domains?: string | string[]; + priority?: number; + [key: string]: any; +}): IRouteConfig { + return createPortMappingRoute({ + sourcePortRange: options.ports, + targetHost: options.targetHost, + portMapper: (context) => context.port + options.offset, + name: options.name || `Offset Mapping (${options.offset > 0 ? '+' : ''}${options.offset}) for ${options.domains || 'all domains'}`, + domains: options.domains, + priority: options.priority, + ...options + }); +} + +/** + * Create a dynamic route with context-based host and port mapping + * @param options Dynamic route options + * @returns Route configuration object + */ +export function createDynamicRoute(options: { + ports: TPortRange; + targetHost: (context: IRouteContext) => string | string[]; + portMapper: (context: IRouteContext) => number; + name?: string; + domains?: string | string[]; + path?: string; + clientIp?: string[]; + priority?: number; + [key: string]: any; +}): IRouteConfig { + // Create route match + const match: IRouteMatch = { + ports: options.ports, + domains: options.domains, + path: options.path, + clientIp: options.clientIp + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [{ + host: options.targetHost, + port: options.portMapper + }] + }; + + // Create the route config + return { + match, + action, + name: options.name || `Dynamic Route for ${options.domains || 'all domains'}`, + priority: options.priority, + ...options + }; +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/http-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/http-helpers.ts new file mode 100644 index 0000000..e80239a --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/http-helpers.ts @@ -0,0 +1,40 @@ +/** + * HTTP Route Helper Functions + * + * This module provides utility functions for creating HTTP route configurations. + */ + +import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js'; + +/** + * Create an HTTP-only route configuration + * @param domains Domain(s) to match + * @param target Target host and port + * @param options Additional route options + * @returns Route configuration object + */ +export function createHttpRoute( + domains: string | string[], + target: { host: string | string[]; port: number }, + options: Partial = {} +): IRouteConfig { + // Create route match + const match: IRouteMatch = { + ports: options.match?.ports || 80, + domains + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [target] + }; + + // Create the route config + return { + match, + action, + name: options.name || `HTTP Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`, + ...options + }; +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/https-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/https-helpers.ts new file mode 100644 index 0000000..2d96887 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/https-helpers.ts @@ -0,0 +1,163 @@ +/** + * HTTPS Route Helper Functions + * + * This module provides utility functions for creating HTTPS route configurations + * including TLS termination and passthrough routes. + */ + +import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js'; +import { SocketHandlers } from './socket-handlers.js'; + +/** + * Create an HTTPS route with TLS termination + * @param domains Domain(s) to match + * @param target Target host and port + * @param options Additional route options + * @returns Route configuration object + */ +export function createHttpsTerminateRoute( + domains: string | string[], + target: { host: string | string[]; port: number }, + options: { + certificate?: 'auto' | { key: string; cert: string }; + httpPort?: number | number[]; + httpsPort?: number | number[]; + reencrypt?: boolean; + name?: string; + [key: string]: any; + } = {} +): IRouteConfig { + // Create route match + const match: IRouteMatch = { + ports: options.httpsPort || 443, + domains + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [target], + tls: { + mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate', + certificate: options.certificate || 'auto' + } + }; + + // Create the route config + return { + match, + action, + name: options.name || `HTTPS Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`, + ...options + }; +} + +/** + * Create an HTTP to HTTPS redirect route + * @param domains Domain(s) to match + * @param httpsPort HTTPS port to redirect to (default: 443) + * @param options Additional route options + * @returns Route configuration object + */ +export function createHttpToHttpsRedirect( + domains: string | string[], + httpsPort: number = 443, + options: Partial = {} +): IRouteConfig { + // Create route match + const match: IRouteMatch = { + ports: options.match?.ports || 80, + domains + }; + + // Create route action + const action: IRouteAction = { + type: 'socket-handler', + socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301) + }; + + // Create the route config + return { + match, + action, + name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`, + ...options + }; +} + +/** + * Create an HTTPS passthrough route (SNI-based forwarding without TLS termination) + * @param domains Domain(s) to match + * @param target Target host and port + * @param options Additional route options + * @returns Route configuration object + */ +export function createHttpsPassthroughRoute( + domains: string | string[], + target: { host: string | string[]; port: number }, + options: Partial = {} +): IRouteConfig { + // Create route match + const match: IRouteMatch = { + ports: options.match?.ports || 443, + domains + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [target], + tls: { + mode: 'passthrough' + } + }; + + // Create the route config + return { + match, + action, + name: options.name || `HTTPS Passthrough for ${Array.isArray(domains) ? domains.join(', ') : domains}`, + ...options + }; +} + +/** + * Create a complete HTTPS server with HTTP to HTTPS redirects + * @param domains Domain(s) to match + * @param target Target host and port + * @param options Additional configuration options + * @returns Array of two route configurations (HTTPS and HTTP redirect) + */ +export function createCompleteHttpsServer( + domains: string | string[], + target: { host: string | string[]; port: number }, + options: { + certificate?: 'auto' | { key: string; cert: string }; + httpPort?: number | number[]; + httpsPort?: number | number[]; + reencrypt?: boolean; + name?: string; + [key: string]: any; + } = {} +): IRouteConfig[] { + // Create the HTTPS route + const httpsRoute = createHttpsTerminateRoute(domains, target, options); + + // Create the HTTP redirect route + const httpRedirectRoute = createHttpToHttpsRedirect( + domains, + // Extract the HTTPS port from the HTTPS route - ensure it's a number + typeof options.httpsPort === 'number' ? options.httpsPort : + Array.isArray(options.httpsPort) ? options.httpsPort[0] : 443, + { + // Set the HTTP port + match: { + ports: options.httpPort || 80, + domains + }, + name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}` + } + ); + + return [httpsRoute, httpRedirectRoute]; +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/index.ts b/ts/proxies/smart-proxy/utils/route-helpers/index.ts new file mode 100644 index 0000000..e5d96fb --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/index.ts @@ -0,0 +1,62 @@ +/** + * Route Helper Functions + * + * This module provides utility functions for creating route configurations for common scenarios. + * These functions aim to simplify the creation of route configurations for typical use cases. + * + * This barrel file re-exports all helper functions for backwards compatibility. + */ + +// HTTP helpers +export { createHttpRoute } from './http-helpers.js'; + +// HTTPS helpers +export { + createHttpsTerminateRoute, + createHttpToHttpsRedirect, + createHttpsPassthroughRoute, + createCompleteHttpsServer +} from './https-helpers.js'; + +// WebSocket helpers +export { createWebSocketRoute } from './websocket-helpers.js'; + +// Load balancer helpers +export { + createLoadBalancerRoute, + createSmartLoadBalancer +} from './load-balancer-helpers.js'; + +// NFTables helpers +export { + createNfTablesRoute, + createNfTablesTerminateRoute, + createCompleteNfTablesHttpsServer +} from './nftables-helpers.js'; + +// Dynamic routing helpers +export { + createPortOffset, + createPortMappingRoute, + createOffsetPortMappingRoute, + createDynamicRoute +} from './dynamic-helpers.js'; + +// API helpers +export { + createApiRoute, + createApiGatewayRoute +} from './api-helpers.js'; + +// Security helpers +export { + addRateLimiting, + addBasicAuth, + addJwtAuth +} from './security-helpers.js'; + +// Socket handlers +export { + SocketHandlers, + createSocketHandlerRoute +} from './socket-handlers.js'; diff --git a/ts/proxies/smart-proxy/utils/route-helpers/load-balancer-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/load-balancer-helpers.ts new file mode 100644 index 0000000..35c04c6 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/load-balancer-helpers.ts @@ -0,0 +1,154 @@ +/** + * Load Balancer Route Helper Functions + * + * This module provides utility functions for creating load balancer route configurations. + */ + +import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../../models/route-types.js'; + +/** + * Create a load balancer route (round-robin between multiple backend hosts) + * @param domains Domain(s) to match + * @param backendsOrHosts Array of backend servers OR array of host strings (legacy) + * @param portOrOptions Port number (legacy) OR options object + * @param options Additional route options (legacy) + * @returns Route configuration object + */ +export function createLoadBalancerRoute( + domains: string | string[], + backendsOrHosts: Array<{ host: string; port: number }> | string[], + portOrOptions?: number | { + tls?: { + mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt'; + certificate?: 'auto' | { key: string; cert: string }; + }; + useTls?: boolean; + certificate?: 'auto' | { key: string; cert: string }; + algorithm?: 'round-robin' | 'least-connections' | 'ip-hash'; + healthCheck?: { + path: string; + interval: number; + timeout: number; + unhealthyThreshold: number; + healthyThreshold: number; + }; + [key: string]: any; + }, + options?: { + tls?: { + mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt'; + certificate?: 'auto' | { key: string; cert: string }; + }; + [key: string]: any; + } +): IRouteConfig { + // Handle legacy signature: (domains, hosts[], port, options) + let backends: Array<{ host: string; port: number }>; + let finalOptions: any; + + if (Array.isArray(backendsOrHosts) && backendsOrHosts.length > 0 && typeof backendsOrHosts[0] === 'string') { + // Legacy signature + const hosts = backendsOrHosts as string[]; + const port = portOrOptions as number; + backends = hosts.map(host => ({ host, port })); + finalOptions = options || {}; + } else { + // New signature + backends = backendsOrHosts as Array<{ host: string; port: number }>; + finalOptions = (portOrOptions as any) || {}; + } + + // Extract hosts and ensure all backends use the same port + const port = backends[0].port; + const hosts = backends.map(backend => backend.host); + + // Create route match + const match: IRouteMatch = { + ports: finalOptions.match?.ports || (finalOptions.tls || finalOptions.useTls ? 443 : 80), + domains + }; + + // Create route target + const target: IRouteTarget = { + host: hosts, + port + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [target] + }; + + // Add TLS configuration if provided + if (finalOptions.tls || finalOptions.useTls) { + action.tls = { + mode: finalOptions.tls?.mode || 'terminate', + certificate: finalOptions.tls?.certificate || finalOptions.certificate || 'auto' + }; + } + + // Add load balancing options + if (finalOptions.algorithm || finalOptions.healthCheck) { + action.loadBalancing = { + algorithm: finalOptions.algorithm || 'round-robin', + healthCheck: finalOptions.healthCheck + }; + } + + // Create the route config + return { + match, + action, + name: finalOptions.name || `Load Balancer for ${Array.isArray(domains) ? domains.join(', ') : domains}`, + ...finalOptions + }; +} + +/** + * Create a smart load balancer with dynamic domain-based backend selection + * @param options Smart load balancer options + * @returns Route configuration object + */ +export function createSmartLoadBalancer(options: { + ports: TPortRange; + domainTargets: Record; + portMapper: (context: IRouteContext) => number; + name?: string; + defaultTarget?: string | string[]; + priority?: number; + [key: string]: any; +}): IRouteConfig { + // Extract all domain keys to create the match criteria + const domains = Object.keys(options.domainTargets); + + // Create the smart host selector function + const hostSelector = (context: IRouteContext) => { + const domain = context.domain || ''; + return options.domainTargets[domain] || options.defaultTarget || 'localhost'; + }; + + // Create route match + const match: IRouteMatch = { + ports: options.ports, + domains + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [{ + host: hostSelector, + port: options.portMapper + }] + }; + + // Create the route config + return { + match, + action, + name: options.name || `Smart Load Balancer for ${domains.join(', ')}`, + priority: options.priority, + ...options + }; +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/nftables-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/nftables-helpers.ts new file mode 100644 index 0000000..eaf3e28 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/nftables-helpers.ts @@ -0,0 +1,202 @@ +/** + * NFTables Route Helper Functions + * + * This module provides utility functions for creating NFTables-based route configurations + * for high-performance packet forwarding at the kernel level. + */ + +import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../../models/route-types.js'; +import { createHttpToHttpsRedirect } from './https-helpers.js'; + +/** + * Create an NFTables-based route for high-performance packet forwarding + * @param nameOrDomains Name or domain(s) to match + * @param target Target host and port + * @param options Additional route options + * @returns Route configuration object + */ +export function createNfTablesRoute( + nameOrDomains: string | string[], + target: { host: string; port: number | 'preserve' }, + options: { + ports?: TPortRange; + protocol?: 'tcp' | 'udp' | 'all'; + preserveSourceIP?: boolean; + ipAllowList?: string[]; + ipBlockList?: string[]; + maxRate?: string; + priority?: number; + useTls?: boolean; + tableName?: string; + useIPSets?: boolean; + useAdvancedNAT?: boolean; + } = {} +): IRouteConfig { + // Determine if this is a name or domain + let name: string; + let domains: string | string[] | undefined; + + if (Array.isArray(nameOrDomains) || (typeof nameOrDomains === 'string' && nameOrDomains.includes('.'))) { + domains = nameOrDomains; + name = Array.isArray(nameOrDomains) ? nameOrDomains[0] : nameOrDomains; + } else { + name = nameOrDomains; + domains = undefined; // No domains + } + + // Create route match + const match: IRouteMatch = { + domains, + ports: options.ports || 80 + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [{ + host: target.host, + port: target.port + }], + forwardingEngine: 'nftables', + nftables: { + protocol: options.protocol || 'tcp', + preserveSourceIP: options.preserveSourceIP, + maxRate: options.maxRate, + priority: options.priority, + tableName: options.tableName, + useIPSets: options.useIPSets, + useAdvancedNAT: options.useAdvancedNAT + } + }; + + // Add TLS options if needed + if (options.useTls) { + action.tls = { + mode: 'passthrough' + }; + } + + // Create the route config + const routeConfig: IRouteConfig = { + name, + match, + action + }; + + // Add security if allowed or blocked IPs are specified + if (options.ipAllowList?.length || options.ipBlockList?.length) { + routeConfig.security = { + ipAllowList: options.ipAllowList, + ipBlockList: options.ipBlockList + }; + } + + return routeConfig; +} + +/** + * Create an NFTables-based TLS termination route + * @param nameOrDomains Name or domain(s) to match + * @param target Target host and port + * @param options Additional route options + * @returns Route configuration object + */ +export function createNfTablesTerminateRoute( + nameOrDomains: string | string[], + target: { host: string; port: number | 'preserve' }, + options: { + ports?: TPortRange; + protocol?: 'tcp' | 'udp' | 'all'; + preserveSourceIP?: boolean; + ipAllowList?: string[]; + ipBlockList?: string[]; + maxRate?: string; + priority?: number; + tableName?: string; + useIPSets?: boolean; + useAdvancedNAT?: boolean; + certificate?: 'auto' | { key: string; cert: string }; + } = {} +): IRouteConfig { + // Create basic NFTables route + const route = createNfTablesRoute( + nameOrDomains, + target, + { + ...options, + ports: options.ports || 443, + useTls: false + } + ); + + // Set TLS termination + route.action.tls = { + mode: 'terminate', + certificate: options.certificate || 'auto' + }; + + return route; +} + +/** + * Create a complete NFTables-based HTTPS setup with HTTP redirect + * @param nameOrDomains Name or domain(s) to match + * @param target Target host and port + * @param options Additional route options + * @returns Array of two route configurations (HTTPS and HTTP redirect) + */ +export function createCompleteNfTablesHttpsServer( + nameOrDomains: string | string[], + target: { host: string; port: number | 'preserve' }, + options: { + httpPort?: TPortRange; + httpsPort?: TPortRange; + protocol?: 'tcp' | 'udp' | 'all'; + preserveSourceIP?: boolean; + ipAllowList?: string[]; + ipBlockList?: string[]; + maxRate?: string; + priority?: number; + tableName?: string; + useIPSets?: boolean; + useAdvancedNAT?: boolean; + certificate?: 'auto' | { key: string; cert: string }; + } = {} +): IRouteConfig[] { + // Create the HTTPS route using NFTables + const httpsRoute = createNfTablesTerminateRoute( + nameOrDomains, + target, + { + ...options, + ports: options.httpsPort || 443 + } + ); + + // Determine the domain(s) for HTTP redirect + const domains = typeof nameOrDomains === 'string' && !nameOrDomains.includes('.') + ? undefined + : nameOrDomains; + + // Extract the HTTPS port for the redirect destination + const httpsPort = typeof options.httpsPort === 'number' + ? options.httpsPort + : Array.isArray(options.httpsPort) && typeof options.httpsPort[0] === 'number' + ? options.httpsPort[0] + : 443; + + // Create the HTTP redirect route (this uses standard forwarding, not NFTables) + const httpRedirectRoute = createHttpToHttpsRedirect( + domains as any, // Type cast needed since domains can be undefined now + httpsPort, + { + match: { + ports: options.httpPort || 80, + domains: domains as any // Type cast needed since domains can be undefined now + }, + name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains || 'all domains'}` + } + ); + + return [httpsRoute, httpRedirectRoute]; +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/security-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/security-helpers.ts new file mode 100644 index 0000000..13447b2 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/security-helpers.ts @@ -0,0 +1,96 @@ +/** + * Security Route Helper Functions + * + * This module provides utility functions for adding security features to routes. + */ + +import type { IRouteConfig } from '../../models/route-types.js'; +import { mergeRouteConfigs } from '../route-utils.js'; + +/** + * Create a rate limiting route pattern + * @param baseRoute Base route to add rate limiting to + * @param rateLimit Rate limiting configuration + * @returns Route with rate limiting + */ +export function addRateLimiting( + baseRoute: IRouteConfig, + rateLimit: { + maxRequests: number; + window: number; // Time window in seconds + keyBy?: 'ip' | 'path' | 'header'; + headerName?: string; // Required if keyBy is 'header' + errorMessage?: string; + } +): IRouteConfig { + return mergeRouteConfigs(baseRoute, { + security: { + rateLimit: { + enabled: true, + maxRequests: rateLimit.maxRequests, + window: rateLimit.window, + keyBy: rateLimit.keyBy || 'ip', + headerName: rateLimit.headerName, + errorMessage: rateLimit.errorMessage || 'Rate limit exceeded. Please try again later.' + } + } + }); +} + +/** + * Create a basic authentication route pattern + * @param baseRoute Base route to add authentication to + * @param auth Authentication configuration + * @returns Route with basic authentication + */ +export function addBasicAuth( + baseRoute: IRouteConfig, + auth: { + users: Array<{ username: string; password: string }>; + realm?: string; + excludePaths?: string[]; + } +): IRouteConfig { + return mergeRouteConfigs(baseRoute, { + security: { + basicAuth: { + enabled: true, + users: auth.users, + realm: auth.realm || 'Restricted Area', + excludePaths: auth.excludePaths || [] + } + } + }); +} + +/** + * Create a JWT authentication route pattern + * @param baseRoute Base route to add JWT authentication to + * @param jwt JWT authentication configuration + * @returns Route with JWT authentication + */ +export function addJwtAuth( + baseRoute: IRouteConfig, + jwt: { + secret: string; + algorithm?: string; + issuer?: string; + audience?: string; + expiresIn?: number; // Time in seconds + excludePaths?: string[]; + } +): IRouteConfig { + return mergeRouteConfigs(baseRoute, { + security: { + jwtAuth: { + enabled: true, + secret: jwt.secret, + algorithm: jwt.algorithm || 'HS256', + issuer: jwt.issuer, + audience: jwt.audience, + expiresIn: jwt.expiresIn, + excludePaths: jwt.excludePaths || [] + } + } + }); +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts b/ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts new file mode 100644 index 0000000..b9be549 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts @@ -0,0 +1,337 @@ +/** + * Socket Handler Functions + * + * This module provides pre-built socket handlers for common use cases + * like echoing, proxying, HTTP responses, and redirects. + */ + +import * as plugins from '../../../../plugins.js'; +import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js'; +import { ProtocolDetector } from '../../../../detection/index.js'; +import { createSocketTracker } from '../../../../core/utils/socket-tracker.js'; + +/** + * Pre-built socket handlers for common use cases + */ +export const SocketHandlers = { + /** + * Simple echo server handler + */ + echo: (socket: plugins.net.Socket, context: IRouteContext) => { + socket.write('ECHO SERVER READY\n'); + socket.on('data', data => socket.write(data)); + }, + + /** + * TCP proxy handler + */ + proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket, context: IRouteContext) => { + const target = plugins.net.connect(targetPort, targetHost); + socket.pipe(target); + target.pipe(socket); + socket.on('close', () => target.destroy()); + target.on('close', () => socket.destroy()); + target.on('error', (err) => { + console.error('Proxy target error:', err); + socket.destroy(); + }); + }, + + /** + * Line-based protocol handler + */ + lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket, context: IRouteContext) => { + let buffer = ''; + socket.on('data', (data) => { + buffer += data.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + lines.forEach(line => { + if (line.trim()) { + handler(line.trim(), socket); + } + }); + }); + }, + + /** + * Simple HTTP response handler (for testing) + */ + httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket, context: IRouteContext) => { + const response = [ + `HTTP/1.1 ${statusCode} ${statusCode === 200 ? 'OK' : 'Error'}`, + 'Content-Type: text/plain', + `Content-Length: ${body.length}`, + 'Connection: close', + '', + body + ].join('\r\n'); + + socket.write(response); + socket.end(); + }, + + /** + * Block connection immediately + */ + block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => { + const finalMessage = message || `Connection blocked from ${context.clientIp}`; + if (finalMessage) { + socket.write(finalMessage); + } + socket.end(); + }, + + /** + * HTTP block response + */ + httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => { + const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`; + const finalMessage = message || defaultMessage; + + const response = [ + `HTTP/1.1 ${statusCode} ${finalMessage}`, + 'Content-Type: text/plain', + `Content-Length: ${finalMessage.length}`, + 'Connection: close', + '', + finalMessage + ].join('\r\n'); + + socket.write(response); + socket.end(); + }, + + /** + * HTTP redirect handler + * Uses the centralized detection module for HTTP parsing + */ + httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => { + const tracker = createSocketTracker(socket); + const connectionId = ProtocolDetector.createConnectionId({ + socketId: context.connectionId || `${Date.now()}-${Math.random()}` + }); + + const handleData = async (data: Buffer) => { + // Use detection module for parsing + const detectionResult = await ProtocolDetector.detectWithConnectionTracking( + data, + connectionId, + { extractFullHeaders: false } // We only need method and path + ); + + if (detectionResult.protocol === 'http' && detectionResult.connectionInfo.path) { + const method = detectionResult.connectionInfo.method || 'GET'; + const path = detectionResult.connectionInfo.path || '/'; + + const domain = context.domain || 'localhost'; + const port = context.port; + + let finalLocation = locationTemplate + .replace('{domain}', domain) + .replace('{port}', String(port)) + .replace('{path}', path) + .replace('{clientIp}', context.clientIp); + + const message = `Redirecting to ${finalLocation}`; + const response = [ + `HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`, + `Location: ${finalLocation}`, + 'Content-Type: text/plain', + `Content-Length: ${message.length}`, + 'Connection: close', + '', + message + ].join('\r\n'); + + socket.write(response); + } else { + // Not a valid HTTP request, close connection + socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); + } + + socket.end(); + // Clean up detection state + ProtocolDetector.cleanupConnections(); + // Clean up all tracked resources + tracker.cleanup(); + }; + + // Use tracker to manage the listener + socket.once('data', handleData); + + tracker.addListener('error', (err) => { + tracker.safeDestroy(err); + }); + + tracker.addListener('close', () => { + tracker.cleanup(); + }); + }, + + /** + * HTTP server handler for ACME challenges and other HTTP needs + * Uses the centralized detection module for HTTP parsing + */ + httpServer: (handler: (req: { method: string; url: string; headers: Record; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => { + const tracker = createSocketTracker(socket); + let requestParsed = false; + let responseTimer: NodeJS.Timeout | null = null; + const connectionId = ProtocolDetector.createConnectionId({ + socketId: context.connectionId || `${Date.now()}-${Math.random()}` + }); + + const processData = async (data: Buffer) => { + if (requestParsed) return; // Only handle the first request + + // Use HttpDetector for parsing + const detectionResult = await ProtocolDetector.detectWithConnectionTracking( + data, + connectionId, + { extractFullHeaders: true } + ); + + if (detectionResult.protocol !== 'http' || !detectionResult.isComplete) { + // Not a complete HTTP request yet + return; + } + + requestParsed = true; + // Remove data listener after parsing request + socket.removeListener('data', processData); + const connInfo = detectionResult.connectionInfo; + + // Create request object from detection result + const req = { + method: connInfo.method || 'GET', + url: connInfo.path || '/', + headers: connInfo.headers || {}, + body: detectionResult.remainingBuffer?.toString() || '' + }; + + // Create response object + let statusCode = 200; + const responseHeaders: Record = {}; + let ended = false; + + const res = { + status: (code: number) => { + statusCode = code; + }, + header: (name: string, value: string) => { + responseHeaders[name] = value; + }, + send: (data: string) => { + if (ended) return; + ended = true; + + // Clear response timer since we're sending now + if (responseTimer) { + clearTimeout(responseTimer); + responseTimer = null; + } + + if (!responseHeaders['content-type']) { + responseHeaders['content-type'] = 'text/plain'; + } + responseHeaders['content-length'] = String(data.length); + responseHeaders['connection'] = 'close'; + + const statusText = statusCode === 200 ? 'OK' : + statusCode === 404 ? 'Not Found' : + statusCode === 500 ? 'Internal Server Error' : 'Response'; + + let response = `HTTP/1.1 ${statusCode} ${statusText}\r\n`; + for (const [name, value] of Object.entries(responseHeaders)) { + response += `${name}: ${value}\r\n`; + } + response += '\r\n'; + response += data; + + socket.write(response); + socket.end(); + }, + end: () => { + if (ended) return; + ended = true; + socket.write('HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'); + socket.end(); + } + }; + + try { + handler(req, res); + // Ensure response is sent even if handler doesn't call send() + responseTimer = setTimeout(() => { + if (!ended) { + res.send(''); + } + responseTimer = null; + }, 1000); + // Track and unref the timer + tracker.addTimer(responseTimer); + } catch (error) { + if (!ended) { + res.status(500); + res.send('Internal Server Error'); + } + // Use safeDestroy for error cases + tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error')); + } + }; + + // Use tracker to manage listeners + tracker.addListener('data', processData); + + tracker.addListener('error', (err) => { + if (!requestParsed) { + tracker.safeDestroy(err); + } + }); + + tracker.addListener('close', () => { + // Clear any pending response timer + if (responseTimer) { + clearTimeout(responseTimer); + responseTimer = null; + } + // Clean up detection state + ProtocolDetector.cleanupConnections(); + // Clean up all tracked resources + tracker.cleanup(); + }); + } +}; + +/** + * Create a socket handler route configuration + * @param domains Domain(s) to match + * @param ports Port(s) to listen on + * @param handler Socket handler function + * @param options Additional route options + * @returns Route configuration object + */ +export function createSocketHandlerRoute( + domains: string | string[], + ports: TPortRange, + handler: (socket: plugins.net.Socket) => void | Promise, + options: { + name?: string; + priority?: number; + path?: string; + } = {} +): IRouteConfig { + return { + name: options.name || 'socket-handler-route', + priority: options.priority !== undefined ? options.priority : 50, + match: { + domains, + ports, + ...(options.path && { path: options.path }) + }, + action: { + type: 'socket-handler', + socketHandler: handler + } + }; +} diff --git a/ts/proxies/smart-proxy/utils/route-helpers/websocket-helpers.ts b/ts/proxies/smart-proxy/utils/route-helpers/websocket-helpers.ts new file mode 100644 index 0000000..52a6a46 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/route-helpers/websocket-helpers.ts @@ -0,0 +1,98 @@ +/** + * WebSocket Route Helper Functions + * + * This module provides utility functions for creating WebSocket route configurations. + */ + +import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js'; + +/** + * Create a WebSocket route configuration + * @param domains Domain(s) to match + * @param targetOrPath Target server OR WebSocket path (legacy) + * @param targetOrOptions Target server (legacy) OR options + * @param options Additional route options (legacy) + * @returns Route configuration object + */ +export function createWebSocketRoute( + domains: string | string[], + targetOrPath: { host: string | string[]; port: number } | string, + targetOrOptions?: { host: string | string[]; port: number } | { + useTls?: boolean; + certificate?: 'auto' | { key: string; cert: string }; + path?: string; + httpPort?: number | number[]; + httpsPort?: number | number[]; + pingInterval?: number; + pingTimeout?: number; + name?: string; + [key: string]: any; + }, + options?: { + useTls?: boolean; + certificate?: 'auto' | { key: string; cert: string }; + httpPort?: number | number[]; + httpsPort?: number | number[]; + pingInterval?: number; + pingTimeout?: number; + name?: string; + [key: string]: any; + } +): IRouteConfig { + // Handle different signatures + let target: { host: string | string[]; port: number }; + let wsPath: string; + let finalOptions: any; + + if (typeof targetOrPath === 'string') { + // Legacy signature: (domains, path, target, options) + wsPath = targetOrPath; + target = targetOrOptions as { host: string | string[]; port: number }; + finalOptions = options || {}; + } else { + // New signature: (domains, target, options) + target = targetOrPath; + finalOptions = (targetOrOptions as any) || {}; + wsPath = finalOptions.path || '/ws'; + } + + // Normalize WebSocket path + const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`; + + // Create route match + const match: IRouteMatch = { + ports: finalOptions.useTls + ? (finalOptions.httpsPort || 443) + : (finalOptions.httpPort || 80), + domains, + path: normalizedPath + }; + + // Create route action + const action: IRouteAction = { + type: 'forward', + targets: [target], + websocket: { + enabled: true, + pingInterval: finalOptions.pingInterval || 30000, // 30 seconds + pingTimeout: finalOptions.pingTimeout || 5000 // 5 seconds + } + }; + + // Add TLS configuration if using HTTPS + if (finalOptions.useTls) { + action.tls = { + mode: 'terminate', + certificate: finalOptions.certificate || 'auto' + }; + } + + // Create the route config + return { + match, + action, + name: finalOptions.name || `WebSocket Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`, + priority: finalOptions.priority || 100, // Higher priority for WebSocket routes + ...finalOptions + }; +}