From 5a3bf2cae62ba9919aa5c73b44f3c28e616f53ac Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Fri, 9 May 2025 17:10:19 +0000 Subject: [PATCH] feat(smartproxy): Migrate internal module paths and update HTTP/ACME components for SmartProxy --- changelog.md | 11 + readme.plan.md | 26 +-- ts/00_commitinfo_data.ts | 2 +- ts/certificate/events/certificate-events.ts | 4 + ts/http/index.ts | 11 + ts/http/port80/challenge-responder.ts | 210 ++++++++++++----- ts/http/port80/index.ts | 10 + ts/http/port80/port80-handler.ts | 241 ++++++++++---------- ts/index.ts | 15 +- ts/plugins.ts | 3 +- ts/port80handler/classes.port80handler.ts | 24 ++ 11 files changed, 363 insertions(+), 194 deletions(-) create mode 100644 ts/port80handler/classes.port80handler.ts diff --git a/changelog.md b/changelog.md index 6ceedce..7f1f578 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-05-09 - 12.1.0 - feat(smartproxy) +Migrate internal module paths and update HTTP/ACME components for SmartProxy + +- Mark migration tasks as complete in readme.plan.md (checkboxes updated to ✅) +- Moved Port80Handler from ts/port80handler to ts/http/port80 (and extracted challenge responder) +- Migrated redirect handlers and router components to ts/http/redirects and ts/http/router respectively +- Updated re-exports in ts/index.ts and ts/plugins.ts to expose new module paths and additional exports +- Refactored CertificateEvents to include deprecation notes on Port80HandlerEvents +- Adjusted internal module organization for TLS, ACME, and forwarding (SNI extraction, client-hello parsing, etc.) +- Added minor logging and formatting improvements in several modules + ## 2025-05-09 - 12.0.0 - BREAKING CHANGE(forwarding) Rename 'sniPassthrough' export to 'httpsPassthrough' for consistent naming and remove outdated forwarding example diff --git a/readme.plan.md b/readme.plan.md index c96f946..3b4c154 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -136,19 +136,19 @@ This component has the cleanest design, so we'll start migration here: - [x] Extract SNI extraction to `ts/tls/sni/sni-extraction.ts` - [x] Extract ClientHello parsing to `ts/tls/sni/client-hello-parser.ts` -### Phase 5: HTTP Component Migration (Week 3) +### Phase 5: HTTP Component Migration (Week 3) ✅ -- [ ] Migrate Port80Handler - - [ ] Move `ts/port80handler/classes.port80handler.ts` → `ts/http/port80/port80-handler.ts` - - [ ] Extract ACME challenge handling to `ts/http/port80/challenge-responder.ts` +- [x] Migrate Port80Handler + - [x] Move `ts/port80handler/classes.port80handler.ts` → `ts/http/port80/port80-handler.ts` + - [x] Extract ACME challenge handling to `ts/http/port80/challenge-responder.ts` -- [ ] Migrate redirect handlers - - [ ] Move `ts/redirect/classes.redirect.ts` → `ts/http/redirects/redirect-handler.ts` - - [ ] Create `ts/http/redirects/ssl-redirect.ts` for specialized redirects +- [x] Migrate redirect handlers + - [x] Move `ts/redirect/classes.redirect.ts` → `ts/http/redirects/redirect-handler.ts` + - [x] Create `ts/http/redirects/ssl-redirect.ts` for specialized redirects -- [ ] Migrate router components - - [ ] Move `ts/classes.router.ts` → `ts/http/router/proxy-router.ts` - - [ ] Extract route matching to `ts/http/router/route-matcher.ts` +- [x] Migrate router components + - [x] Move `ts/classes.router.ts` → `ts/http/router/proxy-router.ts` + - [x] Extract route matching to `ts/http/router/route-matcher.ts` ### Phase 6: Proxy Implementation Migration (Weeks 3-4) @@ -259,9 +259,9 @@ This component has the cleanest design, so we'll start migration here: | (new) | ts/tls/sni/sni-extraction.ts | ✅ | | (new) | ts/tls/sni/client-hello-parser.ts | ✅ | | **HTTP Components** | | | -| ts/port80handler/classes.port80handler.ts | ts/http/port80/port80-handler.ts | ❌ | -| ts/redirect/classes.redirect.ts | ts/http/redirects/redirect-handler.ts | ❌ | -| ts/classes.router.ts | ts/http/router/proxy-router.ts | ❌ | +| ts/port80handler/classes.port80handler.ts | ts/http/port80/port80-handler.ts | ✅ | +| ts/redirect/classes.redirect.ts | ts/http/redirects/redirect-handler.ts | ✅ | +| ts/classes.router.ts | ts/http/router/proxy-router.ts | ✅ | | **SmartProxy Components** | | | | ts/smartproxy/classes.smartproxy.ts | ts/proxies/smart-proxy/smart-proxy.ts | ❌ | | ts/smartproxy/classes.pp.interfaces.ts | ts/proxies/smart-proxy/models/interfaces.ts | ❌ | diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 897d14e..0467ab8 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: '12.0.0', + version: '12.1.0', 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/certificate/events/certificate-events.ts b/ts/certificate/events/certificate-events.ts index 955e387..8269ac2 100644 --- a/ts/certificate/events/certificate-events.ts +++ b/ts/certificate/events/certificate-events.ts @@ -7,10 +7,14 @@ export enum CertificateEvents { CERTIFICATE_FAILED = 'certificate-failed', CERTIFICATE_EXPIRING = 'certificate-expiring', CERTIFICATE_APPLIED = 'certificate-applied', + // Events moved from Port80Handler for compatibility + MANAGER_STARTED = 'manager-started', + MANAGER_STOPPED = 'manager-stopped', } /** * Port80Handler-specific events including certificate-related ones + * @deprecated Use CertificateEvents and HttpEvents instead */ export enum Port80HandlerEvents { CERTIFICATE_ISSUED = 'certificate-issued', diff --git a/ts/http/index.ts b/ts/http/index.ts index 3cc4f9a..bc4d65f 100644 --- a/ts/http/index.ts +++ b/ts/http/index.ts @@ -2,7 +2,18 @@ * HTTP functionality module */ +// Export types and models +export * from './models/http-types.js'; + // Export submodules export * from './port80/index.js'; export * from './router/index.js'; export * from './redirects/index.js'; + +// Convenience namespace exports +export const Http = { + Port80: { + Handler: require('./port80/port80-handler.js').Port80Handler, + ChallengeResponder: require('./port80/challenge-responder.js').ChallengeResponder + } +}; diff --git a/ts/http/port80/challenge-responder.ts b/ts/http/port80/challenge-responder.ts index f7e640c..45d05bc 100644 --- a/ts/http/port80/challenge-responder.ts +++ b/ts/http/port80/challenge-responder.ts @@ -35,58 +35,92 @@ export class ChallengeResponder extends plugins.EventEmitter { */ public async initialize(): Promise { try { - // Initialize SmartAcme - this.smartAcme = new plugins.smartacme.SmartAcme({ - useProduction: this.useProduction, - accountEmail: this.email, - directoryUrl: this.useProduction - ? 'https://acme-v02.api.letsencrypt.org/directory' // Production - : 'https://acme-staging-v02.api.letsencrypt.org/directory', // Staging - }); - // Initialize HTTP-01 challenge handler this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler(); - this.smartAcme.useHttpChallenge(this.http01Handler); + + // Initialize SmartAcme with proper options + this.smartAcme = new plugins.smartacme.SmartAcme({ + accountEmail: this.email, + certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), + environment: this.useProduction ? 'production' : 'integration', + challengeHandlers: [this.http01Handler], + challengePriority: ['http-01'], + }); // Ensure certificate store directory exists await this.ensureCertificateStore(); - // Subscribe to SmartAcme events - this.smartAcme.on('certificate-issued', (data: any) => { - const certData: CertificateData = { - domain: data.domain, - certificate: data.cert, - privateKey: data.key, - expiryDate: new Date(data.expiryDate), - }; - this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData); - }); - - this.smartAcme.on('certificate-renewed', (data: any) => { - const certData: CertificateData = { - domain: data.domain, - certificate: data.cert, - privateKey: data.key, - expiryDate: new Date(data.expiryDate), - }; - this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData); - }); - - this.smartAcme.on('certificate-error', (data: any) => { - const error: CertificateFailure = { - domain: data.domain, - error: data.error instanceof Error ? data.error.message : String(data.error), - isRenewal: data.isRenewal || false, - }; - this.emit(CertificateEvents.CERTIFICATE_FAILED, error); - }); - - await this.smartAcme.initialize(); + // Set up event forwarding from SmartAcme + this.setupEventForwarding(); + + // Start SmartAcme + await this.smartAcme.start(); } catch (error) { throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`); } } + /** + * Sets up event forwarding from SmartAcme to this component + */ + private setupEventForwarding(): void { + if (!this.smartAcme) return; + + // Cast smartAcme to any since different versions have different event APIs + const smartAcmeAny = this.smartAcme as any; + + // Forward certificate events to our own emitter + if (typeof smartAcmeAny.on === 'function') { + smartAcmeAny.on('certificate', (data: any) => { + const certData: CertificateData = { + domain: data.domain, + certificate: data.cert || data.publicKey, + privateKey: data.key || data.privateKey, + expiryDate: new Date(data.expiryDate || data.validUntil), + source: 'http01' + }; + // Emit as issued or renewed based on the renewal flag + const eventType = data.isRenewal + ? CertificateEvents.CERTIFICATE_RENEWED + : CertificateEvents.CERTIFICATE_ISSUED; + this.emit(eventType, certData); + }); + + smartAcmeAny.on('error', (data: any) => { + const failure: CertificateFailure = { + domain: data.domain || 'unknown', + error: data.message || data.toString(), + isRenewal: false + }; + this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); + }); + } else if (smartAcmeAny.eventEmitter && typeof smartAcmeAny.eventEmitter.on === 'function') { + // Alternative event emitter approach for newer versions + smartAcmeAny.eventEmitter.on('certificate', (data: any) => { + const certData: CertificateData = { + domain: data.domain, + certificate: data.cert || data.publicKey, + privateKey: data.key || data.privateKey, + expiryDate: new Date(data.expiryDate || data.validUntil), + source: 'http01' + }; + const eventType = data.isRenewal + ? CertificateEvents.CERTIFICATE_RENEWED + : CertificateEvents.CERTIFICATE_ISSUED; + this.emit(eventType, certData); + }); + + smartAcmeAny.eventEmitter.on('error', (data: any) => { + const failure: CertificateFailure = { + domain: data.domain || 'unknown', + error: data.message || data.toString(), + isRenewal: false + }; + this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); + }); + } + } + /** * Ensure certificate store directory exists */ @@ -110,32 +144,84 @@ export class ChallengeResponder extends plugins.EventEmitter { } const url = req.url || '/'; - + // Check if this is an ACME challenge request if (url.startsWith('/.well-known/acme-challenge/')) { const token = url.split('/').pop() || ''; - - if (token) { - const response = this.http01Handler.getResponse(token); - - if (response) { - // This is a valid ACME challenge - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); - res.writeHead(200); - res.end(response); - return true; + + if (token && this.http01Handler) { + try { + // Try to delegate to the handler - casting to any for flexibility + const handler = this.http01Handler as any; + + // Different versions may have different handler methods + if (typeof handler.handleChallenge === 'function') { + handler.handleChallenge(req, res); + return true; + } else if (typeof handler.handleRequest === 'function') { + // Some versions use handleRequest instead + handler.handleRequest(req, res); + return true; + } else { + // Fall back to manual response + const resp = this.getTokenResponse(token); + if (resp) { + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); + res.writeHead(200); + res.end(resp); + return true; + } + } + } catch (err) { + // Challenge not found } } - + // Invalid ACME challenge res.writeHead(404); res.end('Not found'); return true; } - + return false; } + + /** + * Get the response for a specific token if available + * This is a fallback method in case direct handler access isn't available + */ + private getTokenResponse(token: string): string | null { + if (!this.http01Handler) return null; + + try { + // Cast to any to handle different versions of the API + const handler = this.http01Handler as any; + + // Try different methods that might be available in different versions + if (typeof handler.getResponse === 'function') { + return handler.getResponse(token); + } + + if (typeof handler.getChallengeVerification === 'function') { + return handler.getChallengeVerification(token); + } + + // Try to access the challenges directly from the handler's internal state + if (handler.challenges && typeof handler.challenges === 'object' && handler.challenges[token]) { + return handler.challenges[token]; + } + + // Try the token map if it exists (another common pattern) + if (handler.tokenMap && typeof handler.tokenMap === 'object' && handler.tokenMap[token]) { + return handler.tokenMap[token]; + } + } catch (err) { + console.error('Error getting token response:', err); + } + + return null; + } /** * Request a certificate for a domain @@ -148,16 +234,20 @@ export class ChallengeResponder extends plugins.EventEmitter { } try { - const result = await this.smartAcme.getCertificate(domain); + // Request certificate via SmartAcme + const certObj = await this.smartAcme.getCertificateForDomain(domain); const certData: CertificateData = { domain, - certificate: result.cert, - privateKey: result.key, - expiryDate: new Date(result.expiryDate), + certificate: certObj.publicKey, + privateKey: certObj.privateKey, + expiryDate: new Date(certObj.validUntil), + source: 'http01', + isRenewal }; - // Emit appropriate event + // SmartACME will emit its own events, but we'll emit our own too + // for consistency with the rest of the system if (isRenewal) { this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData); } else { diff --git a/ts/http/port80/index.ts b/ts/http/port80/index.ts index 52f0229..6c4c765 100644 --- a/ts/http/port80/index.ts +++ b/ts/http/port80/index.ts @@ -1,3 +1,13 @@ /** * Port 80 handling */ + +// Export the main components +export { Port80Handler } from './port80-handler.js'; +export { ChallengeResponder } from './challenge-responder.js'; + +// Export backward compatibility interfaces and types +export { + HttpError as Port80HandlerError, + CertificateError as CertError +} from '../models/http-types.js'; diff --git a/ts/http/port80/port80-handler.ts b/ts/http/port80/port80-handler.ts index 82681fd..ff6dc21 100644 --- a/ts/http/port80/port80-handler.ts +++ b/ts/http/port80/port80-handler.ts @@ -15,8 +15,8 @@ import { HttpError, CertificateError, ServerError, - DomainCertificate } from '../models/http-types.js'; +import type { DomainCertificate } from '../models/http-types.js'; import { ChallengeResponder } from './challenge-responder.js'; // Re-export for backward compatibility @@ -105,7 +105,7 @@ export class Port80Handler extends plugins.EventEmitter { if (this.server) { throw new ServerError('Server is already running'); } - + if (this.isShuttingDown) { throw new ServerError('Server is shutting down'); } @@ -115,24 +115,22 @@ export class Port80Handler extends plugins.EventEmitter { console.log('Port80Handler is disabled, skipping start'); return; } - // Initialize SmartAcme with in-memory HTTP-01 challenge handler - if (this.options.enabled) { - this.smartAcmeHttp01Handler = new plugins.smartacme.handlers.Http01MemoryHandler(); - this.smartAcme = new plugins.smartacme.SmartAcme({ - accountEmail: this.options.accountEmail, - certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), - environment: this.options.useProduction ? 'production' : 'integration', - challengeHandlers: [ this.smartAcmeHttp01Handler ], - challengePriority: ['http-01'], - }); - await this.smartAcme.start(); + + // 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) + }`); + } } 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)); @@ -142,11 +140,11 @@ export class Port80Handler extends plugins.EventEmitter { 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); - + this.emit(CertificateEvents.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 @@ -154,14 +152,14 @@ export class Port80Handler extends plugins.EventEmitter { 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) { @@ -172,22 +170,21 @@ export class Port80Handler extends plugins.EventEmitter { } /** - * Stops the HTTP server and renewal timer + * Stops the HTTP server and cleanup resources */ 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); + this.emit(CertificateEvents.MANAGER_STOPPED); resolve(); }); } else { @@ -201,27 +198,27 @@ export class Port80Handler extends plugins.EventEmitter { * Adds a domain with configuration options * @param options Domain configuration options */ - public addDomain(options: IDomainOptions): void { + public addDomain(options: DomainOptions): void { if (!options.domainName || typeof options.domainName !== 'string') { - throw new Port80HandlerError('Invalid domain name'); + throw new HttpError('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 => { @@ -250,18 +247,18 @@ export class Port80Handler extends plugins.EventEmitter { * Gets the certificate for a domain if it exists * @param domain The domain to get the certificate for */ - public getCertificate(domain: string): ICertificateData | null { + public getCertificate(domain: string): CertificateData | 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, @@ -286,7 +283,7 @@ export class Port80Handler extends plugins.EventEmitter { * @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 { + private getDomainInfoForRequest(requestDomain: string): { domainInfo: DomainCertificate, pattern: string } | null { // Try direct match first if (this.domainCertificates.has(requestDomain)) { return { @@ -294,14 +291,14 @@ export class Port80Handler extends plugins.EventEmitter { 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; } @@ -338,16 +335,45 @@ export class Port80Handler extends plugins.EventEmitter { * @param res The HTTP response */ private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { + // Emit request received event with basic info + this.emit(HttpEvents.REQUEST_RECEIVED, { + url: req.url, + method: req.method, + headers: req.headers + }); + const hostHeader = req.headers.host; if (!hostHeader) { - res.statusCode = 400; + res.statusCode = HttpStatus.BAD_REQUEST; res.end('Bad Request: Host header is missing'); return; } - + // Extract domain (ignoring any port in the Host header) const domain = hostHeader.split(':')[0]; + // 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; + } + } + } + // Dynamic provisioning: if domain not yet managed, register for ACME and return 503 if (!this.domainCertificates.has(domain)) { try { @@ -355,14 +381,15 @@ export class Port80Handler extends plugins.EventEmitter { } catch (err) { console.error(`Error registering domain for on-demand provisioning: ${err}`); } - res.statusCode = 503; + res.statusCode = HttpStatus.SERVICE_UNAVAILABLE; res.end('Certificate issuance in progress'); return; } + // Get domain config, using glob pattern matching if needed const domainMatch = this.getDomainInfoForRequest(domain); if (!domainMatch) { - res.statusCode = 404; + res.statusCode = HttpStatus.NOT_FOUND; res.end('Domain not configured'); return; } @@ -370,29 +397,6 @@ export class Port80Handler extends plugins.EventEmitter { 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; - } - // Delegate to Http01MemoryHandler - if (this.smartAcmeHttp01Handler) { - this.smartAcmeHttp01Handler.handleRequest(req, res); - } else { - res.statusCode = 500; - res.end('ACME HTTP-01 handler not initialized'); - } - return; - } - // Check if we should forward non-ACME requests if (options.forward) { this.forwardRequest(req, res, options.forward, 'HTTP'); @@ -405,13 +409,13 @@ export class Port80Handler extends plugins.EventEmitter { const httpsPort = this.options.httpsRedirectPort; const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; - - res.statusCode = 301; + + res.statusCode = HttpStatus.MOVED_PERMANENTLY; 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) { @@ -419,7 +423,7 @@ export class Port80Handler extends plugins.EventEmitter { if (!domainInfo.obtainingInProgress) { this.obtainCertificate(domain).catch(err => { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { + this.emit(CertificateEvents.CERTIFICATE_FAILED, { domain, error: errorMessage, isRenewal: false @@ -427,15 +431,22 @@ export class Port80Handler extends plugins.EventEmitter { console.error(`Error obtaining certificate for ${domain}:`, err); }); } - - res.statusCode = 503; + + res.statusCode = HttpStatus.SERVICE_UNAVAILABLE; res.end('Certificate issuance in progress, please try again later.'); return; } - + // Default response for unhandled request - res.statusCode = 404; + res.statusCode = HttpStatus.NOT_FOUND; res.end('No handlers configured for this request'); + + // Emit request handled event + this.emit(HttpEvents.REQUEST_HANDLED, { + domain, + url: req.url, + statusCode: res.statusCode + }); } /** @@ -446,9 +457,9 @@ export class Port80Handler extends plugins.EventEmitter { * @param requestType Type of request for logging */ private forwardRequest( - req: plugins.http.IncomingMessage, + req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, - target: IForwardConfig, + target: ForwardConfig, requestType: string ): void { const options = { @@ -458,40 +469,47 @@ export class Port80Handler extends plugins.EventEmitter { 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; - + res.statusCode = proxyRes.statusCode || HttpStatus.INTERNAL_SERVER_ERROR; + // 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, { + + this.emit(HttpEvents.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); + + this.emit(HttpEvents.REQUEST_ERROR, { + domain, + error: error.message, + target: `${target.ip}:${target.port}` + }); + if (!res.headersSent) { - res.statusCode = 502; + res.statusCode = HttpStatus.INTERNAL_SERVER_ERROR; res.end(`Proxy error: ${error.message}`); } else { res.end(); } }); - + // Pipe original request to proxy request if (req.readable) { req.pipe(proxyReq); @@ -506,59 +524,48 @@ export class Port80Handler extends plugins.EventEmitter { * @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'); + + if (!this.challengeResponder) { + throw new HttpError('Challenge responder 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; + // 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; domainInfo.certObtained = true; - domainInfo.expiryDate = expiryDate; + domainInfo.expiryDate = certData.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() - }); + + // The event will be emitted by the ChallengeResponder, we just store the certificate } catch (error: any) { - const errorMsg = error?.message || 'Unknown error'; + const errorMsg = error instanceof Error ? error.message : String(error); console.error(`Error during certificate issuance for ${domain}:`, error); - this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { - domain, - error: errorMsg, - isRenewal - } as ICertificateFailure); + + // The failure event will be emitted by the ChallengeResponder throw new CertificateError(errorMsg, domain, isRenewal); } finally { domainInfo.obtainingInProgress = false; @@ -608,7 +615,7 @@ export class Port80Handler extends plugins.EventEmitter { * @param eventType The event type to emit * @param data The certificate data */ - private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void { + private emitCertificateEvent(eventType: CertificateEvents, data: CertificateData): void { this.emit(eventType, data); } @@ -670,7 +677,7 @@ export class Port80Handler extends plugins.EventEmitter { */ public async renewCertificate(domain: string): Promise { if (!this.domainCertificates.has(domain)) { - throw new Port80HandlerError(`Domain not managed: ${domain}`); + throw new HttpError(`Domain not managed: ${domain}`); } // Trigger renewal via ACME await this.obtainCertificate(domain, true); diff --git a/ts/index.ts b/ts/index.ts index 9e2e0b0..ff812dd 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -5,7 +5,17 @@ // Legacy exports (to maintain backward compatibility) export * from './nfttablesproxy/classes.nftablesproxy.js'; export * from './networkproxy/index.js'; -export * from './port80handler/classes.port80handler.js'; +// Export port80handler elements selectively to avoid conflicts +export { + Port80Handler, + default as Port80HandlerDefault, + HttpError, + ServerError, + CertificateError +} from './port80handler/classes.port80handler.js'; +// Use re-export to control the names +export { Port80HandlerEvents } from './certificate/events/certificate-events.js'; + export * from './redirect/classes.redirect.js'; export * from './smartproxy/classes.smartproxy.js'; // Original: export * from './smartproxy/classes.pp.snihandler.js' @@ -19,4 +29,5 @@ export * from './core/models/common-types.js'; // Modular exports for new architecture export * as forwarding from './forwarding/index.js'; export * as certificate from './certificate/index.js'; -export * as tls from './tls/index.js'; \ No newline at end of file +export * as tls from './tls/index.js'; +export * as http from './http/index.js'; \ No newline at end of file diff --git a/ts/plugins.ts b/ts/plugins.ts index d9179d4..572d07f 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -1,5 +1,6 @@ // node native scope import { EventEmitter } from 'events'; +import * as fs from 'fs'; import * as http from 'http'; import * as https from 'https'; import * as net from 'net'; @@ -7,7 +8,7 @@ import * as tls from 'tls'; import * as url from 'url'; import * as http2 from 'http2'; -export { EventEmitter, http, https, net, tls, url, http2 }; +export { EventEmitter, fs, http, https, net, tls, url, http2 }; // tsclass scope import * as tsclass from '@tsclass/tsclass'; diff --git a/ts/port80handler/classes.port80handler.ts b/ts/port80handler/classes.port80handler.ts new file mode 100644 index 0000000..1d40e43 --- /dev/null +++ b/ts/port80handler/classes.port80handler.ts @@ -0,0 +1,24 @@ +/** + * TEMPORARY FILE FOR BACKWARD COMPATIBILITY + * This will be removed in a future version when all imports are updated + * @deprecated Use the new HTTP module instead + */ + +// Re-export the Port80Handler from its new location +export * from '../http/port80/port80-handler.js'; + +// Re-export HTTP error types for backward compatibility +export * from '../http/models/http-types.js'; + +// Re-export selected events to avoid name conflicts +export { + CertificateEvents, + Port80HandlerEvents, + CertProvisionerEvents +} from '../certificate/events/certificate-events.js'; + +// Import the new Port80Handler +import { Port80Handler } from '../http/port80/port80-handler.js'; + +// Export it as the default export for backward compatibility +export default Port80Handler; \ No newline at end of file