feat(AcmeCertManager): Introduce AcmeCertManager for enhanced ACME certificate management
This commit is contained in:
		| @@ -1,5 +1,13 @@ | |||||||
| # Changelog | # Changelog | ||||||
|  |  | ||||||
|  | ## 2025-03-06 - 3.27.0 - feat(AcmeCertManager) | ||||||
|  | Introduce AcmeCertManager for enhanced ACME certificate management | ||||||
|  |  | ||||||
|  | - Refactored the existing Port80Handler to AcmeCertManager. | ||||||
|  | - Added event-driven certificate management with CertManagerEvents. | ||||||
|  | - Introduced options for configuration such as renew thresholds and production mode. | ||||||
|  | - Implemented certificate renewal checks and logging improvements. | ||||||
|  |  | ||||||
| ## 2025-03-05 - 3.26.0 - feat(readme) | ## 2025-03-05 - 3.26.0 - feat(readme) | ||||||
| Updated README with enhanced TLS handling, connection management, and troubleshooting sections. | Updated README with enhanced TLS handling, connection management, and troubleshooting sections. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,6 @@ | |||||||
|  */ |  */ | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: '@push.rocks/smartproxy', |   name: '@push.rocks/smartproxy', | ||||||
|   version: '3.26.0', |   version: '3.27.0', | ||||||
|   description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.' |   description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.' | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import * as http from 'http'; | import * as plugins from './plugins.js'; | ||||||
| import * as acme from 'acme-client'; |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Represents a domain certificate with various status information | ||||||
|  |  */ | ||||||
| interface IDomainCertificate { | interface IDomainCertificate { | ||||||
|   certObtained: boolean; |   certObtained: boolean; | ||||||
|   obtainingInProgress: boolean; |   obtainingInProgress: boolean; | ||||||
| @@ -8,27 +10,147 @@ interface IDomainCertificate { | |||||||
|   privateKey?: string; |   privateKey?: string; | ||||||
|   challengeToken?: string; |   challengeToken?: string; | ||||||
|   challengeKeyAuthorization?: string; |   challengeKeyAuthorization?: string; | ||||||
|  |   expiryDate?: Date; | ||||||
|  |   lastRenewalAttempt?: Date; | ||||||
| } | } | ||||||
|  |  | ||||||
| export class Port80Handler { | /** | ||||||
|   private domainCertificates: Map<string, IDomainCertificate>; |  * Configuration options for the ACME Certificate Manager | ||||||
|   private server: http.Server; |  */ | ||||||
|   private acmeClient: acme.Client | null = null; | interface IAcmeCertManagerOptions { | ||||||
|   private accountKey: string | null = null; |   port?: number; | ||||||
|  |   contactEmail?: string; | ||||||
|  |   useProduction?: boolean; | ||||||
|  |   renewThresholdDays?: number; | ||||||
|  |   httpsRedirectPort?: number; | ||||||
|  |   renewCheckIntervalHours?: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|   constructor() { | /** | ||||||
|  |  * Certificate data that can be emitted via events or set from outside | ||||||
|  |  */ | ||||||
|  | interface ICertificateData { | ||||||
|  |   domain: string; | ||||||
|  |   certificate: string; | ||||||
|  |   privateKey: string; | ||||||
|  |   expiryDate: Date; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Events emitted by the ACME Certificate Manager | ||||||
|  |  */ | ||||||
|  | export enum CertManagerEvents { | ||||||
|  |   CERTIFICATE_ISSUED = 'certificate-issued', | ||||||
|  |   CERTIFICATE_RENEWED = 'certificate-renewed', | ||||||
|  |   CERTIFICATE_FAILED = 'certificate-failed', | ||||||
|  |   CERTIFICATE_EXPIRING = 'certificate-expiring', | ||||||
|  |   MANAGER_STARTED = 'manager-started', | ||||||
|  |   MANAGER_STOPPED = 'manager-stopped', | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Improved ACME Certificate Manager with event emission and external certificate management | ||||||
|  |  */ | ||||||
|  | export class AcmeCertManager extends plugins.EventEmitter { | ||||||
|  |   private domainCertificates: Map<string, IDomainCertificate>; | ||||||
|  |   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<IAcmeCertManagerOptions>; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Creates a new ACME Certificate Manager | ||||||
|  |    * @param options Configuration options | ||||||
|  |    */ | ||||||
|  |   constructor(options: IAcmeCertManagerOptions = {}) { | ||||||
|  |     super(); | ||||||
|     this.domainCertificates = new Map<string, IDomainCertificate>(); |     this.domainCertificates = new Map<string, IDomainCertificate>(); | ||||||
|      |      | ||||||
|     // Create and start an HTTP server on port 80. |     // Default options | ||||||
|     this.server = http.createServer((req, res) => this.handleRequest(req, res)); |     this.options = { | ||||||
|     this.server.listen(80, () => { |       port: options.port ?? 80, | ||||||
|       console.log('Port80Handler is listening on port 80'); |       contactEmail: options.contactEmail ?? 'admin@example.com', | ||||||
|  |       useProduction: options.useProduction ?? false, // Safer default: staging | ||||||
|  |       renewThresholdDays: options.renewThresholdDays ?? 30, | ||||||
|  |       httpsRedirectPort: options.httpsRedirectPort ?? 443, | ||||||
|  |       renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Starts the HTTP server for ACME challenges | ||||||
|  |    */ | ||||||
|  |   public async start(): Promise<void> { | ||||||
|  |     if (this.server) { | ||||||
|  |       throw new Error('Server is already running'); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     if (this.isShuttingDown) { | ||||||
|  |       throw new Error('Server is shutting down'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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 Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`)); | ||||||
|  |           } else if (error.code === 'EADDRINUSE') { | ||||||
|  |             reject(new Error(`Port ${this.options.port} is already in use.`)); | ||||||
|  |           } else { | ||||||
|  |             reject(error); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         this.server.listen(this.options.port, () => { | ||||||
|  |           console.log(`AcmeCertManager is listening on port ${this.options.port}`); | ||||||
|  |           this.startRenewalTimer(); | ||||||
|  |           this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port); | ||||||
|  |           resolve(); | ||||||
|  |         }); | ||||||
|  |       } catch (error) { | ||||||
|  |         reject(error); | ||||||
|  |       } | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Adds a domain to be managed. |    * Stops the HTTP server and renewal timer | ||||||
|    * @param domain The domain to add. |    */ | ||||||
|  |   public async stop(): Promise<void> { | ||||||
|  |     if (!this.server) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     this.isShuttingDown = true; | ||||||
|  |      | ||||||
|  |     // Stop the renewal timer | ||||||
|  |     if (this.renewalTimer) { | ||||||
|  |       clearInterval(this.renewalTimer); | ||||||
|  |       this.renewalTimer = null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return new Promise<void>((resolve) => { | ||||||
|  |       if (this.server) { | ||||||
|  |         this.server.close(() => { | ||||||
|  |           this.server = null; | ||||||
|  |           this.isShuttingDown = false; | ||||||
|  |           this.emit(CertManagerEvents.MANAGER_STOPPED); | ||||||
|  |           resolve(); | ||||||
|  |         }); | ||||||
|  |       } else { | ||||||
|  |         this.isShuttingDown = false; | ||||||
|  |         resolve(); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Adds a domain to be managed for certificates | ||||||
|  |    * @param domain The domain to add | ||||||
|    */ |    */ | ||||||
|   public addDomain(domain: string): void { |   public addDomain(domain: string): void { | ||||||
|     if (!this.domainCertificates.has(domain)) { |     if (!this.domainCertificates.has(domain)) { | ||||||
| @@ -38,8 +160,8 @@ export class Port80Handler { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Removes a domain from management. |    * Removes a domain from management | ||||||
|    * @param domain The domain to remove. |    * @param domain The domain to remove | ||||||
|    */ |    */ | ||||||
|   public removeDomain(domain: string): void { |   public removeDomain(domain: string): void { | ||||||
|     if (this.domainCertificates.delete(domain)) { |     if (this.domainCertificates.delete(domain)) { | ||||||
| @@ -48,45 +170,116 @@ export class Port80Handler { | |||||||
|   } |   } | ||||||
|    |    | ||||||
|   /** |   /** | ||||||
|    * Lazy initialization of the ACME client. |    * Sets a certificate for a domain directly (for externally obtained certificates) | ||||||
|    * Uses Let’s Encrypt’s production directory (for testing you might switch to staging). |    * @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 | ||||||
|    */ |    */ | ||||||
|   private async getAcmeClient(): Promise<acme.Client> { |   public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { | ||||||
|  |     let domainInfo = this.domainCertificates.get(domain); | ||||||
|  |      | ||||||
|  |     if (!domainInfo) { | ||||||
|  |       domainInfo = { 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 { | ||||||
|  |       // 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}`); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     console.log(`Certificate set for ${domain}`); | ||||||
|  |      | ||||||
|  |     // Emit certificate event | ||||||
|  |     this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, { | ||||||
|  |       domain, | ||||||
|  |       certificate, | ||||||
|  |       privateKey, | ||||||
|  |       expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /** | ||||||
|  |    * Gets the certificate for a domain if it exists | ||||||
|  |    * @param domain The domain to get the certificate for | ||||||
|  |    */ | ||||||
|  |   public getCertificate(domain: string): ICertificateData | 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 || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Lazy initialization of the ACME client | ||||||
|  |    * @returns An ACME client instance | ||||||
|  |    */ | ||||||
|  |   private async getAcmeClient(): Promise<plugins.acme.Client> { | ||||||
|     if (this.acmeClient) { |     if (this.acmeClient) { | ||||||
|       return this.acmeClient; |       return this.acmeClient; | ||||||
|     } |     } | ||||||
|     // Generate a new account key and convert Buffer to string. |      | ||||||
|     this.accountKey = (await acme.forge.createPrivateKey()).toString(); |     // Generate a new account key | ||||||
|     this.acmeClient = new acme.Client({ |     this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString(); | ||||||
|       directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate |      | ||||||
|       // For testing, you could use: |     this.acmeClient = new plugins.acme.Client({ | ||||||
|       // directoryUrl: acme.directory.letsencrypt.staging, |       directoryUrl: this.options.useProduction  | ||||||
|  |         ? plugins.acme.directory.letsencrypt.production  | ||||||
|  |         : plugins.acme.directory.letsencrypt.staging, | ||||||
|       accountKey: this.accountKey, |       accountKey: this.accountKey, | ||||||
|     }); |     }); | ||||||
|     // Create a new account. Make sure to update the contact email. |      | ||||||
|  |     // Create a new account | ||||||
|     await this.acmeClient.createAccount({ |     await this.acmeClient.createAccount({ | ||||||
|       termsOfServiceAgreed: true, |       termsOfServiceAgreed: true, | ||||||
|       contact: ['mailto:admin@example.com'], |       contact: [`mailto:${this.options.contactEmail}`], | ||||||
|     }); |     }); | ||||||
|  |      | ||||||
|     return this.acmeClient; |     return this.acmeClient; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Handles incoming HTTP requests on port 80. |    * Handles incoming HTTP requests | ||||||
|    * If the request is for an ACME challenge, it responds with the key authorization. |    * @param req The HTTP request | ||||||
|    * If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance. |    * @param res The HTTP response | ||||||
|    */ |    */ | ||||||
|   private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { |   private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { | ||||||
|     const hostHeader = req.headers.host; |     const hostHeader = req.headers.host; | ||||||
|     if (!hostHeader) { |     if (!hostHeader) { | ||||||
|       res.statusCode = 400; |       res.statusCode = 400; | ||||||
|       res.end('Bad Request: Host header is missing'); |       res.end('Bad Request: Host header is missing'); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |      | ||||||
|     // Extract domain (ignoring any port in the Host header) |     // Extract domain (ignoring any port in the Host header) | ||||||
|     const domain = hostHeader.split(':')[0]; |     const domain = hostHeader.split(':')[0]; | ||||||
|  |  | ||||||
|     // If the request is for an ACME HTTP-01 challenge, handle it. |     // If the request is for an ACME HTTP-01 challenge, handle it | ||||||
|     if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { |     if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { | ||||||
|       this.handleAcmeChallenge(req, res, domain); |       this.handleAcmeChallenge(req, res, domain); | ||||||
|       return; |       return; | ||||||
| @@ -100,38 +293,47 @@ export class Port80Handler { | |||||||
|  |  | ||||||
|     const domainInfo = this.domainCertificates.get(domain)!; |     const domainInfo = this.domainCertificates.get(domain)!; | ||||||
|  |  | ||||||
|     // If certificate exists, redirect to HTTPS on port 443. |     // If certificate exists, redirect to HTTPS | ||||||
|     if (domainInfo.certObtained) { |     if (domainInfo.certObtained) { | ||||||
|       const redirectUrl = `https://${domain}:443${req.url}`; |       const httpsPort = this.options.httpsRedirectPort; | ||||||
|  |       const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; | ||||||
|  |       const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; | ||||||
|  |        | ||||||
|       res.statusCode = 301; |       res.statusCode = 301; | ||||||
|       res.setHeader('Location', redirectUrl); |       res.setHeader('Location', redirectUrl); | ||||||
|       res.end(`Redirecting to ${redirectUrl}`); |       res.end(`Redirecting to ${redirectUrl}`); | ||||||
|     } else { |     } else { | ||||||
|       // Trigger certificate issuance if not already running. |       // Trigger certificate issuance if not already running | ||||||
|       if (!domainInfo.obtainingInProgress) { |       if (!domainInfo.obtainingInProgress) { | ||||||
|         domainInfo.obtainingInProgress = true; |  | ||||||
|         this.obtainCertificate(domain).catch(err => { |         this.obtainCertificate(domain).catch(err => { | ||||||
|  |           this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message }); | ||||||
|           console.error(`Error obtaining certificate for ${domain}:`, err); |           console.error(`Error obtaining certificate for ${domain}:`, err); | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
|  |        | ||||||
|       res.statusCode = 503; |       res.statusCode = 503; | ||||||
|       res.end('Certificate issuance in progress, please try again later.'); |       res.end('Certificate issuance in progress, please try again later.'); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Serves the ACME HTTP-01 challenge response. |    * Serves the ACME HTTP-01 challenge response | ||||||
|  |    * @param req The HTTP request | ||||||
|  |    * @param res The HTTP response | ||||||
|  |    * @param domain The domain for the challenge | ||||||
|    */ |    */ | ||||||
|   private handleAcmeChallenge(req: http.IncomingMessage, res: http.ServerResponse, domain: string): void { |   private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void { | ||||||
|     const domainInfo = this.domainCertificates.get(domain); |     const domainInfo = this.domainCertificates.get(domain); | ||||||
|     if (!domainInfo) { |     if (!domainInfo) { | ||||||
|       res.statusCode = 404; |       res.statusCode = 404; | ||||||
|       res.end('Domain not configured'); |       res.end('Domain not configured'); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     // The token is the last part of the URL. |      | ||||||
|  |     // The token is the last part of the URL | ||||||
|     const urlParts = req.url?.split('/'); |     const urlParts = req.url?.split('/'); | ||||||
|     const token = urlParts ? urlParts[urlParts.length - 1] : ''; |     const token = urlParts ? urlParts[urlParts.length - 1] : ''; | ||||||
|  |      | ||||||
|     if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) { |     if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) { | ||||||
|       res.statusCode = 200; |       res.statusCode = 200; | ||||||
|       res.setHeader('Content-Type', 'text/plain'); |       res.setHeader('Content-Type', 'text/plain'); | ||||||
| @@ -144,71 +346,214 @@ export class Port80Handler { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Uses acme-client to perform a full ACME HTTP-01 challenge to obtain a certificate. |    * Obtains a certificate for a domain using ACME HTTP-01 challenge | ||||||
|    * On success, it stores the certificate and key in memory and clears challenge data. |    * @param domain The domain to obtain a certificate for | ||||||
|  |    * @param isRenewal Whether this is a renewal attempt | ||||||
|    */ |    */ | ||||||
|   private async obtainCertificate(domain: string): Promise<void> { |   private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> { | ||||||
|  |     // Get the domain info | ||||||
|  |     const domainInfo = this.domainCertificates.get(domain); | ||||||
|  |     if (!domainInfo) { | ||||||
|  |       throw new Error(`Domain not found: ${domain}`); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 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 { |     try { | ||||||
|       const client = await this.getAcmeClient(); |       const client = await this.getAcmeClient(); | ||||||
|  |  | ||||||
|       // Create a new order for the domain. |       // Create a new order for the domain | ||||||
|       const order = await client.createOrder({ |       const order = await client.createOrder({ | ||||||
|         identifiers: [{ type: 'dns', value: domain }], |         identifiers: [{ type: 'dns', value: domain }], | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       // Get the authorizations for the order. |       // Get the authorizations for the order | ||||||
|       const authorizations = await client.getAuthorizations(order); |       const authorizations = await client.getAuthorizations(order); | ||||||
|  |        | ||||||
|       for (const authz of authorizations) { |       for (const authz of authorizations) { | ||||||
|         const challenge = authz.challenges.find(ch => ch.type === 'http-01'); |         const challenge = authz.challenges.find(ch => ch.type === 'http-01'); | ||||||
|         if (!challenge) { |         if (!challenge) { | ||||||
|           throw new Error('HTTP-01 challenge not found'); |           throw new Error('HTTP-01 challenge not found'); | ||||||
|         } |         } | ||||||
|         // Get the key authorization for the challenge. |          | ||||||
|  |         // Get the key authorization for the challenge | ||||||
|         const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); |         const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); | ||||||
|         const domainInfo = this.domainCertificates.get(domain)!; |          | ||||||
|  |         // Store the challenge data | ||||||
|         domainInfo.challengeToken = challenge.token; |         domainInfo.challengeToken = challenge.token; | ||||||
|         domainInfo.challengeKeyAuthorization = keyAuthorization; |         domainInfo.challengeKeyAuthorization = keyAuthorization; | ||||||
|  |  | ||||||
|         // Notify the ACME server that the challenge is ready. |         // ACME client type definition workaround - use compatible approach | ||||||
|         // The acme-client examples show that verifyChallenge takes three arguments: |         // First check if challenge verification is needed | ||||||
|         // (authorization, challenge, keyAuthorization). However, the official TypeScript |         const authzUrl = authz.url; | ||||||
|         // types appear to be out-of-sync. As a workaround, we cast client to 'any'. |  | ||||||
|         await (client as any).verifyChallenge(authz, challenge, keyAuthorization); |  | ||||||
|          |          | ||||||
|         await client.completeChallenge(challenge); |         try { | ||||||
|         // Wait until the challenge is validated. |           // Check if authzUrl exists and perform verification | ||||||
|         await client.waitForValidStatus(challenge); |           if (authzUrl) { | ||||||
|         console.log(`HTTP-01 challenge completed for ${domain}`); |             await client.verifyChallenge(authz, challenge); | ||||||
|           } |           } | ||||||
|            |            | ||||||
|       // Generate a CSR and a new private key for the domain. |           // Complete the challenge | ||||||
|       // Convert the resulting Buffers to strings. |           await client.completeChallenge(challenge); | ||||||
|       const [csrBuffer, privateKeyBuffer] = await acme.forge.createCsr({ |            | ||||||
|  |           // 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; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Generate a CSR and private key | ||||||
|  |       const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({ | ||||||
|         commonName: domain, |         commonName: domain, | ||||||
|       }); |       }); | ||||||
|  |        | ||||||
|       const csr = csrBuffer.toString(); |       const csr = csrBuffer.toString(); | ||||||
|       const privateKey = privateKeyBuffer.toString(); |       const privateKey = privateKeyBuffer.toString(); | ||||||
|  |  | ||||||
|       // Finalize the order and obtain the certificate. |       // Finalize the order with our CSR | ||||||
|       await client.finalizeOrder(order, csr); |       await client.finalizeOrder(order, csr); | ||||||
|  |        | ||||||
|  |       // Get the certificate with the full chain | ||||||
|       const certificate = await client.getCertificate(order); |       const certificate = await client.getCertificate(order); | ||||||
|  |  | ||||||
|       const domainInfo = this.domainCertificates.get(domain)!; |       // Store the certificate and key | ||||||
|       domainInfo.certificate = certificate; |       domainInfo.certificate = certificate; | ||||||
|       domainInfo.privateKey = privateKey; |       domainInfo.privateKey = privateKey; | ||||||
|       domainInfo.certObtained = true; |       domainInfo.certObtained = true; | ||||||
|       domainInfo.obtainingInProgress = false; |        | ||||||
|  |       // Clear challenge data | ||||||
|       delete domainInfo.challengeToken; |       delete domainInfo.challengeToken; | ||||||
|       delete domainInfo.challengeKeyAuthorization; |       delete domainInfo.challengeKeyAuthorization; | ||||||
|        |        | ||||||
|       console.log(`Certificate obtained for ${domain}`); |       // Extract expiry date from certificate | ||||||
|       // In a production system, persist the certificate and key and reload your TLS server. |       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) { |       } catch (error) { | ||||||
|  |         console.warn(`Failed to extract expiry date from certificate for ${domain}`); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); | ||||||
|  |        | ||||||
|  |       // Emit the appropriate event | ||||||
|  |       const eventType = isRenewal  | ||||||
|  |         ? CertManagerEvents.CERTIFICATE_RENEWED  | ||||||
|  |         : CertManagerEvents.CERTIFICATE_ISSUED; | ||||||
|  |        | ||||||
|  |       this.emitCertificateEvent(eventType, { | ||||||
|  |         domain, | ||||||
|  |         certificate, | ||||||
|  |         privateKey, | ||||||
|  |         expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default | ||||||
|  |       }); | ||||||
|  |        | ||||||
|  |     } catch (error: any) { | ||||||
|  |       // 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); |         console.error(`Error during certificate issuance for ${domain}:`, error); | ||||||
|       const domainInfo = this.domainCertificates.get(domain); |       } | ||||||
|       if (domainInfo) { |        | ||||||
|  |       // Emit failure event | ||||||
|  |       this.emit(CertManagerEvents.CERTIFICATE_FAILED, { | ||||||
|  |         domain, | ||||||
|  |         error: error.message || 'Unknown error', | ||||||
|  |         isRenewal | ||||||
|  |       }); | ||||||
|  |     } finally { | ||||||
|  |       // Reset flag whether successful or not | ||||||
|       domainInfo.obtainingInProgress = false; |       domainInfo.obtainingInProgress = false; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Starts the certificate renewal timer | ||||||
|  |    */ | ||||||
|  |   private startRenewalTimer(): void { | ||||||
|  |     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 | ||||||
|  |    */ | ||||||
|  |   private checkForRenewals(): void { | ||||||
|  |     if (this.isShuttingDown) { | ||||||
|  |       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 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...`); | ||||||
|  |         this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, { | ||||||
|  |           domain, | ||||||
|  |           expiryDate: domainInfo.expiryDate, | ||||||
|  |           daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)) | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         // Start renewal process | ||||||
|  |         this.obtainCertificate(domain, true).catch(err => { | ||||||
|  |           console.error(`Error renewing certificate for ${domain}:`, err); | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /** | ||||||
|  |    * 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 { | ||||||
|  |     this.emit(eventType, data); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -1,33 +1,359 @@ | |||||||
| import * as plugins from './plugins.js'; | import * as plugins from './plugins.js'; | ||||||
|  |  | ||||||
| export class ProxyRouter { |  | ||||||
|   public reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = []; |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|    * sets a new set of reverse configs to be routed to |  * Optional path pattern configuration that can be added to proxy configs | ||||||
|    * @param reverseCandidatesArg |  | ||||||
|  */ |  */ | ||||||
|   public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]) { | export interface IPathPatternConfig { | ||||||
|     this.reverseProxyConfigs = reverseCandidatesArg; |   pathPattern?: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|    * routes a request |  * Interface for router result with additional metadata | ||||||
|  |  */ | ||||||
|  | export interface IRouterResult { | ||||||
|  |   config: plugins.tsclass.network.IReverseProxyConfig; | ||||||
|  |   pathMatch?: string; | ||||||
|  |   pathParams?: Record<string, string>; | ||||||
|  |   pathRemainder?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export class ProxyRouter { | ||||||
|  |   // Using a Map for O(1) hostname lookups instead of array search | ||||||
|  |   private hostMap: Map<string, plugins.tsclass.network.IReverseProxyConfig[]> = new Map(); | ||||||
|  |   // Store original configs for reference | ||||||
|  |   private reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = []; | ||||||
|  |   // Default config to use when no match is found (optional) | ||||||
|  |   private defaultConfig?: plugins.tsclass.network.IReverseProxyConfig; | ||||||
|  |   // Store path patterns separately since they're not in the original interface | ||||||
|  |   private pathPatterns: Map<plugins.tsclass.network.IReverseProxyConfig, string> = new Map(); | ||||||
|  |  | ||||||
|  |   constructor( | ||||||
|  |     configs?: plugins.tsclass.network.IReverseProxyConfig[], | ||||||
|  |     private readonly logger: {  | ||||||
|  |       error: (message: string, data?: any) => void; | ||||||
|  |       warn: (message: string, data?: any) => void; | ||||||
|  |       info: (message: string, data?: any) => void; | ||||||
|  |       debug: (message: string, data?: any) => void; | ||||||
|  |     } = console | ||||||
|  |   ) { | ||||||
|  |     if (configs) { | ||||||
|  |       this.setNewProxyConfigs(configs); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Sets a new set of reverse configs to be routed to | ||||||
|  |    * @param reverseCandidatesArg Array of reverse proxy configurations | ||||||
|  |    */ | ||||||
|  |   public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]): void { | ||||||
|  |     this.reverseProxyConfigs = [...reverseCandidatesArg]; | ||||||
|  |      | ||||||
|  |     // Reset the host map and path patterns | ||||||
|  |     this.hostMap.clear(); | ||||||
|  |     this.pathPatterns.clear(); | ||||||
|  |      | ||||||
|  |     // Find default config if any (config with "*" as hostname) | ||||||
|  |     this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*'); | ||||||
|  |      | ||||||
|  |     // Group configs by hostname for faster lookups | ||||||
|  |     for (const config of this.reverseProxyConfigs) { | ||||||
|  |       // Skip the default config as it's stored separately | ||||||
|  |       if (config.hostName === '*') continue; | ||||||
|  |        | ||||||
|  |       const hostname = config.hostName.toLowerCase(); // Case-insensitive hostname lookup | ||||||
|  |        | ||||||
|  |       if (!this.hostMap.has(hostname)) { | ||||||
|  |         this.hostMap.set(hostname, []); | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       // Check for path pattern in extended properties  | ||||||
|  |       // (using any to access custom properties not in the interface) | ||||||
|  |       const extendedConfig = config as any; | ||||||
|  |       if (extendedConfig.pathPattern) { | ||||||
|  |         this.pathPatterns.set(config, extendedConfig.pathPattern); | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       // Add to the list of configs for this hostname | ||||||
|  |       this.hostMap.get(hostname).push(config); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Sort configs for each hostname by specificity | ||||||
|  |     // More specific path patterns should be checked first | ||||||
|  |     for (const [hostname, configs] of this.hostMap.entries()) { | ||||||
|  |       if (configs.length > 1) { | ||||||
|  |         // Sort by pathPattern - most specific first  | ||||||
|  |         // (null comes last, exact paths before patterns with wildcards) | ||||||
|  |         configs.sort((a, b) => { | ||||||
|  |           const aPattern = this.pathPatterns.get(a); | ||||||
|  |           const bPattern = this.pathPatterns.get(b); | ||||||
|  |            | ||||||
|  |           // If one has a path and the other doesn't, the one with a path comes first | ||||||
|  |           if (!aPattern && bPattern) return 1; | ||||||
|  |           if (aPattern && !bPattern) return -1; | ||||||
|  |           if (!aPattern && !bPattern) return 0; | ||||||
|  |            | ||||||
|  |           // Both have path patterns - more specific (longer) first | ||||||
|  |           // This is a simple heuristic; we could use a more sophisticated approach | ||||||
|  |           return bPattern.length - aPattern.length; | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.hostMap.size} unique hosts)`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Routes a request based on hostname and path | ||||||
|  |    * @param req The incoming HTTP request | ||||||
|  |    * @returns The matching proxy config or undefined if no match found | ||||||
|    */ |    */ | ||||||
|   public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig { |   public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig { | ||||||
|  |     const result = this.routeReqWithDetails(req); | ||||||
|  |     return result ? result.config : undefined; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /** | ||||||
|  |    * Routes a request with detailed matching information | ||||||
|  |    * @param req The incoming HTTP request | ||||||
|  |    * @returns Detailed routing result including matched config and path information | ||||||
|  |    */ | ||||||
|  |   public routeReqWithDetails(req: plugins.http.IncomingMessage): IRouterResult | undefined { | ||||||
|  |     // Extract and validate host header | ||||||
|     const originalHost = req.headers.host; |     const originalHost = req.headers.host; | ||||||
|     if (!originalHost) { |     if (!originalHost) { | ||||||
|       console.error('No host header found in request'); |       this.logger.error('No host header found in request'); | ||||||
|  |       return this.defaultConfig ? { config: this.defaultConfig } : undefined; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Parse URL for path matching | ||||||
|  |     const urlPath = new URL( | ||||||
|  |       req.url || '/',  | ||||||
|  |       `http://${originalHost}` | ||||||
|  |     ).pathname; | ||||||
|  |      | ||||||
|  |     // Extract hostname without port | ||||||
|  |     const hostWithoutPort = originalHost.split(':')[0].toLowerCase(); | ||||||
|  |      | ||||||
|  |     // Find configs for this hostname | ||||||
|  |     const configs = this.hostMap.get(hostWithoutPort); | ||||||
|  |      | ||||||
|  |     if (configs && configs.length > 0) { | ||||||
|  |       // Check each config for path matching | ||||||
|  |       for (const config of configs) { | ||||||
|  |         // Get the path pattern if any | ||||||
|  |         const pathPattern = this.pathPatterns.get(config); | ||||||
|  |          | ||||||
|  |         // If no path pattern specified, this config matches all paths | ||||||
|  |         if (!pathPattern) { | ||||||
|  |           return { config }; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Check if path matches the pattern | ||||||
|  |         const pathMatch = this.matchPath(urlPath, pathPattern); | ||||||
|  |         if (pathMatch) { | ||||||
|  |           return { | ||||||
|  |             config, | ||||||
|  |             pathMatch: pathMatch.matched, | ||||||
|  |             pathParams: pathMatch.params, | ||||||
|  |             pathRemainder: pathMatch.remainder | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Try wildcard subdomains if no direct match found | ||||||
|  |     // For example, if request is for sub.example.com, try *.example.com | ||||||
|  |     const domainParts = hostWithoutPort.split('.'); | ||||||
|  |     if (domainParts.length > 2) { | ||||||
|  |       const wildcardDomain = `*.${domainParts.slice(1).join('.')}`; | ||||||
|  |       const wildcardConfigs = this.hostMap.get(wildcardDomain); | ||||||
|  |        | ||||||
|  |       if (wildcardConfigs && wildcardConfigs.length > 0) { | ||||||
|  |         // Use the first matching wildcard config | ||||||
|  |         // Could add path matching logic here as well | ||||||
|  |         return { config: wildcardConfigs[0] }; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Fall back to default config if available | ||||||
|  |     if (this.defaultConfig) { | ||||||
|  |       this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`); | ||||||
|  |       return { config: this.defaultConfig }; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     this.logger.error(`No config found for host: ${hostWithoutPort}`); | ||||||
|     return undefined; |     return undefined; | ||||||
|   } |   } | ||||||
|     // Strip port from host if present |    | ||||||
|     const hostWithoutPort = originalHost.split(':')[0]; |   /** | ||||||
|     const correspodingReverseProxyConfig = this.reverseProxyConfigs.find((reverseConfig) => { |    * Sets a path pattern for an existing config | ||||||
|       return reverseConfig.hostName === hostWithoutPort; |    * @param config The existing configuration | ||||||
|     }); |    * @param pathPattern The path pattern to set | ||||||
|     if (!correspodingReverseProxyConfig) { |    * @returns Boolean indicating if the config was found and updated | ||||||
|       console.error(`No config found for host: ${hostWithoutPort}`); |    */ | ||||||
|  |   public setPathPattern( | ||||||
|  |     config: plugins.tsclass.network.IReverseProxyConfig,  | ||||||
|  |     pathPattern: string | ||||||
|  |   ): boolean { | ||||||
|  |     const exists = this.reverseProxyConfigs.includes(config); | ||||||
|  |     if (exists) { | ||||||
|  |       this.pathPatterns.set(config, pathPattern); | ||||||
|  |       return true; | ||||||
|     } |     } | ||||||
|     return correspodingReverseProxyConfig; |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Matches a URL path against a pattern | ||||||
|  |    * Supports: | ||||||
|  |    * - Exact matches: /users/profile | ||||||
|  |    * - Wildcards: /api/* (matches any path starting with /api/) | ||||||
|  |    * - Path parameters: /users/:id (captures id as a parameter) | ||||||
|  |    *  | ||||||
|  |    * @param path The URL path to match | ||||||
|  |    * @param pattern The pattern to match against | ||||||
|  |    * @returns Match result with params and remainder, or null if no match | ||||||
|  |    */ | ||||||
|  |   private matchPath(path: string, pattern: string): {  | ||||||
|  |     matched: string;  | ||||||
|  |     params: Record<string, string>;  | ||||||
|  |     remainder: string; | ||||||
|  |   } | null { | ||||||
|  |     // Handle exact match | ||||||
|  |     if (path === pattern) { | ||||||
|  |       return { | ||||||
|  |         matched: pattern, | ||||||
|  |         params: {}, | ||||||
|  |         remainder: '' | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Handle wildcard match | ||||||
|  |     if (pattern.endsWith('/*')) { | ||||||
|  |       const prefix = pattern.slice(0, -2); | ||||||
|  |       if (path === prefix || path.startsWith(`${prefix}/`)) { | ||||||
|  |         return { | ||||||
|  |           matched: prefix, | ||||||
|  |           params: {}, | ||||||
|  |           remainder: path.slice(prefix.length) | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Handle path parameters | ||||||
|  |     const patternParts = pattern.split('/'); | ||||||
|  |     const pathParts = path.split('/'); | ||||||
|  |      | ||||||
|  |     // Check if paths are compatible length | ||||||
|  |     if ( | ||||||
|  |       // If pattern doesn't end with wildcard, paths must have the same number of parts | ||||||
|  |       (!pattern.endsWith('/*') && patternParts.length !== pathParts.length) || | ||||||
|  |       // If pattern ends with wildcard, path must have at least as many parts as the pattern | ||||||
|  |       (pattern.endsWith('/*') && pathParts.length < patternParts.length - 1) | ||||||
|  |     ) { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     const params: Record<string, string> = {}; | ||||||
|  |     const matchedParts: string[] = []; | ||||||
|  |      | ||||||
|  |     // Compare path parts | ||||||
|  |     for (let i = 0; i < patternParts.length; i++) { | ||||||
|  |       const patternPart = patternParts[i]; | ||||||
|  |        | ||||||
|  |       // Handle wildcard at the end | ||||||
|  |       if (patternPart === '*' && i === patternParts.length - 1) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       // If pathParts[i] doesn't exist, we've reached the end of the path | ||||||
|  |       if (i >= pathParts.length) { | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       const pathPart = pathParts[i]; | ||||||
|  |        | ||||||
|  |       // Handle parameter | ||||||
|  |       if (patternPart.startsWith(':')) { | ||||||
|  |         const paramName = patternPart.slice(1); | ||||||
|  |         params[paramName] = pathPart; | ||||||
|  |         matchedParts.push(pathPart); | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       // Handle exact match for this part | ||||||
|  |       if (patternPart !== pathPart) { | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       matchedParts.push(pathPart); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Calculate the remainder | ||||||
|  |     let remainder = ''; | ||||||
|  |     if (pattern.endsWith('/*')) { | ||||||
|  |       remainder = '/' + pathParts.slice(patternParts.length - 1).join('/'); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return { | ||||||
|  |       matched: matchedParts.join('/'), | ||||||
|  |       params, | ||||||
|  |       remainder | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /** | ||||||
|  |    * Gets all currently active proxy configurations | ||||||
|  |    * @returns Array of all active configurations | ||||||
|  |    */ | ||||||
|  |   public getProxyConfigs(): plugins.tsclass.network.IReverseProxyConfig[] { | ||||||
|  |     return [...this.reverseProxyConfigs]; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /** | ||||||
|  |    * Gets all hostnames that this router is configured to handle | ||||||
|  |    * @returns Array of hostnames | ||||||
|  |    */ | ||||||
|  |   public getHostnames(): string[] { | ||||||
|  |     return Array.from(this.hostMap.keys()); | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /** | ||||||
|  |    * Adds a single new proxy configuration | ||||||
|  |    * @param config The configuration to add | ||||||
|  |    * @param pathPattern Optional path pattern for route matching | ||||||
|  |    */ | ||||||
|  |   public addProxyConfig( | ||||||
|  |     config: plugins.tsclass.network.IReverseProxyConfig,  | ||||||
|  |     pathPattern?: string | ||||||
|  |   ): void { | ||||||
|  |     this.reverseProxyConfigs.push(config); | ||||||
|  |      | ||||||
|  |     // Store path pattern if provided | ||||||
|  |     if (pathPattern) { | ||||||
|  |       this.pathPatterns.set(config, pathPattern); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     this.setNewProxyConfigs(this.reverseProxyConfigs); | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /** | ||||||
|  |    * Removes a proxy configuration by hostname | ||||||
|  |    * @param hostname The hostname to remove | ||||||
|  |    * @returns Boolean indicating whether any configs were removed | ||||||
|  |    */ | ||||||
|  |   public removeProxyConfig(hostname: string): boolean { | ||||||
|  |     const initialCount = this.reverseProxyConfigs.length; | ||||||
|  |     this.reverseProxyConfigs = this.reverseProxyConfigs.filter( | ||||||
|  |       config => config.hostName !== hostname | ||||||
|  |     ); | ||||||
|  |      | ||||||
|  |     if (initialCount !== this.reverseProxyConfigs.length) { | ||||||
|  |       this.setNewProxyConfigs(this.reverseProxyConfigs); | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return false; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -1,11 +1,13 @@ | |||||||
| // node native scope | // node native scope | ||||||
|  | import { EventEmitter } from 'events'; | ||||||
| import * as http from 'http'; | import * as http from 'http'; | ||||||
| import * as https from 'https'; | import * as https from 'https'; | ||||||
| import * as net from 'net'; | import * as net from 'net'; | ||||||
| import * as tls from 'tls'; | import * as tls from 'tls'; | ||||||
| import * as url from 'url'; | import * as url from 'url'; | ||||||
|  |  | ||||||
| export { http, https, net, tls, url }; |  | ||||||
|  | export { EventEmitter, http, https, net, tls, url }; | ||||||
|  |  | ||||||
| // tsclass scope | // tsclass scope | ||||||
| import * as tsclass from '@tsclass/tsclass'; | import * as tsclass from '@tsclass/tsclass'; | ||||||
| @@ -22,9 +24,10 @@ import * as smartstring from '@push.rocks/smartstring'; | |||||||
| export { lik, smartdelay, smartrequest, smartpromise, smartstring }; | export { lik, smartdelay, smartrequest, smartpromise, smartstring }; | ||||||
|  |  | ||||||
| // third party scope | // third party scope | ||||||
|  | import * as acme from 'acme-client'; | ||||||
| import prettyMs from 'pretty-ms'; | import prettyMs from 'pretty-ms'; | ||||||
| import * as ws from 'ws'; | import * as ws from 'ws'; | ||||||
| import wsDefault from 'ws'; | import wsDefault from 'ws'; | ||||||
| import { minimatch } from 'minimatch'; | import { minimatch } from 'minimatch'; | ||||||
|  |  | ||||||
| export { prettyMs, ws, wsDefault, minimatch }; | export { acme, prettyMs, ws, wsDefault, minimatch }; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user