2025-05-18 15:38:07 +00:00
import * as plugins from '../../plugins.js' ;
2025-05-19 17:28:05 +00:00
import { HttpProxy } from '../http-proxy/index.js' ;
2025-05-18 15:38:07 +00:00
import type { IRouteConfig , IRouteTls } from './models/route-types.js' ;
2025-05-18 18:29:59 +00:00
import type { IAcmeOptions } from './models/interfaces.js' ;
2025-05-18 15:38:07 +00:00
import { CertStore } from './cert-store.js' ;
2025-05-19 03:40:58 +00:00
import type { AcmeStateManager } from './acme-state-manager.js' ;
2025-05-19 23:37:11 +00:00
import { logger } from '../../core/utils/logger.js' ;
2025-05-18 15:38:07 +00:00
export interface ICertStatus {
domain : string ;
status : 'valid' | 'pending' | 'expired' | 'error' ;
expiryDate? : Date ;
issueDate? : Date ;
source : 'static' | 'acme' ;
error? : string ;
}
export interface ICertificateData {
cert : string ;
key : string ;
ca? : string ;
expiryDate : Date ;
issueDate : Date ;
}
export class SmartCertManager {
private certStore : CertStore ;
private smartAcme : plugins.smartacme.SmartAcme | null = null ;
2025-05-19 17:28:05 +00:00
private httpProxy : HttpProxy | null = null ;
2025-05-18 15:38:07 +00:00
private renewalTimer : NodeJS.Timeout | null = null ;
private pendingChallenges : Map < string , string > = new Map ( ) ;
2025-05-18 15:56:52 +00:00
private challengeRoute : IRouteConfig | null = null ;
2025-05-18 15:38:07 +00:00
// Track certificate status by route name
private certStatus : Map < string , ICertStatus > = new Map ( ) ;
2025-05-18 18:29:59 +00:00
// Global ACME defaults from top-level configuration
private globalAcmeDefaults : IAcmeOptions | null = null ;
2025-05-18 15:38:07 +00:00
// Callback to update SmartProxy routes for challenges
private updateRoutesCallback ? : ( routes : IRouteConfig [ ] ) = > Promise < void > ;
2025-05-19 01:59:52 +00:00
// 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 ;
2025-05-19 03:40:58 +00:00
// ACME state manager reference
private acmeStateManager : AcmeStateManager | null = null ;
2025-05-18 15:38:07 +00:00
constructor (
private routes : IRouteConfig [ ] ,
private certDir : string = './certs' ,
private acmeOptions ? : {
email? : string ;
useProduction? : boolean ;
port? : number ;
2025-05-19 03:40:58 +00:00
} ,
private initialState ? : {
challengeRouteActive? : boolean ;
2025-05-18 15:38:07 +00:00
}
) {
this . certStore = new CertStore ( certDir ) ;
2025-05-19 03:40:58 +00:00
// Apply initial state if provided
if ( initialState ) {
this . challengeRouteActive = initialState . challengeRouteActive || false ;
}
2025-05-18 15:38:07 +00:00
}
2025-05-19 17:28:05 +00:00
public setHttpProxy ( httpProxy : HttpProxy ) : void {
this . httpProxy = httpProxy ;
2025-05-18 15:38:07 +00:00
}
2025-05-19 03:40:58 +00:00
/ * *
* Set the ACME state manager
* /
public setAcmeStateManager ( stateManager : AcmeStateManager ) : void {
this . acmeStateManager = stateManager ;
}
2025-05-18 18:29:59 +00:00
/ * *
* Set global ACME defaults from top - level configuration
* /
public setGlobalAcmeDefaults ( defaults : IAcmeOptions ) : void {
this . globalAcmeDefaults = defaults ;
}
2025-05-18 15:38:07 +00:00
/ * *
* Set callback for updating routes ( used for challenge routes )
* /
public setUpdateRoutesCallback ( callback : ( routes : IRouteConfig [ ] ) = > Promise < void > ) : void {
this . updateRoutesCallback = callback ;
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'debug' , 'Route update callback set successfully' , { component : 'certificate-manager' } ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( '[DEBUG] Route update callback set successfully' ) ;
}
2025-05-18 15:38:07 +00:00
}
/ * *
* Initialize certificate manager and provision certificates for all routes
* /
public async initialize ( ) : Promise < void > {
// Create certificate directory if it doesn't exist
await this . certStore . initialize ( ) ;
// Initialize SmartAcme if we have any ACME routes
const hasAcmeRoutes = this . routes . some ( r = >
r . action . tls ? . certificate === 'auto'
) ;
if ( hasAcmeRoutes && this . acmeOptions ? . email ) {
2025-05-18 15:56:52 +00:00
// Create HTTP-01 challenge handler
const http01Handler = new plugins . smartacme . handlers . Http01MemoryHandler ( ) ;
// Set up challenge handler integration with our routing
this . setupChallengeHandler ( http01Handler ) ;
// Create SmartAcme instance with built-in MemoryCertManager and HTTP-01 handler
2025-05-18 15:38:07 +00:00
this . smartAcme = new plugins . smartacme . SmartAcme ( {
accountEmail : this.acmeOptions.email ,
environment : this.acmeOptions.useProduction ? 'production' : 'integration' ,
2025-05-18 15:56:52 +00:00
certManager : new plugins . smartacme . certmanagers . MemoryCertManager ( ) ,
challengeHandlers : [ http01Handler ]
2025-05-18 15:38:07 +00:00
} ) ;
await this . smartAcme . start ( ) ;
2025-05-19 01:59:52 +00:00
2025-05-19 03:40:58 +00:00
// Add challenge route once at initialization if not already active
if ( ! this . challengeRouteActive ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'Adding ACME challenge route during initialization' , { component : 'certificate-manager' } ) ;
2025-05-19 03:40:58 +00:00
await this . addChallengeRoute ( ) ;
} else {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'Challenge route already active from previous instance' , { component : 'certificate-manager' } ) ;
2025-05-19 03:40:58 +00:00
}
2025-05-18 15:38:07 +00:00
}
2025-05-19 22:07:08 +00:00
// Skip automatic certificate provisioning during initialization
// This will be called later after ports are listening
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'Certificate manager initialized. Deferring certificate provisioning until after ports are listening.' , { component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
// Start renewal timer
this . startRenewalTimer ( ) ;
}
/ * *
* Provision certificates for all routes that need them
* /
2025-05-19 22:07:08 +00:00
public async provisionAllCertificates ( ) : Promise < void > {
2025-05-18 15:38:07 +00:00
const certRoutes = this . routes . filter ( r = >
r . action . tls ? . mode === 'terminate' ||
r . action . tls ? . mode === 'terminate-and-reencrypt'
) ;
2025-05-19 01:59:52 +00:00
// 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 ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'error' , ` Failed to provision certificate for route ${ route . name } ` , { routeName : route.name , error , component : 'certificate-manager' } ) ;
2025-05-19 01:59:52 +00:00
}
2025-05-18 15:38:07 +00:00
}
2025-05-19 01:59:52 +00:00
} finally {
this . isProvisioning = false ;
2025-05-18 15:38:07 +00:00
}
}
/ * *
* Provision certificate for a single route
* /
2025-05-19 01:59:52 +00:00
public async provisionCertificate ( route : IRouteConfig , allowConcurrent : boolean = false ) : Promise < void > {
2025-05-18 15:38:07 +00:00
const tls = route . action . tls ;
if ( ! tls || ( tls . mode !== 'terminate' && tls . mode !== 'terminate-and-reencrypt' ) ) {
return ;
}
2025-05-19 01:59:52 +00:00
// Check if provisioning is already in progress (prevent concurrent provisioning)
if ( ! allowConcurrent && this . isProvisioning ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Certificate provisioning already in progress, skipping ${ route . name } ` , { routeName : route.name , component : 'certificate-manager' } ) ;
2025-05-19 01:59:52 +00:00
return ;
}
2025-05-18 15:38:07 +00:00
const domains = this . extractDomainsFromRoute ( route ) ;
if ( domains . length === 0 ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , ` Route ${ route . name } has TLS termination but no domains ` , { routeName : route.name , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
return ;
}
const primaryDomain = domains [ 0 ] ;
if ( tls . certificate === 'auto' ) {
// ACME certificate
await this . provisionAcmeCertificate ( route , domains ) ;
} else if ( typeof tls . certificate === 'object' ) {
// Static certificate
await this . provisionStaticCertificate ( route , primaryDomain , tls . certificate ) ;
}
}
/ * *
* Provision ACME certificate
* /
private async provisionAcmeCertificate (
route : IRouteConfig ,
domains : string [ ]
) : Promise < void > {
if ( ! this . smartAcme ) {
2025-05-18 18:29:59 +00:00
throw new Error (
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
'Please ensure you have configured ACME with an email address either:\n' +
'1. In the top-level "acme" configuration\n' +
'2. In the route\'s "tls.acme" configuration'
) ;
2025-05-18 15:38:07 +00:00
}
const primaryDomain = domains [ 0 ] ;
const routeName = route . name || primaryDomain ;
// Check if we already have a valid certificate
const existingCert = await this . certStore . getCertificate ( routeName ) ;
if ( existingCert && this . isCertificateValid ( existingCert ) ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Using existing valid certificate for ${ primaryDomain } ` , { domain : primaryDomain , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
await this . applyCertificate ( primaryDomain , existingCert ) ;
this . updateCertStatus ( routeName , 'valid' , 'acme' , existingCert ) ;
return ;
}
2025-05-18 18:29:59 +00:00
// Apply renewal threshold from global defaults or route config
const renewThreshold = route . action . tls ? . acme ? . renewBeforeDays ||
this . globalAcmeDefaults ? . renewThresholdDays ||
30 ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Requesting ACME certificate for ${ domains . join ( ', ' ) } (renew ${ renewThreshold } days before expiry) ` , { domains : domains.join ( ', ' ) , renewThreshold , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
this . updateCertStatus ( routeName , 'pending' , 'acme' ) ;
try {
2025-05-19 01:59:52 +00:00
// Challenge route should already be active from initialization
// No need to add it for each certificate
2025-05-18 15:38:07 +00:00
2025-05-19 10:11:29 +00:00
// Determine if we should request a wildcard certificate
// Only request wildcards if:
// 1. The primary domain is not already a wildcard
// 2. The domain has multiple parts (can have subdomains)
// 3. We have DNS-01 challenge support (required for wildcards)
const hasDnsChallenge = ( this . smartAcme as any ) . challengeHandlers ? . some ( ( handler : any ) = >
handler . getSupportedTypes && handler . getSupportedTypes ( ) . includes ( 'dns-01' )
) ;
const shouldIncludeWildcard = ! primaryDomain . startsWith ( '*.' ) &&
primaryDomain . includes ( '.' ) &&
primaryDomain . split ( '.' ) . length >= 2 &&
hasDnsChallenge ;
if ( shouldIncludeWildcard ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Requesting wildcard certificate for ${ primaryDomain } (DNS-01 available) ` , { domain : primaryDomain , challengeType : 'DNS-01' , component : 'certificate-manager' } ) ;
2025-05-19 10:11:29 +00:00
}
// Use smartacme to get certificate with optional wildcard
const cert = await this . smartAcme . getCertificateForDomain (
primaryDomain ,
shouldIncludeWildcard ? { includeWildcard : true } : undefined
) ;
2025-05-19 01:59:52 +00:00
2025-05-18 15:51:09 +00:00
// SmartAcme's Cert object has these properties:
2025-05-18 15:56:52 +00:00
// - publicKey: The certificate PEM string
// - privateKey: The private key PEM string
2025-05-18 15:51:09 +00:00
// - csr: Certificate signing request
2025-05-18 15:56:52 +00:00
// - validUntil: Timestamp in milliseconds
2025-05-18 15:51:09 +00:00
// - domainName: The domain name
2025-05-18 15:38:07 +00:00
const certData : ICertificateData = {
2025-05-18 15:56:52 +00:00
cert : cert.publicKey ,
key : cert.privateKey ,
ca : cert.publicKey , // Use same as cert for now
expiryDate : new Date ( cert . validUntil ) ,
issueDate : new Date ( cert . created )
2025-05-18 15:38:07 +00:00
} ;
await this . certStore . saveCertificate ( routeName , certData ) ;
await this . applyCertificate ( primaryDomain , certData ) ;
this . updateCertStatus ( routeName , 'valid' , 'acme' , certData ) ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Successfully provisioned ACME certificate for ${ primaryDomain } ` , { domain : primaryDomain , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
} catch ( error ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'error' , ` Failed to provision ACME certificate for ${ primaryDomain } : ${ error . message } ` , { domain : primaryDomain , error : error.message , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
this . updateCertStatus ( routeName , 'error' , 'acme' , undefined , error . message ) ;
throw error ;
}
}
/ * *
* Provision static certificate
* /
private async provisionStaticCertificate (
route : IRouteConfig ,
domain : string ,
certConfig : { key : string ; cert : string ; keyFile? : string ; certFile? : string }
) : Promise < void > {
const routeName = route . name || domain ;
try {
let key : string = certConfig . key ;
let cert : string = certConfig . cert ;
// Load from files if paths are provided
if ( certConfig . keyFile ) {
const keyFile = await plugins . smartfile . SmartFile . fromFilePath ( certConfig . keyFile ) ;
key = keyFile . contents . toString ( ) ;
}
if ( certConfig . certFile ) {
const certFile = await plugins . smartfile . SmartFile . fromFilePath ( certConfig . certFile ) ;
cert = certFile . contents . toString ( ) ;
}
// Parse certificate to get dates
// Parse certificate to get dates - for now just use defaults
// TODO: Implement actual certificate parsing if needed
const certInfo = { validTo : new Date ( Date . now ( ) + 90 * 24 * 60 * 60 * 1000 ) , validFrom : new Date ( ) } ;
const certData : ICertificateData = {
cert ,
key ,
expiryDate : certInfo.validTo ,
issueDate : certInfo.validFrom
} ;
// Save to store for consistency
await this . certStore . saveCertificate ( routeName , certData ) ;
await this . applyCertificate ( domain , certData ) ;
this . updateCertStatus ( routeName , 'valid' , 'static' , certData ) ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Successfully loaded static certificate for ${ domain } ` , { domain , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
} catch ( error ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'error' , ` Failed to provision static certificate for ${ domain } : ${ error . message } ` , { domain , error : error.message , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
this . updateCertStatus ( routeName , 'error' , 'static' , undefined , error . message ) ;
throw error ;
}
}
/ * *
2025-05-19 17:28:05 +00:00
* Apply certificate to HttpProxy
2025-05-18 15:38:07 +00:00
* /
private async applyCertificate ( domain : string , certData : ICertificateData ) : Promise < void > {
2025-05-19 17:28:05 +00:00
if ( ! this . httpProxy ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , ` HttpProxy not set, cannot apply certificate for domain ${ domain } ` , { domain , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
return ;
}
2025-05-19 17:28:05 +00:00
// Apply certificate to HttpProxy
this . httpProxy . updateCertificate ( domain , certData . cert , certData . key ) ;
2025-05-18 15:38:07 +00:00
// Also apply for wildcard if it's a subdomain
if ( domain . includes ( '.' ) && ! domain . startsWith ( '*.' ) ) {
const parts = domain . split ( '.' ) ;
if ( parts . length >= 2 ) {
const wildcardDomain = ` *. ${ parts . slice ( - 2 ) . join ( '.' ) } ` ;
2025-05-19 17:28:05 +00:00
this . httpProxy . updateCertificate ( wildcardDomain , certData . cert , certData . key ) ;
2025-05-18 15:38:07 +00:00
}
}
}
/ * *
* Extract domains from route configuration
* /
private extractDomainsFromRoute ( route : IRouteConfig ) : string [ ] {
if ( ! route . match . domains ) {
return [ ] ;
}
const domains = Array . isArray ( route . match . domains )
? route . match . domains
: [ route . match . domains ] ;
// Filter out wildcards and patterns
return domains . filter ( d = >
! d . includes ( '*' ) &&
! d . includes ( '{' ) &&
d . includes ( '.' )
) ;
}
/ * *
* Check if certificate is valid
* /
private isCertificateValid ( cert : ICertificateData ) : boolean {
const now = new Date ( ) ;
2025-05-18 18:29:59 +00:00
// Use renewal threshold from global defaults or fallback to 30 days
const renewThresholdDays = this . globalAcmeDefaults ? . renewThresholdDays || 30 ;
const expiryThreshold = new Date ( now . getTime ( ) + renewThresholdDays * 24 * 60 * 60 * 1000 ) ;
2025-05-18 15:38:07 +00:00
return cert . expiryDate > expiryThreshold ;
}
/ * *
* Add challenge route to SmartProxy
2025-05-20 16:01:32 +00:00
*
* This method adds a special route for ACME HTTP - 01 challenges , which typically uses port 80 .
* Since we may already be listening on port 80 for regular routes , we need to be
* careful about how we add this route to avoid binding conflicts .
2025-05-18 15:38:07 +00:00
* /
private async addChallengeRoute ( ) : Promise < void > {
2025-05-20 16:01:32 +00:00
// Check with state manager first - avoid duplication
2025-05-19 03:40:58 +00:00
if ( this . acmeStateManager && this . acmeStateManager . isChallengeRouteActive ( ) ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , 'Challenge route already active in global state, skipping' , { component : 'certificate-manager' } ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( '[INFO] Challenge route already active in global state, skipping' ) ;
}
2025-05-19 03:40:58 +00:00
this . challengeRouteActive = true ;
return ;
}
2025-05-19 01:59:52 +00:00
if ( this . challengeRouteActive ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , 'Challenge route already active locally, skipping' , { component : 'certificate-manager' } ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( '[INFO] Challenge route already active locally, skipping' ) ;
}
2025-05-19 01:59:52 +00:00
return ;
}
2025-05-18 15:38:07 +00:00
if ( ! this . updateRoutesCallback ) {
throw new Error ( 'No route update callback set' ) ;
}
2025-05-18 15:56:52 +00:00
if ( ! this . challengeRoute ) {
throw new Error ( 'Challenge route not initialized' ) ;
}
2025-05-20 15:32:19 +00:00
// Get the challenge port
const challengePort = this . globalAcmeDefaults ? . port || 80 ;
// Check if any existing routes are already using this port
2025-05-20 16:01:32 +00:00
// This helps us determine if we need to create a new binding or can reuse existing one
2025-05-20 15:32:19 +00:00
const portInUseByRoutes = this . routes . some ( route = > {
const routePorts = Array . isArray ( route . match . ports ) ? route . match . ports : [ route . match . ports ] ;
return routePorts . some ( p = > {
// Handle both number and port range objects
if ( typeof p === 'number' ) {
return p === challengePort ;
} else if ( typeof p === 'object' && 'from' in p && 'to' in p ) {
// Port range case - check if challengePort is in range
return challengePort >= p . from && challengePort <= p . to ;
}
return false ;
} ) ;
} ) ;
2025-05-20 16:01:32 +00:00
2025-05-19 01:59:52 +00:00
try {
2025-05-20 16:01:32 +00:00
// Log whether port is already in use by other routes
if ( portInUseByRoutes ) {
2025-05-20 15:44:48 +00:00
try {
2025-05-20 16:01:32 +00:00
logger . log ( 'info' , ` Port ${ challengePort } is already used by another route, merging ACME challenge route ` , {
2025-05-20 15:44:48 +00:00
port : challengePort ,
2025-05-20 16:01:32 +00:00
component : 'certificate-manager'
2025-05-20 15:44:48 +00:00
} ) ;
} catch ( error ) {
// Silently handle logging errors
2025-05-20 16:01:32 +00:00
console . log ( ` [INFO] Port ${ challengePort } is already used by another route, merging ACME challenge route ` ) ;
}
} else {
try {
logger . log ( 'info' , ` Adding new ACME challenge route on port ${ challengePort } ` , {
port : challengePort ,
component : 'certificate-manager'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [INFO] Adding new ACME challenge route on port ${ challengePort } ` ) ;
2025-05-20 15:44:48 +00:00
}
}
2025-05-20 16:01:32 +00:00
// Add the challenge route to the existing routes
const challengeRoute = this . challengeRoute ;
2025-05-19 01:59:52 +00:00
const updatedRoutes = [ . . . this . routes , challengeRoute ] ;
2025-05-20 16:01:32 +00:00
// With the re-ordering of start(), port binding should already be done
// This updateRoutes call should just add the route without binding again
2025-05-19 01:59:52 +00:00
await this . updateRoutesCallback ( updatedRoutes ) ;
this . challengeRouteActive = true ;
2025-05-19 03:40:58 +00:00
// Register with state manager
if ( this . acmeStateManager ) {
this . acmeStateManager . addChallengeRoute ( challengeRoute ) ;
}
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , 'ACME challenge route successfully added' , { component : 'certificate-manager' } ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( '[INFO] ACME challenge route successfully added' ) ;
}
2025-05-19 01:59:52 +00:00
} catch ( error ) {
2025-05-20 16:01:32 +00:00
// Enhanced error handling based on error type
2025-05-19 01:59:52 +00:00
if ( ( error as any ) . code === 'EADDRINUSE' ) {
2025-05-20 15:44:48 +00:00
try {
2025-05-20 16:01:32 +00:00
logger . log ( 'warn' , ` Challenge port ${ challengePort } is unavailable - it's already in use by another process. Consider configuring a different ACME port. ` , {
port : challengePort ,
error : ( error as Error ) . message ,
component : 'certificate-manager'
} ) ;
} catch ( logError ) {
// Silently handle logging errors
console . log ( ` [WARN] Challenge port ${ challengePort } is unavailable - it's already in use by another process. Consider configuring a different ACME port. ` ) ;
}
// Provide a more informative and actionable error message
throw new Error (
` ACME HTTP-01 challenge port ${ challengePort } is already in use by another process. ` +
` Please configure a different port using the acme.port setting (e.g., 8080). `
) ;
} else if ( error . message && error . message . includes ( 'EADDRINUSE' ) ) {
// Some Node.js versions embed the error code in the message rather than the code property
try {
logger . log ( 'warn' , ` Port ${ challengePort } conflict detected: ${ error . message } ` , {
2025-05-20 15:44:48 +00:00
port : challengePort ,
component : 'certificate-manager'
} ) ;
} catch ( logError ) {
// Silently handle logging errors
2025-05-20 16:01:32 +00:00
console . log ( ` [WARN] Port ${ challengePort } conflict detected: ${ error . message } ` ) ;
2025-05-20 15:44:48 +00:00
}
2025-05-20 15:32:19 +00:00
2025-05-20 16:01:32 +00:00
// More detailed error message with suggestions
2025-05-20 15:32:19 +00:00
throw new Error (
2025-05-20 16:01:32 +00:00
` ACME HTTP challenge port ${ challengePort } conflict detected. ` +
` To resolve this issue, try one of these approaches: \ n ` +
` 1. Configure a different port in ACME settings (acme.port) \ n ` +
` 2. Add a regular route that uses port ${ challengePort } before initializing the certificate manager \ n ` +
` 3. Stop any other services that might be using port ${ challengePort } `
2025-05-20 15:32:19 +00:00
) ;
2025-05-19 01:59:52 +00:00
}
2025-05-20 15:32:19 +00:00
2025-05-20 16:01:32 +00:00
// Log and rethrow other types of errors
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'error' , ` Failed to add challenge route: ${ ( error as Error ) . message } ` , {
error : ( error as Error ) . message ,
component : 'certificate-manager'
} ) ;
} catch ( logError ) {
// Silently handle logging errors
console . log ( ` [ERROR] Failed to add challenge route: ${ ( error as Error ) . message } ` ) ;
}
2025-05-19 01:59:52 +00:00
throw error ;
}
2025-05-18 15:38:07 +00:00
}
/ * *
* Remove challenge route from SmartProxy
* /
private async removeChallengeRoute ( ) : Promise < void > {
2025-05-19 01:59:52 +00:00
if ( ! this . challengeRouteActive ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , 'Challenge route not active, skipping removal' , { component : 'certificate-manager' } ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( '[INFO] Challenge route not active, skipping removal' ) ;
}
2025-05-19 01:59:52 +00:00
return ;
}
2025-05-18 15:38:07 +00:00
if ( ! this . updateRoutesCallback ) {
return ;
}
2025-05-19 01:59:52 +00:00
try {
const filteredRoutes = this . routes . filter ( r = > r . name !== 'acme-challenge' ) ;
await this . updateRoutesCallback ( filteredRoutes ) ;
this . challengeRouteActive = false ;
2025-05-19 03:40:58 +00:00
// Remove from state manager
if ( this . acmeStateManager ) {
this . acmeStateManager . removeChallengeRoute ( 'acme-challenge' ) ;
}
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , 'ACME challenge route successfully removed' , { component : 'certificate-manager' } ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( '[INFO] ACME challenge route successfully removed' ) ;
}
2025-05-19 01:59:52 +00:00
} catch ( error ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'error' , ` Failed to remove challenge route: ${ error . message } ` , { error : error.message , component : 'certificate-manager' } ) ;
} catch ( logError ) {
// Silently handle logging errors
console . log ( ` [ERROR] Failed to remove challenge route: ${ error . message } ` ) ;
}
2025-05-19 01:59:52 +00:00
// Reset the flag even on error to avoid getting stuck
this . challengeRouteActive = false ;
throw error ;
}
2025-05-18 15:38:07 +00:00
}
/ * *
* Start renewal timer
* /
private startRenewalTimer ( ) : void {
// Check for renewals every 12 hours
this . renewalTimer = setInterval ( ( ) = > {
this . checkAndRenewCertificates ( ) ;
} , 12 * 60 * 60 * 1000 ) ;
// Also do an immediate check
this . checkAndRenewCertificates ( ) ;
}
/ * *
* Check and renew certificates that are expiring
* /
private async checkAndRenewCertificates ( ) : Promise < void > {
for ( const route of this . routes ) {
if ( route . action . tls ? . certificate === 'auto' ) {
const routeName = route . name || this . extractDomainsFromRoute ( route ) [ 0 ] ;
const cert = await this . certStore . getCertificate ( routeName ) ;
if ( cert && ! this . isCertificateValid ( cert ) ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Certificate for ${ routeName } needs renewal ` , { routeName , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
try {
await this . provisionCertificate ( route ) ;
} catch ( error ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'error' , ` Failed to renew certificate for ${ routeName } : ${ error . message } ` , { routeName , error : error.message , component : 'certificate-manager' } ) ;
2025-05-18 15:38:07 +00:00
}
}
}
}
}
/ * *
* Update certificate status
* /
private updateCertStatus (
routeName : string ,
status : ICertStatus [ 'status' ] ,
source : ICertStatus [ 'source' ] ,
certData? : ICertificateData ,
error? : string
) : void {
this . certStatus . set ( routeName , {
domain : routeName ,
status ,
source ,
expiryDate : certData?.expiryDate ,
issueDate : certData?.issueDate ,
error
} ) ;
}
/ * *
* Get certificate status for a route
* /
public getCertificateStatus ( routeName : string ) : ICertStatus | undefined {
return this . certStatus . get ( routeName ) ;
}
/ * *
* Force renewal of a certificate
* /
public async renewCertificate ( routeName : string ) : Promise < void > {
const route = this . routes . find ( r = > r . name === routeName ) ;
if ( ! route ) {
throw new Error ( ` Route ${ routeName } not found ` ) ;
}
// Remove existing certificate to force renewal
await this . certStore . deleteCertificate ( routeName ) ;
await this . provisionCertificate ( route ) ;
}
/ * *
2025-05-18 15:56:52 +00:00
* Setup challenge handler integration with SmartProxy routing
2025-05-18 15:38:07 +00:00
* /
2025-05-18 15:56:52 +00:00
private setupChallengeHandler ( http01Handler : plugins.smartacme.handlers.Http01MemoryHandler ) : void {
2025-05-18 18:29:59 +00:00
// Use challenge port from global config or default to 80
const challengePort = this . globalAcmeDefaults ? . port || 80 ;
2025-05-18 15:56:52 +00:00
// Create a challenge route that delegates to SmartAcme's HTTP-01 handler
const challengeRoute : IRouteConfig = {
name : 'acme-challenge' ,
priority : 1000 , // High priority
match : {
2025-05-18 18:29:59 +00:00
ports : challengePort ,
2025-05-18 15:56:52 +00:00
path : '/.well-known/acme-challenge/*'
} ,
action : {
type : 'static' ,
handler : async ( context ) = > {
// Extract the token from the path
const token = context . path ? . split ( '/' ) . pop ( ) ;
if ( ! token ) {
return { status : 404 , body : 'Not found' } ;
}
// Create mock request/response objects for SmartAcme
const mockReq = {
url : context.path ,
method : 'GET' ,
headers : context.headers || { }
} ;
let responseData : any = null ;
const mockRes = {
statusCode : 200 ,
setHeader : ( name : string , value : string ) = > { } ,
end : ( data : any ) = > {
responseData = data ;
}
} ;
// Use SmartAcme's handler
const handled = await new Promise < boolean > ( ( resolve ) = > {
http01Handler . handleRequest ( mockReq as any , mockRes as any , ( ) = > {
resolve ( false ) ;
} ) ;
// Give it a moment to process
setTimeout ( ( ) = > resolve ( true ) , 100 ) ;
} ) ;
if ( handled && responseData ) {
return {
status : mockRes.statusCode ,
headers : { 'Content-Type' : 'text/plain' } ,
body : responseData
} ;
} else {
return { status : 404 , body : 'Not found' } ;
}
}
}
} ;
2025-05-18 15:38:07 +00:00
2025-05-18 15:56:52 +00:00
// Store the challenge route to add it when needed
this . challengeRoute = challengeRoute ;
2025-05-18 15:38:07 +00:00
}
/ * *
* Stop certificate manager
* /
public async stop ( ) : Promise < void > {
if ( this . renewalTimer ) {
clearInterval ( this . renewalTimer ) ;
this . renewalTimer = null ;
}
2025-05-19 01:59:52 +00:00
// Always remove challenge route on shutdown
if ( this . challengeRoute ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'Removing ACME challenge route during shutdown' , { component : 'certificate-manager' } ) ;
2025-05-19 01:59:52 +00:00
await this . removeChallengeRoute ( ) ;
}
2025-05-18 15:38:07 +00:00
if ( this . smartAcme ) {
await this . smartAcme . stop ( ) ;
}
2025-05-19 01:59:52 +00:00
// Clear any pending challenges
2025-05-18 15:38:07 +00:00
if ( this . pendingChallenges . size > 0 ) {
this . pendingChallenges . clear ( ) ;
}
}
/ * *
* Get ACME options ( for recreating after route updates )
* /
public getAcmeOptions ( ) : { email? : string ; useProduction? : boolean ; port? : number } | undefined {
return this . acmeOptions ;
}
2025-05-19 13:23:16 +00:00
/ * *
* Get certificate manager state
* /
public getState ( ) : { challengeRouteActive : boolean } {
return {
challengeRouteActive : this.challengeRouteActive
} ;
}
2025-05-18 15:38:07 +00:00
}