import * as plugins from '../plugins.js'; import { IncomingMessage, ServerResponse } from 'http'; import { Port80HandlerEvents } from '../common/types.js'; import type { IForwardConfig, IDomainOptions, ICertificateData, ICertificateFailure, ICertificateExpiring, IAcmeOptions } from '../common/types.js'; // (fs and path I/O moved to CertProvisioner) // ACME HTTP-01 challenge handler storing tokens in memory (diskless) class DisklessHttp01Handler { private storage: Map; constructor(storage: Map) { this.storage = storage; } public getSupportedTypes(): string[] { return ['http-01']; } public async prepare(ch: any): Promise { this.storage.set(ch.token, ch.keyAuthorization); } public async verify(ch: any): Promise { return; } public async cleanup(ch: any): Promise { this.storage.delete(ch.token); } } /** * 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'; } } /** * Represents a domain configuration with certificate status information */ interface IDomainCertificate { options: IDomainOptions; certObtained: boolean; obtainingInProgress: boolean; certificate?: string; privateKey?: string; expiryDate?: Date; lastRenewalAttempt?: Date; } /** * Configuration options for the Port80Handler */ // Port80Handler options moved to common types /** * Port80Handler with ACME certificate management and request forwarding capabilities * Now with glob pattern support for domain matching */ export class Port80Handler extends plugins.EventEmitter { private domainCertificates: Map; // In-memory storage for ACME HTTP-01 challenge tokens private acmeHttp01Storage: Map = new Map(); // SmartAcme instance for certificate management private smartAcme: plugins.smartacme.SmartAcme | null = null; private server: plugins.http.Server | null = null; // Renewal scheduling is handled externally by SmartProxy // (Removed internal renewal timer) private isShuttingDown: boolean = false; private options: Required; /** * Creates a new Port80Handler * @param options Configuration options */ constructor(options: IAcmeOptions = {}) { super(); 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 httpsRedirectPort: options.httpsRedirectPort ?? 443, 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 ?? [] }; } /** * Starts the HTTP server for ACME challenges */ public async start(): Promise { 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; } // Initialize SmartAcme for ACME challenge management (diskless HTTP handler) if (this.options.enabled) { this.smartAcme = new plugins.smartacme.SmartAcme({ accountEmail: this.options.contactEmail, certManager: new plugins.smartacme.MemoryCertManager(), environment: this.options.useProduction ? 'production' : 'integration', challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ], challengePriority: ['http-01'], }); await this.smartAcme.start(); } return new Promise((resolve, reject) => { try { this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res)); this.server.on('error', (error: NodeJS.ErrnoException) => { 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.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 */ public async stop(): Promise { if (!this.server) { return; } this.isShuttingDown = true; 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 */ 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 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 */ public removeDomain(domain: string): void { 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 */ public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { 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: IDomainOptions = { 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}`); // (Persistence of certificates moved to CertProvisioner) // 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 */ public getCertificate(domain: string): ICertificateData | null { // 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() }; } /** * 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 */ private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null { // 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 */ 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; } } /** * Handles incoming HTTP requests * @param req The HTTP request * @param res The HTTP response */ private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { 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; // Handle ACME HTTP-01 challenge requests or forwarding if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { // Forward ACME requests if configured if (options.acmeForward) { this.forwardRequest(req, res, options.acmeForward, 'ACME challenge'); return; } // If not managing ACME for this domain, return 404 if (!options.acmeMaintenance) { res.statusCode = 404; res.end('Not found'); return; } // Serve challenge response from in-memory storage const token = req.url.split('/').pop() || ''; const keyAuth = this.acmeHttp01Storage.get(token); if (keyAuth) { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end(keyAuth); console.log(`Served ACME challenge response for ${domain}`); } else { res.statusCode = 404; res.end('Challenge token not found'); } 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 */ 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(); } } /** * 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 */ /** * Obtains a certificate for a domain using SmartAcme HTTP-01 challenges * @param domain The domain to obtain a certificate for * @param isRenewal Whether this is a renewal attempt */ private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise { if (this.isGlobPattern(domain)) { throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal); } const domainInfo = this.domainCertificates.get(domain)!; if (!domainInfo.options.acmeMaintenance) { console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`); return; } if (domainInfo.obtainingInProgress) { console.log(`Certificate issuance already in progress for ${domain}`); return; } if (!this.smartAcme) { throw new Port80HandlerError('SmartAcme is not initialized'); } domainInfo.obtainingInProgress = true; domainInfo.lastRenewalAttempt = new Date(); try { // Request certificate via SmartAcme const certObj = await this.smartAcme.getCertificateForDomain(domain); const certificate = certObj.publicKey; const privateKey = certObj.privateKey; const expiryDate = new Date(certObj.validUntil); domainInfo.certificate = certificate; domainInfo.privateKey = privateKey; domainInfo.certObtained = true; domainInfo.expiryDate = expiryDate; console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); // Persistence moved to CertProvisioner const eventType = isRenewal ? Port80HandlerEvents.CERTIFICATE_RENEWED : Port80HandlerEvents.CERTIFICATE_ISSUED; this.emitCertificateEvent(eventType, { domain, certificate, privateKey, expiryDate: expiryDate || this.getDefaultExpiryDate() }); } catch (error: any) { const errorMsg = error?.message || 'Unknown error'; console.error(`Error during certificate issuance for ${domain}:`, error); this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { domain, error: errorMsg, isRenewal } as ICertificateFailure); throw new CertificateError(errorMsg, domain, isRenewal); } finally { domainInfo.obtainingInProgress = false; } } /** * 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: Port80HandlerEvents, data: ICertificateData): void { this.emit(eventType, data); } /** * Gets all domains and their certificate status * @returns Map of domains to certificate status */ public getDomainCertificateStatus(): Map { 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: 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; } /** * Gets information about managed domains * @returns Array of domain information */ public getManagedDomains(): Array<{ domain: string; isGlobPattern: boolean; hasCertificate: boolean; hasForwarding: boolean; sslRedirect: boolean; acmeMaintenance: boolean; }> { 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 */ public getConfig(): Required { return { ...this.options }; } /** * Request a certificate renewal for a specific domain. * @param domain The domain to renew. */ public async renewCertificate(domain: string): Promise { if (!this.domainCertificates.has(domain)) { throw new Port80HandlerError(`Domain not managed: ${domain}`); } // Trigger renewal via ACME await this.obtainCertificate(domain, true); } }