UNPKG

@push.rocks/smartproxy

Version:

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.

928 lines 75.5 kB
import * as plugins from '../plugins.js'; import { IncomingMessage, ServerResponse } from 'http'; import * as fs from 'fs'; import * as path from 'path'; /** * Custom error classes for better error handling */ export class Port80HandlerError extends Error { constructor(message) { super(message); this.name = 'Port80HandlerError'; } } export class CertificateError extends Port80HandlerError { constructor(message, domain, isRenewal = false) { super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`); this.domain = domain; this.isRenewal = isRenewal; this.name = 'CertificateError'; } } export class ServerError extends Port80HandlerError { constructor(message, code) { super(message); this.code = code; this.name = 'ServerError'; } } /** * Events emitted by the Port80Handler */ export var Port80HandlerEvents; (function (Port80HandlerEvents) { Port80HandlerEvents["CERTIFICATE_ISSUED"] = "certificate-issued"; Port80HandlerEvents["CERTIFICATE_RENEWED"] = "certificate-renewed"; Port80HandlerEvents["CERTIFICATE_FAILED"] = "certificate-failed"; Port80HandlerEvents["CERTIFICATE_EXPIRING"] = "certificate-expiring"; Port80HandlerEvents["MANAGER_STARTED"] = "manager-started"; Port80HandlerEvents["MANAGER_STOPPED"] = "manager-stopped"; Port80HandlerEvents["REQUEST_FORWARDED"] = "request-forwarded"; })(Port80HandlerEvents || (Port80HandlerEvents = {})); /** * Port80Handler with ACME certificate management and request forwarding capabilities * Now with glob pattern support for domain matching */ export class Port80Handler extends plugins.EventEmitter { /** * Creates a new Port80Handler * @param options Configuration options */ constructor(options = {}) { super(); this.server = null; this.acmeClient = null; this.accountKey = null; this.renewalTimer = null; this.isShuttingDown = false; this.domainCertificates = new Map(); // Default options this.options = { port: options.port ?? 80, contactEmail: options.contactEmail ?? 'admin@example.com', useProduction: options.useProduction ?? false, // Safer default: staging renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements httpsRedirectPort: options.httpsRedirectPort ?? 443, renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, enabled: options.enabled ?? true, // Enable by default autoRenew: options.autoRenew ?? true, // Auto-renew by default certificateStore: options.certificateStore ?? './certs', // Default store location skipConfiguredCerts: options.skipConfiguredCerts ?? false }; } /** * Starts the HTTP server for ACME challenges */ async start() { if (this.server) { throw new ServerError('Server is already running'); } if (this.isShuttingDown) { throw new ServerError('Server is shutting down'); } // Skip if disabled if (this.options.enabled === false) { console.log('Port80Handler is disabled, skipping start'); return; } return new Promise((resolve, reject) => { try { // Load certificates from store if enabled if (this.options.certificateStore) { this.loadCertificatesFromStore(); } this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res)); this.server.on('error', (error) => { if (error.code === 'EACCES') { 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 ServerError(`Port ${this.options.port} is already in use.`, error.code)); } else { reject(new ServerError(error.message, error.code)); } }); this.server.listen(this.options.port, () => { console.log(`Port80Handler is listening on port ${this.options.port}`); this.startRenewalTimer(); this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port); // Start certificate process for domains with acmeMaintenance enabled for (const [domain, domainInfo] of this.domainCertificates.entries()) { // Skip glob patterns for certificate issuance if (this.isGlobPattern(domain)) { console.log(`Skipping initial certificate for glob pattern: ${domain}`); continue; } 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) { const message = error instanceof Error ? error.message : 'Unknown error starting server'; reject(new ServerError(message)); } }); } /** * Stops the HTTP server and renewal timer */ async stop() { if (!this.server) { return; } this.isShuttingDown = true; // Stop the renewal timer if (this.renewalTimer) { clearInterval(this.renewalTimer); this.renewalTimer = null; } return new Promise((resolve) => { if (this.server) { this.server.close(() => { this.server = null; this.isShuttingDown = false; this.emit(Port80HandlerEvents.MANAGER_STOPPED); resolve(); }); } else { this.isShuttingDown = false; resolve(); } }); } /** * Adds a domain with configuration options * @param options Domain configuration options */ addDomain(options) { 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 and not a glob pattern, start certificate process immediately if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) { 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`); } } /** * Removes a domain from management * @param domain The domain to remove */ removeDomain(domain) { if (this.domainCertificates.delete(domain)) { console.log(`Domain removed: ${domain}`); } } /** * Sets a certificate for a domain directly (for externally obtained certificates) * @param domain The domain for the certificate * @param certificate The certificate (PEM format) * @param privateKey The private key (PEM format) * @param expiryDate Optional expiry date */ setCertificate(domain, certificate, privateKey, expiryDate) { if (!domain || !certificate || !privateKey) { throw new Port80HandlerError('Domain, certificate and privateKey are required'); } // Don't allow setting certificates for glob patterns if (this.isGlobPattern(domain)) { throw new Port80HandlerError('Cannot set certificate for glob pattern domains'); } let domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { // Create default domain options if not already configured const defaultOptions = { domainName: domain, sslRedirect: true, acmeMaintenance: true }; domainInfo = { options: defaultOptions, certObtained: false, obtainingInProgress: false }; this.domainCertificates.set(domain, domainInfo); } domainInfo.certificate = certificate; domainInfo.privateKey = privateKey; domainInfo.certObtained = true; domainInfo.obtainingInProgress = false; if (expiryDate) { domainInfo.expiryDate = expiryDate; } else { // Extract expiry date from certificate domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); } console.log(`Certificate set for ${domain}`); // Save certificate to store if enabled if (this.options.certificateStore) { this.saveCertificateToStore(domain, certificate, privateKey); } // Emit certificate event this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, { domain, certificate, privateKey, expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() }); } /** * Gets the certificate for a domain if it exists * @param domain The domain to get the certificate for */ getCertificate(domain) { // Can't get certificates for glob patterns if (this.isGlobPattern(domain)) { return null; } const domainInfo = this.domainCertificates.get(domain); if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) { return null; } return { domain, certificate: domainInfo.certificate, privateKey: domainInfo.privateKey, expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() }; } /** * Saves a certificate to the filesystem store * @param domain The domain for the certificate * @param certificate The certificate (PEM format) * @param privateKey The private key (PEM format) * @private */ saveCertificateToStore(domain, certificate, privateKey) { // Skip if certificate store is not enabled if (!this.options.certificateStore) return; try { const storePath = this.options.certificateStore; // Ensure the directory exists if (!fs.existsSync(storePath)) { fs.mkdirSync(storePath, { recursive: true }); console.log(`Created certificate store directory: ${storePath}`); } const certPath = path.join(storePath, `${domain}.cert.pem`); const keyPath = path.join(storePath, `${domain}.key.pem`); // Write certificate and private key files fs.writeFileSync(certPath, certificate); fs.writeFileSync(keyPath, privateKey); // Set secure permissions for private key try { fs.chmodSync(keyPath, 0o600); } catch (err) { console.log(`Warning: Could not set secure permissions on ${keyPath}`); } console.log(`Saved certificate for ${domain} to ${certPath}`); } catch (err) { console.error(`Error saving certificate for ${domain}:`, err); } } /** * Loads certificates from the certificate store * @private */ loadCertificatesFromStore() { if (!this.options.certificateStore) return; try { const storePath = this.options.certificateStore; // Ensure the directory exists if (!fs.existsSync(storePath)) { fs.mkdirSync(storePath, { recursive: true }); console.log(`Created certificate store directory: ${storePath}`); return; } // Get list of certificate files const files = fs.readdirSync(storePath); const certFiles = files.filter(file => file.endsWith('.cert.pem')); // Load each certificate for (const certFile of certFiles) { const domain = certFile.replace('.cert.pem', ''); const keyFile = `${domain}.key.pem`; // Skip if key file doesn't exist if (!files.includes(keyFile)) { console.log(`Warning: Found certificate for ${domain} but no key file`); continue; } // Skip if we should skip configured certs if (this.options.skipConfiguredCerts) { const domainInfo = this.domainCertificates.get(domain); if (domainInfo && domainInfo.certObtained) { console.log(`Skipping already configured certificate for ${domain}`); continue; } } // Load certificate and key try { const certificate = fs.readFileSync(path.join(storePath, certFile), 'utf8'); const privateKey = fs.readFileSync(path.join(storePath, keyFile), 'utf8'); // Extract expiry date let expiryDate; try { const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); if (matches && matches[1]) { expiryDate = new Date(matches[1]); } } catch (err) { console.log(`Warning: Could not extract expiry date from certificate for ${domain}`); } // Check if domain is already registered let domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { // Register domain if not already registered domainInfo = { options: { domainName: domain, sslRedirect: true, acmeMaintenance: true }, certObtained: false, obtainingInProgress: false }; this.domainCertificates.set(domain, domainInfo); } // Set certificate domainInfo.certificate = certificate; domainInfo.privateKey = privateKey; domainInfo.certObtained = true; domainInfo.expiryDate = expiryDate; console.log(`Loaded certificate for ${domain} from store, valid until ${expiryDate?.toISOString() || 'unknown'}`); } catch (err) { console.error(`Error loading certificate for ${domain}:`, err); } } } catch (err) { console.error('Error loading certificates from store:', err); } } /** * Check if a domain is a glob pattern * @param domain Domain to check * @returns True if the domain is a glob pattern */ isGlobPattern(domain) { 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 */ getDomainInfoForRequest(requestDomain) { // Try direct match first if (this.domainCertificates.has(requestDomain)) { return { domainInfo: this.domainCertificates.get(requestDomain), pattern: requestDomain }; } // Then try glob patterns for (const [pattern, domainInfo] of this.domainCertificates.entries()) { if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) { return { domainInfo, pattern }; } } 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 */ domainMatchesPattern(domain, pattern) { // 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; } } /** * Lazy initialization of the ACME client * @returns An ACME client instance */ async getAcmeClient() { if (this.acmeClient) { 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}`); } } /** * Handles incoming HTTP requests * @param req The HTTP request * @param res The HTTP response */ handleRequest(req, res) { const hostHeader = req.headers.host; if (!hostHeader) { res.statusCode = 400; res.end('Bad Request: Host header is missing'); return; } // Extract domain (ignoring any port in the Host header) const domain = hostHeader.split(':')[0]; // Get domain config, using glob pattern matching if needed const domainMatch = this.getDomainInfoForRequest(domain); if (!domainMatch) { res.statusCode = 404; res.end('Domain not configured'); return; } const { domainInfo, pattern } = domainMatch; const options = domainInfo.options; // If the request is for an ACME HTTP-01 challenge, handle it if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) { // Check if we should forward ACME requests if (options.acmeForward) { this.forwardRequest(req, res, options.acmeForward, 'ACME challenge'); return; } // Only handle ACME challenges for non-glob patterns if (!this.isGlobPattern(pattern)) { 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 // (Skip for glob patterns as they won't have certificates) if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) { const httpsPort = this.options.httpsRedirectPort; const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; res.statusCode = 301; res.setHeader('Location', redirectUrl); res.end(`Redirecting to ${redirectUrl}`); return; } // Handle case where certificate maintenance is enabled but not yet obtained // (Skip for glob patterns as they can't have certificates) if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) { // Trigger certificate issuance if not already running if (!domainInfo.obtainingInProgress) { this.obtainCertificate(domain).catch(err => { 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 */ forwardRequest(req, res, target, requestType) { 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(); } } /** * Serves the ACME HTTP-01 challenge response * @param req The HTTP request * @param res The HTTP response * @param domain The domain for the challenge */ handleAcmeChallenge(req, res, domain) { const domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { res.statusCode = 404; res.end('Domain not configured'); return; } // The token is the last part of the URL const urlParts = req.url?.split('/'); const token = urlParts ? urlParts[urlParts.length - 1] : ''; if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end(domainInfo.challengeKeyAuthorization); console.log(`Served ACME challenge response for ${domain}`); } else { res.statusCode = 404; res.end('Challenge token not found'); } } /** * 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 */ async obtainCertificate(domain, isRenewal = false) { // Don't allow certificate issuance for glob patterns if (this.isGlobPattern(domain)) { throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal); } // Get the domain info const domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { 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 if (domainInfo.obtainingInProgress) { console.log(`Certificate issuance already in progress for ${domain}`); return; } domainInfo.obtainingInProgress = true; domainInfo.lastRenewalAttempt = new Date(); try { const client = await this.getAcmeClient(); // Create a new order for the domain const order = await client.createOrder({ identifiers: [{ type: 'dns', value: domain }], }); // Get the authorizations for the order const authorizations = await client.getAuthorizations(order); // Process each authorization await this.processAuthorizations(client, domain, authorizations); // Generate a CSR and private key const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({ commonName: domain, }); const csr = csrBuffer.toString(); const privateKey = privateKeyBuffer.toString(); // Finalize the order with our CSR await client.finalizeOrder(order, csr); // Get the certificate with the full chain const certificate = await client.getCertificate(order); // Store the certificate and key domainInfo.certificate = certificate; domainInfo.privateKey = privateKey; domainInfo.certObtained = true; // Clear challenge data delete domainInfo.challengeToken; delete domainInfo.challengeKeyAuthorization; // Extract expiry date from certificate domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); // Save the certificate to the store if enabled if (this.options.certificateStore) { this.saveCertificateToStore(domain, certificate, privateKey); } // Emit the appropriate event const eventType = isRenewal ? Port80HandlerEvents.CERTIFICATE_RENEWED : Port80HandlerEvents.CERTIFICATE_ISSUED; this.emitCertificateEvent(eventType, { domain, certificate, privateKey, expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() }); } catch (error) { // Check for rate limit errors if (error.message && (error.message.includes('rateLimited') || error.message.includes('too many certificates') || error.message.includes('rate limit'))) { console.error(`Rate limit reached for ${domain}. Waiting before retry.`); } else { console.error(`Error during certificate issuance for ${domain}:`, error); } // Emit failure event this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { domain, error: error.message || 'Unknown error', isRenewal }); 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 */ async processAuthorizations(client, domain, authorizations) { 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 */ startRenewalTimer() { if (this.renewalTimer) { clearInterval(this.renewalTimer); } // Convert hours to milliseconds const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000; this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval); // Prevent the timer from keeping the process alive if (this.renewalTimer.unref) { this.renewalTimer.unref(); } console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`); } /** * Checks for certificates that need renewal */ checkForRenewals() { if (this.isShuttingDown) { return; } // Skip renewal if auto-renewal is disabled if (this.options.autoRenew === false) { console.log('Auto-renewal is disabled, skipping certificate renewal check'); return; } console.log('Checking for certificates that need renewal...'); const now = new Date(); const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000; for (const [domain, domainInfo] of this.domainCertificates.entries()) { // Skip glob patterns if (this.isGlobPattern(domain)) { continue; } // Skip domains with acmeMaintenance disabled if (!domainInfo.options.acmeMaintenance) { continue; } // Skip domains without certificates or already in renewal if (!domainInfo.certObtained || domainInfo.obtainingInProgress) { continue; } // Skip domains without expiry dates if (!domainInfo.expiryDate) { continue; } const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime(); // Check if certificate is near expiry if (timeUntilExpiry <= renewThresholdMs) { console.log(`Certificate for ${domain} expires soon, renewing...`); const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)); this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, { domain, expiryDate: domainInfo.expiryDate, daysRemaining }); // Start renewal process this.obtainCertificate(domain, true).catch(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 */ extractExpiryDateFromCertificate(certificate, domain) { 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 */ getDefaultExpiryDate() { 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 */ emitCertificateEvent(eventType, data) { this.emit(eventType, data); } /** * Gets all domains and their certificate status * @returns Map of domains to certificate status */ getDomainCertificateStatus() { const result = new Map(); const now = new Date(); for (const [domain, domainInfo] of this.domainCertificates.entries()) { // Skip glob patterns if (this.isGlobPattern(domain)) continue; const status = { 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; } /** * Gets information about managed domains * @returns Array of domain information */ getManagedDomains() { return Array.from(this.domainCertificates.entries()).map(([domain, info]) => ({ domain, isGlobPattern: this.isGlobPattern(domain), hasCertificate: info.certObtained, hasForwarding: !!info.options.forward, sslRedirect: info.options.sslRedirect, acmeMaintenance: info.options.acmeMaintenance })); } /** * Gets configuration details * @returns Current configuration */ getConfig() { return { ...this.options }; } } //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5wb3J0ODBoYW5kbGVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHMvcG9ydDgwaGFuZGxlci9jbGFzc2VzLnBvcnQ4MGhhbmRsZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxlQUFlLENBQUM7QUFDekMsT0FBTyxFQUFFLGVBQWUsRUFBRSxjQUFjLEVBQUUsTUFBTSxNQUFNLENBQUM7QUFDdkQsT0FBTyxLQUFLLEVBQUUsTUFBTSxJQUFJLENBQUM7QUFDekIsT0FBTyxLQUFLLElBQUksTUFBTSxNQUFNLENBQUM7QUFFN0I7O0dBRUc7QUFDSCxNQUFNLE9BQU8sa0JBQW1CLFNBQVEsS0FBSztJQUMzQyxZQUFZLE9BQWU7UUFDekIsS0FBSyxDQUFDLE9BQU8sQ0FBQyxDQUFDO1FBQ2YsSUFBSSxDQUFDLElBQUksR0FBRyxvQkFBb0IsQ0FBQztJQUNuQyxDQUFDO0NBQ0Y7QUFFRCxNQUFNLE9BQU8sZ0JBQWlCLFNBQVEsa0JBQWtCO0lBQ3RELFlBQ0UsT0FBZSxFQUNDLE1BQWMsRUFDZCxZQUFxQixLQUFLO1FBRTFDLEtBQUssQ0FBQyxHQUFHLE9BQU8sZUFBZSxNQUFNLEdBQUcsU0FBUyxDQUFDLENBQUMsQ0FBQyxZQUFZLENBQUMsQ0FBQyxDQUFDLEVBQUUsRUFBRSxDQUFDLENBQUM7UUFIekQsV0FBTSxHQUFOLE1BQU0sQ0FBUTtRQUNkLGNBQVMsR0FBVCxTQUFTLENBQWlCO1FBRzFDLElBQUksQ0FBQyxJQUFJLEdBQUcsa0JBQWtCLENBQUM7SUFDakMsQ0FBQztDQUNGO0FBRUQsTUFBTSxPQUFPLFdBQVksU0FBUSxrQkFBa0I7SUFDakQsWUFBWSxPQUFlLEVBQWtCLElBQWE7UUFDeEQsS0FBSyxDQUFDLE9BQU8sQ0FBQyxDQUFDO1FBRDRCLFNBQUksR0FBSixJQUFJLENBQVM7UUFFeEQsSUFBSSxDQUFDLElBQUksR0FBRyxhQUFhLENBQUM7SUFDNUIsQ0FBQztDQUNG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