diff --git a/changelog.md b/changelog.md index d0a2450..3a4ace1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-18 - 4.2.5 - fix(networkproxy) +Refactor certificate management components: rename AcmeCertManager to Port80Handler and update related event names from CertManagerEvents to Port80HandlerEvents. The changes update internal API usage in ts/classes.networkproxy.ts and ts/classes.port80handler.ts to unify and simplify ACME certificate handling and HTTP-01 challenge management. + +- Renamed AcmeCertManager to Port80Handler in ts/classes.networkproxy.ts +- Updated event names from CertManagerEvents to Port80HandlerEvents +- Modified API calls for certificate issuance and renewal in ts/classes.port80handler.ts +- Refactored domain registration and certificate extraction logic + ## 2025-03-18 - 4.2.4 - fix(ts/index.ts) Fix export order in ts/index.ts by moving the port proxy export back and adding interfaces export for proper module exposure diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 614f76d..1a14094 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '4.2.4', + version: '4.2.5', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' } diff --git a/ts/classes.networkproxy.ts b/ts/classes.networkproxy.ts index 295f8bf..16766cd 100644 --- a/ts/classes.networkproxy.ts +++ b/ts/classes.networkproxy.ts @@ -1,6 +1,6 @@ import * as plugins from './plugins.js'; import { ProxyRouter } from './classes.router.js'; -import { AcmeCertManager, CertManagerEvents } from './classes.port80handler.js'; +import { Port80Handler, Port80HandlerEvents, type IDomainOptions } from './classes.port80handler.js'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -72,8 +72,8 @@ export class NetworkProxy { private defaultCertificates: { key: string; cert: string }; private certificateCache: Map = new Map(); - // ACME certificate manager - private certManager: AcmeCertManager | null = null; + // Port80Handler for certificate management + private port80Handler: Port80Handler | null = null; private certificateStoreDir: string; // New connection pool for backend connections @@ -375,16 +375,16 @@ export class NetworkProxy { } /** - * Initializes the ACME certificate manager for automatic certificate issuance + * Initializes the Port80Handler for ACME certificate management * @private */ - private async initializeAcmeManager(): Promise { + private async initializePort80Handler(): Promise { if (!this.options.acme.enabled) { return; } // Create certificate manager - this.certManager = new AcmeCertManager({ + this.port80Handler = new Port80Handler({ port: this.options.acme.port, contactEmail: this.options.acme.contactEmail, useProduction: this.options.acme.useProduction, @@ -394,32 +394,32 @@ export class NetworkProxy { }); // Register event handlers - this.certManager.on(CertManagerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this)); - this.certManager.on(CertManagerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this)); - this.certManager.on(CertManagerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this)); - this.certManager.on(CertManagerEvents.CERTIFICATE_EXPIRING, (data) => { + this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this)); + this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this)); + this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this)); + this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data) => { this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`); }); - // Start the manager + // Start the handler try { - await this.certManager.start(); - this.log('info', `ACME Certificate Manager started on port ${this.options.acme.port}`); + await this.port80Handler.start(); + this.log('info', `Port80Handler started on port ${this.options.acme.port}`); // Add domains from proxy configs - this.registerDomainsWithAcmeManager(); + this.registerDomainsWithPort80Handler(); } catch (error) { - this.log('error', `Failed to start ACME Certificate Manager: ${error}`); - this.certManager = null; + this.log('error', `Failed to start Port80Handler: ${error}`); + this.port80Handler = null; } } /** - * Registers domains from proxy configs with the ACME manager + * Registers domains from proxy configs with the Port80Handler * @private */ - private registerDomainsWithAcmeManager(): void { - if (!this.certManager) return; + private registerDomainsWithPort80Handler(): void { + if (!this.port80Handler) return; // Get all hostnames from proxy configs this.proxyConfigs.forEach(config => { @@ -461,26 +461,32 @@ export class NetworkProxy { this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`); } - // Update the certificate in the manager - this.certManager.setCertificate(hostname, cert, key, expiryDate); + // Update the certificate in the handler + this.port80Handler.setCertificate(hostname, cert, key, expiryDate); // Also update our own certificate cache this.updateCertificateCache(hostname, cert, key, expiryDate); this.log('info', `Loaded existing certificate for ${hostname}`); } else { - // Register the domain for certificate issuance - this.certManager.addDomain(hostname); + // Register the domain for certificate issuance with new domain options format + const domainOptions: IDomainOptions = { + domainName: hostname, + sslRedirect: true, + acmeMaintenance: true + }; + + this.port80Handler.addDomain(domainOptions); this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`); } } catch (error) { - this.log('error', `Error registering domain ${hostname} with ACME manager: ${error}`); + this.log('error', `Error registering domain ${hostname} with Port80Handler: ${error}`); } }); } /** - * Handles newly issued or renewed certificates from ACME manager + * Handles newly issued or renewed certificates from Port80Handler * @private */ private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void { @@ -556,13 +562,21 @@ export class NetworkProxy { } // Check if we should trigger certificate issuance - if (this.options.acme?.enabled && this.certManager && !domain.includes('*')) { + if (this.options.acme?.enabled && this.port80Handler && !domain.includes('*')) { // Check if this domain is already registered - const certData = this.certManager.getCertificate(domain); + const certData = this.port80Handler.getCertificate(domain); if (!certData) { this.log('info', `No certificate found for ${domain}, registering for issuance`); - this.certManager.addDomain(domain); + + // Register with new domain options format + const domainOptions: IDomainOptions = { + domainName: domain, + sslRedirect: true, + acmeMaintenance: true + }; + + this.port80Handler.addDomain(domainOptions); } } @@ -587,9 +601,9 @@ export class NetworkProxy { public async start(): Promise { this.startTime = Date.now(); - // Initialize ACME certificate manager if enabled + // Initialize Port80Handler if enabled if (this.options.acme.enabled) { - await this.initializeAcmeManager(); + await this.initializePort80Handler(); } // Create the HTTPS server @@ -1588,13 +1602,13 @@ export class NetworkProxy { } this.connectionPool.clear(); - // Stop ACME certificate manager if it's running - if (this.certManager) { + // Stop Port80Handler if it's running + if (this.port80Handler) { try { - await this.certManager.stop(); - this.log('info', 'ACME Certificate Manager stopped'); + await this.port80Handler.stop(); + this.log('info', 'Port80Handler stopped'); } catch (error) { - this.log('error', 'Error stopping ACME Certificate Manager', error); + this.log('error', 'Error stopping Port80Handler', error); } } @@ -1619,8 +1633,8 @@ export class NetworkProxy { return false; } - if (!this.certManager) { - this.log('error', 'ACME certificate manager is not initialized'); + if (!this.port80Handler) { + this.log('error', 'Port80Handler is not initialized'); return false; } @@ -1631,7 +1645,14 @@ export class NetworkProxy { } try { - this.certManager.addDomain(domain); + // Use the new domain options format + const domainOptions: IDomainOptions = { + domainName: domain, + sslRedirect: true, + acmeMaintenance: true + }; + + this.port80Handler.addDomain(domainOptions); this.log('info', `Certificate request submitted for domain: ${domain}`); return true; } catch (error) { diff --git a/ts/classes.port80handler.ts b/ts/classes.port80handler.ts index c6b3dd3..eb37d5e 100644 --- a/ts/classes.port80handler.ts +++ b/ts/classes.port80handler.ts @@ -1,9 +1,58 @@ import * as plugins from './plugins.js'; +import { IncomingMessage, ServerResponse } from 'http'; /** - * Represents a domain certificate with various status information + * Custom error classes for better error handling + */ +export class Port80HandlerError extends Error { + constructor(message: string) { + super(message); + this.name = 'Port80HandlerError'; + } +} + +export class CertificateError extends Port80HandlerError { + constructor( + message: string, + public readonly domain: string, + public readonly isRenewal: boolean = false + ) { + super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`); + this.name = 'CertificateError'; + } +} + +export class ServerError extends Port80HandlerError { + constructor(message: string, public readonly code?: string) { + super(message); + this.name = 'ServerError'; + } +} + +/** + * Domain forwarding configuration + */ +export interface IForwardConfig { + ip: string; + port: number; +} + +/** + * Domain configuration options + */ +export interface IDomainOptions { + domainName: string; + sslRedirect: boolean; // if true redirects the request to port 443 + acmeMaintenance: boolean; // tries to always have a valid cert for this domain + forward?: IForwardConfig; // forwards all http requests to that target + acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config +} + +/** + * Represents a domain configuration with certificate status information */ interface IDomainCertificate { + options: IDomainOptions; certObtained: boolean; obtainingInProgress: boolean; certificate?: string; @@ -15,9 +64,9 @@ interface IDomainCertificate { } /** - * Configuration options for the ACME Certificate Manager + * Configuration options for the Port80Handler */ -interface IAcmeCertManagerOptions { +interface IPort80HandlerOptions { port?: number; contactEmail?: string; useProduction?: boolean; @@ -29,7 +78,7 @@ interface IAcmeCertManagerOptions { /** * Certificate data that can be emitted via events or set from outside */ -interface ICertificateData { +export interface ICertificateData { domain: string; certificate: string; privateKey: string; @@ -37,34 +86,53 @@ interface ICertificateData { } /** - * Events emitted by the ACME Certificate Manager + * Events emitted by the Port80Handler */ -export enum CertManagerEvents { +export enum Port80HandlerEvents { CERTIFICATE_ISSUED = 'certificate-issued', CERTIFICATE_RENEWED = 'certificate-renewed', CERTIFICATE_FAILED = 'certificate-failed', CERTIFICATE_EXPIRING = 'certificate-expiring', MANAGER_STARTED = 'manager-started', MANAGER_STOPPED = 'manager-stopped', + REQUEST_FORWARDED = 'request-forwarded', } /** - * Improved ACME Certificate Manager with event emission and external certificate management + * Certificate failure payload type */ -export class AcmeCertManager extends plugins.EventEmitter { +export interface ICertificateFailure { + domain: string; + error: string; + isRenewal: boolean; +} + +/** + * Certificate expiry payload type + */ +export interface ICertificateExpiring { + domain: string; + expiryDate: Date; + daysRemaining: number; +} + +/** + * Port80Handler with ACME certificate management and request forwarding capabilities + */ +export class Port80Handler extends plugins.EventEmitter { private domainCertificates: Map; private server: plugins.http.Server | null = null; private acmeClient: plugins.acme.Client | null = null; private accountKey: string | null = null; private renewalTimer: NodeJS.Timeout | null = null; private isShuttingDown: boolean = false; - private options: Required; + private options: Required; /** - * Creates a new ACME Certificate Manager + * Creates a new Port80Handler * @param options Configuration options */ - constructor(options: IAcmeCertManagerOptions = {}) { + constructor(options: IPort80HandlerOptions = {}) { super(); this.domainCertificates = new Map(); @@ -73,7 +141,7 @@ export class AcmeCertManager extends plugins.EventEmitter { port: options.port ?? 80, contactEmail: options.contactEmail ?? 'admin@example.com', useProduction: options.useProduction ?? false, // Safer default: staging - renewThresholdDays: options.renewThresholdDays ?? 30, + renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements httpsRedirectPort: options.httpsRedirectPort ?? 443, renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, }; @@ -84,11 +152,11 @@ export class AcmeCertManager extends plugins.EventEmitter { */ public async start(): Promise { if (this.server) { - throw new Error('Server is already running'); + throw new ServerError('Server is already running'); } if (this.isShuttingDown) { - throw new Error('Server is shutting down'); + throw new ServerError('Server is shutting down'); } return new Promise((resolve, reject) => { @@ -97,22 +165,33 @@ export class AcmeCertManager extends plugins.EventEmitter { this.server.on('error', (error: NodeJS.ErrnoException) => { if (error.code === 'EACCES') { - reject(new Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`)); + reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code)); } else if (error.code === 'EADDRINUSE') { - reject(new Error(`Port ${this.options.port} is already in use.`)); + reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code)); } else { - reject(error); + reject(new ServerError(error.message, error.code)); } }); this.server.listen(this.options.port, () => { - console.log(`AcmeCertManager is listening on port ${this.options.port}`); + console.log(`Port80Handler is listening on port ${this.options.port}`); this.startRenewalTimer(); - this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port); + this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port); + + // Start certificate process for domains with acmeMaintenance enabled + for (const [domain, domainInfo] of this.domainCertificates.entries()) { + if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) { + this.obtainCertificate(domain).catch(err => { + console.error(`Error obtaining initial certificate for ${domain}:`, err); + }); + } + } + resolve(); }); } catch (error) { - reject(error); + const message = error instanceof Error ? error.message : 'Unknown error starting server'; + reject(new ServerError(message)); } }); } @@ -138,7 +217,7 @@ export class AcmeCertManager extends plugins.EventEmitter { this.server.close(() => { this.server = null; this.isShuttingDown = false; - this.emit(CertManagerEvents.MANAGER_STOPPED); + this.emit(Port80HandlerEvents.MANAGER_STOPPED); resolve(); }); } else { @@ -149,13 +228,41 @@ export class AcmeCertManager extends plugins.EventEmitter { } /** - * Adds a domain to be managed for certificates - * @param domain The domain to add + * Adds a domain with configuration options + * @param options Domain configuration options */ - public addDomain(domain: string): void { - if (!this.domainCertificates.has(domain)) { - this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false }); - console.log(`Domain added: ${domain}`); + public addDomain(options: IDomainOptions): void { + if (!options.domainName || typeof options.domainName !== 'string') { + throw new Port80HandlerError('Invalid domain name'); + } + + const domainName = options.domainName; + + if (!this.domainCertificates.has(domainName)) { + this.domainCertificates.set(domainName, { + options, + certObtained: false, + obtainingInProgress: false + }); + + console.log(`Domain added: ${domainName} with configuration:`, { + sslRedirect: options.sslRedirect, + acmeMaintenance: options.acmeMaintenance, + hasForward: !!options.forward, + hasAcmeForward: !!options.acmeForward + }); + + // If acmeMaintenance is enabled, start certificate process immediately + if (options.acmeMaintenance && this.server) { + 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`); } } @@ -177,10 +284,25 @@ export class AcmeCertManager extends plugins.EventEmitter { * @param expiryDate Optional expiry date */ public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { + if (!domain || !certificate || !privateKey) { + throw new Port80HandlerError('Domain, certificate and privateKey are required'); + } + let domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { - domainInfo = { certObtained: false, obtainingInProgress: false }; + // Create default domain options if not already configured + const defaultOptions: IDomainOptions = { + domainName: domain, + sslRedirect: true, + acmeMaintenance: true + }; + + domainInfo = { + options: defaultOptions, + certObtained: false, + obtainingInProgress: false + }; this.domainCertificates.set(domain, domainInfo); } @@ -192,27 +314,18 @@ export class AcmeCertManager extends plugins.EventEmitter { if (expiryDate) { domainInfo.expiryDate = expiryDate; } else { - // Try to extract expiry date from certificate - try { - // This is a simplistic approach - in a real implementation, use a proper - // certificate parsing library like node-forge or x509 - const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); - if (matches && matches[1]) { - domainInfo.expiryDate = new Date(matches[1]); - } - } catch (error) { - console.warn(`Failed to extract expiry date from certificate for ${domain}`); - } + // Extract expiry date from certificate + domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); } console.log(`Certificate set for ${domain}`); // Emit certificate event - this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, { + this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, { domain, certificate, privateKey, - expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default + expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() }); } @@ -231,7 +344,7 @@ export class AcmeCertManager extends plugins.EventEmitter { domain, certificate: domainInfo.certificate, privateKey: domainInfo.privateKey, - expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default + expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() }; } @@ -244,23 +357,28 @@ export class AcmeCertManager extends plugins.EventEmitter { return this.acmeClient; } - // Generate a new account key - this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString(); - - this.acmeClient = new plugins.acme.Client({ - directoryUrl: this.options.useProduction - ? plugins.acme.directory.letsencrypt.production - : plugins.acme.directory.letsencrypt.staging, - accountKey: this.accountKey, - }); - - // Create a new account - await this.acmeClient.createAccount({ - termsOfServiceAgreed: true, - contact: [`mailto:${this.options.contactEmail}`], - }); - - return this.acmeClient; + try { + // Generate a new account key + this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString(); + + this.acmeClient = new plugins.acme.Client({ + directoryUrl: this.options.useProduction + ? plugins.acme.directory.letsencrypt.production + : plugins.acme.directory.letsencrypt.staging, + accountKey: this.accountKey, + }); + + // Create a new account + await this.acmeClient.createAccount({ + termsOfServiceAgreed: true, + contact: [`mailto:${this.options.contactEmail}`], + }); + + return this.acmeClient; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client'; + throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`); + } } /** @@ -279,12 +397,7 @@ export class AcmeCertManager extends plugins.EventEmitter { // Extract domain (ignoring any port in the Host header) const domain = hostHeader.split(':')[0]; - // If the request is for an ACME HTTP-01 challenge, handle it - if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { - this.handleAcmeChallenge(req, res, domain); - return; - } - + // Check if domain is configured if (!this.domainCertificates.has(domain)) { res.statusCode = 404; res.end('Domain not configured'); @@ -292,9 +405,28 @@ export class AcmeCertManager extends plugins.EventEmitter { } const domainInfo = this.domainCertificates.get(domain)!; + const options = domainInfo.options; - // If certificate exists, redirect to HTTPS - if (domainInfo.certObtained) { + // If the request is for an ACME HTTP-01 challenge, handle it + if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { + // Check if we should forward ACME requests + if (options.acmeForward) { + this.forwardRequest(req, res, options.acmeForward, 'ACME challenge'); + return; + } + + this.handleAcmeChallenge(req, res, domain); + return; + } + + // Check if we should forward non-ACME requests + if (options.forward) { + this.forwardRequest(req, res, options.forward, 'HTTP'); + return; + } + + // If certificate exists and sslRedirect is enabled, redirect to HTTPS + if (domainInfo.certObtained && options.sslRedirect) { const httpsPort = this.options.httpsRedirectPort; const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; @@ -302,17 +434,93 @@ export class AcmeCertManager extends plugins.EventEmitter { res.statusCode = 301; res.setHeader('Location', redirectUrl); res.end(`Redirecting to ${redirectUrl}`); - } else { + return; + } + + // Handle case where certificate maintenance is enabled but not yet obtained + if (options.acmeMaintenance && !domainInfo.certObtained) { // Trigger certificate issuance if not already running if (!domainInfo.obtainingInProgress) { this.obtainCertificate(domain).catch(err => { - this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message }); + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { + domain, + error: errorMessage, + isRenewal: false + }); console.error(`Error obtaining certificate for ${domain}:`, err); }); } res.statusCode = 503; res.end('Certificate issuance in progress, please try again later.'); + return; + } + + // Default response for unhandled request + res.statusCode = 404; + res.end('No handlers configured for this request'); + } + + /** + * 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( + req: plugins.http.IncomingMessage, + res: plugins.http.ServerResponse, + target: IForwardConfig, + requestType: string + ): void { + const options = { + hostname: target.ip, + port: target.port, + path: req.url, + method: req.method, + headers: { ...req.headers } + }; + + const domain = req.headers.host?.split(':')[0] || 'unknown'; + console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`); + + const proxyReq = plugins.http.request(options, (proxyRes) => { + // Copy status code + res.statusCode = proxyRes.statusCode || 500; + + // Copy headers + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value) res.setHeader(key, value); + } + + // Pipe response data + proxyRes.pipe(res); + + this.emit(Port80HandlerEvents.REQUEST_FORWARDED, { + domain, + requestType, + target: `${target.ip}:${target.port}`, + statusCode: proxyRes.statusCode + }); + }); + + proxyReq.on('error', (error) => { + console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error); + if (!res.headersSent) { + res.statusCode = 502; + res.end(`Proxy error: ${error.message}`); + } else { + res.end(); + } + }); + + // Pipe original request to proxy request + if (req.readable) { + req.pipe(proxyReq); + } else { + proxyReq.end(); } } @@ -354,7 +562,13 @@ export class AcmeCertManager extends plugins.EventEmitter { // Get the domain info const domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { - throw new Error(`Domain not found: ${domain}`); + throw new CertificateError('Domain not found', domain, isRenewal); + } + + // Verify that acmeMaintenance is enabled + if (!domainInfo.options.acmeMaintenance) { + console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`); + return; } // Prevent concurrent certificate issuance @@ -377,40 +591,8 @@ export class AcmeCertManager extends plugins.EventEmitter { // Get the authorizations for the order const authorizations = await client.getAuthorizations(order); - for (const authz of authorizations) { - const challenge = authz.challenges.find(ch => ch.type === 'http-01'); - if (!challenge) { - throw new Error('HTTP-01 challenge not found'); - } - - // Get the key authorization for the challenge - const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); - - // Store the challenge data - domainInfo.challengeToken = challenge.token; - domainInfo.challengeKeyAuthorization = keyAuthorization; - - // ACME client type definition workaround - use compatible approach - // First check if challenge verification is needed - const authzUrl = authz.url; - - try { - // Check if authzUrl exists and perform verification - if (authzUrl) { - await client.verifyChallenge(authz, challenge); - } - - // Complete the challenge - await client.completeChallenge(challenge); - - // Wait for validation - await client.waitForValidStatus(challenge); - console.log(`HTTP-01 challenge completed for ${domain}`); - } catch (error) { - console.error(`Challenge error for ${domain}:`, error); - throw error; - } - } + // Process each authorization + await this.processAuthorizations(client, domain, authorizations); // Generate a CSR and private key const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({ @@ -436,28 +618,20 @@ export class AcmeCertManager extends plugins.EventEmitter { delete domainInfo.challengeKeyAuthorization; // Extract expiry date from certificate - try { - const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); - if (matches && matches[1]) { - domainInfo.expiryDate = new Date(matches[1]); - console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`); - } - } catch (error) { - console.warn(`Failed to extract expiry date from certificate for ${domain}`); - } + domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); // Emit the appropriate event const eventType = isRenewal - ? CertManagerEvents.CERTIFICATE_RENEWED - : CertManagerEvents.CERTIFICATE_ISSUED; + ? Port80HandlerEvents.CERTIFICATE_RENEWED + : Port80HandlerEvents.CERTIFICATE_ISSUED; this.emitCertificateEvent(eventType, { domain, certificate, privateKey, - expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default + expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() }); } catch (error: any) { @@ -473,17 +647,76 @@ export class AcmeCertManager extends plugins.EventEmitter { } // Emit failure event - this.emit(CertManagerEvents.CERTIFICATE_FAILED, { + this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { domain, error: error.message || 'Unknown error', isRenewal - }); + } as ICertificateFailure); + + throw new CertificateError( + error.message || 'Certificate issuance failed', + domain, + isRenewal + ); } finally { // Reset flag whether successful or not domainInfo.obtainingInProgress = false; } } + /** + * Process ACME authorizations by verifying and completing challenges + * @param client ACME client + * @param domain Domain name + * @param authorizations Authorizations to process + */ + private async processAuthorizations( + client: plugins.acme.Client, + domain: string, + authorizations: plugins.acme.Authorization[] + ): Promise { + const domainInfo = this.domainCertificates.get(domain); + if (!domainInfo) { + throw new CertificateError('Domain not found during authorization', domain); + } + + for (const authz of authorizations) { + const challenge = authz.challenges.find(ch => ch.type === 'http-01'); + if (!challenge) { + throw new CertificateError('HTTP-01 challenge not found', domain); + } + + // Get the key authorization for the challenge + const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); + + // Store the challenge data + domainInfo.challengeToken = challenge.token; + domainInfo.challengeKeyAuthorization = keyAuthorization; + + // ACME client type definition workaround - use compatible approach + // First check if challenge verification is needed + const authzUrl = authz.url; + + try { + // Check if authzUrl exists and perform verification + if (authzUrl) { + await client.verifyChallenge(authz, challenge); + } + + // Complete the challenge + await client.completeChallenge(challenge); + + // Wait for validation + await client.waitForValidStatus(challenge); + console.log(`HTTP-01 challenge completed for ${domain}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error'; + console.error(`Challenge error for ${domain}:`, error); + throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain); + } + } + } + /** * Starts the certificate renewal timer */ @@ -519,6 +752,11 @@ export class AcmeCertManager extends plugins.EventEmitter { const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000; for (const [domain, domainInfo] of this.domainCertificates.entries()) { + // Skip domains with acmeMaintenance disabled + if (!domainInfo.options.acmeMaintenance) { + continue; + } + // Skip domains without certificates or already in renewal if (!domainInfo.certObtained || domainInfo.obtainingInProgress) { continue; @@ -534,26 +772,67 @@ export class AcmeCertManager extends plugins.EventEmitter { // Check if certificate is near expiry if (timeUntilExpiry <= renewThresholdMs) { console.log(`Certificate for ${domain} expires soon, renewing...`); - this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, { + + const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)); + + this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, { domain, expiryDate: domainInfo.expiryDate, - daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)) - }); + daysRemaining + } as ICertificateExpiring); // Start renewal process this.obtainCertificate(domain, true).catch(err => { - console.error(`Error renewing certificate for ${domain}:`, err); + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + console.error(`Error renewing certificate for ${domain}:`, errorMessage); }); } } } + /** + * 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 + } + /** * Emits a certificate event with the certificate data * @param eventType The event type to emit * @param data The certificate data */ - private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void { + private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void { this.emit(eventType, data); } } \ No newline at end of file