fix(acme): Fix port 80 ACME management and challenge route concurrency issues by deduplicating port listeners, preserving challenge route state across certificate manager recreations, and adding mutex locks to route updates.

This commit is contained in:
2025-05-19 03:40:58 +00:00
parent 0bd35c4fb3
commit 3fcdce611c
13 changed files with 1494 additions and 351 deletions

View File

@ -0,0 +1,112 @@
import type { IRouteConfig } from './models/route-types.js';
/**
* Global state store for ACME operations
* Tracks active challenge routes and port allocations
*/
export class AcmeStateManager {
private activeChallengeRoutes: Map<string, IRouteConfig> = new Map();
private acmePortAllocations: Set<number> = new Set();
private primaryChallengeRoute: IRouteConfig | null = null;
/**
* Check if a challenge route is active
*/
public isChallengeRouteActive(): boolean {
return this.activeChallengeRoutes.size > 0;
}
/**
* Register a challenge route as active
*/
public addChallengeRoute(route: IRouteConfig): void {
this.activeChallengeRoutes.set(route.name, route);
// Track the primary challenge route
if (!this.primaryChallengeRoute || route.priority > (this.primaryChallengeRoute.priority || 0)) {
this.primaryChallengeRoute = route;
}
// Track port allocations
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
ports.forEach(port => this.acmePortAllocations.add(port));
}
/**
* Remove a challenge route
*/
public removeChallengeRoute(routeName: string): void {
const route = this.activeChallengeRoutes.get(routeName);
if (!route) return;
this.activeChallengeRoutes.delete(routeName);
// Update primary challenge route if needed
if (this.primaryChallengeRoute?.name === routeName) {
this.primaryChallengeRoute = null;
// Find new primary route with highest priority
let highestPriority = -1;
for (const [_, activeRoute] of this.activeChallengeRoutes) {
const priority = activeRoute.priority || 0;
if (priority > highestPriority) {
highestPriority = priority;
this.primaryChallengeRoute = activeRoute;
}
}
}
// Update port allocations - only remove if no other routes use this port
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
ports.forEach(port => {
let portStillUsed = false;
for (const [_, activeRoute] of this.activeChallengeRoutes) {
const activePorts = Array.isArray(activeRoute.match.ports) ?
activeRoute.match.ports : [activeRoute.match.ports];
if (activePorts.includes(port)) {
portStillUsed = true;
break;
}
}
if (!portStillUsed) {
this.acmePortAllocations.delete(port);
}
});
}
/**
* Get all active challenge routes
*/
public getActiveChallengeRoutes(): IRouteConfig[] {
return Array.from(this.activeChallengeRoutes.values());
}
/**
* Get the primary challenge route
*/
public getPrimaryChallengeRoute(): IRouteConfig | null {
return this.primaryChallengeRoute;
}
/**
* Check if a port is allocated for ACME
*/
public isPortAllocatedForAcme(port: number): boolean {
return this.acmePortAllocations.has(port);
}
/**
* Get all ACME ports
*/
public getAcmePorts(): number[] {
return Array.from(this.acmePortAllocations);
}
/**
* Clear all state (for shutdown or reset)
*/
public clear(): void {
this.activeChallengeRoutes.clear();
this.acmePortAllocations.clear();
this.primaryChallengeRoute = null;
}
}

View File

