fix(acme): Refactor ACME challenge route lifecycle to prevent port 80 EADDRINUSE errors
This commit is contained in:
@ -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.'
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user