2025-05-09 17:00:27 +00:00
import * as plugins from '../../plugins.js' ;
2025-03-18 14:53:39 +00:00
import { IncomingMessage , ServerResponse } from 'http' ;
2025-05-09 17:00:27 +00:00
import { CertificateEvents } from '../../certificate/events/certificate-events.js' ;
2025-05-02 14:58:33 +00:00
import type {
2025-05-09 17:00:27 +00:00
ForwardConfig ,
DomainOptions ,
CertificateData ,
CertificateFailure ,
CertificateExpiring ,
AcmeOptions
} from '../../certificate/models/certificate-types.js' ;
import {
HttpEvents ,
HttpStatus ,
HttpError ,
CertificateError ,
ServerError ,
} from '../models/http-types.js' ;
2025-05-09 17:10:19 +00:00
import type { DomainCertificate } from '../models/http-types.js' ;
2025-05-09 17:00:27 +00:00
import { ChallengeResponder } from './challenge-responder.js' ;
2025-02-24 09:53:39 +00:00
2025-05-09 17:00:27 +00:00
// Re-export for backward compatibility
export {
HttpError as Port80HandlerError ,
CertificateError ,
ServerError
2025-03-18 14:53:39 +00:00
}
2025-05-09 17:00:27 +00:00
// Port80Handler events enum for backward compatibility
export const Port80HandlerEvents = CertificateEvents ;
2025-02-24 09:53:39 +00:00
2025-03-06 08:27:44 +00:00
/ * *
2025-03-18 14:53:39 +00:00
* Configuration options for the Port80Handler
2025-03-06 08:27:44 +00:00
* /
2025-05-02 14:58:33 +00:00
// Port80Handler options moved to common types
2025-03-06 08:27:44 +00:00
2025-03-18 14:53:39 +00:00
/ * *
* Port80Handler with ACME certificate management and request forwarding capabilities
2025-03-18 15:00:24 +00:00
* Now with glob pattern support for domain matching
2025-03-18 14:53:39 +00:00
* /
export class Port80Handler extends plugins . EventEmitter {
2025-05-09 17:00:27 +00:00
private domainCertificates : Map < string , DomainCertificate > ;
private challengeResponder : ChallengeResponder | null = null ;
2025-03-06 08:27:44 +00:00
private server : plugins.http.Server | null = null ;
2025-05-09 17:00:27 +00:00
2025-05-01 15:39:20 +00:00
// Renewal scheduling is handled externally by SmartProxy
2025-03-06 08:27:44 +00:00
private isShuttingDown : boolean = false ;
2025-05-09 17:00:27 +00:00
private options : Required < AcmeOptions > ;
2025-02-24 09:53:39 +00:00
2025-03-06 08:27:44 +00:00
/ * *
2025-03-18 14:53:39 +00:00
* Creates a new Port80Handler
2025-03-06 08:27:44 +00:00
* @param options Configuration options
* /
2025-05-09 17:00:27 +00:00
constructor ( options : AcmeOptions = { } ) {
2025-03-06 08:27:44 +00:00
super ( ) ;
2025-05-09 17:00:27 +00:00
this . domainCertificates = new Map < string , DomainCertificate > ( ) ;
2025-03-06 08:27:44 +00:00
// Default options
this . options = {
port : options.port ? ? 80 ,
2025-05-05 10:46:05 +00:00
accountEmail : options.accountEmail ? ? 'admin@example.com' ,
2025-03-06 08:27:44 +00:00
useProduction : options.useProduction ? ? false , // Safer default: staging
httpsRedirectPort : options.httpsRedirectPort ? ? 443 ,
2025-05-02 14:58:33 +00:00
enabled : options.enabled ? ? true , // Enable by default
certificateStore : options.certificateStore ? ? './certs' ,
skipConfiguredCerts : options.skipConfiguredCerts ? ? false ,
renewThresholdDays : options.renewThresholdDays ? ? 30 ,
renewCheckIntervalHours : options.renewCheckIntervalHours ? ? 24 ,
autoRenew : options.autoRenew ? ? true ,
domainForwards : options.domainForwards ? ? [ ]
2025-03-06 08:27:44 +00:00
} ;
2025-05-09 17:00:27 +00:00
// Initialize challenge responder
if ( this . options . enabled ) {
this . challengeResponder = new ChallengeResponder (
this . options . useProduction ,
this . options . accountEmail ,
this . options . certificateStore
) ;
// Forward certificate events from the challenge responder
this . challengeResponder . on ( CertificateEvents . CERTIFICATE_ISSUED , ( data : CertificateData ) = > {
this . emit ( CertificateEvents . CERTIFICATE_ISSUED , data ) ;
} ) ;
this . challengeResponder . on ( CertificateEvents . CERTIFICATE_RENEWED , ( data : CertificateData ) = > {
this . emit ( CertificateEvents . CERTIFICATE_RENEWED , data ) ;
} ) ;
this . challengeResponder . on ( CertificateEvents . CERTIFICATE_FAILED , ( error : CertificateFailure ) = > {
this . emit ( CertificateEvents . CERTIFICATE_FAILED , error ) ;
} ) ;
this . challengeResponder . on ( CertificateEvents . CERTIFICATE_EXPIRING , ( expiry : CertificateExpiring ) = > {
this . emit ( CertificateEvents . CERTIFICATE_EXPIRING , expiry ) ;
} ) ;
}
2025-03-06 08:27:44 +00:00
}
/ * *
* Starts the HTTP server for ACME challenges
* /
public async start ( ) : Promise < void > {
if ( this . server ) {
2025-03-18 14:53:39 +00:00
throw new ServerError ( 'Server is already running' ) ;
2025-03-06 08:27:44 +00:00
}
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
if ( this . isShuttingDown ) {
2025-03-18 14:53:39 +00:00
throw new ServerError ( 'Server is shutting down' ) ;
2025-03-06 08:27:44 +00:00
}
2025-02-24 09:53:39 +00:00
2025-03-25 22:30:57 +00:00
// Skip if disabled
if ( this . options . enabled === false ) {
console . log ( 'Port80Handler is disabled, skipping start' ) ;
return ;
}
2025-05-09 17:10:19 +00:00
// Initialize the challenge responder if enabled
if ( this . options . enabled && this . challengeResponder ) {
try {
await this . challengeResponder . initialize ( ) ;
} catch ( error ) {
throw new ServerError ( ` Failed to initialize challenge responder: ${
error instanceof Error ? error.message : String ( error )
} ` );
}
2025-05-01 11:48:04 +00:00
}
2025-03-25 22:30:57 +00:00
2025-03-06 08:27:44 +00:00
return new Promise ( ( resolve , reject ) = > {
try {
this . server = plugins . http . createServer ( ( req , res ) = > this . handleRequest ( req , res ) ) ;
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
this . server . on ( 'error' , ( error : NodeJS.ErrnoException ) = > {
if ( error . code === 'EACCES' ) {
2025-03-18 14:53:39 +00:00
reject ( new ServerError ( ` Permission denied to bind to port ${ this . options . port } . Try running with elevated privileges or use a port > 1024. ` , error . code ) ) ;
2025-03-06 08:27:44 +00:00
} else if ( error . code === 'EADDRINUSE' ) {
2025-03-18 14:53:39 +00:00
reject ( new ServerError ( ` Port ${ this . options . port } is already in use. ` , error . code ) ) ;
2025-03-06 08:27:44 +00:00
} else {
2025-03-18 14:53:39 +00:00
reject ( new ServerError ( error . message , error . code ) ) ;
2025-03-06 08:27:44 +00:00
}
} ) ;
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
this . server . listen ( this . options . port , ( ) = > {
2025-03-18 14:53:39 +00:00
console . log ( ` Port80Handler is listening on port ${ this . options . port } ` ) ;
2025-05-09 17:10:19 +00:00
this . emit ( CertificateEvents . MANAGER_STARTED , this . options . port ) ;
2025-03-18 14:53:39 +00:00
// Start certificate process for domains with acmeMaintenance enabled
for ( const [ domain , domainInfo ] of this . domainCertificates . entries ( ) ) {
2025-03-18 15:00:24 +00:00
// Skip glob patterns for certificate issuance
if ( this . isGlobPattern ( domain ) ) {
console . log ( ` Skipping initial certificate for glob pattern: ${ domain } ` ) ;
continue ;
}
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
if ( domainInfo . options . acmeMaintenance && ! domainInfo . certObtained && ! domainInfo . obtainingInProgress ) {
this . obtainCertificate ( domain ) . catch ( err = > {
console . error ( ` Error obtaining initial certificate for ${ domain } : ` , err ) ;
} ) ;
}
}
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
resolve ( ) ;
} ) ;
} catch ( error ) {
2025-03-18 14:53:39 +00:00
const message = error instanceof Error ? error . message : 'Unknown error starting server' ;
reject ( new ServerError ( message ) ) ;
2025-03-06 08:27:44 +00:00
}
2025-02-24 09:53:39 +00:00
} ) ;
}
/ * *
2025-05-09 17:10:19 +00:00
* Stops the HTTP server and cleanup resources
2025-03-06 08:27:44 +00:00
* /
public async stop ( ) : Promise < void > {
if ( ! this . server ) {
return ;
}
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
this . isShuttingDown = true ;
return new Promise < void > ( ( resolve ) = > {
if ( this . server ) {
this . server . close ( ( ) = > {
this . server = null ;
this . isShuttingDown = false ;
2025-05-09 17:10:19 +00:00
this . emit ( CertificateEvents . MANAGER_STOPPED ) ;
2025-03-06 08:27:44 +00:00
resolve ( ) ;
} ) ;
} else {
this . isShuttingDown = false ;
resolve ( ) ;
}
} ) ;
}
/ * *
2025-03-18 14:53:39 +00:00
* Adds a domain with configuration options
* @param options Domain configuration options
2025-02-24 09:53:39 +00:00
* /
2025-05-09 17:10:19 +00:00
public addDomain ( options : DomainOptions ) : void {
2025-03-18 14:53:39 +00:00
if ( ! options . domainName || typeof options . domainName !== 'string' ) {
2025-05-09 17:10:19 +00:00
throw new HttpError ( 'Invalid domain name' ) ;
2025-03-18 14:53:39 +00:00
}
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
const domainName = options . domainName ;
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
if ( ! this . domainCertificates . has ( domainName ) ) {
this . domainCertificates . set ( domainName , {
options ,
certObtained : false ,
obtainingInProgress : false
} ) ;
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
console . log ( ` Domain added: ${ domainName } with configuration: ` , {
sslRedirect : options.sslRedirect ,
acmeMaintenance : options.acmeMaintenance ,
hasForward : ! ! options . forward ,
hasAcmeForward : ! ! options . acmeForward
} ) ;
2025-05-09 17:10:19 +00:00
2025-03-18 15:00:24 +00:00
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
if ( options . acmeMaintenance && this . server && ! this . isGlobPattern ( domainName ) ) {
2025-03-18 14:53:39 +00:00
this . obtainCertificate ( domainName ) . catch ( err = > {
console . error ( ` Error obtaining initial certificate for ${ domainName } : ` , err ) ;
} ) ;
}
} else {
// Update existing domain with new options
const existing = this . domainCertificates . get ( domainName ) ! ;
existing . options = options ;
console . log ( ` Domain ${ domainName } configuration updated ` ) ;
2025-02-24 09:53:39 +00:00
}
}
/ * *
2025-03-06 08:27:44 +00:00
* Removes a domain from management
* @param domain The domain to remove
2025-02-24 09:53:39 +00:00
* /
public removeDomain ( domain : string ) : void {
if ( this . domainCertificates . delete ( domain ) ) {
console . log ( ` Domain removed: ${ domain } ` ) ;
}
}
2025-03-06 08:27:44 +00:00
/ * *
* Gets the certificate for a domain if it exists
* @param domain The domain to get the certificate for
* /
2025-05-09 17:10:19 +00:00
public getCertificate ( domain : string ) : CertificateData | null {
2025-03-18 15:00:24 +00:00
// Can't get certificates for glob patterns
if ( this . isGlobPattern ( domain ) ) {
return null ;
}
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
const domainInfo = this . domainCertificates . get ( domain ) ;
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
if ( ! domainInfo || ! domainInfo . certObtained || ! domainInfo . certificate || ! domainInfo . privateKey ) {
return null ;
}
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
return {
domain ,
certificate : domainInfo.certificate ,
privateKey : domainInfo.privateKey ,
2025-03-18 14:53:39 +00:00
expiryDate : domainInfo.expiryDate || this . getDefaultExpiryDate ( )
2025-03-06 08:27:44 +00:00
} ;
}
2025-02-24 09:53:39 +00:00
2025-03-25 22:30:57 +00:00
2025-03-18 15:00:24 +00:00
/ * *
* Check if a domain is a glob pattern
* @param domain Domain to check
* @returns True if the domain is a glob pattern
* /
private isGlobPattern ( domain : string ) : boolean {
return domain . includes ( '*' ) ;
}
/ * *
* Get domain info for a specific domain , using glob pattern matching if needed
* @param requestDomain The actual domain from the request
* @returns The domain info or null if not found
* /
2025-05-09 17:10:19 +00:00
private getDomainInfoForRequest ( requestDomain : string ) : { domainInfo : DomainCertificate , pattern : string } | null {
2025-03-18 15:00:24 +00:00
// Try direct match first
if ( this . domainCertificates . has ( requestDomain ) ) {
return {
domainInfo : this.domainCertificates.get ( requestDomain ) ! ,
pattern : requestDomain
} ;
}
2025-05-09 17:10:19 +00:00
2025-03-18 15:00:24 +00:00
// Then try glob patterns
for ( const [ pattern , domainInfo ] of this . domainCertificates . entries ( ) ) {
if ( this . isGlobPattern ( pattern ) && this . domainMatchesPattern ( requestDomain , pattern ) ) {
return { domainInfo , pattern } ;
}
}
2025-05-09 17:10:19 +00:00
2025-03-18 15:00:24 +00:00
return null ;
}
/ * *
* Check if a domain matches a glob pattern
* @param domain The domain to check
* @param pattern The pattern to match against
* @returns True if the domain matches the pattern
* /
private domainMatchesPattern ( domain : string , pattern : string ) : boolean {
// Handle different glob pattern styles
if ( pattern . startsWith ( '*.' ) ) {
// *.example.com matches any subdomain
const suffix = pattern . substring ( 2 ) ;
return domain . endsWith ( suffix ) && domain . includes ( '.' ) && domain !== suffix ;
} else if ( pattern . endsWith ( '.*' ) ) {
// example.* matches any TLD
const prefix = pattern . substring ( 0 , pattern . length - 2 ) ;
const domainParts = domain . split ( '.' ) ;
return domain . startsWith ( prefix + '.' ) && domainParts . length >= 2 ;
} else if ( pattern === '*' ) {
// Wildcard matches everything
return true ;
} else {
// Exact match (shouldn't reach here as we check exact matches first)
return domain === pattern ;
}
}
2025-02-24 09:53:39 +00:00
/ * *
2025-03-06 08:27:44 +00:00
* Handles incoming HTTP requests
* @param req The HTTP request
* @param res The HTTP response
2025-02-24 09:53:39 +00:00
* /
2025-03-06 08:27:44 +00:00
private handleRequest ( req : plugins.http.IncomingMessage , res : plugins.http.ServerResponse ) : void {
2025-05-09 17:10:19 +00:00
// Emit request received event with basic info
this . emit ( HttpEvents . REQUEST_RECEIVED , {
url : req.url ,
method : req.method ,
headers : req.headers
} ) ;
2025-02-24 09:53:39 +00:00
const hostHeader = req . headers . host ;
if ( ! hostHeader ) {
2025-05-09 17:10:19 +00:00
res . statusCode = HttpStatus . BAD_REQUEST ;
2025-02-24 09:53:39 +00:00
res . end ( 'Bad Request: Host header is missing' ) ;
return ;
}
2025-05-09 17:10:19 +00:00
2025-02-24 09:53:39 +00:00
// Extract domain (ignoring any port in the Host header)
const domain = hostHeader . split ( ':' ) [ 0 ] ;
2025-05-09 17:10:19 +00:00
// Check if this is an ACME challenge request that our ChallengeResponder can handle
if ( this . challengeResponder && req . url ? . startsWith ( '/.well-known/acme-challenge/' ) ) {
// Handle ACME HTTP-01 challenge with the challenge responder
const domainMatch = this . getDomainInfoForRequest ( domain ) ;
// If there's a specific ACME forwarding config for this domain, use that instead
if ( domainMatch ? . domainInfo . options . acmeForward ) {
this . forwardRequest ( req , res , domainMatch . domainInfo . options . acmeForward , 'ACME challenge' ) ;
return ;
}
// If domain exists and has acmeMaintenance enabled, or we don't have the domain yet
// (for auto-provisioning), try to handle the ACME challenge
if ( ! domainMatch || domainMatch . domainInfo . options . acmeMaintenance ) {
// Let the challenge responder try to handle this request
if ( this . challengeResponder . handleRequest ( req , res ) ) {
// Challenge was handled
return ;
}
}
}
2025-05-05 17:03:22 +00:00
// Dynamic provisioning: if domain not yet managed, register for ACME and return 503
if ( ! this . domainCertificates . has ( domain ) ) {
try {
this . addDomain ( { domainName : domain , sslRedirect : false , acmeMaintenance : true } ) ;
} catch ( err ) {
console . error ( ` Error registering domain for on-demand provisioning: ${ err } ` ) ;
}
2025-05-09 17:10:19 +00:00
res . statusCode = HttpStatus . SERVICE_UNAVAILABLE ;
2025-05-05 17:03:22 +00:00
res . end ( 'Certificate issuance in progress' ) ;
return ;
}
2025-05-09 17:10:19 +00:00
2025-03-18 15:00:24 +00:00
// Get domain config, using glob pattern matching if needed
const domainMatch = this . getDomainInfoForRequest ( domain ) ;
if ( ! domainMatch ) {
2025-05-09 17:10:19 +00:00
res . statusCode = HttpStatus . NOT_FOUND ;
2025-03-18 14:53:39 +00:00
res . end ( 'Domain not configured' ) ;
return ;
}
2025-03-18 15:00:24 +00:00
const { domainInfo , pattern } = domainMatch ;
2025-03-18 14:53:39 +00:00
const options = domainInfo . options ;
// Check if we should forward non-ACME requests
if ( options . forward ) {
this . forwardRequest ( req , res , options . forward , 'HTTP' ) ;
2025-02-24 09:53:39 +00:00
return ;
}
2025-03-18 14:53:39 +00:00
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
2025-03-18 15:00:24 +00:00
// (Skip for glob patterns as they won't have certificates)
if ( ! this . isGlobPattern ( pattern ) && domainInfo . certObtained && options . sslRedirect ) {
2025-03-06 08:27:44 +00:00
const httpsPort = this . options . httpsRedirectPort ;
const portSuffix = httpsPort === 443 ? '' : ` : ${ httpsPort } ` ;
const redirectUrl = ` https:// ${ domain } ${ portSuffix } ${ req . url || '/' } ` ;
2025-05-09 17:10:19 +00:00
res . statusCode = HttpStatus . MOVED_PERMANENTLY ;
2025-02-24 09:53:39 +00:00
res . setHeader ( 'Location' , redirectUrl ) ;
res . end ( ` Redirecting to ${ redirectUrl } ` ) ;
2025-03-18 14:53:39 +00:00
return ;
}
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
// Handle case where certificate maintenance is enabled but not yet obtained
2025-03-18 15:00:24 +00:00
// (Skip for glob patterns as they can't have certificates)
if ( ! this . isGlobPattern ( pattern ) && options . acmeMaintenance && ! domainInfo . certObtained ) {
2025-03-06 08:27:44 +00:00
// Trigger certificate issuance if not already running
2025-02-24 09:53:39 +00:00
if ( ! domainInfo . obtainingInProgress ) {
this . obtainCertificate ( domain ) . catch ( err = > {
2025-03-18 14:53:39 +00:00
const errorMessage = err instanceof Error ? err . message : 'Unknown error' ;
2025-05-09 17:10:19 +00:00
this . emit ( CertificateEvents . CERTIFICATE_FAILED , {
2025-03-18 14:53:39 +00:00
domain ,
error : errorMessage ,
isRenewal : false
} ) ;
2025-02-24 09:53:39 +00:00
console . error ( ` Error obtaining certificate for ${ domain } : ` , err ) ;
} ) ;
}
2025-05-09 17:10:19 +00:00
res . statusCode = HttpStatus . SERVICE_UNAVAILABLE ;
2025-02-24 09:53:39 +00:00
res . end ( 'Certificate issuance in progress, please try again later.' ) ;
2025-03-18 14:53:39 +00:00
return ;
}
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
// Default response for unhandled request
2025-05-09 17:10:19 +00:00
res . statusCode = HttpStatus . NOT_FOUND ;
2025-03-18 14:53:39 +00:00
res . end ( 'No handlers configured for this request' ) ;
2025-05-09 17:10:19 +00:00
// Emit request handled event
this . emit ( HttpEvents . REQUEST_HANDLED , {
domain ,
url : req.url ,
statusCode : res.statusCode
} ) ;
2025-03-18 14:53:39 +00:00
}
/ * *
* Forwards an HTTP request to the specified target
* @param req The original request
* @param res The response object
* @param target The forwarding target ( IP and port )
* @param requestType Type of request for logging
* /
private forwardRequest (
2025-05-09 17:10:19 +00:00
req : plugins.http.IncomingMessage ,
2025-03-18 14:53:39 +00:00
res : plugins.http.ServerResponse ,
2025-05-09 17:10:19 +00:00
target : ForwardConfig ,
2025-03-18 14:53:39 +00:00
requestType : string
) : void {
const options = {
hostname : target.ip ,
port : target.port ,
path : req.url ,
method : req.method ,
headers : { . . . req . headers }
} ;
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
const domain = req . headers . host ? . split ( ':' ) [ 0 ] || 'unknown' ;
console . log ( ` Forwarding ${ requestType } request for ${ domain } to ${ target . ip } : ${ target . port } ` ) ;
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
const proxyReq = plugins . http . request ( options , ( proxyRes ) = > {
// Copy status code
2025-05-09 17:10:19 +00:00
res . statusCode = proxyRes . statusCode || HttpStatus . INTERNAL_SERVER_ERROR ;
2025-03-18 14:53:39 +00:00
// Copy headers
for ( const [ key , value ] of Object . entries ( proxyRes . headers ) ) {
if ( value ) res . setHeader ( key , value ) ;
}
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
// Pipe response data
proxyRes . pipe ( res ) ;
2025-05-09 17:10:19 +00:00
this . emit ( HttpEvents . REQUEST_FORWARDED , {
2025-03-18 14:53:39 +00:00
domain ,
requestType ,
target : ` ${ target . ip } : ${ target . port } ` ,
statusCode : proxyRes.statusCode
} ) ;
} ) ;
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
proxyReq . on ( 'error' , ( error ) = > {
console . error ( ` Error forwarding request to ${ target . ip } : ${ target . port } : ` , error ) ;
2025-05-09 17:10:19 +00:00
this . emit ( HttpEvents . REQUEST_ERROR , {
domain ,
error : error.message ,
target : ` ${ target . ip } : ${ target . port } `
} ) ;
2025-03-18 14:53:39 +00:00
if ( ! res . headersSent ) {
2025-05-09 17:10:19 +00:00
res . statusCode = HttpStatus . INTERNAL_SERVER_ERROR ;
2025-03-18 14:53:39 +00:00
res . end ( ` Proxy error: ${ error . message } ` ) ;
} else {
res . end ( ) ;
}
} ) ;
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
// Pipe original request to proxy request
if ( req . readable ) {
req . pipe ( proxyReq ) ;
} else {
proxyReq . end ( ) ;
2025-02-24 09:53:39 +00:00
}
}
/ * *
2025-03-06 08:27:44 +00:00
* Obtains a certificate for a domain using ACME HTTP - 01 challenge
* @param domain The domain to obtain a certificate for
* @param isRenewal Whether this is a renewal attempt
2025-02-24 09:53:39 +00:00
* /
2025-03-06 08:27:44 +00:00
private async obtainCertificate ( domain : string , isRenewal : boolean = false ) : Promise < void > {
2025-03-18 15:00:24 +00:00
if ( this . isGlobPattern ( domain ) ) {
throw new CertificateError ( 'Cannot obtain certificates for glob pattern domains' , domain , isRenewal ) ;
}
2025-05-09 17:10:19 +00:00
2025-05-01 12:13:18 +00:00
const domainInfo = this . domainCertificates . get ( domain ) ! ;
2025-05-09 17:10:19 +00:00
2025-03-18 14:53:39 +00:00
if ( ! domainInfo . options . acmeMaintenance ) {
console . log ( ` Skipping certificate issuance for ${ domain } - acmeMaintenance is disabled ` ) ;
return ;
2025-03-06 08:27:44 +00:00
}
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
if ( domainInfo . obtainingInProgress ) {
console . log ( ` Certificate issuance already in progress for ${ domain } ` ) ;
return ;
}
2025-05-09 17:10:19 +00:00
if ( ! this . challengeResponder ) {
throw new HttpError ( 'Challenge responder is not initialized' ) ;
2025-05-01 12:13:18 +00:00
}
2025-05-09 17:10:19 +00:00
2025-03-06 08:27:44 +00:00
domainInfo . obtainingInProgress = true ;
domainInfo . lastRenewalAttempt = new Date ( ) ;
2025-05-09 17:10:19 +00:00
2025-02-24 09:53:39 +00:00
try {
2025-05-09 17:10:19 +00:00
// Request certificate via ChallengeResponder
const certData = await this . challengeResponder . requestCertificate ( domain , isRenewal ) ;
// Update domain info with certificate data
domainInfo . certificate = certData . certificate ;
domainInfo . privateKey = certData . privateKey ;
2025-02-24 09:53:39 +00:00
domainInfo . certObtained = true ;
2025-05-09 17:10:19 +00:00
domainInfo . expiryDate = certData . expiryDate ;
2025-02-24 09:53:39 +00:00
2025-03-06 08:27:44 +00:00
console . log ( ` Certificate ${ isRenewal ? 'renewed' : 'obtained' } for ${ domain } ` ) ;
2025-05-09 17:10:19 +00:00
// The event will be emitted by the ChallengeResponder, we just store the certificate
2025-03-06 08:27:44 +00:00
} catch ( error : any ) {
2025-05-09 17:10:19 +00:00
const errorMsg = error instanceof Error ? error.message : String ( error ) ;
2025-05-01 12:13:18 +00:00
console . error ( ` Error during certificate issuance for ${ domain } : ` , error ) ;
2025-05-09 17:10:19 +00:00
// The failure event will be emitted by the ChallengeResponder
2025-05-01 12:13:18 +00:00
throw new CertificateError ( errorMsg , domain , isRenewal ) ;
2025-03-06 08:27:44 +00:00
} finally {
domainInfo . obtainingInProgress = false ;
2025-02-24 09:53:39 +00:00
}
}
2025-03-06 08:27:44 +00:00
2025-03-18 14:53:39 +00:00
/ * *
* Extract expiry date from certificate using a more robust approach
* @param certificate Certificate PEM string
* @param domain Domain for logging
* @returns Extracted expiry date or default
* /
private extractExpiryDateFromCertificate ( certificate : string , domain : string ) : Date {
try {
// This is still using regex, but in a real implementation you would use
// a library like node-forge or x509 to properly parse the certificate
const matches = certificate . match ( /Not After\s*:\s*(.*?)(?:\n|$)/i ) ;
if ( matches && matches [ 1 ] ) {
const expiryDate = new Date ( matches [ 1 ] ) ;
// Validate that we got a valid date
if ( ! isNaN ( expiryDate . getTime ( ) ) ) {
console . log ( ` Certificate for ${ domain } will expire on ${ expiryDate . toISOString ( ) } ` ) ;
return expiryDate ;
}
}
console . warn ( ` Could not extract valid expiry date from certificate for ${ domain } , using default ` ) ;
return this . getDefaultExpiryDate ( ) ;
} catch ( error ) {
console . warn ( ` Failed to extract expiry date from certificate for ${ domain } , using default ` ) ;
return this . getDefaultExpiryDate ( ) ;
}
}
/ * *
* Get a default expiry date ( 90 days from now )
* @returns Default expiry date
* /
private getDefaultExpiryDate ( ) : Date {
return new Date ( Date . now ( ) + 90 * 24 * 60 * 60 * 1000 ) ; // 90 days default
}
2025-03-06 08:27:44 +00:00
/ * *
* Emits a certificate event with the certificate data
* @param eventType The event type to emit
* @param data The certificate data
* /
2025-05-09 17:10:19 +00:00
private emitCertificateEvent ( eventType : CertificateEvents , data : CertificateData ) : void {
2025-03-06 08:27:44 +00:00
this . emit ( eventType , data ) ;
}
2025-03-25 22:30:57 +00:00
/ * *
* Gets all domains and their certificate status
* @returns Map of domains to certificate status
* /
public getDomainCertificateStatus ( ) : Map < string , {
certObtained : boolean ;
expiryDate? : Date ;
daysRemaining? : number ;
obtainingInProgress : boolean ;
lastRenewalAttempt? : Date ;
} > {
const result = new Map < string , {
certObtained : boolean ;
expiryDate? : Date ;
daysRemaining? : number ;
obtainingInProgress : boolean ;
lastRenewalAttempt? : Date ;
} > ( ) ;
const now = new Date ( ) ;
for ( const [ domain , domainInfo ] of this . domainCertificates . entries ( ) ) {
// Skip glob patterns
if ( this . isGlobPattern ( domain ) ) continue ;
const status : {
certObtained : boolean ;
expiryDate? : Date ;
daysRemaining? : number ;
obtainingInProgress : boolean ;
lastRenewalAttempt? : Date ;
} = {
certObtained : domainInfo.certObtained ,
expiryDate : domainInfo.expiryDate ,
obtainingInProgress : domainInfo.obtainingInProgress ,
lastRenewalAttempt : domainInfo.lastRenewalAttempt
} ;
// Calculate days remaining if expiry date is available
if ( domainInfo . expiryDate ) {
const daysRemaining = Math . ceil (
( domainInfo . expiryDate . getTime ( ) - now . getTime ( ) ) / ( 24 * 60 * 60 * 1000 )
) ;
status . daysRemaining = daysRemaining ;
}
result . set ( domain , status ) ;
}
return result ;
}
2025-05-01 15:39:20 +00:00
/ * *
* Request a certificate renewal for a specific domain .
* @param domain The domain to renew .
* /
public async renewCertificate ( domain : string ) : Promise < void > {
if ( ! this . domainCertificates . has ( domain ) ) {
2025-05-09 17:10:19 +00:00
throw new HttpError ( ` Domain not managed: ${ domain } ` ) ;
2025-05-01 15:39:20 +00:00
}
// Trigger renewal via ACME
await this . obtainCertificate ( domain , true ) ;
}
2025-03-06 08:27:44 +00:00
}