@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.
936 lines • 80.8 kB
JavaScript
import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
// Importing required components
import { ConnectionManager } from './connection-manager.js';
import { SecurityManager } from './security-manager.js';
import { TlsManager } from './tls-manager.js';
import { HttpProxyBridge } from './http-proxy-bridge.js';
import { TimeoutManager } from './timeout-manager.js';
import { PortManager } from './port-manager.js';
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { RouteConnectionHandler } from './route-connection-handler.js';
import { NFTablesManager } from './nftables-manager.js';
// Certificate manager
import { SmartCertManager } from './certificate-manager.js';
// Import mutex for route update synchronization
import { Mutex } from './utils/mutex.js';
// Import ACME state manager
import { AcmeStateManager } from './acme-state-manager.js';
// Import metrics collector
import { MetricsCollector } from './metrics-collector.js';
/**
* SmartProxy - Pure route-based API
*
* SmartProxy is a unified proxy system that works with routes to define connection handling behavior.
* Each route contains matching criteria (ports, domains, etc.) and an action to take (forward, redirect, block).
*
* Configuration is provided through a set of routes, with each route defining:
* - What to match (ports, domains, paths, client IPs)
* - What to do with matching traffic (forward, redirect, block)
* - How to handle TLS (passthrough, terminate, terminate-and-reencrypt)
* - Security settings (IP restrictions, connection limits)
* - Advanced options (timeout, headers, etc.)
*/
export class SmartProxy extends plugins.EventEmitter {
/**
* Constructor for SmartProxy
*
* @param settingsArg Configuration options containing routes and other settings
* Routes define how traffic is matched and handled, with each route having:
* - match: criteria for matching traffic (ports, domains, paths, IPs)
* - action: what to do with matched traffic (forward, redirect, block)
*
* Example:
* ```ts
* const proxy = new SmartProxy({
* routes: [
* {
* match: {
* ports: 443,
* domains: ['example.com', '*.example.com']
* },
* action: {
* type: 'forward',
* target: { host: '10.0.0.1', port: 8443 },
* tls: { mode: 'passthrough' }
* }
* }
* ],
* defaults: {
* target: { host: 'localhost', port: 8080 },
* security: { ipAllowList: ['*'] }
* }
* });
* ```
*/
constructor(settingsArg) {
super();
this.connectionLogger = null;
this.isShuttingDown = false;
// Certificate manager for ACME and static certificates
this.certManager = null;
// Global challenge route tracking
this.globalChallengeRouteActive = false;
this.routeUpdateLock = null; // Will be initialized as AsyncMutex
// Track port usage across route updates
this.portUsageMap = new Map();
// Set reasonable defaults for all settings
this.settings = {
...settingsArg,
initialDataTimeout: settingsArg.initialDataTimeout || 120000,
socketTimeout: settingsArg.socketTimeout || 3600000,
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000,
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 86400000,
inactivityTimeout: settingsArg.inactivityTimeout || 14400000,
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000,
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024,
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true,
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
allowSessionTicket: settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true,
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000,
httpProxyPort: settingsArg.httpProxyPort || 8443,
};
// Normalize ACME options if provided (support both email and accountEmail)
if (this.settings.acme) {
// Support both 'email' and 'accountEmail' fields
if (this.settings.acme.accountEmail && !this.settings.acme.email) {
this.settings.acme.email = this.settings.acme.accountEmail;
}
// Set reasonable defaults for commonly used fields
this.settings.acme = {
enabled: this.settings.acme.enabled !== false, // Enable by default if acme object exists
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, // Enable by default
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
routeForwards: this.settings.acme.routeForwards || [],
...this.settings.acme // Preserve any additional fields
};
}
// Initialize component managers
this.timeoutManager = new TimeoutManager(this);
this.securityManager = new SecurityManager(this);
this.connectionManager = new ConnectionManager(this);
// Create the route manager with SharedRouteManager API
// Create a logger adapter to match ILogger interface
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)
};
this.routeManager = new RouteManager({
logger: loggerAdapter,
enableDetailedLogging: this.settings.enableDetailedLogging,
routes: this.settings.routes
});
// Create other required components
this.tlsManager = new TlsManager(this);
this.httpProxyBridge = new HttpProxyBridge(this);
// Initialize connection handler with route support
this.routeConnectionHandler = new RouteConnectionHandler(this);
// Initialize port manager
this.portManager = new PortManager(this);
// Initialize NFTablesManager
this.nftablesManager = new NFTablesManager(this);
// Initialize route update mutex for synchronization
this.routeUpdateLock = new Mutex();
// Initialize ACME state manager
this.acmeStateManager = new AcmeStateManager();
// Initialize metrics collector with reference to this SmartProxy instance
this.metricsCollector = new MetricsCollector(this, {
sampleIntervalMs: this.settings.metrics?.sampleIntervalMs,
retentionSeconds: this.settings.metrics?.retentionSeconds
});
}
/**
* Helper method to create and configure certificate manager
* This ensures consistent setup including the required ACME callback
*/
async createCertificateManager(routes, certStore = './certs', acmeOptions, initialState) {
const certManager = new SmartCertManager(routes, certStore, acmeOptions, initialState);
// Always set up the route update callback for ACME challenges
certManager.setUpdateRoutesCallback(async (routes) => {
await this.updateRoutes(routes);
});
// Connect with HttpProxy if available
if (this.httpProxyBridge.getHttpProxy()) {
certManager.setHttpProxy(this.httpProxyBridge.getHttpProxy());
}
// Set the ACME state manager
certManager.setAcmeStateManager(this.acmeStateManager);
// Pass down the global ACME config if available
if (this.settings.acme) {
certManager.setGlobalAcmeDefaults(this.settings.acme);
}
// Pass down the custom certificate provision function if available
if (this.settings.certProvisionFunction) {
certManager.setCertProvisionFunction(this.settings.certProvisionFunction);
}
// Pass down the fallback to ACME setting
if (this.settings.certProvisionFallbackToAcme !== undefined) {
certManager.setCertProvisionFallbackToAcme(this.settings.certProvisionFallbackToAcme);
}
await certManager.initialize();
return certManager;
}
/**
* Initialize certificate manager
*/
async initializeCertificateManager() {
// Extract global ACME options if any routes use auto certificates
const autoRoutes = this.settings.routes.filter(r => r.action.tls?.certificate === 'auto');
if (autoRoutes.length === 0 && !this.hasStaticCertRoutes()) {
logger.log('info', 'No routes require certificate management', { component: 'certificate-manager' });
return;
}
// Prepare ACME options with priority:
// 1. Use top-level ACME config if available
// 2. Fall back to first auto route's ACME config
// 3. Otherwise use undefined
let acmeOptions;
if (this.settings.acme?.email) {
// Use top-level ACME config
acmeOptions = {
email: this.settings.acme.email,
useProduction: this.settings.acme.useProduction || false,
port: this.settings.acme.port || 80
};
logger.log('info', `Using top-level ACME configuration with email: ${acmeOptions.email}`, { component: 'certificate-manager' });
}
else if (autoRoutes.length > 0) {
// Check for route-level ACME config
const routeWithAcme = autoRoutes.find(r => r.action.tls?.acme?.email);
if (routeWithAcme?.action.tls?.acme) {
const routeAcme = routeWithAcme.action.tls.acme;
acmeOptions = {
email: routeAcme.email,
useProduction: routeAcme.useProduction || false,
port: routeAcme.challengePort || 80
};
logger.log('info', `Using route-level ACME configuration from route '${routeWithAcme.name}' with email: ${acmeOptions.email}`, { component: 'certificate-manager' });
}
}
// Validate we have required configuration
if (autoRoutes.length > 0 && !acmeOptions?.email) {
throw new Error('ACME email is required for automatic certificate provisioning. ' +
'Please provide email in either:\n' +
'1. Top-level "acme" configuration\n' +
'2. Individual route\'s "tls.acme" configuration');
}
// Use the helper method to create and configure the certificate manager
this.certManager = await this.createCertificateManager(this.settings.routes, this.settings.acme?.certificateStore || './certs', acmeOptions);
}
/**
* Check if we have routes with static certificates
*/
hasStaticCertRoutes() {
return this.settings.routes.some(r => r.action.tls?.certificate &&
r.action.tls.certificate !== 'auto');
}
/**
* Start the proxy server with support for both configuration types
*/
async start() {
// Don't start if already shutting down
if (this.isShuttingDown) {
logger.log('warn', "Cannot start SmartProxy while it's in the shutdown process");
return;
}
// Validate the route configuration
const configWarnings = this.routeManager.validateConfiguration();
// Also validate ACME configuration
const acmeWarnings = this.validateAcmeConfiguration();
const allWarnings = [...configWarnings, ...acmeWarnings];
if (allWarnings.length > 0) {
logger.log('warn', `${allWarnings.length} configuration warnings found`, { count: allWarnings.length });
for (const warning of allWarnings) {
logger.log('warn', `${warning}`);
}
}
// Get listening ports from RouteManager
const listeningPorts = this.routeManager.getListeningPorts();
// Initialize port usage tracking
this.portUsageMap = this.updatePortUsageMap(this.settings.routes);
// Log port usage for startup
logger.log('info', `SmartProxy starting with ${listeningPorts.length} ports: ${listeningPorts.join(', ')}`, {
portCount: listeningPorts.length,
ports: listeningPorts,
component: 'smart-proxy'
});
// Provision NFTables rules for routes that use NFTables
for (const route of this.settings.routes) {
if (route.action.forwardingEngine === 'nftables') {
await this.nftablesManager.provisionRoute(route);
}
}
// Initialize and start HttpProxy if needed - before port binding
if (this.settings.useHttpProxy && this.settings.useHttpProxy.length > 0) {
await this.httpProxyBridge.initialize();
await this.httpProxyBridge.start();
}
// Start port listeners using the PortManager BEFORE initializing certificate manager
// This ensures all required ports are bound and ready when adding ACME challenge routes
await this.portManager.addPorts(listeningPorts);
// Initialize certificate manager AFTER port binding is complete
// This ensures the ACME challenge port is already bound and ready when needed
await this.initializeCertificateManager();
// Connect certificate manager with HttpProxy if both are available
if (this.certManager && this.httpProxyBridge.getHttpProxy()) {
this.certManager.setHttpProxy(this.httpProxyBridge.getHttpProxy());
}
// Now that ports are listening, provision any required certificates
if (this.certManager) {
logger.log('info', 'Starting certificate provisioning now that ports are ready', { component: 'certificate-manager' });
await this.certManager.provisionAllCertificates();
}
// Start the metrics collector now that all components are initialized
this.metricsCollector.start();
// Set up periodic connection logging and inactivity checks
this.connectionLogger = setInterval(() => {
// Immediately return if shutting down
if (this.isShuttingDown)
return;
// Perform inactivity check
this.connectionManager.performInactivityCheck();
// Log connection statistics
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
let tlsConnections = 0;
let nonTlsConnections = 0;
let completedTlsHandshakes = 0;
let pendingTlsHandshakes = 0;
let keepAliveConnections = 0;
let httpProxyConnections = 0;
// Get connection records for analysis
const connectionRecords = this.connectionManager.getConnections();
// Analyze active connections
for (const record of connectionRecords.values()) {
// Track connection stats
if (record.isTLS) {
tlsConnections++;
if (record.tlsHandshakeComplete) {
completedTlsHandshakes++;
}
else {
pendingTlsHandshakes++;
}
}
else {
nonTlsConnections++;
}
if (record.hasKeepAlive) {
keepAliveConnections++;
}
if (record.usingNetworkProxy) {
httpProxyConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
}
// Get termination stats
const terminationStats = this.connectionManager.getTerminationStats();
// Log detailed stats
logger.log('info', 'Connection statistics', {
activeConnections: connectionRecords.size,
tls: {
total: tlsConnections,
completed: completedTlsHandshakes,
pending: pendingTlsHandshakes
},
nonTls: nonTlsConnections,
keepAlive: keepAliveConnections,
httpProxy: httpProxyConnections,
longestRunning: {
incoming: plugins.prettyMs(maxIncoming),
outgoing: plugins.prettyMs(maxOutgoing)
},
terminationStats: {
incoming: terminationStats.incoming,
outgoing: terminationStats.outgoing
},
component: 'connection-manager'
});
}, this.settings.inactivityCheckInterval || 60000);
// Make sure the interval doesn't keep the process alive
if (this.connectionLogger.unref) {
this.connectionLogger.unref();
}
}
/**
* Extract domain configurations from routes for certificate provisioning
*
* Note: This method has been removed as we now work directly with routes
*/
/**
* Stop the proxy server
*/
async stop() {
logger.log('info', 'SmartProxy shutting down...');
this.isShuttingDown = true;
this.portManager.setShuttingDown(true);
// Stop certificate manager
if (this.certManager) {
await this.certManager.stop();
logger.log('info', 'Certificate manager stopped');
}
// Stop NFTablesManager
await this.nftablesManager.stop();
logger.log('info', 'NFTablesManager stopped');
// Stop the connection logger
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
this.connectionLogger = null;
}
// Stop all port listeners
await this.portManager.closeAll();
logger.log('info', 'All servers closed. Cleaning up active connections...');
// Clean up all active connections
this.connectionManager.clearConnections();
// Stop HttpProxy
await this.httpProxyBridge.stop();
// Clear ACME state manager
this.acmeStateManager.clear();
// Stop metrics collector
this.metricsCollector.stop();
// Flush any pending deduplicated logs
connectionLogDeduplicator.flushAll();
logger.log('info', 'SmartProxy shutdown complete.');
}
/**
* Updates the domain configurations for the proxy
*
* Note: This legacy method has been removed. Use updateRoutes instead.
*/
async updateDomainConfigs() {
logger.log('warn', 'Method updateDomainConfigs() is deprecated. Use updateRoutes() instead.');
throw new Error('updateDomainConfigs() is deprecated - use updateRoutes() instead');
}
/**
* Verify the challenge route has been properly removed from routes
*/
async verifyChallengeRouteRemoved() {
const maxRetries = 10;
const retryDelay = 100; // milliseconds
for (let i = 0; i < maxRetries; i++) {
// Check if the challenge route is still in the active routes
const challengeRouteExists = this.settings.routes.some(r => r.name === 'acme-challenge');
if (!challengeRouteExists) {
try {
logger.log('info', 'Challenge route successfully removed from routes');
}
catch (error) {
// Silently handle logging errors
console.log('[INFO] Challenge route successfully removed from routes');
}
return;
}
// Wait before retrying
await plugins.smartdelay.delayFor(retryDelay);
}
const error = `Failed to verify challenge route removal after ${maxRetries} attempts`;
try {
logger.log('error', error);
}
catch (logError) {
// Silently handle logging errors
console.log(`[ERROR] ${error}`);
}
throw new Error(error);
}
/**
* Update routes with new configuration
*
* This method replaces the current route configuration with the provided routes.
* It also provisions certificates for routes that require TLS termination and have
* `certificate: 'auto'` set in their TLS configuration.
*
* @param newRoutes Array of route configurations to use
*
* Example:
* ```ts
* proxy.updateRoutes([
* {
* match: { ports: 443, domains: 'secure.example.com' },
* action: {
* type: 'forward',
* target: { host: '10.0.0.1', port: 8443 },
* tls: { mode: 'terminate', certificate: 'auto' }
* }
* }
* ]);
* ```
*/
async updateRoutes(newRoutes) {
return this.routeUpdateLock.runExclusive(async () => {
try {
logger.log('info', `Updating routes (${newRoutes.length} routes)`, {
routeCount: newRoutes.length,
component: 'route-manager'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[INFO] Updating routes (${newRoutes.length} routes)`);
}
// Track port usage before and after updates
const oldPortUsage = this.updatePortUsageMap(this.settings.routes);
const newPortUsage = this.updatePortUsageMap(newRoutes);
// Get the lists of currently listening ports and new ports needed
const currentPorts = new Set(this.portManager.getListeningPorts());
const newPortsSet = new Set(newPortUsage.keys());
// Log the port usage for debugging
try {
logger.log('debug', `Current listening ports: ${Array.from(currentPorts).join(', ')}`, {
ports: Array.from(currentPorts),
component: 'smart-proxy'
});
logger.log('debug', `Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`, {
ports: Array.from(newPortsSet),
component: 'smart-proxy'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[DEBUG] Current listening ports: ${Array.from(currentPorts).join(', ')}`);
console.log(`[DEBUG] Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`);
}
// Find orphaned ports - ports that no longer have any routes
const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
// Find new ports that need binding (only ports that we aren't already listening on)
const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p));
// Check for ACME challenge port to give it special handling
const acmePort = this.settings.acme?.port || 80;
const acmePortNeeded = newPortsSet.has(acmePort);
const acmePortListed = newBindingPorts.includes(acmePort);
if (acmePortNeeded && acmePortListed) {
try {
logger.log('info', `Adding ACME challenge port ${acmePort} to routes`, {
port: acmePort,
component: 'smart-proxy'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[INFO] Adding ACME challenge port ${acmePort} to routes`);
}
}
// Get existing routes that use NFTables and update them
const oldNfTablesRoutes = this.settings.routes.filter(r => r.action.forwardingEngine === 'nftables');
const newNfTablesRoutes = newRoutes.filter(r => r.action.forwardingEngine === 'nftables');
// Update existing NFTables routes
for (const oldRoute of oldNfTablesRoutes) {
const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
if (!newRoute) {
// Route was removed
await this.nftablesManager.deprovisionRoute(oldRoute);
}
else {
// Route was updated
await this.nftablesManager.updateRoute(oldRoute, newRoute);
}
}
// Add new NFTables routes
for (const newRoute of newNfTablesRoutes) {
const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
if (!oldRoute) {
// New route
await this.nftablesManager.provisionRoute(newRoute);
}
}
// Update routes in RouteManager
this.routeManager.updateRoutes(newRoutes);
// Release orphaned ports first to free resources
if (orphanedPorts.length > 0) {
try {
logger.log('info', `Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`, {
ports: orphanedPorts,
component: 'smart-proxy'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[INFO] Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`);
}
await this.portManager.removePorts(orphanedPorts);
}
// Add new ports if needed
if (newBindingPorts.length > 0) {
try {
logger.log('info', `Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`, {
ports: newBindingPorts,
component: 'smart-proxy'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[INFO] Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`);
}
// Handle port binding with improved error recovery
try {
await this.portManager.addPorts(newBindingPorts);
}
catch (error) {
// Special handling for port binding errors
// This provides better diagnostics for ACME challenge port conflicts
if (error.code === 'EADDRINUSE') {
const port = error.port || newBindingPorts[0];
const isAcmePort = port === acmePort;
if (isAcmePort) {
try {
logger.log('warn', `Could not bind to ACME challenge port ${port}. It may be in use by another application.`, {
port,
component: 'smart-proxy'
});
}
catch (logError) {
console.log(`[WARN] Could not bind to ACME challenge port ${port}. It may be in use by another application.`);
}
// Re-throw with more helpful message
throw new Error(`ACME challenge port ${port} is already in use by another application. ` +
`Configure a different port in settings.acme.port (e.g., 8080) or free up port ${port}.`);
}
}
// Re-throw the original error for other cases
throw error;
}
}
// Update settings with the new routes
this.settings.routes = newRoutes;
// Save the new port usage map for future reference
this.portUsageMap = newPortUsage;
// If HttpProxy is initialized, resync the configurations
if (this.httpProxyBridge.getHttpProxy()) {
await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes);
}
// Update certificate manager with new routes
if (this.certManager) {
const existingAcmeOptions = this.certManager.getAcmeOptions();
const existingState = this.certManager.getState();
// Store global state before stopping
this.globalChallengeRouteActive = existingState.challengeRouteActive;
// Only stop the cert manager if absolutely necessary
// First check if there's an ACME route on the same port already
const acmePort = existingAcmeOptions?.port || 80;
const acmePortInUse = newPortUsage.has(acmePort) && newPortUsage.get(acmePort).size > 0;
try {
logger.log('debug', `ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`, {
port: acmePort,
inUse: acmePortInUse,
component: 'smart-proxy'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[DEBUG] ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`);
}
await this.certManager.stop();
// Verify the challenge route has been properly removed
await this.verifyChallengeRouteRemoved();
// Create new certificate manager with preserved state
this.certManager = await this.createCertificateManager(newRoutes, './certs', existingAcmeOptions, { challengeRouteActive: this.globalChallengeRouteActive });
}
});
}
/**
* Manually provision a certificate for a route
*/
async provisionCertificate(routeName) {
if (!this.certManager) {
throw new Error('Certificate manager not initialized');
}
const route = this.settings.routes.find(r => r.name === routeName);
if (!route) {
throw new Error(`Route ${routeName} not found`);
}
await this.certManager.provisionCertificate(route);
}
/**
* Update the port usage map based on the provided routes
*
* This tracks which ports are used by which routes, allowing us to
* detect when a port is no longer needed and can be released.
*/
updatePortUsageMap(routes) {
// Reset the usage map
const portUsage = new Map();
for (const route of routes) {
// Get the ports for this route
const portsConfig = Array.isArray(route.match.ports)
? route.match.ports
: [route.match.ports];
// Expand port range objects to individual port numbers
const expandedPorts = [];
for (const portConfig of portsConfig) {
if (typeof portConfig === 'number') {
expandedPorts.push(portConfig);
}
else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) {
// Expand the port range
for (let p = portConfig.from; p <= portConfig.to; p++) {
expandedPorts.push(p);
}
}
}
// Use route name if available, otherwise generate a unique ID
const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
// Add each port to the usage map
for (const port of expandedPorts) {
if (!portUsage.has(port)) {
portUsage.set(port, new Set());
}
portUsage.get(port).add(routeName);
}
}
// Log port usage for debugging
for (const [port, routes] of portUsage.entries()) {
try {
logger.log('debug', `Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`, {
port,
routeCount: routes.size,
component: 'smart-proxy'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[DEBUG] Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`);
}
}
return portUsage;
}
/**
* Find ports that have no routes in the new configuration
*/
findOrphanedPorts(oldUsage, newUsage) {
const orphanedPorts = [];
for (const [port, routes] of oldUsage.entries()) {
if (!newUsage.has(port) || newUsage.get(port).size === 0) {
orphanedPorts.push(port);
try {
logger.log('info', `Port ${port} no longer has any associated routes, will be released`, {
port,
component: 'smart-proxy'
});
}
catch (error) {
// Silently handle logging errors
console.log(`[INFO] Port ${port} no longer has any associated routes, will be released`);
}
}
}
return orphanedPorts;
}
/**
* Force renewal of a certificate
*/
async renewCertificate(routeName) {
if (!this.certManager) {
throw new Error('Certificate manager not initialized');
}
await this.certManager.renewCertificate(routeName);
}
/**
* Get certificate status for a route
*/
getCertificateStatus(routeName) {
if (!this.certManager) {
return undefined;
}
return this.certManager.getCertificateStatus(routeName);
}
/**
* Get proxy metrics with clean API
*
* @returns IMetrics interface with grouped metrics methods
*/
getMetrics() {
return this.metricsCollector;
}
/**
* Validates if a domain name is valid for certificate issuance
*/
isValidDomain(domain) {
// Very basic domain validation
if (!domain || domain.length === 0) {
return false;
}
// Check for wildcard domains (they can't get ACME certs)
if (domain.includes('*')) {
logger.log('warn', `Wildcard domains like "${domain}" are not supported for automatic ACME certificates`, { domain, component: 'certificate-manager' });
return false;
}
// Check if domain has at least one dot and no invalid characters
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])?)*$/;
if (!validDomainRegex.test(domain)) {
logger.log('warn', `Domain "${domain}" has invalid format for certificate issuance`, { domain, component: 'certificate-manager' });
return false;
}
return true;
}
/**
* Add a new listening port without changing the route configuration
*
* This allows you to add a port listener without updating routes.
* Useful for preparing to listen on a port before adding routes for it.
*
* @param port The port to start listening on
* @returns Promise that resolves when the port is listening
*/
async addListeningPort(port) {
return this.portManager.addPort(port);
}
/**
* Stop listening on a specific port without changing the route configuration
*
* This allows you to stop a port listener without updating routes.
* Useful for temporary maintenance or port changes.
*
* @param port The port to stop listening on
* @returns Promise that resolves when the port is closed
*/
async removeListeningPort(port) {
return this.portManager.removePort(port);
}
/**
* Get a list of all ports currently being listened on
*
* @returns Array of port numbers
*/
getListeningPorts() {
return this.portManager.getListeningPorts();
}
/**
* Get statistics about current connections
*/
getStatistics() {
const connectionRecords = this.connectionManager.getConnections();
const terminationStats = this.connectionManager.getTerminationStats();
let tlsConnections = 0;
let nonTlsConnections = 0;
let keepAliveConnections = 0;
let httpProxyConnections = 0;
// Analyze active connections
for (const record of connectionRecords.values()) {
if (record.isTLS)
tlsConnections++;
else
nonTlsConnections++;
if (record.hasKeepAlive)
keepAliveConnections++;
if (record.usingNetworkProxy)
httpProxyConnections++;
}
return {
activeConnections: connectionRecords.size,
tlsConnections,
nonTlsConnections,
keepAliveConnections,
httpProxyConnections,
terminationStats,
acmeEnabled: !!this.certManager,
port80HandlerPort: this.certManager ? 80 : null,
routes: this.routeManager.getListeningPorts().length,
listeningPorts: this.portManager.getListeningPorts(),
activePorts: this.portManager.getListeningPorts().length
};
}
/**
* Get a list of eligible domains for ACME certificates
*/
getEligibleDomainsForCertificates() {
const domains = [];
// Get domains from routes
const routes = this.settings.routes || [];
for (const route of routes) {
if (!route.match.domains)
continue;
// Skip routes without TLS termination or auto certificates
if (route.action.type !== 'forward' ||
!route.action.tls ||
route.action.tls.mode === 'passthrough' ||
route.action.tls.certificate !== 'auto')
continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Skip domains that can't be used with ACME
const eligibleDomains = routeDomains.filter(domain => !domain.includes('*') && this.isValidDomain(domain));
domains.push(...eligibleDomains);
}
// Legacy mode is no longer supported
return domains;
}
/**
* Get NFTables status
*/
async getNfTablesStatus() {
return this.nftablesManager.getStatus();
}
/**
* Validate ACME configuration
*/
validateAcmeConfiguration() {
const warnings = [];
// Check for routes with certificate: 'auto'
const autoRoutes = this.settings.routes.filter(r => r.action.tls?.certificate === 'auto');
if (autoRoutes.length === 0) {
return warnings;
}
// Check if we have ACME email configuration
const hasTopLevelEmail = this.settings.acme?.email;
const routesWithEmail = autoRoutes.filter(r => r.action.tls?.acme?.email);
if (!hasTopLevelEmail && routesWithEmail.length === 0) {
warnings.push('Routes with certificate: "auto" require ACME email configuration. ' +
'Add email to either top-level "acme" config or individual route\'s "tls.acme" config.');
}
// Check for port 80 availability for challenges
if (autoRoutes.length > 0) {
const challengePort = this.settings.acme?.port || 80;
const portsInUse = this.routeManager.getListeningPorts();
if (!portsInUse.includes(challengePort)) {
warnings.push(`Port ${challengePort} is not configured for any routes but is needed for ACME challenges. ` +
`Add a route listening on port ${challengePort} or ensure it's accessible for HTTP-01 challenges.`);
}
}
// Check for mismatched environments
if (this.settings.acme?.useProduction) {
const stagingRoutes = autoRoutes.filter(r => r.action.tls?.acme?.useProduction === false);
if (stagingRoutes.length > 0) {
warnings.push('Top-level ACME uses production but some routes use staging. ' +
'Consider aligning environments to avoid certificate issues.');
}
}
// Check for wildcard domains with auto certificates
for (const route of autoRoutes) {
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
const wildcardDomains = domains.filter(d => d?.includes('*'));
if (wildcardDomains.length > 0) {
warnings.push(`Route "${route.name}" has wildcard domain(s) ${wildcardDomains.join(', ')} ` +
'with certificate: "auto". Wildcard certificates require DNS-01 challenges, ' +
'which are not currently supported. Use static certificates instead.');
}
}
return warnings;
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnQtcHJveHkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9wcm94aWVzL3NtYXJ0LXByb3h5L3NtYXJ0LXByb3h5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFDNUMsT0FBTyxFQUFFLE1BQU0sRUFBRSxNQUFNLDRCQUE0QixDQUFDO0FBQ3BELE9BQU8sRUFBRSx5QkFBeUIsRUFBRSxNQUFNLHNDQUFzQyxDQUFDO0FBRWpGLGdDQUFnQztBQUNoQyxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSx5QkFBeUIsQ0FBQztBQUM1RCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFDeEQsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBQzlDLE9BQU8sRUFBRSxlQUFlLEVBQUUsTUFBTSx3QkFBd0IsQ0FBQztBQUN6RCxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBQ2hELE9BQU8sRUFBRSxrQkFBa0IsSUFBSSxZQUFZLEVBQUUsTUFBTSxxQ0FBcUMsQ0FBQztBQUN6RixPQUFPLEVBQUUsc0JBQXNCLEVBQUUsTUFBTSwrQkFBK0IsQ0FBQztBQUN2RSxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFFeEQsc0JBQXNCO0FBQ3RCLE9BQU8sRUFBRSxnQkFBZ0IsRUFBb0IsTUFBTSwwQkFBMEIsQ0FBQztBQVE5RSxnREFBZ0Q7QUFDaEQsT0FBTyxFQUFFLEtBQUssRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBRXpDLDRCQUE0QjtBQUM1QixPQUFPLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSx5QkFBeUIsQ0FBQztBQUUzRCwyQkFBMkI7QUFDM0IsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0sd0JB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