fix(acme): Refactor ACME challenge route lifecycle to prevent port 80 EADDRINUSE errors

This commit is contained in:
2025-05-19 01:59:52 +00:00
parent a54cbf7417
commit 094edfafd1
6 changed files with 559 additions and 92 deletions

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '19.2.3',
version: '19.2.4',
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
}

View File

@ -38,6 +38,12 @@ export class SmartCertManager {
// Callback to update SmartProxy routes for challenges
private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
// Flag to track if challenge route is currently active
private challengeRouteActive: boolean = false;
// Flag to track if provisioning is in progress
private isProvisioning: boolean = false;
constructor(
private routes: IRouteConfig[],
private certDir: string = './certs',
@ -96,6 +102,10 @@ export class SmartCertManager {
});
await this.smartAcme.start();
// Add challenge route once at initialization
console.log('Adding ACME challenge route during initialization');
await this.addChallengeRoute();
}
// Provision certificates for all routes
@ -114,24 +124,37 @@ export class SmartCertManager {
r.action.tls?.mode === 'terminate-and-reencrypt'
);
for (const route of certRoutes) {
try {
await this.provisionCertificate(route);
} catch (error) {
console.error(`Failed to provision certificate for route ${route.name}: ${error}`);
// Set provisioning flag to prevent concurrent operations
this.isProvisioning = true;
try {
for (const route of certRoutes) {
try {
await this.provisionCertificate(route, true); // Allow concurrent since we're managing it here
} catch (error) {
console.error(`Failed to provision certificate for route ${route.name}: ${error}`);
}
}
} finally {
this.isProvisioning = false;
}
}
/**
* Provision certificate for a single route
*/
public async provisionCertificate(route: IRouteConfig): Promise<void> {
public async provisionCertificate(route: IRouteConfig, allowConcurrent: boolean = false): Promise<void> {
const tls = route.action.tls;
if (!tls || (tls.mode !== 'terminate' && tls.mode !== 'terminate-and-reencrypt')) {
return;
}
// Check if provisioning is already in progress (prevent concurrent provisioning)
if (!allowConcurrent && this.isProvisioning) {
console.log(`Certificate provisioning already in progress, skipping ${route.name}`);
return;
}
const domains = this.extractDomainsFromRoute(route);
if (domains.length === 0) {
console.warn(`Route ${route.name} has TLS termination but no domains`);
@ -186,13 +209,12 @@ export class SmartCertManager {
this.updateCertStatus(routeName, 'pending', 'acme');
try {
// Add challenge route before requesting certificate
await this.addChallengeRoute();
try {
// Use smartacme to get certificate
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
// Challenge route should already be active from initialization
// No need to add it for each certificate
// Use smartacme to get certificate
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
// SmartAcme's Cert object has these properties:
// - publicKey: The certificate PEM string
// - privateKey: The private key PEM string
@ -211,18 +233,9 @@ export class SmartCertManager {
await this.applyCertificate(primaryDomain, certData);
this.updateCertStatus(routeName, 'valid', 'acme', certData);
console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`);
} catch (error) {
console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`);
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
throw error;
} finally {
// Always remove challenge route after provisioning
await this.removeChallengeRoute();
}
console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`);
} catch (error) {
// Handle outer try-catch from adding challenge route
console.error(`Failed to setup ACME challenge for ${primaryDomain}: ${error}`);
console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`);
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
throw error;
}
@ -337,6 +350,11 @@ export class SmartCertManager {
* Add challenge route to SmartProxy
*/
private async addChallengeRoute(): Promise<void> {
if (this.challengeRouteActive) {
console.log('Challenge route already active, skipping');
return;
}
if (!this.updateRoutesCallback) {
throw new Error('No route update callback set');
}
@ -346,20 +364,44 @@ export class SmartCertManager {
}
const challengeRoute = this.challengeRoute;
const updatedRoutes = [...this.routes, challengeRoute];
await this.updateRoutesCallback(updatedRoutes);
try {
const updatedRoutes = [...this.routes, challengeRoute];
await this.updateRoutesCallback(updatedRoutes);
this.challengeRouteActive = true;
console.log('ACME challenge route successfully added');
} catch (error) {
console.error('Failed to add challenge route:', error);
if ((error as any).code === 'EADDRINUSE') {
throw new Error(`Port ${this.globalAcmeDefaults?.port || 80} is already in use for ACME challenges`);
}
throw error;
}
}
/**
* Remove challenge route from SmartProxy
*/
private async removeChallengeRoute(): Promise<void> {
if (!this.challengeRouteActive) {
console.log('Challenge route not active, skipping removal');
return;
}
if (!this.updateRoutesCallback) {
return;
}
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
await this.updateRoutesCallback(filteredRoutes);
try {
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
await this.updateRoutesCallback(filteredRoutes);
this.challengeRouteActive = false;
console.log('ACME challenge route successfully removed');
} catch (error) {
console.error('Failed to remove challenge route:', error);
// Reset the flag even on error to avoid getting stuck
this.challengeRouteActive = false;
throw error;
}
}
/**
@ -512,14 +554,19 @@ export class SmartCertManager {
this.renewalTimer = null;
}
// Always remove challenge route on shutdown
if (this.challengeRoute) {
console.log('Removing ACME challenge route during shutdown');
await this.removeChallengeRoute();
}
if (this.smartAcme) {
await this.smartAcme.stop();
}
// Remove any active challenge routes
// Clear any pending challenges
if (this.pendingChallenges.size > 0) {
this.pendingChallenges.clear();
await this.removeChallengeRoute();
}
}