UNPKG

@push.rocks/smartproxy

Version:

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.

845 lines 85.2 kB
import * as plugins from '../../plugins.js'; import { logger } from '../../core/utils/logger.js'; // Rust bridge and helpers import { RustProxyBridge } from './rust-proxy-bridge.js'; import { RoutePreprocessor } from './route-preprocessor.js'; import { SocketHandlerServer } from './socket-handler-server.js'; import { DatagramHandlerServer } from './datagram-handler-server.js'; import { ChallengeProviderRelayServer } from './challenge-provider-relay-server.js'; import { RustMetricsAdapter } from './rust-metrics-adapter.js'; // Route management import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; import { RouteValidator } from './utils/route-validator.js'; import { buildRustProxyOptions } from './utils/rust-config.js'; import { generateDefaultCertificate } from './utils/default-cert-generator.js'; import { Mutex } from './utils/mutex.js'; import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js'; /** * SmartProxy - Rust-backed proxy engine with TypeScript configuration API. * * All networking (TCP, TLS, HTTP reverse proxy, connection management, security) * is handled by the Rust binary. TypeScript is only: * - The npm module interface (types, route helpers) * - The thin IPC wrapper (this class) * - Socket-handler callback relay (for JS-defined handlers) * - Certificate provisioning callbacks (certProvisionFunction) */ export class SmartProxy extends plugins.EventEmitter { settings; routeManager; bridge; preprocessor; socketHandlerServer = null; datagramHandlerServer = null; challengeProviderRelayServer = null; challengeProviders = new Map(); challengeRuntimeOptions; metricsAdapter; nftablesManager = null; routeUpdateLock; stopping = false; certProvisionPromise = null; constructor(settingsArg) { super(); // Apply defaults this.settings = { ...settingsArg, initialDataTimeout: settingsArg.initialDataTimeout || 60_000, socketTimeout: settingsArg.socketTimeout || 60_000, maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3_600_000, inactivityTimeout: settingsArg.inactivityTimeout || 75_000, gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30_000, maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, keepAliveTreatment: settingsArg.keepAliveTreatment || 'standard', keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 4, extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 3_600_000, }; // Normalize ACME options if (this.settings.acme) { if (this.settings.acme.accountEmail && !this.settings.acme.email) { this.settings.acme.email = this.settings.acme.accountEmail; } this.settings.acme = { enabled: this.settings.acme.enabled !== false, port: this.settings.acme.port || 80, email: this.settings.acme.email, useProduction: this.settings.acme.useProduction || false, renewThresholdDays: this.settings.acme.renewThresholdDays || 30, autoRenew: this.settings.acme.autoRenew !== false, skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24, routeForwards: this.settings.acme.routeForwards || [], ...this.settings.acme, }; } // Validate routes if (this.settings.routes?.length) { const validation = RouteValidator.validateRoutes(this.settings.routes); if (!validation.valid) { RouteValidator.logValidationErrors(validation.errors); throw new Error(`Initial route validation failed: ${validation.errors.size} route(s) have errors`); } } // Create logger adapter const loggerAdapter = { debug: (message, data) => logger.log('debug', message, data), info: (message, data) => logger.log('info', message, data), warn: (message, data) => logger.log('warn', message, data), error: (message, data) => logger.log('error', message, data), }; // Initialize components this.routeManager = new RouteManager({ logger: loggerAdapter, enableDetailedLogging: this.settings.enableDetailedLogging, routes: this.settings.routes, }); this.bridge = new RustProxyBridge(); this.preprocessor = new RoutePreprocessor(); this.metricsAdapter = new RustMetricsAdapter(this.bridge, this.settings.metrics?.sampleIntervalMs ?? 1000); this.routeUpdateLock = new Mutex(); } /** * Register a runtime challenge provider family. Routes reference providerId + challengeType; * deployment wiring and provider secrets stay outside route configs. */ registerChallengeProvider(providerId, provider) { if (!providerId || typeof providerId !== 'string') { throw new Error('Challenge providerId must be a non-empty string'); } this.challengeProviders.set(providerId, provider); } /** * Start the proxy. * Spawns the Rust binary, configures socket relay if needed, sends routes, handles cert provisioning. */ async start() { await this.validateChallengeRoutes(this.settings.routes); let didSpawn = false; try { // Spawn Rust binary const spawned = await this.bridge.spawn(); if (!spawned) { throw new Error('RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' + 'or build locally with: pnpm build'); } didSpawn = true; // Handle unexpected exit (only emits error if not intentionally stopping) this.bridge.removeAllListeners('exit'); this.bridge.on('exit', (code, signal) => { if (this.stopping) return; logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' }); this.emit('error', new Error(`RustProxy exited (code=${code}, signal=${signal})`)); }); const hasChallengeRoutes = this.hasChallengeRoutes(this.settings.routes); if (hasChallengeRoutes) { await this.ensureChallengeProviderRelay(); } // Check if any routes need TS-side handling (socket handlers, dynamic functions) const hasHandlerRoutes = this.settings.routes.some((r) => (r.action.type === 'socket-handler' && r.action.socketHandler) || r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')); // Start socket handler relay server (but don't tell Rust yet - proxy not started) if (hasHandlerRoutes) { this.socketHandlerServer = new SocketHandlerServer(this.preprocessor); await this.socketHandlerServer.start(); } // Check if any routes need datagram handler relay (UDP socket-handler routes) const hasDatagramHandlers = this.settings.routes.some((r) => r.action.type === 'socket-handler' && r.action.datagramHandler); if (hasDatagramHandlers) { const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`; this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor); await this.datagramHandlerServer.start(); } // Preprocess routes (strip JS functions, convert socket-handler routes) const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes); // When certProvisionFunction handles cert provisioning, // disable Rust's built-in ACME to prevent race condition. let acmeForRust = this.settings.acme; if (this.settings.certProvisionFunction && acmeForRust?.enabled) { acmeForRust = { ...acmeForRust, enabled: false }; logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' }); } // Build Rust config const config = this.buildRustConfig(rustRoutes, acmeForRust); // Start the Rust proxy await this.bridge.startProxy(config); // Now that Rust proxy is running, configure socket handler relay if (this.socketHandlerServer) { await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath()); } // Configure challenge provider relay. The path is also present in startup config, // but this hot setter keeps runtime changes explicit and mirrors other relays. if (this.challengeProviderRelayServer) { await this.bridge.setChallengeProviderRelay(this.challengeProviderRelayServer.getSocketPath(), this.challengeRuntimeOptions); } // Configure datagram handler relay if (this.datagramHandlerServer) { await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath()); } // Load default self-signed fallback certificate (domain: '*') if (!this.settings.disableDefaultCert) { try { const defaultCert = generateDefaultCertificate(); await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key); logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' }); } catch (err) { logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' }); } } // Load consumer-stored certificates const preloadedDomains = new Set(); if (this.settings.certStore) { try { const stored = await this.settings.certStore.loadAll(); for (const entry of stored) { await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca); preloadedDomains.add(entry.domain); } logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' }); } catch (err) { logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' }); } } // Apply NFTables rules for routes using nftables forwarding engine await this.applyNftablesRules(this.settings.routes); // Start metrics polling BEFORE cert provisioning — the Rust engine is already // running and accepting connections, so metrics should be available immediately. // Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must // not block metrics collection. this.metricsAdapter.startPolling(); logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' }); // Fire-and-forget cert provisioning — Rust engine is already running and serving traffic. // Events (certificate-issued / certificate-failed) fire independently per domain. this.certProvisionPromise = this.provisionCertificatesViaCallback(preloadedDomains) .catch((err) => logger.log('error', `Unexpected error in cert provisioning: ${err.message}`, { component: 'smart-proxy' })); } catch (err) { await this.cleanupRuntimeResourcesAfterStartFailure(didSpawn); throw err; } } /** * Stop the proxy. */ async stop() { logger.log('info', 'SmartProxy shutting down...', { component: 'smart-proxy' }); this.stopping = true; // Wait for in-flight cert provisioning to bail out (it checks this.stopping) if (this.certProvisionPromise) { await this.certProvisionPromise; this.certProvisionPromise = null; } // Clean up NFTables rules if (this.nftablesManager) { await this.nftablesManager.cleanup(); this.nftablesManager = null; } // Stop metrics polling this.metricsAdapter.stopPolling(); // Remove exit listener before killing to avoid spurious error events this.bridge.removeAllListeners('exit'); // Stop Rust proxy try { await this.bridge.stopProxy(); } catch { // Ignore if already stopped } this.bridge.kill(); // Stop socket handler relay if (this.socketHandlerServer) { await this.socketHandlerServer.stop(); this.socketHandlerServer = null; } // Stop datagram handler relay if (this.datagramHandlerServer) { await this.datagramHandlerServer.stop(); this.datagramHandlerServer = null; } // Stop challenge provider relay if (this.challengeProviderRelayServer) { await this.challengeProviderRelayServer.stop(); this.challengeProviderRelayServer = null; } this.challengeRuntimeOptions = undefined; logger.log('info', 'SmartProxy shutdown complete.', { component: 'smart-proxy' }); } /** * Update routes atomically. */ async updateRoutes(newRoutes) { await this.routeUpdateLock.runExclusive(async () => { await this.validateChallengeRoutes(newRoutes); // Validate before starting relays so failed route updates do not leak runtime resources. const validation = RouteValidator.validateRoutes(newRoutes); if (!validation.valid) { RouteValidator.logValidationErrors(validation.errors); throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`); } const hasChallengeRoutes = this.hasChallengeRoutes(newRoutes); const challengeRelayWasStarted = Boolean(this.challengeProviderRelayServer); // Preprocess for Rust const rustRoutes = this.preprocessor.preprocessForRust(newRoutes); try { if (hasChallengeRoutes) { await this.ensureChallengeProviderRelay(); await this.bridge.setChallengeProviderRelay(this.challengeProviderRelayServer.getSocketPath(), this.challengeRuntimeOptions); } // Send to Rust await this.bridge.updateRoutes(rustRoutes); } catch (err) { if (hasChallengeRoutes && !challengeRelayWasStarted && this.challengeProviderRelayServer) { await this.bridge.setChallengeProviderRelay('').catch(() => undefined); await this.challengeProviderRelayServer.stop(); this.challengeProviderRelayServer = null; this.challengeRuntimeOptions = undefined; } throw err; } // Update local route manager this.routeManager.updateRoutes(newRoutes); // Update socket handler relay if handler routes changed const hasHandlerRoutes = newRoutes.some((r) => (r.action.type === 'socket-handler' && r.action.socketHandler) || r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')); if (hasHandlerRoutes && !this.socketHandlerServer) { this.socketHandlerServer = new SocketHandlerServer(this.preprocessor); await this.socketHandlerServer.start(); await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath()); } else if (!hasHandlerRoutes && this.socketHandlerServer) { await this.socketHandlerServer.stop(); this.socketHandlerServer = null; } // Update datagram handler relay if datagram handler routes changed const hasDatagramHandlers = newRoutes.some((r) => r.action.type === 'socket-handler' && r.action.datagramHandler); if (hasDatagramHandlers && !this.datagramHandlerServer) { const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`; this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor); await this.datagramHandlerServer.start(); await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath()); } else if (!hasDatagramHandlers && this.datagramHandlerServer) { await this.datagramHandlerServer.stop(); this.datagramHandlerServer = null; } if (!hasChallengeRoutes && this.challengeProviderRelayServer) { await this.bridge.setChallengeProviderRelay('').catch((err) => { logger.log('warn', `Failed to clear challenge provider relay in Rust: ${err.message}`, { component: 'smart-proxy' }); }); await this.challengeProviderRelayServer.stop(); this.challengeProviderRelayServer = null; this.challengeRuntimeOptions = undefined; } // Update NFTables rules if (this.nftablesManager) { await this.nftablesManager.cleanup(); this.nftablesManager = null; } await this.applyNftablesRules(newRoutes); // Update stored routes this.settings.routes = newRoutes; logger.log('info', `Routes updated (${newRoutes.length} routes)`, { component: 'smart-proxy' }); }); // Fire-and-forget cert provisioning outside the mutex — routes are already updated, // cert provisioning doesn't need the route update lock and may be slow. this.certProvisionPromise = this.provisionCertificatesViaCallback() .catch((err) => logger.log('error', `Unexpected error in cert provisioning after route update: ${err.message}`, { component: 'smart-proxy' })); } /** * Update the global ingress security policy without changing routes. * The Rust engine applies this before route selection and backend connection. */ async updateSecurityPolicy(policy) { this.settings.securityPolicy = policy; await this.bridge.setSecurityPolicy(policy); } /** * Provision a certificate for a named route. */ async provisionCertificate(routeName) { await this.bridge.provisionCertificate(routeName); } /** * Force renewal of a certificate. */ async renewCertificate(routeName) { await this.bridge.renewCertificate(routeName); } /** * Get certificate status for a route (async - calls Rust). */ async getCertificateStatus(routeName) { return this.bridge.getCertificateStatus(routeName); } /** * Get the metrics interface. */ getMetrics() { return this.metricsAdapter; } /** * Get sanitized active connection snapshots from the Rust engine. */ async getActiveConnectionSnapshots(options = {}) { return this.bridge.getActiveConnectionSnapshots(options); } /** * Get statistics (async - calls Rust). */ async getStatistics() { return this.bridge.getStatistics(); } /** * Add a listening port at runtime. */ async addListeningPort(port) { await this.bridge.addListeningPort(port); } /** * Remove a listening port at runtime. */ async removeListeningPort(port) { await this.bridge.removeListeningPort(port); } /** * Get all currently listening ports (async - calls Rust). */ async getListeningPorts() { if (!this.bridge.running) return []; return this.bridge.getListeningPorts(); } /** * Get eligible domains for ACME certificates (sync - reads local routes). */ getEligibleDomainsForCertificates() { const domains = []; for (const route of this.settings.routes || []) { if (!route.match.domains) continue; if (route.action.type !== 'forward' || !route.action.tls || route.action.tls.mode === 'passthrough' || route.action.tls.certificate !== 'auto') continue; const routeDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; const eligible = routeDomains.filter((d) => !d.includes('*') && this.isValidDomain(d)); domains.push(...eligible); } return domains; } /** * Get NFTables status. */ getNfTablesStatus() { return this.nftablesManager?.status() ?? null; } // --- Private helpers --- async cleanupRuntimeResourcesAfterStartFailure(didSpawn) { this.metricsAdapter.stopPolling(); if (this.nftablesManager) { await this.nftablesManager.cleanup().catch((err) => { logger.log('warn', `Failed to clean NFTables after start failure: ${err.message}`, { component: 'smart-proxy' }); }); this.nftablesManager = null; } if (this.socketHandlerServer) { await this.socketHandlerServer.stop().catch((err) => { logger.log('warn', `Failed to stop socket handler relay after start failure: ${err.message}`, { component: 'smart-proxy' }); }); this.socketHandlerServer = null; } if (this.datagramHandlerServer) { await this.datagramHandlerServer.stop().catch((err) => { logger.log('warn', `Failed to stop datagram handler relay after start failure: ${err.message}`, { component: 'smart-proxy' }); }); this.datagramHandlerServer = null; } if (this.challengeProviderRelayServer) { await this.challengeProviderRelayServer.stop().catch((err) => { logger.log('warn', `Failed to stop challenge provider relay after start failure: ${err.message}`, { component: 'smart-proxy' }); }); this.challengeProviderRelayServer = null; this.challengeRuntimeOptions = undefined; } if (didSpawn) { this.bridge.removeAllListeners('exit'); await this.bridge.stopProxy().catch(() => undefined); this.bridge.kill(); } } /** * Apply NFTables rules for routes using the nftables forwarding engine. */ async applyNftablesRules(routes) { const nftRoutes = routes.filter(r => r.action.forwardingEngine === 'nftables'); if (nftRoutes.length === 0) return; const tableName = nftRoutes.find(r => r.action.nftables?.tableName)?.action.nftables?.tableName ?? 'smartproxy'; const nft = new plugins.smartnftables.SmartNftables({ tableName }); await nft.initialize(); for (const route of nftRoutes) { const routeId = route.name || 'unnamed'; const targets = route.action.targets; if (!targets) continue; const nftOpts = route.action.nftables; const protocol = nftOpts?.protocol ?? 'tcp'; const preserveSourceIP = nftOpts?.preserveSourceIP ?? false; const ports = Array.isArray(route.match.ports) ? route.match.ports.flatMap(p => typeof p === 'number' ? [p] : []) : typeof route.match.ports === 'number' ? [route.match.ports] : []; for (const target of targets) { const targetHost = Array.isArray(target.host) ? target.host[0] : target.host; if (typeof targetHost !== 'string') continue; for (const sourcePort of ports) { const targetPort = typeof target.port === 'number' ? target.port : sourcePort; await nft.nat.addPortForwarding(`${routeId}-${sourcePort}-${targetPort}`, { sourcePort, targetHost, targetPort, protocol, preserveSourceIP, }); } } } this.nftablesManager = nft; logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' }); } /** * Build the Rust configuration object from TS settings. */ buildRustConfig(routes, acmeOverride) { return buildRustProxyOptions(this.settings, routes, acmeOverride, this.challengeRuntimeOptions); } hasChallengeRoutes(routes) { return routes.some((route) => Boolean(route.security?.challenge)); } async ensureChallengeProviderRelay() { const runtimeOptions = this.settings.challenge || {}; if (!this.challengeProviderRelayServer) { this.challengeProviderRelayServer = new ChallengeProviderRelayServer(this.challengeProviders, { providerTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000, }); await this.challengeProviderRelayServer.start(); } if (!this.challengeRuntimeOptions) { this.challengeRuntimeOptions = { cookieSigningKey: runtimeOptions.cookieSigningKey || plugins.crypto.randomBytes(32).toString('base64url'), pendingCookieName: runtimeOptions.pendingCookieName || '__smartproxy_challenge_pending', clearanceCookieName: runtimeOptions.clearanceCookieName || '__smartproxy_clearance', reservedPathPrefix: runtimeOptions.reservedPathPrefix || '/.well-known/smartproxy-challenge', relaySocketPath: this.challengeProviderRelayServer.getSocketPath(), relayTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000, pendingTtlSeconds: runtimeOptions.pendingTtlSeconds ?? 300, }; } else { this.challengeRuntimeOptions = { ...this.challengeRuntimeOptions, relaySocketPath: this.challengeProviderRelayServer.getSocketPath(), }; } } async validateChallengeRoutes(routes) { const manifestCache = new Map(); const errors = []; for (const route of routes) { const challenge = route.security?.challenge; if (!challenge) continue; const routeName = route.name || route.id || 'unnamed route'; if (!challenge.providerId || typeof challenge.providerId !== 'string') { errors.push(`${routeName}: challenge.providerId must be a non-empty string`); } if (!challenge.challengeType || typeof challenge.challengeType !== 'string') { errors.push(`${routeName}: challenge.challengeType must be a non-empty string`); } if (!route.name && !route.id) { errors.push(`${routeName}: challenge routes must set a stable route name or id`); } this.validateChallengeIntentShape(challenge, errors, routeName); if (route.action.type !== 'forward') { errors.push(`${routeName}: challenge routes must use forward actions`); } if (route.action.forwardingEngine === 'nftables') { errors.push(`${routeName}: challenge routes cannot use nftables forwarding`); } if (route.action.tls?.mode === 'passthrough') { errors.push(`${routeName}: challenge routes cannot use TLS passthrough`); } for (const target of route.action.targets || []) { if (typeof target.host === 'function' || typeof target.port === 'function') { errors.push(`${routeName}: challenge routes cannot use dynamic target host or port functions`); } if (target.tls?.mode === 'passthrough') { errors.push(`${routeName}: challenge routes cannot use target TLS passthrough`); } } if (route.match.transport === 'udp' || route.match.transport === 'all') { errors.push(`${routeName}: challenge routes currently require HTTP-visible TCP handling`); } if (route.match.protocol !== 'http') { errors.push(`${routeName}: challenge routes must set match.protocol to 'http'`); } this.collectForbiddenChallengeKeys(challenge, `security.challenge`, errors, routeName); const provider = this.challengeProviders.get(challenge.providerId); if (!provider) { errors.push(`${routeName}: challenge provider '${challenge.providerId}' is not registered`); continue; } let manifest = manifestCache.get(challenge.providerId); if (!manifest) { manifest = await provider.getManifest(); manifestCache.set(challenge.providerId, manifest); } if (!manifest.challengeTypes.some((type) => type.challengeType === challenge.challengeType)) { errors.push(`${routeName}: challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`); } } if (errors.length > 0) { throw new Error(`Challenge route validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); } } validateChallengeIntentShape(challengeArg, errors, routeName) { if (!this.isPlainRecord(challengeArg)) { errors.push(`${routeName}: security.challenge must be an object`); return; } this.validateAllowedChallengeKeys(challengeArg, 'security.challenge', new Set(['providerId', 'challengeType', 'policyRef', 'settings', 'applyTo', 'clearance']), errors, routeName); const settings = challengeArg.settings; if (settings !== undefined && !this.isPlainRecord(settings)) { errors.push(`${routeName}: security.challenge.settings must be an object when set`); } const applyTo = challengeArg.applyTo; if (applyTo !== undefined) { if (!this.isPlainRecord(applyTo)) { errors.push(`${routeName}: security.challenge.applyTo must be an object when set`); } else { this.validateAllowedChallengeKeys(applyTo, 'security.challenge.applyTo', new Set(['methods', 'browserNavigationsOnly', 'includePaths', 'excludePaths']), errors, routeName); } } const clearance = challengeArg.clearance; if (clearance !== undefined) { if (!this.isPlainRecord(clearance)) { errors.push(`${routeName}: security.challenge.clearance must be an object when set`); } else { this.validateAllowedChallengeKeys(clearance, 'security.challenge.clearance', new Set(['ttlSeconds', 'bindToHost', 'bindToRoute', 'bindToIp']), errors, routeName); } } } validateAllowedChallengeKeys(valueArg, pathArg, allowedKeysArg, errors, routeName) { for (const key of Object.keys(valueArg)) { if (!allowedKeysArg.has(key)) { errors.push(`${routeName}: ${pathArg}.${key} is not part of the persisted challenge intent shape`); } } } isPlainRecord(valueArg) { return Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg); } collectForbiddenChallengeKeys(value, path, errors, routeName) { if (!value || typeof value !== 'object') return; const forbiddenKeyPattern = /(endpoint|url|uri|host|hostname|address|ipAddress|port|secret|token|password|credential|apiKey|apikey|accessKey|privateKey|keyfile|certfile|deployment|container|socketPath)/i; for (const [key, nestedValue] of Object.entries(value)) { const childPath = `${path}.${key}`; if (forbiddenKeyPattern.test(key) && !['providerId', 'challengeType', 'policyRef', 'bindToHost', 'bindToIp', 'includePaths', 'excludePaths'].includes(key)) { errors.push(`${routeName}: ${childPath} looks like runtime wiring or secret material and must not be stored in route config`); } if (typeof nestedValue === 'string' && this.looksLikeRuntimeChallengeValue(nestedValue) && !['includePaths', 'excludePaths'].includes(key)) { errors.push(`${routeName}: ${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`); } if (nestedValue && typeof nestedValue === 'object') { this.collectForbiddenChallengeKeys(nestedValue, childPath, errors, routeName); } } } looksLikeRuntimeChallengeValue(valueArg) { return /^https?:\/\//i.test(valueArg) || /^wss?:\/\//i.test(valueArg) || /(?:^|\b)(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?:\b|$)/.test(valueArg) || /^[a-z0-9.-]+:\d+$/i.test(valueArg); } /** * For routes with certificate: 'auto', call certProvisionFunction if set. * If the callback returns a cert object, load it into Rust. * If it returns 'http01', let Rust handle ACME. */ async provisionCertificatesViaCallback(skipDomains = new Set()) { const provisionFn = this.settings.certProvisionFunction; if (!provisionFn) return; // Phase 1: Collect all unique (domain, route) pairs that need provisioning const seen = new Set(skipDomains); const tasks = []; for (const route of this.settings.routes) { if (route.action.tls?.certificate !== 'auto') continue; if (!route.match.domains) continue; const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains); for (const domain of certDomains) { if (seen.has(domain)) continue; seen.add(domain); tasks.push({ domain, route }); } } if (tasks.length === 0) return; // Phase 2: Process all domains in parallel with concurrency limit const concurrency = this.settings.certProvisionConcurrency ?? 4; const semaphore = new ConcurrencySemaphore(concurrency); const promises = tasks.map(async ({ domain, route }) => { await semaphore.acquire(); try { await this.provisionSingleDomain(domain, route, provisionFn); } finally { semaphore.release(); } }); await Promise.allSettled(promises); } /** * Provision a single domain's certificate via the callback. * Includes per-domain timeout and shutdown checks. */ async provisionSingleDomain(domain, route, provisionFn) { if (this.stopping) return; let expiryDate; let source = 'certProvisionFunction'; const eventComms = { log: (msg) => logger.log('info', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }), warn: (msg) => logger.log('warn', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }), error: (msg) => logger.log('error', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }), setExpiryDate: (date) => { expiryDate = date.toISOString(); }, setSource: (s) => { source = s; }, }; const timeoutMs = this.settings.certProvisionTimeout ?? 300_000; // 5 min default try { const result = await this.withTimeout(provisionFn(domain, eventComms), timeoutMs, `Certificate provisioning timed out for ${domain} after ${timeoutMs}ms`); if (this.stopping) return; if (result === 'http01') { if (route.name) { try { await this.bridge.provisionCertificate(route.name); logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' }); } catch (provisionErr) { logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` + 'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' }); } } return; } if (result && typeof result === 'object') { if (this.stopping) return; const certObj = result; await this.bridge.loadCertificate(domain, certObj.publicKey, certObj.privateKey, certObj.ca); logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' }); // Persist to consumer store if (this.settings.certStore?.save) { try { await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey, certObj.ca); } catch (storeErr) { logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' }); } } this.emit('certificate-issued', { domain, expiryDate: expiryDate || (certObj.validUntil ? new Date(certObj.validUntil).toISOString() : undefined), source, }); } } catch (err) { logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' }); this.emit('certificate-failed', { domain, error: err.message, source, }); // Fallback to ACME if enabled and route has a name if (this.settings.certProvisionFallbackToAcme !== false && route.name) { try { await this.bridge.provisionCertificate(route.name); logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' }); } catch (acmeErr) { logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` + (this.settings.disableDefaultCert ? ' — TLS will fail for this domain (disableDefaultCert is true)' : ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' }); } } } } /** * Race a promise against a timeout. Rejects with the given message if the timeout fires first. */ withTimeout(promise, ms, message) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(message)), ms); promise.then((val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); }); }); } /** * Normalize routing glob patterns into valid domain identifiers for cert provisioning. * - `*nevermind.cloud` → `['nevermind.cloud', '*.nevermind.cloud']` * - `*.lossless.digital` → `['*.lossless.digital']` (already valid wildcard) * - `code.foss.global` → `['code.foss.global']` (plain domain) * - `*mid*.example.com` → skipped with warning (unsupported glob) */ normalizeDomainsForCertProvisioning(rawDomains) { const result = []; for (const raw of rawDomains) { // Plain domain — no glob characters if (!raw.includes('*')) { result.push(raw); continue; } // Valid wildcard: *.example.com if (raw.startsWith('*.') && !raw.slice(2).includes('*')) { result.push(raw); continue; } // Routing glob like *example.com (leading star, no dot after it) // Convert to bare domain + wildcard pair if (raw.startsWith('*') && !raw.startsWith('*.') && !raw.slice(1).includes('*')) { const baseDomain = raw.slice(1); // Remove leading * result.push(baseDomain); result.push(`*.${baseDomain}`); continue; } // Unsupported glob pattern (e.g. *mid*.example.com) logger.log('warn', `Skipping unsupported glob pattern for cert provisioning: ${raw}`, { component: 'smart-proxy' }); } return result; } isValidDomain(domain) { if (!domain || domain.length === 0) return false; if (domain.includes('*')) return false; const validDomainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return validDomainRegex.test(domain); } } //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnQtcHJveHkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9wcm94aWVzL3NtYXJ0LXByb3h5L3NtYXJ0LXByb3h5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFDNUMsT0FBTyxFQUFFLE1BQU0sRUFBRSxNQUFNLDRCQUE0QixDQUFDO0FBRXBELDBCQUEwQjtBQUMxQixPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sd0JBQXdCLENBQUM7QUFDekQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0sNEJBQTRCLENBQUM7QUFDakUsT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFDckUsT0FBTyxFQUFFLDRCQUE0QixFQUFFLE1BQU0sc0NBQXNDLENBQUM7QUFDcEYsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sMkJBQTJCLENBQUM7QUFFL0QsbUJBQW1CO0FBQ25CLE9BQU8sRUFBRSxrQkFBa0IsSUFBSSxZQUFZLEVBQUUsTUFBTSxxQ0FBcUMsQ0FBQztBQUN6RixPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sNEJBQTRCLENBQUM7QUFDNUQsT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0sd0JBQXdCLENBQUM7QUFDL0QsT0FBTyxFQUFFLDBCQUEwQixFQUFFLE1BQU0sbUNBQW1DLENBQUM7QUFDL0UsT0FBTyxFQUFFLEtBQUssRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sRUFBRSxvQkFBb0IsRUFBRSxNQUFNLGtDQUFrQyxDQUFDO0FBVXhFOzs7Ozs7Ozs7R0FTRztBQUNILE1BQU0sT0FBTyxVQUFXLFNBQVEsT0FBTyxDQUFDLFlBQVk7SUFDM0MsUUFBUSxDQUFxQjtJQUM3QixZQUFZLENBQWU7SUFFMUIsTUFBTSxDQUFrQjtJQUN4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