smartproxy/ts/proxies/smart-proxy/smart-proxy.ts
2025-05-20 15:44:48 +00:00

1034 lines
36 KiB
TypeScript

import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.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 { RouteManager } from './route-manager.js';
import { RouteConnectionHandler } from './route-connection-handler.js';
import { NFTablesManager } from './nftables-manager.js';
// Certificate manager
import { SmartCertManager, type ICertStatus } from './certificate-manager.js';
// Import types and utilities
import type {
ISmartProxyOptions
} from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
// Import mutex for route update synchronization
import { Mutex } from './utils/mutex.js';
// Import ACME state manager
import { AcmeStateManager } from './acme-state-manager.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 {
// Port manager handles dynamic listener management
private portManager: PortManager;
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
// Component managers
private connectionManager: ConnectionManager;
private securityManager: SecurityManager;
private tlsManager: TlsManager;
private httpProxyBridge: HttpProxyBridge;
private timeoutManager: TimeoutManager;
public routeManager: RouteManager; // Made public for route management
private routeConnectionHandler: RouteConnectionHandler;
private nftablesManager: NFTablesManager;
// Certificate manager for ACME and static certificates
private certManager: SmartCertManager | null = null;
// Global challenge route tracking
private globalChallengeRouteActive: boolean = false;
private routeUpdateLock: any = null; // Will be initialized as AsyncMutex
private acmeStateManager: AcmeStateManager;
// Track port usage across route updates
private portUsageMap: Map<number, Set<string>> = new Map();
/**
* 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: ISmartProxyOptions) {
super();
// 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.settings);
this.securityManager = new SecurityManager(this.settings);
this.connectionManager = new ConnectionManager(
this.settings,
this.securityManager,
this.timeoutManager
);
// Create the route manager
this.routeManager = new RouteManager(this.settings);
// Create other required components
this.tlsManager = new TlsManager(this.settings);
this.httpProxyBridge = new HttpProxyBridge(this.settings);
// Initialize connection handler with route support
this.routeConnectionHandler = new RouteConnectionHandler(
this.settings,
this.connectionManager,
this.securityManager,
this.tlsManager,
this.httpProxyBridge,
this.timeoutManager,
this.routeManager
);
// Initialize port manager
this.portManager = new PortManager(this.settings, this.routeConnectionHandler);
// Initialize NFTablesManager
this.nftablesManager = new NFTablesManager(this.settings);
// Initialize route update mutex for synchronization
this.routeUpdateLock = new Mutex();
// Initialize ACME state manager
this.acmeStateManager = new AcmeStateManager();
}
/**
* The settings for the SmartProxy
*/
public settings: ISmartProxyOptions;
/**
* Helper method to create and configure certificate manager
* This ensures consistent setup including the required ACME callback
*/
private async createCertificateManager(
routes: IRouteConfig[],
certStore: string = './certs',
acmeOptions?: any,
initialState?: { challengeRouteActive?: boolean }
): Promise<SmartCertManager> {
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);
}
await certManager.initialize();
return certManager;
}
/**
* Initialize certificate manager
*/
private async initializeCertificateManager(): Promise<void> {
// 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: { email?: string; useProduction?: boolean; port?: number } | undefined;
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
*/
private hasStaticCertRoutes(): boolean {
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
*/
public 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;
}
// Initialize certificate manager before starting servers
await this.initializeCertificateManager();
// Initialize and start HttpProxy if needed
if (this.settings.useHttpProxy && this.settings.useHttpProxy.length > 0) {
await this.httpProxyBridge.initialize();
// Connect HttpProxy with certificate manager
if (this.certManager) {
this.certManager.setHttpProxy(this.httpProxyBridge.getHttpProxy());
}
await this.httpProxyBridge.start();
}
// 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);
}
}
// Start port listeners using the PortManager
await this.portManager.addPorts(listeningPorts);
// 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();
}
// 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
*/
public 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();
logger.log('info', 'SmartProxy shutdown complete.');
}
/**
* Updates the domain configurations for the proxy
*
* Note: This legacy method has been removed. Use updateRoutes instead.
*/
public async updateDomainConfigs(): Promise<void> {
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
*/
private async verifyChallengeRouteRemoved(): Promise<void> {
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' }
* }
* }
* ]);
* ```
*/
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
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);
// Find orphaned ports - ports that no longer have any routes
const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
// Find new ports that need binding
const currentPorts = new Set(this.portManager.getListeningPorts());
const newPortsSet = new Set(newPortUsage.keys());
const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p));
// Get existing routes that use NFTables
const oldNfTablesRoutes = this.settings.routes.filter(
r => r.action.forwardingEngine === 'nftables'
);
// Get new routes that use NFTables
const newNfTablesRoutes = newRoutes.filter(
r => r.action.forwardingEngine === 'nftables'
);
// Find routes to remove, update, or add
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);
}
}
// Find new routes to add
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
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 (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(', ')}`);
}
await this.portManager.addPorts(newBindingPorts);
}
// 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
*/
public async provisionCertificate(routeName: string): Promise<void> {
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.
*/
private updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
// Reset the usage map
const portUsage = new Map<number, Set<string>>();
for (const route of routes) {
// Get the ports for this route
const portsConfig = Array.isArray(route.match.ports)
? route.match.ports
: [route.match.ports];
// Expand port range objects to individual port numbers
const expandedPorts: number[] = [];
for (const portConfig of portsConfig) {
if (typeof portConfig === 'number') {
expandedPorts.push(portConfig);
} else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) {
// Expand the port range
for (let p = portConfig.from; p <= portConfig.to; p++) {
expandedPorts.push(p);
}
}
}
// Use route name if available, otherwise generate a unique ID
const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
// Add each port to the usage map
for (const port of expandedPorts) {
if (!portUsage.has(port)) {
portUsage.set(port, new Set());
}
portUsage.get(port)!.add(routeName);
}
}
// Log port usage for debugging
for (const [port, routes] of portUsage.entries()) {
try {
logger.log('debug', `Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`, {
port,
routeCount: routes.size,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[DEBUG] Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`);
}
}
return portUsage;
}
/**
* Find ports that have no routes in the new configuration
*/
private findOrphanedPorts(oldUsage: Map<number, Set<string>>, newUsage: Map<number, Set<string>>): number[] {
const orphanedPorts: number[] = [];
for (const [port, routes] of oldUsage.entries()) {
if (!newUsage.has(port) || newUsage.get(port)!.size === 0) {
orphanedPorts.push(port);
try {
logger.log('info', `Port ${port} no longer has any associated routes, will be released`, {
port,
component: 'smart-proxy'
});
} catch (error) {
// Silently handle logging errors
console.log(`[INFO] Port ${port} no longer has any associated routes, will be released`);
}
}
}
return orphanedPorts;
}
/**
* Force renewal of a certificate
*/
public async renewCertificate(routeName: string): Promise<void> {
if (!this.certManager) {
throw new Error('Certificate manager not initialized');
}
await this.certManager.renewCertificate(routeName);
}
/**
* Get certificate status for a route
*/
public getCertificateStatus(routeName: string): ICertStatus | undefined {
if (!this.certManager) {
return undefined;
}
return this.certManager.getCertificateStatus(routeName);
}
/**
* Validates if a domain name is valid for certificate issuance
*/
private isValidDomain(domain: string): boolean {
// 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
*/
public async addListeningPort(port: number): Promise<void> {
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
*/
public async removeListeningPort(port: number): Promise<void> {
return this.portManager.removePort(port);
}
/**
* Get a list of all ports currently being listened on
*
* @returns Array of port numbers
*/
public getListeningPorts(): number[] {
return this.portManager.getListeningPorts();
}
/**
* Get statistics about current connections
*/
public getStatistics(): any {
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
*/
public getEligibleDomainsForCertificates(): string[] {
const domains: string[] = [];
// 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
*/
public async getNfTablesStatus(): Promise<Record<string, any>> {
return this.nftablesManager.getStatus();
}
/**
* Validate ACME configuration
*/
private validateAcmeConfiguration(): string[] {
const warnings: string[] = [];
// 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;
}
}