@ -3,6 +3,7 @@ import { NetworkProxy } from '../network-proxy/index.js';
import type { IRouteConfig, IRouteTls } from './models/route-types.js';
import type { IAcmeOptions } from './models/interfaces.js';
import { CertStore } from './cert-store.js';
import type { AcmeStateManager } from './acme-state-manager.js';
export interface ICertStatus {
domain: string;
@ -44,6 +45,9 @@ export class SmartCertManager {
// Flag to track if provisioning is in progress
private isProvisioning: boolean = false;
// ACME state manager reference
private acmeStateManager: AcmeStateManager | null = null;
constructor(
private routes: IRouteConfig[],
private certDir: string = './certs',
@ -51,15 +55,39 @@ export class SmartCertManager {
email?: string;
useProduction?: boolean;
port?: number;
},
private initialState?: {
challengeRouteActive?: boolean;
}
) {
this.certStore = new CertStore(certDir);
// Apply initial state if provided
if (initialState) {
this.challengeRouteActive = initialState.challengeRouteActive || false;
}
}
public setNetworkProxy(networkProxy: NetworkProxy): void {
this.networkProxy = networkProxy;
}
/**
* Get the current state of the certificate manager
*/
public getState(): { challengeRouteActive: boolean } {
return {
challengeRouteActive: this.challengeRouteActive
};
}
/**
* Set the ACME state manager
*/
public setAcmeStateManager(stateManager: AcmeStateManager): void {
this.acmeStateManager = stateManager;
}
/**
* Set global ACME defaults from top-level configuration
*/
@ -103,9 +131,13 @@ export class SmartCertManager {
await this.smartAcme.start();
// Add challenge route once at initialization
console.log('Adding ACME challenge route during initialization');
await this.addChallengeRoute();
// Add challenge route once at initialization if not already active
if (!this.challengeRouteActive) {
console.log('Adding ACME challenge route during initialization');
await this.addChallengeRoute();
} else {
console.log('Challenge route already active from previous instance');
}
}
// Provision certificates for all routes
@ -350,8 +382,15 @@ export class SmartCertManager {
* Add challenge route to SmartProxy
*/
private async addChallengeRoute(): Promise<void> {
// Check with state manager first
if (this.acmeStateManager && this.acmeStateManager.isChallengeRouteActive()) {
console.log('Challenge route already active in global state, skipping');
this.challengeRouteActive = true;
return;
}
if (this.challengeRouteActive) {
console.log('Challenge route already active, skipping');
console.log('Challenge route already active locally, skipping');
return;
}
@ -368,6 +407,12 @@ export class SmartCertManager {
const updatedRoutes = [...this.routes, challengeRoute];
await this.updateRoutesCallback(updatedRoutes);
this.challengeRouteActive = true;
// Register with state manager
if (this.acmeStateManager) {
this.acmeStateManager.addChallengeRoute(challengeRoute);
}
console.log('ACME challenge route successfully added');
} catch (error) {
console.error('Failed to add challenge route:', error);
@ -395,6 +440,12 @@ export class SmartCertManager {
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
await this.updateRoutesCallback(filteredRoutes);
this.challengeRouteActive = false;
// Remove from state manager
if (this.acmeStateManager) {
this.acmeStateManager.removeChallengeRoute('acme-challenge');
}
console.log('ACME challenge route successfully removed');
} catch (error) {
console.error('Failed to remove challenge route:', error);

View File

@ -20,6 +20,12 @@ import type {
} 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
*
@ -52,6 +58,11 @@ export class SmartProxy extends plugins.EventEmitter {
// 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;
/**
* Constructor for SmartProxy
*
@ -171,6 +182,12 @@ export class SmartProxy extends plugins.EventEmitter {
// 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();
}
/**
@ -185,9 +202,10 @@ export class SmartProxy extends plugins.EventEmitter {
private async createCertificateManager(
routes: IRouteConfig[],
certStore: string = './certs',
acmeOptions?: any
acmeOptions?: any,
initialState?: { challengeRouteActive?: boolean }
): Promise<SmartCertManager> {
const certManager = new SmartCertManager(routes, certStore, acmeOptions);
const certManager = new SmartCertManager(routes, certStore, acmeOptions, initialState);
// Always set up the route update callback for ACME challenges
certManager.setUpdateRoutesCallback(async (routes) => {
@ -199,6 +217,9 @@ export class SmartProxy extends plugins.EventEmitter {
certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
}
// 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);
@ -441,7 +462,9 @@ export class SmartProxy extends plugins.EventEmitter {
// Stop NetworkProxy
await this.networkProxyBridge.stop();
// Clear ACME state manager
this.acmeStateManager.clear();
console.log('SmartProxy shutdown complete.');
}
@ -456,6 +479,29 @@ export class SmartProxy extends plugins.EventEmitter {
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) {
console.log('Challenge route successfully removed from routes');
return;
}
// Wait before retrying
await plugins.smartdelay.delayFor(retryDelay);
}
throw new Error('Failed to verify challenge route removal after ' + maxRetries + ' attempts');
}
/**
* Update routes with new configuration
*
@ -480,70 +526,81 @@ export class SmartProxy extends plugins.EventEmitter {
* ```
*/
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
console.log(`Updating routes (${newRoutes.length} routes)`);
return this.routeUpdateLock.runExclusive(async () => {
console.log(`Updating routes (${newRoutes.length} routes)`);
// 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);
// Get the new set of required ports
const requiredPorts = this.routeManager.getListeningPorts();
// Update port listeners to match the new configuration
await this.portManager.updatePorts(requiredPorts);
// Update settings with the new routes
this.settings.routes = newRoutes;
// If NetworkProxy is initialized, resync the configurations
if (this.networkProxyBridge.getNetworkProxy()) {
await this.networkProxyBridge.syncRoutesToNetworkProxy(newRoutes);
}
// Update certificate manager with new routes
if (this.certManager) {
const existingAcmeOptions = this.certManager.getAcmeOptions();
await this.certManager.stop();
// Use the helper method to create and configure the certificate manager
this.certManager = await this.createCertificateManager(
newRoutes,
'./certs',
existingAcmeOptions
// 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);
// Get the new set of required ports
const requiredPorts = this.routeManager.getListeningPorts();
// Update port listeners to match the new configuration
await this.portManager.updatePorts(requiredPorts);
// Update settings with the new routes
this.settings.routes = newRoutes;
// If NetworkProxy is initialized, resync the configurations
if (this.networkProxyBridge.getNetworkProxy()) {
await this.networkProxyBridge.syncRoutesToNetworkProxy(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;
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 }
);
}
});
}
/**

View File

@ -0,0 +1,45 @@
/**
* Simple mutex implementation for async operations
*/
export class Mutex {
private isLocked: boolean = false;
private waitQueue: Array<() => void> = [];
/**
* Acquire the lock
*/
async acquire(): Promise<void> {
return new Promise<void>((resolve) => {
if (!this.isLocked) {
this.isLocked = true;
resolve();
} else {
this.waitQueue.push(resolve);
}
});
}
/**
* Release the lock
*/
release(): void {
this.isLocked = false;
const nextResolve = this.waitQueue.shift();
if (nextResolve) {
this.isLocked = true;
nextResolve();
}
}
/**
* Run a function exclusively with the lock
*/
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
}