update structure
This commit is contained in:
		| @@ -1,7 +1,9 @@ | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import type { IAcmeOptions } from './types.js'; | ||||
| import { Port80Handler } from '../port80handler/classes.port80handler.js'; | ||||
| import type { AcmeOptions } from '../models/certificate-types.js'; | ||||
| import { ensureCertificateDirectory } from '../utils/certificate-helpers.js'; | ||||
| // We'll need to update this import when we move the Port80Handler | ||||
| import { Port80Handler } from '../../port80handler/classes.port80handler.js'; | ||||
|  | ||||
| /** | ||||
|  * Factory to create a Port80Handler with common setup. | ||||
| @@ -10,14 +12,37 @@ import { Port80Handler } from '../port80handler/classes.port80handler.js'; | ||||
|  * @returns A new Port80Handler instance | ||||
|  */ | ||||
| export function buildPort80Handler( | ||||
|   options: IAcmeOptions | ||||
|   options: AcmeOptions | ||||
| ): Port80Handler { | ||||
|   if (options.certificateStore) { | ||||
|     const certStorePath = path.resolve(options.certificateStore); | ||||
|     if (!fs.existsSync(certStorePath)) { | ||||
|       fs.mkdirSync(certStorePath, { recursive: true }); | ||||
|       console.log(`Created certificate store directory: ${certStorePath}`); | ||||
|     } | ||||
|     ensureCertificateDirectory(options.certificateStore); | ||||
|     console.log(`Ensured certificate store directory: ${options.certificateStore}`); | ||||
|   } | ||||
|   return new Port80Handler(options); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Creates default ACME options with sensible defaults | ||||
|  * @param email Account email for ACME provider | ||||
|  * @param certificateStore Path to store certificates | ||||
|  * @param useProduction Whether to use production ACME servers | ||||
|  * @returns Configured ACME options | ||||
|  */ | ||||
| export function createDefaultAcmeOptions( | ||||
|   email: string, | ||||
|   certificateStore: string, | ||||
|   useProduction: boolean = false | ||||
| ): AcmeOptions { | ||||
|   return { | ||||
|     accountEmail: email, | ||||
|     enabled: true, | ||||
|     port: 80, | ||||
|     useProduction, | ||||
|     httpsRedirectPort: 443, | ||||
|     renewThresholdDays: 30, | ||||
|     renewCheckIntervalHours: 24, | ||||
|     autoRenew: true, | ||||
|     certificateStore, | ||||
|     skipConfiguredCerts: false | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										110
									
								
								ts/certificate/acme/challenge-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								ts/certificate/acme/challenge-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { AcmeOptions, CertificateData } from '../models/certificate-types.js'; | ||||
| import { CertificateEvents } from '../events/certificate-events.js'; | ||||
|  | ||||
| /** | ||||
|  * Manages ACME challenges and certificate validation | ||||
|  */ | ||||
| export class AcmeChallengeHandler extends plugins.EventEmitter { | ||||
|   private options: AcmeOptions; | ||||
|   private client: any; // ACME client from plugins | ||||
|   private pendingChallenges: Map<string, any>; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new ACME challenge handler | ||||
|    * @param options ACME configuration options | ||||
|    */ | ||||
|   constructor(options: AcmeOptions) { | ||||
|     super(); | ||||
|     this.options = options; | ||||
|     this.pendingChallenges = new Map(); | ||||
|  | ||||
|     // Initialize ACME client if needed | ||||
|     // This is just a placeholder implementation since we don't use the actual | ||||
|     // client directly in this implementation - it's handled by Port80Handler | ||||
|     this.client = null; | ||||
|     console.log('Created challenge handler with options:', | ||||
|       options.accountEmail, | ||||
|       options.useProduction ? 'production' : 'staging' | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets or creates the ACME account key | ||||
|    */ | ||||
|   private getAccountKey(): Buffer { | ||||
|     // Implementation details would depend on plugin requirements | ||||
|     // This is a simplified version | ||||
|     if (!this.options.certificateStore) { | ||||
|       throw new Error('Certificate store is required for ACME challenges'); | ||||
|     } | ||||
|      | ||||
|     // This is just a placeholder - actual implementation would check for | ||||
|     // existing account key and create one if needed | ||||
|     return Buffer.from('account-key-placeholder'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates a domain using HTTP-01 challenge | ||||
|    * @param domain Domain to validate | ||||
|    * @param challengeToken ACME challenge token | ||||
|    * @param keyAuthorization Key authorization for the challenge | ||||
|    */ | ||||
|   public async handleHttpChallenge( | ||||
|     domain: string, | ||||
|     challengeToken: string, | ||||
|     keyAuthorization: string | ||||
|   ): Promise<void> { | ||||
|     // Store challenge for response | ||||
|     this.pendingChallenges.set(challengeToken, keyAuthorization); | ||||
|      | ||||
|     try { | ||||
|       // Wait for challenge validation - this would normally be handled by the ACME client | ||||
|       await new Promise(resolve => setTimeout(resolve, 1000)); | ||||
|       this.emit(CertificateEvents.CERTIFICATE_ISSUED, { | ||||
|         domain, | ||||
|         success: true | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       this.emit(CertificateEvents.CERTIFICATE_FAILED, { | ||||
|         domain, | ||||
|         error: error instanceof Error ? error.message : String(error), | ||||
|         isRenewal: false | ||||
|       }); | ||||
|       throw error; | ||||
|     } finally { | ||||
|       // Clean up the challenge | ||||
|       this.pendingChallenges.delete(challengeToken); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Responds to an HTTP-01 challenge request | ||||
|    * @param token Challenge token from the request path | ||||
|    * @returns The key authorization if found | ||||
|    */ | ||||
|   public getChallengeResponse(token: string): string | null { | ||||
|     return this.pendingChallenges.get(token) || null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Checks if a request path is an ACME challenge | ||||
|    * @param path Request path | ||||
|    * @returns True if this is an ACME challenge request | ||||
|    */ | ||||
|   public isAcmeChallenge(path: string): boolean { | ||||
|     return path.startsWith('/.well-known/acme-challenge/'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extracts the challenge token from an ACME challenge path | ||||
|    * @param path Request path | ||||
|    * @returns The challenge token if valid | ||||
|    */ | ||||
|   public extractChallengeToken(path: string): string | null { | ||||
|     if (!this.isAcmeChallenge(path)) return null; | ||||
|      | ||||
|     const parts = path.split('/'); | ||||
|     return parts[parts.length - 1] || null; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										3
									
								
								ts/certificate/acme/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/certificate/acme/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * ACME certificate provisioning | ||||
|  */ | ||||
							
								
								
									
										32
									
								
								ts/certificate/events/certificate-events.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								ts/certificate/events/certificate-events.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| /** | ||||
|  * Certificate-related events emitted by certificate management components | ||||
|  */ | ||||
| export enum CertificateEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate-issued', | ||||
|   CERTIFICATE_RENEWED = 'certificate-renewed', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed', | ||||
|   CERTIFICATE_EXPIRING = 'certificate-expiring', | ||||
|   CERTIFICATE_APPLIED = 'certificate-applied', | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Port80Handler-specific events including certificate-related ones | ||||
|  */ | ||||
| export enum Port80HandlerEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate-issued', | ||||
|   CERTIFICATE_RENEWED = 'certificate-renewed', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed', | ||||
|   CERTIFICATE_EXPIRING = 'certificate-expiring', | ||||
|   MANAGER_STARTED = 'manager-started', | ||||
|   MANAGER_STOPPED = 'manager-stopped', | ||||
|   REQUEST_FORWARDED = 'request-forwarded', | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate provider events | ||||
|  */ | ||||
| export enum CertProvisionerEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate', | ||||
|   CERTIFICATE_RENEWED = 'certificate', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed' | ||||
| } | ||||
							
								
								
									
										67
									
								
								ts/certificate/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								ts/certificate/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| /** | ||||
|  * Certificate management module for SmartProxy | ||||
|  * Provides certificate provisioning, storage, and management capabilities | ||||
|  */ | ||||
|  | ||||
| // Certificate types and models | ||||
| export * from './models/certificate-types.js'; | ||||
|  | ||||
| // Certificate events | ||||
| export * from './events/certificate-events.js'; | ||||
|  | ||||
| // Certificate providers | ||||
| export * from './providers/cert-provisioner.js'; | ||||
|  | ||||
| // ACME related exports | ||||
| export * from './acme/acme-factory.js'; | ||||
| export * from './acme/challenge-handler.js'; | ||||
|  | ||||
| // Certificate utilities | ||||
| export * from './utils/certificate-helpers.js'; | ||||
|  | ||||
| // Certificate storage | ||||
| export * from './storage/file-storage.js'; | ||||
|  | ||||
| // Convenience function to create a certificate provisioner with common settings | ||||
| import { CertProvisioner } from './providers/cert-provisioner.js'; | ||||
| import { buildPort80Handler } from './acme/acme-factory.js'; | ||||
| import type { AcmeOptions, DomainForwardConfig } from './models/certificate-types.js'; | ||||
| import type { DomainConfig } from '../forwarding/config/domain-config.js'; | ||||
|  | ||||
| /** | ||||
|  * Creates a complete certificate provisioning system with default settings | ||||
|  * @param domainConfigs Domain configurations | ||||
|  * @param acmeOptions ACME options for certificate provisioning | ||||
|  * @param networkProxyBridge Bridge to apply certificates to network proxy | ||||
|  * @param certProvider Optional custom certificate provider | ||||
|  * @returns Configured CertProvisioner | ||||
|  */ | ||||
| export function createCertificateProvisioner( | ||||
|   domainConfigs: DomainConfig[], | ||||
|   acmeOptions: AcmeOptions, | ||||
|   networkProxyBridge: any, // Placeholder until NetworkProxyBridge is migrated | ||||
|   certProvider?: any // Placeholder until cert provider type is properly defined | ||||
| ): CertProvisioner { | ||||
|   // Build the Port80Handler for ACME challenges | ||||
|   const port80Handler = buildPort80Handler(acmeOptions); | ||||
|  | ||||
|   // Extract ACME-specific configuration | ||||
|   const { | ||||
|     renewThresholdDays = 30, | ||||
|     renewCheckIntervalHours = 24, | ||||
|     autoRenew = true, | ||||
|     domainForwards = [] | ||||
|   } = acmeOptions; | ||||
|  | ||||
|   // Create and return the certificate provisioner | ||||
|   return new CertProvisioner( | ||||
|     domainConfigs, | ||||
|     port80Handler, | ||||
|     networkProxyBridge, | ||||
|     certProvider, | ||||
|     renewThresholdDays, | ||||
|     renewCheckIntervalHours, | ||||
|     autoRenew, | ||||
|     domainForwards | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										97
									
								
								ts/certificate/models/certificate-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								ts/certificate/models/certificate-types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * Certificate data structure containing all necessary information | ||||
|  * about a certificate | ||||
|  */ | ||||
| export interface CertificateData { | ||||
|   domain: string; | ||||
|   certificate: string; | ||||
|   privateKey: string; | ||||
|   expiryDate: Date; | ||||
|   // Optional source and renewal information for event emissions | ||||
|   source?: 'static' | 'http01' | 'dns01'; | ||||
|   isRenewal?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificates pair (private and public keys) | ||||
|  */ | ||||
| export interface Certificates { | ||||
|   privateKey: string; | ||||
|   publicKey: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate failure payload type | ||||
|  */ | ||||
| export interface CertificateFailure { | ||||
|   domain: string; | ||||
|   error: string; | ||||
|   isRenewal: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate expiry payload type | ||||
|  */ | ||||
| export interface CertificateExpiring { | ||||
|   domain: string; | ||||
|   expiryDate: Date; | ||||
|   daysRemaining: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain forwarding configuration | ||||
|  */ | ||||
| export interface ForwardConfig { | ||||
|   ip: string; | ||||
|   port: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain-specific forwarding configuration for ACME challenges | ||||
|  */ | ||||
| export interface DomainForwardConfig { | ||||
|   domain: string; | ||||
|   forwardConfig?: ForwardConfig; | ||||
|   acmeForwardConfig?: ForwardConfig; | ||||
|   sslRedirect?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain configuration options | ||||
|  */ | ||||
| export interface DomainOptions { | ||||
|   domainName: string; | ||||
|   sslRedirect: boolean;   // if true redirects the request to port 443 | ||||
|   acmeMaintenance: boolean; // tries to always have a valid cert for this domain | ||||
|   forward?: ForwardConfig; // forwards all http requests to that target | ||||
|   acmeForward?: ForwardConfig; // forwards letsencrypt requests to this config | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Unified ACME configuration options used across proxies and handlers | ||||
|  */ | ||||
| export interface AcmeOptions { | ||||
|   accountEmail?: string;          // Email for Let's Encrypt account | ||||
|   enabled?: boolean;              // Whether ACME is enabled | ||||
|   port?: number;                  // Port to listen on for ACME challenges (default: 80) | ||||
|   useProduction?: boolean;        // Use production environment (default: staging) | ||||
|   httpsRedirectPort?: number;     // Port to redirect HTTP requests to HTTPS (default: 443) | ||||
|   renewThresholdDays?: number;    // Days before expiry to renew certificates | ||||
|   renewCheckIntervalHours?: number; // How often to check for renewals (in hours) | ||||
|   autoRenew?: boolean;            // Whether to automatically renew certificates | ||||
|   certificateStore?: string;      // Directory to store certificates | ||||
|   skipConfiguredCerts?: boolean;  // Skip domains with existing certificates | ||||
|   domainForwards?: DomainForwardConfig[]; // Domain-specific forwarding configs | ||||
| } | ||||
|  | ||||
| // Backwards compatibility interfaces | ||||
| export interface ICertificates extends Certificates {} | ||||
| export interface ICertificateData extends CertificateData {} | ||||
| export interface ICertificateFailure extends CertificateFailure {} | ||||
| export interface ICertificateExpiring extends CertificateExpiring {} | ||||
| export interface IForwardConfig extends ForwardConfig {} | ||||
| export interface IDomainForwardConfig extends DomainForwardConfig {} | ||||
| export interface IDomainOptions extends DomainOptions {} | ||||
| export interface IAcmeOptions extends AcmeOptions {} | ||||
| @@ -1,27 +1,40 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js'; | ||||
| import { Port80Handler } from '../port80handler/classes.port80handler.js'; | ||||
| import { Port80HandlerEvents } from '../common/types.js'; | ||||
| import { subscribeToPort80Handler } from '../common/eventUtils.js'; | ||||
| import type { ICertificateData } from '../common/types.js'; | ||||
| import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { DomainConfig } from '../../forwarding/config/domain-config.js'; | ||||
| import type { CertificateData, DomainForwardConfig, DomainOptions } from '../models/certificate-types.js'; | ||||
| import { Port80HandlerEvents, CertProvisionerEvents } from '../events/certificate-events.js'; | ||||
| import { Port80Handler } from '../../port80handler/classes.port80handler.js'; | ||||
| // We need to define this interface until we migrate NetworkProxyBridge | ||||
| interface NetworkProxyBridge { | ||||
|   applyExternalCertificate(certData: CertificateData): void; | ||||
| } | ||||
|  | ||||
| // This will be imported after NetworkProxyBridge is migrated | ||||
| // import type { NetworkProxyBridge } from '../../proxies/smart-proxy/network-proxy-bridge.js'; | ||||
|  | ||||
| // For backward compatibility | ||||
| export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'; | ||||
|  | ||||
| /** | ||||
|  * Type for static certificate provisioning | ||||
|  */ | ||||
| export type CertProvisionObject = plugins.tsclass.network.ICert | 'http01' | 'dns01'; | ||||
|  | ||||
| /** | ||||
|  * CertProvisioner manages certificate provisioning and renewal workflows, | ||||
|  * unifying static certificates and HTTP-01 challenges via Port80Handler. | ||||
|  */ | ||||
| export class CertProvisioner extends plugins.EventEmitter { | ||||
|   private domainConfigs: IDomainConfig[]; | ||||
|   private domainConfigs: DomainConfig[]; | ||||
|   private port80Handler: Port80Handler; | ||||
|   private networkProxyBridge: NetworkProxyBridge; | ||||
|   private certProvisionFunction?: (domain: string) => Promise<ISmartProxyCertProvisionObject>; | ||||
|   private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>; | ||||
|   private certProvisionFunction?: (domain: string) => Promise<CertProvisionObject>; | ||||
|   private forwardConfigs: DomainForwardConfig[]; | ||||
|   private renewThresholdDays: number; | ||||
|   private renewCheckIntervalHours: number; | ||||
|   private autoRenew: boolean; | ||||
|   private renewManager?: plugins.taskbuffer.TaskManager; | ||||
|   // Track provisioning type per domain: 'http01' or 'static' | ||||
|   private provisionMap: Map<string, 'http01' | 'static'>; | ||||
|   // Track provisioning type per domain | ||||
|   private provisionMap: Map<string, 'http01' | 'dns01' | 'static'>; | ||||
|  | ||||
|   /** | ||||
|    * @param domainConfigs Array of domain configuration objects | ||||
| @@ -31,16 +44,17 @@ export class CertProvisioner extends plugins.EventEmitter { | ||||
|    * @param renewThresholdDays Days before expiry to trigger renewals | ||||
|    * @param renewCheckIntervalHours Interval in hours to check for renewals | ||||
|    * @param autoRenew Whether to automatically schedule renewals | ||||
|    * @param forwardConfigs Domain forwarding configurations for ACME challenges | ||||
|    */ | ||||
|   constructor( | ||||
|     domainConfigs: IDomainConfig[], | ||||
|     domainConfigs: DomainConfig[], | ||||
|     port80Handler: Port80Handler, | ||||
|     networkProxyBridge: NetworkProxyBridge, | ||||
|     certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>, | ||||
|     certProvider?: (domain: string) => Promise<CertProvisionObject>, | ||||
|     renewThresholdDays: number = 30, | ||||
|     renewCheckIntervalHours: number = 24, | ||||
|     autoRenew: boolean = true, | ||||
|     forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = [] | ||||
|     forwardConfigs: DomainForwardConfig[] = [] | ||||
|   ) { | ||||
|     super(); | ||||
|     this.domainConfigs = domainConfigs; | ||||
| @@ -59,99 +73,180 @@ export class CertProvisioner extends plugins.EventEmitter { | ||||
|    */ | ||||
|   public async start(): Promise<void> { | ||||
|     // Subscribe to Port80Handler certificate events | ||||
|     subscribeToPort80Handler(this.port80Handler, { | ||||
|       onCertificateIssued: (data: ICertificateData) => { | ||||
|         this.emit('certificate', { ...data, source: 'http01', isRenewal: false }); | ||||
|       }, | ||||
|       onCertificateRenewed: (data: ICertificateData) => { | ||||
|         this.emit('certificate', { ...data, source: 'http01', isRenewal: true }); | ||||
|       } | ||||
|     }); | ||||
|     this.setupEventSubscriptions(); | ||||
|  | ||||
|     // Apply external forwarding for ACME challenges | ||||
|     this.setupForwardingConfigs(); | ||||
|  | ||||
|     // Apply external forwarding for ACME challenges (e.g. Synology) | ||||
|     for (const f of this.forwardConfigs) { | ||||
|       this.port80Handler.addDomain({ | ||||
|         domainName: f.domain, | ||||
|         sslRedirect: f.sslRedirect, | ||||
|         acmeMaintenance: false, | ||||
|         forward: f.forwardConfig, | ||||
|         acmeForward: f.acmeForwardConfig | ||||
|       }); | ||||
|     } | ||||
|     // Initial provisioning for all domains | ||||
|     const domains = this.domainConfigs.flatMap(cfg => cfg.domains); | ||||
|     for (const domain of domains) { | ||||
|       const isWildcard = domain.includes('*'); | ||||
|       let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; | ||||
|       if (this.certProvisionFunction) { | ||||
|         try { | ||||
|           provision = await this.certProvisionFunction(domain); | ||||
|         } catch (err) { | ||||
|           console.error(`certProvider error for ${domain}:`, err); | ||||
|         } | ||||
|       } else if (isWildcard) { | ||||
|         // No certProvider: cannot handle wildcard without DNS-01 support | ||||
|         console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`); | ||||
|         continue; | ||||
|       } | ||||
|       if (provision === 'http01') { | ||||
|         if (isWildcard) { | ||||
|           console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); | ||||
|           continue; | ||||
|         } | ||||
|         this.provisionMap.set(domain, 'http01'); | ||||
|         this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true }); | ||||
|       } else { | ||||
|         // Static certificate (e.g., DNS-01 provisioned or user-provided) supports wildcard domains | ||||
|         this.provisionMap.set(domain, 'static'); | ||||
|         const certObj = provision as plugins.tsclass.network.ICert; | ||||
|         const certData: ICertificateData = { | ||||
|           domain: certObj.domainName, | ||||
|           certificate: certObj.publicKey, | ||||
|           privateKey: certObj.privateKey, | ||||
|           expiryDate: new Date(certObj.validUntil) | ||||
|         }; | ||||
|         this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|         this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); | ||||
|       } | ||||
|     } | ||||
|     await this.provisionAllDomains(); | ||||
|  | ||||
|     // Schedule renewals if enabled | ||||
|     if (this.autoRenew) { | ||||
|       this.renewManager = new plugins.taskbuffer.TaskManager(); | ||||
|       const renewTask = new plugins.taskbuffer.Task({ | ||||
|         name: 'CertificateRenewals', | ||||
|         taskFunction: async () => { | ||||
|           for (const [domain, type] of this.provisionMap.entries()) { | ||||
|             // Skip wildcard domains | ||||
|             if (domain.includes('*')) continue; | ||||
|             try { | ||||
|               if (type === 'http01') { | ||||
|                 await this.port80Handler.renewCertificate(domain); | ||||
|               } else if (type === 'static' && this.certProvisionFunction) { | ||||
|                 const provision2 = await this.certProvisionFunction(domain); | ||||
|                 if (provision2 !== 'http01') { | ||||
|                   const certObj = provision2 as plugins.tsclass.network.ICert; | ||||
|                   const certData: ICertificateData = { | ||||
|                     domain: certObj.domainName, | ||||
|                     certificate: certObj.publicKey, | ||||
|                     privateKey: certObj.privateKey, | ||||
|                     expiryDate: new Date(certObj.validUntil) | ||||
|                   }; | ||||
|                   this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|                   this.emit('certificate', { ...certData, source: 'static', isRenewal: true }); | ||||
|                 } | ||||
|               } | ||||
|             } catch (err) { | ||||
|               console.error(`Renewal error for ${domain}:`, err); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       this.scheduleRenewals(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set up event subscriptions for certificate events | ||||
|    */ | ||||
|   private setupEventSubscriptions(): void { | ||||
|     // We need to reimplement subscribeToPort80Handler here | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: CertificateData) => { | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, { ...data, source: 'http01', isRenewal: false }); | ||||
|     }); | ||||
|  | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: CertificateData) => { | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, { ...data, source: 'http01', isRenewal: true }); | ||||
|     }); | ||||
|  | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (error) => { | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_FAILED, error); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set up forwarding configurations for the Port80Handler | ||||
|    */ | ||||
|   private setupForwardingConfigs(): void { | ||||
|     for (const config of this.forwardConfigs) { | ||||
|       const domainOptions: DomainOptions = { | ||||
|         domainName: config.domain, | ||||
|         sslRedirect: config.sslRedirect || false, | ||||
|         acmeMaintenance: false, | ||||
|         forward: config.forwardConfig, | ||||
|         acmeForward: config.acmeForwardConfig | ||||
|       }; | ||||
|       this.port80Handler.addDomain(domainOptions); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Provision certificates for all configured domains | ||||
|    */ | ||||
|   private async provisionAllDomains(): Promise<void> { | ||||
|     const domains = this.domainConfigs.flatMap(cfg => cfg.domains); | ||||
|  | ||||
|     for (const domain of domains) { | ||||
|       await this.provisionDomain(domain); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Provision a certificate for a single domain | ||||
|    * @param domain Domain to provision | ||||
|    */ | ||||
|   private async provisionDomain(domain: string): Promise<void> { | ||||
|     const isWildcard = domain.includes('*'); | ||||
|     let provision: CertProvisionObject = 'http01'; | ||||
|  | ||||
|     // Try to get a certificate from the provision function | ||||
|     if (this.certProvisionFunction) { | ||||
|       try { | ||||
|         provision = await this.certProvisionFunction(domain); | ||||
|       } catch (err) { | ||||
|         console.error(`certProvider error for ${domain}:`, err); | ||||
|       } | ||||
|     } else if (isWildcard) { | ||||
|       // No certProvider: cannot handle wildcard without DNS-01 support | ||||
|       console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Handle different provisioning methods | ||||
|     if (provision === 'http01') { | ||||
|       if (isWildcard) { | ||||
|         console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       this.provisionMap.set(domain, 'http01'); | ||||
|       this.port80Handler.addDomain({ | ||||
|         domainName: domain, | ||||
|         sslRedirect: true, | ||||
|         acmeMaintenance: true | ||||
|       }); | ||||
|       const hours = this.renewCheckIntervalHours; | ||||
|       const cronExpr = `0 0 */${hours} * * *`; | ||||
|       this.renewManager.addAndScheduleTask(renewTask, cronExpr); | ||||
|       this.renewManager.start(); | ||||
|     } else if (provision === 'dns01') { | ||||
|       // DNS-01 challenges would be handled by the certProvisionFunction | ||||
|       this.provisionMap.set(domain, 'dns01'); | ||||
|       // DNS-01 handling would go here if implemented | ||||
|     } else { | ||||
|       // Static certificate (e.g., DNS-01 provisioned or user-provided) | ||||
|       this.provisionMap.set(domain, 'static'); | ||||
|       const certObj = provision as plugins.tsclass.network.ICert; | ||||
|       const certData: CertificateData = { | ||||
|         domain: certObj.domainName, | ||||
|         certificate: certObj.publicKey, | ||||
|         privateKey: certObj.privateKey, | ||||
|         expiryDate: new Date(certObj.validUntil), | ||||
|         source: 'static', | ||||
|         isRenewal: false | ||||
|       }; | ||||
|  | ||||
|       this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Schedule certificate renewals using a task manager | ||||
|    */ | ||||
|   private scheduleRenewals(): void { | ||||
|     this.renewManager = new plugins.taskbuffer.TaskManager(); | ||||
|  | ||||
|     const renewTask = new plugins.taskbuffer.Task({ | ||||
|       name: 'CertificateRenewals', | ||||
|       taskFunction: async () => await this.performRenewals() | ||||
|     }); | ||||
|  | ||||
|     const hours = this.renewCheckIntervalHours; | ||||
|     const cronExpr = `0 0 */${hours} * * *`; | ||||
|  | ||||
|     this.renewManager.addAndScheduleTask(renewTask, cronExpr); | ||||
|     this.renewManager.start(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Perform renewals for all domains that need it | ||||
|    */ | ||||
|   private async performRenewals(): Promise<void> { | ||||
|     for (const [domain, type] of this.provisionMap.entries()) { | ||||
|       // Skip wildcard domains for HTTP-01 challenges | ||||
|       if (domain.includes('*') && type === 'http01') continue; | ||||
|  | ||||
|       try { | ||||
|         await this.renewDomain(domain, type); | ||||
|       } catch (err) { | ||||
|         console.error(`Renewal error for ${domain}:`, err); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Renew a certificate for a specific domain | ||||
|    * @param domain Domain to renew | ||||
|    * @param provisionType Type of provisioning for this domain | ||||
|    */ | ||||
|   private async renewDomain(domain: string, provisionType: 'http01' | 'dns01' | 'static'): Promise<void> { | ||||
|     if (provisionType === 'http01') { | ||||
|       await this.port80Handler.renewCertificate(domain); | ||||
|     } else if ((provisionType === 'static' || provisionType === 'dns01') && this.certProvisionFunction) { | ||||
|       const provision = await this.certProvisionFunction(domain); | ||||
|  | ||||
|       if (provision !== 'http01' && provision !== 'dns01') { | ||||
|         const certObj = provision as plugins.tsclass.network.ICert; | ||||
|         const certData: CertificateData = { | ||||
|           domain: certObj.domainName, | ||||
|           certificate: certObj.publicKey, | ||||
|           privateKey: certObj.privateKey, | ||||
|           expiryDate: new Date(certObj.validUntil), | ||||
|           source: 'static', | ||||
|           isRenewal: true | ||||
|         }; | ||||
|  | ||||
|         this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|         this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, certData); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -159,7 +254,6 @@ export class CertProvisioner extends plugins.EventEmitter { | ||||
|    * Stop all scheduled renewal tasks. | ||||
|    */ | ||||
|   public async stop(): Promise<void> { | ||||
|     // Stop scheduled renewals | ||||
|     if (this.renewManager) { | ||||
|       this.renewManager.stop(); | ||||
|     } | ||||
| @@ -171,30 +265,62 @@ export class CertProvisioner extends plugins.EventEmitter { | ||||
|    */ | ||||
|   public async requestCertificate(domain: string): Promise<void> { | ||||
|     const isWildcard = domain.includes('*'); | ||||
|  | ||||
|     // Determine provisioning method | ||||
|     let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; | ||||
|     let provision: CertProvisionObject = 'http01'; | ||||
|  | ||||
|     if (this.certProvisionFunction) { | ||||
|       provision = await this.certProvisionFunction(domain); | ||||
|     } else if (isWildcard) { | ||||
|       // Cannot perform HTTP-01 on wildcard without certProvider | ||||
|       throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`); | ||||
|     } | ||||
|  | ||||
|     if (provision === 'http01') { | ||||
|       if (isWildcard) { | ||||
|         throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`); | ||||
|       } | ||||
|       await this.port80Handler.renewCertificate(domain); | ||||
|     } else if (provision === 'dns01') { | ||||
|       // DNS-01 challenges would be handled by external mechanisms | ||||
|       // This is a placeholder for future implementation | ||||
|       console.log(`DNS-01 challenge requested for ${domain}`); | ||||
|     } else { | ||||
|       // Static certificate (e.g., DNS-01 provisioned) supports wildcards | ||||
|       const certObj = provision as plugins.tsclass.network.ICert; | ||||
|       const certData: ICertificateData = { | ||||
|       const certData: CertificateData = { | ||||
|         domain: certObj.domainName, | ||||
|         certificate: certObj.publicKey, | ||||
|         privateKey: certObj.privateKey, | ||||
|         expiryDate: new Date(certObj.validUntil) | ||||
|         expiryDate: new Date(certObj.validUntil), | ||||
|         source: 'static', | ||||
|         isRenewal: false | ||||
|       }; | ||||
|  | ||||
|       this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|       this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|   /** | ||||
|    * Add a new domain for certificate provisioning | ||||
|    * @param domain Domain to add | ||||
|    * @param options Domain configuration options | ||||
|    */ | ||||
|   public async addDomain(domain: string, options?: { | ||||
|     sslRedirect?: boolean; | ||||
|     acmeMaintenance?: boolean; | ||||
|   }): Promise<void> { | ||||
|     const domainOptions: DomainOptions = { | ||||
|       domainName: domain, | ||||
|       sslRedirect: options?.sslRedirect || true, | ||||
|       acmeMaintenance: options?.acmeMaintenance || true | ||||
|     }; | ||||
|  | ||||
|     this.port80Handler.addDomain(domainOptions); | ||||
|     await this.provisionDomain(domain); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // For backward compatibility | ||||
| export { CertProvisioner as CertificateProvisioner } | ||||
							
								
								
									
										3
									
								
								ts/certificate/providers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/certificate/providers/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * Certificate providers | ||||
|  */ | ||||
							
								
								
									
										234
									
								
								ts/certificate/storage/file-storage.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								ts/certificate/storage/file-storage.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,234 @@ | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { CertificateData, Certificates } from '../models/certificate-types.js'; | ||||
| import { ensureCertificateDirectory } from '../utils/certificate-helpers.js'; | ||||
|  | ||||
| /** | ||||
|  * FileStorage provides file system storage for certificates | ||||
|  */ | ||||
| export class FileStorage { | ||||
|   private storageDir: string; | ||||
|    | ||||
|   /** | ||||
|    * Creates a new file storage provider | ||||
|    * @param storageDir Directory to store certificates | ||||
|    */ | ||||
|   constructor(storageDir: string) { | ||||
|     this.storageDir = path.resolve(storageDir); | ||||
|     ensureCertificateDirectory(this.storageDir); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Save a certificate to the file system | ||||
|    * @param domain Domain name  | ||||
|    * @param certData Certificate data to save | ||||
|    */ | ||||
|   public async saveCertificate(domain: string, certData: CertificateData): Promise<void> { | ||||
|     const sanitizedDomain = this.sanitizeDomain(domain); | ||||
|     const certDir = path.join(this.storageDir, sanitizedDomain); | ||||
|     ensureCertificateDirectory(certDir); | ||||
|      | ||||
|     const certPath = path.join(certDir, 'fullchain.pem'); | ||||
|     const keyPath = path.join(certDir, 'privkey.pem'); | ||||
|     const metaPath = path.join(certDir, 'metadata.json'); | ||||
|      | ||||
|     // Write certificate and private key | ||||
|     await fs.promises.writeFile(certPath, certData.certificate, 'utf8'); | ||||
|     await fs.promises.writeFile(keyPath, certData.privateKey, 'utf8'); | ||||
|      | ||||
|     // Write metadata | ||||
|     const metadata = { | ||||
|       domain: certData.domain, | ||||
|       expiryDate: certData.expiryDate.toISOString(), | ||||
|       source: certData.source || 'unknown', | ||||
|       issuedAt: new Date().toISOString() | ||||
|     }; | ||||
|      | ||||
|     await fs.promises.writeFile( | ||||
|       metaPath,  | ||||
|       JSON.stringify(metadata, null, 2),  | ||||
|       'utf8' | ||||
|     ); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Load a certificate from the file system | ||||
|    * @param domain Domain name | ||||
|    * @returns Certificate data if found, null otherwise | ||||
|    */ | ||||
|   public async loadCertificate(domain: string): Promise<CertificateData | null> { | ||||
|     const sanitizedDomain = this.sanitizeDomain(domain); | ||||
|     const certDir = path.join(this.storageDir, sanitizedDomain); | ||||
|      | ||||
|     if (!fs.existsSync(certDir)) { | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     const certPath = path.join(certDir, 'fullchain.pem'); | ||||
|     const keyPath = path.join(certDir, 'privkey.pem'); | ||||
|     const metaPath = path.join(certDir, 'metadata.json'); | ||||
|      | ||||
|     try { | ||||
|       // Check if all required files exist | ||||
|       if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) { | ||||
|         return null; | ||||
|       } | ||||
|        | ||||
|       // Read certificate and private key | ||||
|       const certificate = await fs.promises.readFile(certPath, 'utf8'); | ||||
|       const privateKey = await fs.promises.readFile(keyPath, 'utf8'); | ||||
|        | ||||
|       // Try to read metadata if available | ||||
|       let expiryDate = new Date(); | ||||
|       let source: 'static' | 'http01' | 'dns01' | undefined; | ||||
|        | ||||
|       if (fs.existsSync(metaPath)) { | ||||
|         const metaContent = await fs.promises.readFile(metaPath, 'utf8'); | ||||
|         const metadata = JSON.parse(metaContent); | ||||
|          | ||||
|         if (metadata.expiryDate) { | ||||
|           expiryDate = new Date(metadata.expiryDate); | ||||
|         } | ||||
|          | ||||
|         if (metadata.source) { | ||||
|           source = metadata.source as 'static' | 'http01' | 'dns01'; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       return { | ||||
|         domain, | ||||
|         certificate, | ||||
|         privateKey, | ||||
|         expiryDate, | ||||
|         source | ||||
|       }; | ||||
|     } catch (error) { | ||||
|       console.error(`Error loading certificate for ${domain}:`, error); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Delete a certificate from the file system | ||||
|    * @param domain Domain name | ||||
|    */ | ||||
|   public async deleteCertificate(domain: string): Promise<boolean> { | ||||
|     const sanitizedDomain = this.sanitizeDomain(domain); | ||||
|     const certDir = path.join(this.storageDir, sanitizedDomain); | ||||
|      | ||||
|     if (!fs.existsSync(certDir)) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Recursively delete the certificate directory | ||||
|       await this.deleteDirectory(certDir); | ||||
|       return true; | ||||
|     } catch (error) { | ||||
|       console.error(`Error deleting certificate for ${domain}:`, error); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * List all domains with stored certificates | ||||
|    * @returns Array of domain names | ||||
|    */ | ||||
|   public async listCertificates(): Promise<string[]> { | ||||
|     try { | ||||
|       const entries = await fs.promises.readdir(this.storageDir, { withFileTypes: true }); | ||||
|       return entries | ||||
|         .filter(entry => entry.isDirectory()) | ||||
|         .map(entry => entry.name); | ||||
|     } catch (error) { | ||||
|       console.error('Error listing certificates:', error); | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Check if a certificate is expiring soon | ||||
|    * @param domain Domain name | ||||
|    * @param thresholdDays Days threshold to consider expiring | ||||
|    * @returns Information about expiring certificate or null | ||||
|    */ | ||||
|   public async isExpiringSoon( | ||||
|     domain: string,  | ||||
|     thresholdDays: number = 30 | ||||
|   ): Promise<{ domain: string; expiryDate: Date; daysRemaining: number } | null> { | ||||
|     const certData = await this.loadCertificate(domain); | ||||
|      | ||||
|     if (!certData) { | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     const now = new Date(); | ||||
|     const expiryDate = certData.expiryDate; | ||||
|     const timeRemaining = expiryDate.getTime() - now.getTime(); | ||||
|     const daysRemaining = Math.floor(timeRemaining / (1000 * 60 * 60 * 24)); | ||||
|      | ||||
|     if (daysRemaining <= thresholdDays) { | ||||
|       return { | ||||
|         domain, | ||||
|         expiryDate, | ||||
|         daysRemaining | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     return null; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Check all certificates for expiration | ||||
|    * @param thresholdDays Days threshold to consider expiring | ||||
|    * @returns List of expiring certificates | ||||
|    */ | ||||
|   public async getExpiringCertificates( | ||||
|     thresholdDays: number = 30 | ||||
|   ): Promise<Array<{ domain: string; expiryDate: Date; daysRemaining: number }>> { | ||||
|     const domains = await this.listCertificates(); | ||||
|     const expiringCerts = []; | ||||
|      | ||||
|     for (const domain of domains) { | ||||
|       const expiring = await this.isExpiringSoon(domain, thresholdDays); | ||||
|       if (expiring) { | ||||
|         expiringCerts.push(expiring); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return expiringCerts; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Delete a directory recursively | ||||
|    * @param directoryPath Directory to delete | ||||
|    */ | ||||
|   private async deleteDirectory(directoryPath: string): Promise<void> { | ||||
|     if (fs.existsSync(directoryPath)) { | ||||
|       const entries = await fs.promises.readdir(directoryPath, { withFileTypes: true }); | ||||
|        | ||||
|       for (const entry of entries) { | ||||
|         const fullPath = path.join(directoryPath, entry.name); | ||||
|          | ||||
|         if (entry.isDirectory()) { | ||||
|           await this.deleteDirectory(fullPath); | ||||
|         } else { | ||||
|           await fs.promises.unlink(fullPath); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       await fs.promises.rmdir(directoryPath); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Sanitize a domain name for use as a directory name | ||||
|    * @param domain Domain name | ||||
|    * @returns Sanitized domain name | ||||
|    */ | ||||
|   private sanitizeDomain(domain: string): string { | ||||
|     // Replace wildcard and any invalid filesystem characters | ||||
|     return domain.replace(/\*/g, '_wildcard_').replace(/[/\\:*?"<>|]/g, '_'); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										3
									
								
								ts/certificate/storage/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/certificate/storage/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * Certificate storage mechanisms | ||||
|  */ | ||||
| @@ -1,17 +1,18 @@ | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import { fileURLToPath } from 'url'; | ||||
| import type { Certificates } from '../models/certificate-types.js'; | ||||
|  | ||||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||||
|  | ||||
| export interface ICertificates { | ||||
|   privateKey: string; | ||||
|   publicKey: string; | ||||
| } | ||||
|  | ||||
| export function loadDefaultCertificates(): ICertificates { | ||||
| /** | ||||
|  * Loads the default SSL certificates from the assets directory | ||||
|  * @returns The certificate key pair | ||||
|  */ | ||||
| export function loadDefaultCertificates(): Certificates { | ||||
|   try { | ||||
|     const certPath = path.join(__dirname, '..', 'assets', 'certs'); | ||||
|     // Need to adjust path from /ts/certificate/utils to /assets/certs | ||||
|     const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs'); | ||||
|     const privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'); | ||||
|     const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8'); | ||||
|  | ||||
| @@ -28,3 +29,22 @@ export function loadDefaultCertificates(): ICertificates { | ||||
|     throw error; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Checks if a certificate file exists at the specified path | ||||
|  * @param certPath Path to check for certificate | ||||
|  * @returns True if the certificate exists, false otherwise | ||||
|  */ | ||||
| export function certificateExists(certPath: string): boolean { | ||||
|   return fs.existsSync(certPath); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Ensures the certificate directory exists | ||||
|  * @param dirPath Path to the certificate directory | ||||
|  */ | ||||
| export function ensureCertificateDirectory(dirPath: string): void { | ||||
|   if (!fs.existsSync(dirPath)) { | ||||
|     fs.mkdirSync(dirPath, { recursive: true }); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										3
									
								
								ts/core/events/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/core/events/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * Common event definitions | ||||
|  */ | ||||
							
								
								
									
										8
									
								
								ts/core/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								ts/core/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| /** | ||||
|  * Core functionality module | ||||
|  */ | ||||
|  | ||||
| // Export submodules | ||||
| export * from './models/index.js'; | ||||
| export * from './utils/index.js'; | ||||
| export * from './events/index.js'; | ||||
							
								
								
									
										91
									
								
								ts/core/models/common-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								ts/core/models/common-types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * Shared types for certificate management and domain options | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Domain forwarding configuration | ||||
|  */ | ||||
| export interface IForwardConfig { | ||||
|   ip: string; | ||||
|   port: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain configuration options | ||||
|  */ | ||||
| export interface IDomainOptions { | ||||
|   domainName: string; | ||||
|   sslRedirect: boolean;   // if true redirects the request to port 443 | ||||
|   acmeMaintenance: boolean; // tries to always have a valid cert for this domain | ||||
|   forward?: IForwardConfig; // forwards all http requests to that target | ||||
|   acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate data that can be emitted via events or set from outside | ||||
|  */ | ||||
| export interface ICertificateData { | ||||
|   domain: string; | ||||
|   certificate: string; | ||||
|   privateKey: string; | ||||
|   expiryDate: Date; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Events emitted by the Port80Handler | ||||
|  */ | ||||
| export enum Port80HandlerEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate-issued', | ||||
|   CERTIFICATE_RENEWED = 'certificate-renewed', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed', | ||||
|   CERTIFICATE_EXPIRING = 'certificate-expiring', | ||||
|   MANAGER_STARTED = 'manager-started', | ||||
|   MANAGER_STOPPED = 'manager-stopped', | ||||
|   REQUEST_FORWARDED = 'request-forwarded', | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate failure payload type | ||||
|  */ | ||||
| export interface ICertificateFailure { | ||||
|   domain: string; | ||||
|   error: string; | ||||
|   isRenewal: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate expiry payload type | ||||
|  */ | ||||
| export interface ICertificateExpiring { | ||||
|   domain: string; | ||||
|   expiryDate: Date; | ||||
|   daysRemaining: number; | ||||
| } | ||||
| /** | ||||
|  * Forwarding configuration for specific domains in ACME setup | ||||
|  */ | ||||
| export interface IDomainForwardConfig { | ||||
|   domain: string; | ||||
|   forwardConfig?: IForwardConfig; | ||||
|   acmeForwardConfig?: IForwardConfig; | ||||
|   sslRedirect?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Unified ACME configuration options used across proxies and handlers | ||||
|  */ | ||||
| export interface IAcmeOptions { | ||||
|   accountEmail?: string;          // Email for Let's Encrypt account | ||||
|   enabled?: boolean;              // Whether ACME is enabled | ||||
|   port?: number;                  // Port to listen on for ACME challenges (default: 80) | ||||
|   useProduction?: boolean;        // Use production environment (default: staging) | ||||
|   httpsRedirectPort?: number;     // Port to redirect HTTP requests to HTTPS (default: 443) | ||||
|   renewThresholdDays?: number;    // Days before expiry to renew certificates | ||||
|   renewCheckIntervalHours?: number; // How often to check for renewals (in hours) | ||||
|   autoRenew?: boolean;            // Whether to automatically renew certificates | ||||
|   certificateStore?: string;      // Directory to store certificates | ||||
|   skipConfiguredCerts?: boolean;  // Skip domains with existing certificates | ||||
|   domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs | ||||
| } | ||||
							
								
								
									
										5
									
								
								ts/core/models/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								ts/core/models/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| /** | ||||
|  * Core data models and interfaces | ||||
|  */ | ||||
|  | ||||
| export * from './common-types.js'; | ||||
							
								
								
									
										34
									
								
								ts/core/utils/event-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								ts/core/utils/event-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| import type { Port80Handler } from '../../port80handler/classes.port80handler.js'; | ||||
| import { Port80HandlerEvents } from '../models/common-types.js'; | ||||
| import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from '../models/common-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Subscribers callback definitions for Port80Handler events | ||||
|  */ | ||||
| export interface Port80HandlerSubscribers { | ||||
|   onCertificateIssued?: (data: ICertificateData) => void; | ||||
|   onCertificateRenewed?: (data: ICertificateData) => void; | ||||
|   onCertificateFailed?: (data: ICertificateFailure) => void; | ||||
|   onCertificateExpiring?: (data: ICertificateExpiring) => void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Subscribes to Port80Handler events based on provided callbacks | ||||
|  */ | ||||
| export function subscribeToPort80Handler( | ||||
|   handler: Port80Handler, | ||||
|   subscribers: Port80HandlerSubscribers | ||||
| ): void { | ||||
|   if (subscribers.onCertificateIssued) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, subscribers.onCertificateIssued); | ||||
|   } | ||||
|   if (subscribers.onCertificateRenewed) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, subscribers.onCertificateRenewed); | ||||
|   } | ||||
|   if (subscribers.onCertificateFailed) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, subscribers.onCertificateFailed); | ||||
|   } | ||||
|   if (subscribers.onCertificateExpiring) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, subscribers.onCertificateExpiring); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										7
									
								
								ts/core/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								ts/core/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| /** | ||||
|  * Core utility functions | ||||
|  */ | ||||
|  | ||||
| export * from './event-utils.js'; | ||||
| export * from './validation-utils.js'; | ||||
| export * from './ip-utils.js'; | ||||
							
								
								
									
										175
									
								
								ts/core/utils/ip-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								ts/core/utils/ip-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * Utility class for IP address operations | ||||
|  */ | ||||
| export class IpUtils { | ||||
|   /** | ||||
|    * Check if the IP matches any of the glob patterns | ||||
|    * | ||||
|    * This method checks IP addresses against glob patterns and handles IPv4/IPv6 normalization. | ||||
|    * It's used to implement IP filtering based on security configurations. | ||||
|    * | ||||
|    * @param ip - The IP address to check | ||||
|    * @param patterns - Array of glob patterns | ||||
|    * @returns true if IP matches any pattern, false otherwise | ||||
|    */ | ||||
|   public static isGlobIPMatch(ip: string, patterns: string[]): boolean { | ||||
|     if (!ip || !patterns || patterns.length === 0) return false; | ||||
|  | ||||
|     // Normalize the IP being checked | ||||
|     const normalizedIPVariants = this.normalizeIP(ip); | ||||
|     if (normalizedIPVariants.length === 0) return false; | ||||
|  | ||||
|     // Normalize the pattern IPs for consistent comparison | ||||
|     const expandedPatterns = patterns.flatMap(pattern => this.normalizeIP(pattern)); | ||||
|  | ||||
|     // Check for any match between normalized IP variants and patterns | ||||
|     return normalizedIPVariants.some((ipVariant) => | ||||
|       expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern)) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Normalize IP addresses for consistent comparison | ||||
|    *  | ||||
|    * @param ip The IP address to normalize | ||||
|    * @returns Array of normalized IP forms | ||||
|    */ | ||||
|   public static normalizeIP(ip: string): string[] { | ||||
|     if (!ip) return []; | ||||
|      | ||||
|     // Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) | ||||
|     if (ip.startsWith('::ffff:')) { | ||||
|       const ipv4 = ip.slice(7); | ||||
|       return [ip, ipv4]; | ||||
|     } | ||||
|      | ||||
|     // Handle IPv4 addresses by also checking IPv4-mapped form | ||||
|     if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { | ||||
|       return [ip, `::ffff:${ip}`]; | ||||
|     } | ||||
|      | ||||
|     return [ip]; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if an IP is authorized using security rules | ||||
|    * | ||||
|    * @param ip - The IP address to check | ||||
|    * @param allowedIPs - Array of allowed IP patterns | ||||
|    * @param blockedIPs - Array of blocked IP patterns | ||||
|    * @returns true if IP is authorized, false if blocked | ||||
|    */ | ||||
|   public static isIPAuthorized(ip: string, allowedIPs: string[] = [], blockedIPs: string[] = []): boolean { | ||||
|     // Skip IP validation if no rules are defined | ||||
|     if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     // First check if IP is blocked - blocked IPs take precedence | ||||
|     if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) { | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     // Then check if IP is allowed (if no allowed IPs are specified, all non-blocked IPs are allowed) | ||||
|     return allowedIPs.length === 0 || this.isGlobIPMatch(ip, allowedIPs); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if an IP address is a private network address | ||||
|    *  | ||||
|    * @param ip The IP address to check | ||||
|    * @returns true if the IP is a private network address, false otherwise | ||||
|    */ | ||||
|   public static isPrivateIP(ip: string): boolean { | ||||
|     if (!ip) return false; | ||||
|  | ||||
|     // Handle IPv4-mapped IPv6 addresses | ||||
|     if (ip.startsWith('::ffff:')) { | ||||
|       ip = ip.slice(7); | ||||
|     } | ||||
|  | ||||
|     // Check IPv4 private ranges | ||||
|     if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { | ||||
|       const parts = ip.split('.').map(Number); | ||||
|        | ||||
|       // Check common private ranges | ||||
|       // 10.0.0.0/8 | ||||
|       if (parts[0] === 10) return true; | ||||
|        | ||||
|       // 172.16.0.0/12 | ||||
|       if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; | ||||
|        | ||||
|       // 192.168.0.0/16 | ||||
|       if (parts[0] === 192 && parts[1] === 168) return true; | ||||
|        | ||||
|       // 127.0.0.0/8 (localhost) | ||||
|       if (parts[0] === 127) return true; | ||||
|        | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // IPv6 local addresses | ||||
|     return ip === '::1' || ip.startsWith('fc00:') || ip.startsWith('fd00:') || ip.startsWith('fe80:'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if an IP address is a public network address | ||||
|    *  | ||||
|    * @param ip The IP address to check | ||||
|    * @returns true if the IP is a public network address, false otherwise | ||||
|    */ | ||||
|   public static isPublicIP(ip: string): boolean { | ||||
|     return !this.isPrivateIP(ip); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Convert a subnet CIDR to an IP range for filtering | ||||
|    *  | ||||
|    * @param cidr The CIDR notation (e.g., "192.168.1.0/24") | ||||
|    * @returns Array of glob patterns that match the CIDR range | ||||
|    */ | ||||
|   public static cidrToGlobPatterns(cidr: string): string[] { | ||||
|     if (!cidr || !cidr.includes('/')) return []; | ||||
|      | ||||
|     const [ipPart, prefixPart] = cidr.split('/'); | ||||
|     const prefix = parseInt(prefixPart, 10); | ||||
|      | ||||
|     if (isNaN(prefix) || prefix < 0 || prefix > 32) return []; | ||||
|      | ||||
|     // For IPv4 only for now | ||||
|     if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ipPart)) return []; | ||||
|      | ||||
|     const ipParts = ipPart.split('.').map(Number); | ||||
|     const fullMask = Math.pow(2, 32 - prefix) - 1; | ||||
|      | ||||
|     // Convert IP to a numeric value | ||||
|     const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; | ||||
|      | ||||
|     // Calculate network address (IP & ~fullMask) | ||||
|     const networkNum = ipNum & ~fullMask; | ||||
|      | ||||
|     // For large ranges, return wildcard patterns | ||||
|     if (prefix <= 8) { | ||||
|       return [`${(networkNum >>> 24) & 255}.*.*.*`]; | ||||
|     } else if (prefix <= 16) { | ||||
|       return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.*.*`]; | ||||
|     } else if (prefix <= 24) { | ||||
|       return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.${(networkNum >>> 8) & 255}.*`]; | ||||
|     } | ||||
|      | ||||
|     // For small ranges, create individual IP patterns | ||||
|     const patterns = []; | ||||
|     const maxAddresses = Math.min(256, Math.pow(2, 32 - prefix)); | ||||
|      | ||||
|     for (let i = 0; i < maxAddresses; i++) { | ||||
|       const currentIpNum = networkNum + i; | ||||
|       patterns.push( | ||||
|         `${(currentIpNum >>> 24) & 255}.${(currentIpNum >>> 16) & 255}.${(currentIpNum >>> 8) & 255}.${currentIpNum & 255}` | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     return patterns; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										177
									
								
								ts/core/utils/validation-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								ts/core/utils/validation-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { IDomainOptions, IAcmeOptions } from '../models/common-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Collection of validation utilities for configuration and domain options | ||||
|  */ | ||||
| export class ValidationUtils { | ||||
|   /** | ||||
|    * Validates domain configuration options | ||||
|    *  | ||||
|    * @param domainOptions The domain options to validate | ||||
|    * @returns An object with validation result and error message if invalid | ||||
|    */ | ||||
|   public static validateDomainOptions(domainOptions: IDomainOptions): { isValid: boolean; error?: string } { | ||||
|     if (!domainOptions) { | ||||
|       return { isValid: false, error: 'Domain options cannot be null or undefined' }; | ||||
|     } | ||||
|  | ||||
|     if (!domainOptions.domainName) { | ||||
|       return { isValid: false, error: 'Domain name is required' }; | ||||
|     } | ||||
|  | ||||
|     // Check domain pattern | ||||
|     if (!this.isValidDomainName(domainOptions.domainName)) { | ||||
|       return { isValid: false, error: `Invalid domain name: ${domainOptions.domainName}` }; | ||||
|     } | ||||
|  | ||||
|     // Validate forward config if provided | ||||
|     if (domainOptions.forward) { | ||||
|       if (!domainOptions.forward.ip) { | ||||
|         return { isValid: false, error: 'Forward IP is required when forward is specified' }; | ||||
|       } | ||||
|  | ||||
|       if (!domainOptions.forward.port) { | ||||
|         return { isValid: false, error: 'Forward port is required when forward is specified' }; | ||||
|       } | ||||
|  | ||||
|       if (!this.isValidPort(domainOptions.forward.port)) { | ||||
|         return { isValid: false, error: `Invalid forward port: ${domainOptions.forward.port}` }; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Validate ACME forward config if provided | ||||
|     if (domainOptions.acmeForward) { | ||||
|       if (!domainOptions.acmeForward.ip) { | ||||
|         return { isValid: false, error: 'ACME forward IP is required when acmeForward is specified' }; | ||||
|       } | ||||
|  | ||||
|       if (!domainOptions.acmeForward.port) { | ||||
|         return { isValid: false, error: 'ACME forward port is required when acmeForward is specified' }; | ||||
|       } | ||||
|  | ||||
|       if (!this.isValidPort(domainOptions.acmeForward.port)) { | ||||
|         return { isValid: false, error: `Invalid ACME forward port: ${domainOptions.acmeForward.port}` }; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return { isValid: true }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates ACME configuration options | ||||
|    *  | ||||
|    * @param acmeOptions The ACME options to validate | ||||
|    * @returns An object with validation result and error message if invalid | ||||
|    */ | ||||
|   public static validateAcmeOptions(acmeOptions: IAcmeOptions): { isValid: boolean; error?: string } { | ||||
|     if (!acmeOptions) { | ||||
|       return { isValid: false, error: 'ACME options cannot be null or undefined' }; | ||||
|     } | ||||
|  | ||||
|     if (acmeOptions.enabled) { | ||||
|       if (!acmeOptions.accountEmail) { | ||||
|         return { isValid: false, error: 'Account email is required when ACME is enabled' }; | ||||
|       } | ||||
|  | ||||
|       if (!this.isValidEmail(acmeOptions.accountEmail)) { | ||||
|         return { isValid: false, error: `Invalid email: ${acmeOptions.accountEmail}` }; | ||||
|       } | ||||
|  | ||||
|       if (acmeOptions.port && !this.isValidPort(acmeOptions.port)) { | ||||
|         return { isValid: false, error: `Invalid ACME port: ${acmeOptions.port}` }; | ||||
|       } | ||||
|  | ||||
|       if (acmeOptions.httpsRedirectPort && !this.isValidPort(acmeOptions.httpsRedirectPort)) { | ||||
|         return { isValid: false, error: `Invalid HTTPS redirect port: ${acmeOptions.httpsRedirectPort}` }; | ||||
|       } | ||||
|  | ||||
|       if (acmeOptions.renewThresholdDays && acmeOptions.renewThresholdDays < 1) { | ||||
|         return { isValid: false, error: 'Renew threshold days must be greater than 0' }; | ||||
|       } | ||||
|  | ||||
|       if (acmeOptions.renewCheckIntervalHours && acmeOptions.renewCheckIntervalHours < 1) { | ||||
|         return { isValid: false, error: 'Renew check interval hours must be greater than 0' }; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return { isValid: true }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates a port number | ||||
|    *  | ||||
|    * @param port The port to validate | ||||
|    * @returns true if the port is valid, false otherwise | ||||
|    */ | ||||
|   public static isValidPort(port: number): boolean { | ||||
|     return typeof port === 'number' && port > 0 && port <= 65535 && Number.isInteger(port); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates a domain name | ||||
|    *  | ||||
|    * @param domain The domain name to validate | ||||
|    * @returns true if the domain name is valid, false otherwise | ||||
|    */ | ||||
|   public static isValidDomainName(domain: string): boolean { | ||||
|     if (!domain || typeof domain !== 'string') { | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     // Wildcard domain check (*.example.com) | ||||
|     if (domain.startsWith('*.')) { | ||||
|       domain = domain.substring(2); | ||||
|     } | ||||
|  | ||||
|     // Simple domain validation pattern | ||||
|     const domainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; | ||||
|     return domainPattern.test(domain); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates an email address | ||||
|    *  | ||||
|    * @param email The email to validate | ||||
|    * @returns true if the email is valid, false otherwise | ||||
|    */ | ||||
|   public static isValidEmail(email: string): boolean { | ||||
|     if (!email || typeof email !== 'string') { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Basic email validation pattern | ||||
|     const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | ||||
|     return emailPattern.test(email); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates a certificate format (PEM) | ||||
|    *  | ||||
|    * @param cert The certificate content to validate | ||||
|    * @returns true if the certificate appears to be in PEM format, false otherwise | ||||
|    */ | ||||
|   public static isValidCertificate(cert: string): boolean { | ||||
|     if (!cert || typeof cert !== 'string') { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     return cert.includes('-----BEGIN CERTIFICATE-----') &&  | ||||
|            cert.includes('-----END CERTIFICATE-----'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates a private key format (PEM) | ||||
|    *  | ||||
|    * @param key The private key content to validate | ||||
|    * @returns true if the key appears to be in PEM format, false otherwise | ||||
|    */ | ||||
|   public static isValidPrivateKey(key: string): boolean { | ||||
|     if (!key || typeof key !== 'string') { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     return key.includes('-----BEGIN PRIVATE KEY-----') &&  | ||||
|            key.includes('-----END PRIVATE KEY-----'); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										31
									
								
								ts/forwarding/config/domain-config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								ts/forwarding/config/domain-config.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import type { ForwardConfig } from './forwarding-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Domain configuration with unified forwarding configuration | ||||
|  */ | ||||
| export interface DomainConfig { | ||||
|   // Core properties - domain patterns | ||||
|   domains: string[]; | ||||
|  | ||||
|   // Unified forwarding configuration | ||||
|   forwarding: ForwardConfig; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Helper function to create a domain configuration | ||||
|  */ | ||||
| export function createDomainConfig( | ||||
|   domains: string | string[], | ||||
|   forwarding: ForwardConfig | ||||
| ): DomainConfig { | ||||
|   // Normalize domains to an array | ||||
|   const domainArray = Array.isArray(domains) ? domains : [domains]; | ||||
|  | ||||
|   return { | ||||
|     domains: domainArray, | ||||
|     forwarding | ||||
|   }; | ||||
| } | ||||
|  | ||||
| // Backwards compatibility | ||||
| export interface IDomainConfig extends DomainConfig {} | ||||
							
								
								
									
										283
									
								
								ts/forwarding/config/domain-manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										283
									
								
								ts/forwarding/config/domain-manager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,283 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { DomainConfig } from './domain-config.js'; | ||||
| import { ForwardingHandler } from '../handlers/base-handler.js'; | ||||
| import { ForwardingHandlerEvents } from './forwarding-types.js'; | ||||
| import { ForwardingHandlerFactory } from '../factory/forwarding-factory.js'; | ||||
|  | ||||
| /** | ||||
|  * Events emitted by the DomainManager | ||||
|  */ | ||||
| export enum DomainManagerEvents { | ||||
|   DOMAIN_ADDED = 'domain-added', | ||||
|   DOMAIN_REMOVED = 'domain-removed', | ||||
|   DOMAIN_MATCHED = 'domain-matched', | ||||
|   DOMAIN_MATCH_FAILED = 'domain-match-failed', | ||||
|   CERTIFICATE_NEEDED = 'certificate-needed', | ||||
|   CERTIFICATE_LOADED = 'certificate-loaded', | ||||
|   ERROR = 'error' | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Manages domains and their forwarding handlers | ||||
|  */ | ||||
| export class DomainManager extends plugins.EventEmitter { | ||||
|   private domainConfigs: DomainConfig[] = []; | ||||
|   private domainHandlers: Map<string, ForwardingHandler> = new Map(); | ||||
|    | ||||
|   /** | ||||
|    * Create a new DomainManager | ||||
|    * @param initialDomains Optional initial domain configurations | ||||
|    */ | ||||
|   constructor(initialDomains?: DomainConfig[]) { | ||||
|     super(); | ||||
|      | ||||
|     if (initialDomains) { | ||||
|       this.setDomainConfigs(initialDomains); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Set or replace all domain configurations | ||||
|    * @param configs Array of domain configurations | ||||
|    */ | ||||
|   public async setDomainConfigs(configs: DomainConfig[]): Promise<void> { | ||||
|     // Clear existing handlers | ||||
|     this.domainHandlers.clear(); | ||||
|      | ||||
|     // Store new configurations | ||||
|     this.domainConfigs = [...configs]; | ||||
|      | ||||
|     // Initialize handlers for each domain | ||||
|     for (const config of this.domainConfigs) { | ||||
|       await this.createHandlersForDomain(config); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Add a new domain configuration | ||||
|    * @param config The domain configuration to add | ||||
|    */ | ||||
|   public async addDomainConfig(config: DomainConfig): Promise<void> { | ||||
|     // Check if any of these domains already exist | ||||
|     for (const domain of config.domains) { | ||||
|       if (this.domainHandlers.has(domain)) { | ||||
|         // Remove existing handler for this domain | ||||
|         this.domainHandlers.delete(domain); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Add the new configuration | ||||
|     this.domainConfigs.push(config); | ||||
|      | ||||
|     // Create handlers for the new domain | ||||
|     await this.createHandlersForDomain(config); | ||||
|      | ||||
|     this.emit(DomainManagerEvents.DOMAIN_ADDED, { | ||||
|       domains: config.domains, | ||||
|       forwardingType: config.forwarding.type | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Remove a domain configuration | ||||
|    * @param domain The domain to remove | ||||
|    * @returns True if the domain was found and removed | ||||
|    */ | ||||
|   public removeDomainConfig(domain: string): boolean { | ||||
|     // Find the config that includes this domain | ||||
|     const index = this.domainConfigs.findIndex(config =>  | ||||
|       config.domains.includes(domain) | ||||
|     ); | ||||
|      | ||||
|     if (index === -1) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Get the config | ||||
|     const config = this.domainConfigs[index]; | ||||
|      | ||||
|     // Remove all handlers for this config | ||||
|     for (const domainName of config.domains) { | ||||
|       this.domainHandlers.delete(domainName); | ||||
|     } | ||||
|      | ||||
|     // Remove the config | ||||
|     this.domainConfigs.splice(index, 1); | ||||
|      | ||||
|     this.emit(DomainManagerEvents.DOMAIN_REMOVED, { | ||||
|       domains: config.domains | ||||
|     }); | ||||
|      | ||||
|     return true; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Find the handler for a domain | ||||
|    * @param domain The domain to find a handler for | ||||
|    * @returns The handler or undefined if no match | ||||
|    */ | ||||
|   public findHandlerForDomain(domain: string): ForwardingHandler | undefined { | ||||
|     // Try exact match | ||||
|     if (this.domainHandlers.has(domain)) { | ||||
|       return this.domainHandlers.get(domain); | ||||
|     } | ||||
|      | ||||
|     // Try wildcard matches | ||||
|     const wildcardHandler = this.findWildcardHandler(domain); | ||||
|     if (wildcardHandler) { | ||||
|       return wildcardHandler; | ||||
|     } | ||||
|      | ||||
|     // No match found | ||||
|     return undefined; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle a connection for a domain | ||||
|    * @param domain The domain | ||||
|    * @param socket The client socket | ||||
|    * @returns True if the connection was handled | ||||
|    */ | ||||
|   public handleConnection(domain: string, socket: plugins.net.Socket): boolean { | ||||
|     const handler = this.findHandlerForDomain(domain); | ||||
|      | ||||
|     if (!handler) { | ||||
|       this.emit(DomainManagerEvents.DOMAIN_MATCH_FAILED, { | ||||
|         domain, | ||||
|         remoteAddress: socket.remoteAddress | ||||
|       }); | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     this.emit(DomainManagerEvents.DOMAIN_MATCHED, { | ||||
|       domain, | ||||
|       handlerType: handler.constructor.name, | ||||
|       remoteAddress: socket.remoteAddress | ||||
|     }); | ||||
|      | ||||
|     // Handle the connection | ||||
|     handler.handleConnection(socket); | ||||
|     return true; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle an HTTP request for a domain | ||||
|    * @param domain The domain | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    * @returns True if the request was handled | ||||
|    */ | ||||
|   public handleHttpRequest(domain: string, req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): boolean { | ||||
|     const handler = this.findHandlerForDomain(domain); | ||||
|      | ||||
|     if (!handler) { | ||||
|       this.emit(DomainManagerEvents.DOMAIN_MATCH_FAILED, { | ||||
|         domain, | ||||
|         remoteAddress: req.socket.remoteAddress | ||||
|       }); | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     this.emit(DomainManagerEvents.DOMAIN_MATCHED, { | ||||
|       domain, | ||||
|       handlerType: handler.constructor.name, | ||||
|       remoteAddress: req.socket.remoteAddress | ||||
|     }); | ||||
|      | ||||
|     // Handle the request | ||||
|     handler.handleHttpRequest(req, res); | ||||
|     return true; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Create handlers for a domain configuration | ||||
|    * @param config The domain configuration | ||||
|    */ | ||||
|   private async createHandlersForDomain(config: DomainConfig): Promise<void> { | ||||
|     try { | ||||
|       // Create a handler for this forwarding configuration | ||||
|       const handler = ForwardingHandlerFactory.createHandler(config.forwarding); | ||||
|        | ||||
|       // Initialize the handler | ||||
|       await handler.initialize(); | ||||
|        | ||||
|       // Set up event forwarding | ||||
|       this.setupHandlerEvents(handler, config); | ||||
|        | ||||
|       // Store the handler for each domain in the config | ||||
|       for (const domain of config.domains) { | ||||
|         this.domainHandlers.set(domain, handler); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       this.emit(DomainManagerEvents.ERROR, { | ||||
|         domains: config.domains, | ||||
|         error: error instanceof Error ? error.message : String(error) | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Set up event forwarding from a handler | ||||
|    * @param handler The handler | ||||
|    * @param config The domain configuration for this handler | ||||
|    */ | ||||
|   private setupHandlerEvents(handler: ForwardingHandler, config: DomainConfig): void { | ||||
|     // Forward relevant events | ||||
|     handler.on(ForwardingHandlerEvents.CERTIFICATE_NEEDED, (data) => { | ||||
|       this.emit(DomainManagerEvents.CERTIFICATE_NEEDED, { | ||||
|         ...data, | ||||
|         domains: config.domains | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     handler.on(ForwardingHandlerEvents.CERTIFICATE_LOADED, (data) => { | ||||
|       this.emit(DomainManagerEvents.CERTIFICATE_LOADED, { | ||||
|         ...data, | ||||
|         domains: config.domains | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     handler.on(ForwardingHandlerEvents.ERROR, (data) => { | ||||
|       this.emit(DomainManagerEvents.ERROR, { | ||||
|         ...data, | ||||
|         domains: config.domains | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Find a handler for a domain using wildcard matching | ||||
|    * @param domain The domain to find a handler for | ||||
|    * @returns The handler or undefined if no match | ||||
|    */ | ||||
|   private findWildcardHandler(domain: string): ForwardingHandler | undefined { | ||||
|     // Exact match already checked in findHandlerForDomain | ||||
|      | ||||
|     // Try subdomain wildcard (*.example.com) | ||||
|     if (domain.includes('.')) { | ||||
|       const parts = domain.split('.'); | ||||
|       if (parts.length > 2) { | ||||
|         const wildcardDomain = `*.${parts.slice(1).join('.')}`; | ||||
|         if (this.domainHandlers.has(wildcardDomain)) { | ||||
|           return this.domainHandlers.get(wildcardDomain); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Try full wildcard | ||||
|     if (this.domainHandlers.has('*')) { | ||||
|       return this.domainHandlers.get('*'); | ||||
|     } | ||||
|      | ||||
|     // No match found | ||||
|     return undefined; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Get all domain configurations | ||||
|    * @returns Array of domain configurations | ||||
|    */ | ||||
|   public getDomainConfigs(): DomainConfig[] { | ||||
|     return [...this.domainConfigs]; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										171
									
								
								ts/forwarding/config/forwarding-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								ts/forwarding/config/forwarding-types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | ||||
| import type * as plugins from '../../plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * The primary forwarding types supported by SmartProxy | ||||
|  */ | ||||
| export type ForwardingType = | ||||
|   | 'http-only'                // HTTP forwarding only (no HTTPS) | ||||
|   | 'https-passthrough'        // Pass-through TLS traffic (SNI forwarding) | ||||
|   | 'https-terminate-to-http'  // Terminate TLS and forward to HTTP backend | ||||
|   | 'https-terminate-to-https'; // Terminate TLS and forward to HTTPS backend | ||||
|  | ||||
| /** | ||||
|  * Target configuration for forwarding | ||||
|  */ | ||||
| export interface TargetConfig { | ||||
|   host: string | string[];  // Support single host or round-robin | ||||
|   port: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * HTTP-specific options for forwarding | ||||
|  */ | ||||
| export interface HttpOptions { | ||||
|   enabled?: boolean;                 // Whether HTTP is enabled | ||||
|   redirectToHttps?: boolean;         // Redirect HTTP to HTTPS | ||||
|   headers?: Record<string, string>;  // Custom headers for HTTP responses | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * HTTPS-specific options for forwarding | ||||
|  */ | ||||
| export interface HttpsOptions { | ||||
|   customCert?: {                    // Use custom cert instead of auto-provisioned | ||||
|     key: string; | ||||
|     cert: string; | ||||
|   }; | ||||
|   forwardSni?: boolean;             // Forward SNI info in passthrough mode | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * ACME certificate handling options | ||||
|  */ | ||||
| export interface AcmeForwardingOptions { | ||||
|   enabled?: boolean;                // Enable ACME certificate provisioning   | ||||
|   maintenance?: boolean;            // Auto-renew certificates | ||||
|   production?: boolean;             // Use production ACME servers | ||||
|   forwardChallenges?: {             // Forward ACME challenges | ||||
|     host: string; | ||||
|     port: number; | ||||
|     useTls?: boolean; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Security options for forwarding | ||||
|  */ | ||||
| export interface SecurityOptions { | ||||
|   allowedIps?: string[];            // IPs allowed to connect | ||||
|   blockedIps?: string[];            // IPs blocked from connecting | ||||
|   maxConnections?: number;          // Max simultaneous connections | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Advanced options for forwarding | ||||
|  */ | ||||
| export interface AdvancedOptions { | ||||
|   portRanges?: Array<{ from: number; to: number }>; // Allowed port ranges | ||||
|   networkProxyPort?: number;        // Custom NetworkProxy port if using terminate mode | ||||
|   keepAlive?: boolean;              // Enable TCP keepalive | ||||
|   timeout?: number;                 // Connection timeout in ms | ||||
|   headers?: Record<string, string>; // Custom headers with support for variables like {sni} | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Unified forwarding configuration interface | ||||
|  */ | ||||
| export interface ForwardConfig { | ||||
|   // Define the primary forwarding type - use-case driven approach | ||||
|   type: ForwardingType; | ||||
|    | ||||
|   // Target configuration | ||||
|   target: TargetConfig; | ||||
|    | ||||
|   // Protocol options | ||||
|   http?: HttpOptions; | ||||
|   https?: HttpsOptions; | ||||
|   acme?: AcmeForwardingOptions; | ||||
|    | ||||
|   // Security and advanced options | ||||
|   security?: SecurityOptions; | ||||
|   advanced?: AdvancedOptions; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Event types emitted by forwarding handlers | ||||
|  */ | ||||
| export enum ForwardingHandlerEvents { | ||||
|   CONNECTED = 'connected', | ||||
|   DISCONNECTED = 'disconnected', | ||||
|   ERROR = 'error', | ||||
|   DATA_FORWARDED = 'data-forwarded', | ||||
|   HTTP_REQUEST = 'http-request', | ||||
|   HTTP_RESPONSE = 'http-response', | ||||
|   CERTIFICATE_NEEDED = 'certificate-needed', | ||||
|   CERTIFICATE_LOADED = 'certificate-loaded' | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Base interface for forwarding handlers | ||||
|  */ | ||||
| export interface IForwardingHandler extends plugins.EventEmitter { | ||||
|   initialize(): Promise<void>; | ||||
|   handleConnection(socket: plugins.net.Socket): void; | ||||
|   handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Helper function types for common forwarding patterns | ||||
|  */ | ||||
| export const httpOnly = ( | ||||
|   partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'> | ||||
| ): ForwardConfig => ({ | ||||
|   type: 'http-only', | ||||
|   target: partialConfig.target, | ||||
|   http: { enabled: true, ...(partialConfig.http || {}) }, | ||||
|   ...(partialConfig.security ? { security: partialConfig.security } : {}), | ||||
|   ...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {}) | ||||
| }); | ||||
|  | ||||
| export const tlsTerminateToHttp = ( | ||||
|   partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'> | ||||
| ): ForwardConfig => ({ | ||||
|   type: 'https-terminate-to-http', | ||||
|   target: partialConfig.target, | ||||
|   https: { ...(partialConfig.https || {}) }, | ||||
|   acme: { enabled: true, maintenance: true, ...(partialConfig.acme || {}) }, | ||||
|   http: { enabled: true, redirectToHttps: true, ...(partialConfig.http || {}) }, | ||||
|   ...(partialConfig.security ? { security: partialConfig.security } : {}), | ||||
|   ...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {}) | ||||
| }); | ||||
|  | ||||
| export const tlsTerminateToHttps = ( | ||||
|   partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'> | ||||
| ): ForwardConfig => ({ | ||||
|   type: 'https-terminate-to-https', | ||||
|   target: partialConfig.target, | ||||
|   https: { ...(partialConfig.https || {}) }, | ||||
|   acme: { enabled: true, maintenance: true, ...(partialConfig.acme || {}) }, | ||||
|   http: { enabled: true, redirectToHttps: true, ...(partialConfig.http || {}) }, | ||||
|   ...(partialConfig.security ? { security: partialConfig.security } : {}), | ||||
|   ...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {}) | ||||
| }); | ||||
|  | ||||
| export const httpsPassthrough = ( | ||||
|   partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'> | ||||
| ): ForwardConfig => ({ | ||||
|   type: 'https-passthrough', | ||||
|   target: partialConfig.target, | ||||
|   https: { forwardSni: true, ...(partialConfig.https || {}) }, | ||||
|   ...(partialConfig.security ? { security: partialConfig.security } : {}), | ||||
|   ...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {}) | ||||
| }); | ||||
|  | ||||
| // Backwards compatibility interfaces with 'I' prefix | ||||
| export interface ITargetConfig extends TargetConfig {} | ||||
| export interface IHttpOptions extends HttpOptions {} | ||||
| export interface IHttpsOptions extends HttpsOptions {} | ||||
| export interface IAcmeForwardingOptions extends AcmeForwardingOptions {} | ||||
| export interface ISecurityOptions extends SecurityOptions {} | ||||
| export interface IAdvancedOptions extends AdvancedOptions {} | ||||
| export interface IForwardConfig extends ForwardConfig {} | ||||
							
								
								
									
										7
									
								
								ts/forwarding/config/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								ts/forwarding/config/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| /** | ||||
|  * Forwarding configuration exports | ||||
|  */ | ||||
|  | ||||
| export * from './forwarding-types.js'; | ||||
| export * from './domain-config.js'; | ||||
| export * from './domain-manager.js'; | ||||
							
								
								
									
										156
									
								
								ts/forwarding/factory/forwarding-factory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								ts/forwarding/factory/forwarding-factory.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | ||||
| import type { ForwardConfig } from '../config/forwarding-types.js'; | ||||
| import type { ForwardingHandler } from '../handlers/base-handler.js'; | ||||
| import { HttpForwardingHandler } from '../handlers/http-handler.js'; | ||||
| import { HttpsPassthroughHandler } from '../handlers/https-passthrough-handler.js'; | ||||
| import { HttpsTerminateToHttpHandler } from '../handlers/https-terminate-to-http-handler.js'; | ||||
| import { HttpsTerminateToHttpsHandler } from '../handlers/https-terminate-to-https-handler.js'; | ||||
|  | ||||
| /** | ||||
|  * Factory for creating forwarding handlers based on the configuration type | ||||
|  */ | ||||
| export class ForwardingHandlerFactory { | ||||
|   /** | ||||
|    * Create a forwarding handler based on the configuration | ||||
|    * @param config The forwarding configuration | ||||
|    * @returns The appropriate forwarding handler | ||||
|    */ | ||||
|   public static createHandler(config: ForwardConfig): ForwardingHandler { | ||||
|     // Create the appropriate handler based on the forwarding type | ||||
|     switch (config.type) { | ||||
|       case 'http-only': | ||||
|         return new HttpForwardingHandler(config); | ||||
|          | ||||
|       case 'https-passthrough': | ||||
|         return new HttpsPassthroughHandler(config); | ||||
|          | ||||
|       case 'https-terminate-to-http': | ||||
|         return new HttpsTerminateToHttpHandler(config); | ||||
|          | ||||
|       case 'https-terminate-to-https': | ||||
|         return new HttpsTerminateToHttpsHandler(config); | ||||
|          | ||||
|       default: | ||||
|         // Type system should prevent this, but just in case: | ||||
|         throw new Error(`Unknown forwarding type: ${(config as any).type}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Apply default values to a forwarding configuration based on its type | ||||
|    * @param config The original forwarding configuration | ||||
|    * @returns A configuration with defaults applied | ||||
|    */ | ||||
|   public static applyDefaults(config: ForwardConfig): ForwardConfig { | ||||
|     // Create a deep copy of the configuration | ||||
|     const result: ForwardConfig = JSON.parse(JSON.stringify(config)); | ||||
|      | ||||
|     // Apply defaults based on forwarding type | ||||
|     switch (config.type) { | ||||
|       case 'http-only': | ||||
|         // Set defaults for HTTP-only mode | ||||
|         result.http = { | ||||
|           enabled: true, | ||||
|           ...config.http | ||||
|         }; | ||||
|         break; | ||||
|          | ||||
|       case 'https-passthrough': | ||||
|         // Set defaults for HTTPS passthrough | ||||
|         result.https = { | ||||
|           forwardSni: true, | ||||
|           ...config.https | ||||
|         }; | ||||
|         // SNI forwarding doesn't do HTTP | ||||
|         result.http = { | ||||
|           enabled: false, | ||||
|           ...config.http | ||||
|         }; | ||||
|         break; | ||||
|          | ||||
|       case 'https-terminate-to-http': | ||||
|         // Set defaults for HTTPS termination to HTTP | ||||
|         result.https = { | ||||
|           ...config.https | ||||
|         }; | ||||
|         // Support HTTP access by default in this mode | ||||
|         result.http = { | ||||
|           enabled: true, | ||||
|           redirectToHttps: true, | ||||
|           ...config.http | ||||
|         }; | ||||
|         // Enable ACME by default | ||||
|         result.acme = { | ||||
|           enabled: true, | ||||
|           maintenance: true, | ||||
|           ...config.acme | ||||
|         }; | ||||
|         break; | ||||
|          | ||||
|       case 'https-terminate-to-https': | ||||
|         // Similar to terminate-to-http but with different target handling | ||||
|         result.https = { | ||||
|           ...config.https | ||||
|         }; | ||||
|         result.http = { | ||||
|           enabled: true, | ||||
|           redirectToHttps: true, | ||||
|           ...config.http | ||||
|         }; | ||||
|         result.acme = { | ||||
|           enabled: true, | ||||
|           maintenance: true, | ||||
|           ...config.acme | ||||
|         }; | ||||
|         break; | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Validate a forwarding configuration | ||||
|    * @param config The configuration to validate | ||||
|    * @throws Error if the configuration is invalid | ||||
|    */ | ||||
|   public static validateConfig(config: ForwardConfig): void { | ||||
|     // Validate common properties | ||||
|     if (!config.target) { | ||||
|       throw new Error('Forwarding configuration must include a target'); | ||||
|     } | ||||
|      | ||||
|     if (!config.target.host || (Array.isArray(config.target.host) && config.target.host.length === 0)) { | ||||
|       throw new Error('Target must include a host or array of hosts'); | ||||
|     } | ||||
|      | ||||
|     if (!config.target.port || config.target.port <= 0 || config.target.port > 65535) { | ||||
|       throw new Error('Target must include a valid port (1-65535)'); | ||||
|     } | ||||
|      | ||||
|     // Type-specific validation | ||||
|     switch (config.type) { | ||||
|       case 'http-only': | ||||
|         // HTTP-only needs http.enabled to be true | ||||
|         if (config.http?.enabled === false) { | ||||
|           throw new Error('HTTP-only forwarding must have HTTP enabled'); | ||||
|         } | ||||
|         break; | ||||
|          | ||||
|       case 'https-passthrough': | ||||
|         // HTTPS passthrough doesn't support HTTP | ||||
|         if (config.http?.enabled === true) { | ||||
|           throw new Error('HTTPS passthrough does not support HTTP'); | ||||
|         } | ||||
|          | ||||
|         // HTTPS passthrough doesn't work with ACME | ||||
|         if (config.acme?.enabled === true) { | ||||
|           throw new Error('HTTPS passthrough does not support ACME'); | ||||
|         } | ||||
|         break; | ||||
|          | ||||
|       case 'https-terminate-to-http': | ||||
|       case 'https-terminate-to-https': | ||||
|         // These modes support all options, nothing specific to validate | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										5
									
								
								ts/forwarding/factory/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								ts/forwarding/factory/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| /** | ||||
|  * Forwarding factory implementations | ||||
|  */ | ||||
|  | ||||
| export { ForwardingHandlerFactory } from './forwarding-factory.js'; | ||||
							
								
								
									
										127
									
								
								ts/forwarding/handlers/base-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								ts/forwarding/handlers/base-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { | ||||
|   ForwardConfig, | ||||
|   IForwardingHandler | ||||
| } from '../config/forwarding-types.js'; | ||||
| import { ForwardingHandlerEvents } from '../config/forwarding-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Base class for all forwarding handlers | ||||
|  */ | ||||
| export abstract class ForwardingHandler extends plugins.EventEmitter implements IForwardingHandler { | ||||
|   /** | ||||
|    * Create a new ForwardingHandler | ||||
|    * @param config The forwarding configuration | ||||
|    */ | ||||
|   constructor(protected config: ForwardConfig) { | ||||
|     super(); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Initialize the handler | ||||
|    * Base implementation does nothing, subclasses should override as needed | ||||
|    */ | ||||
|   public async initialize(): Promise<void> { | ||||
|     // Base implementation - no initialization needed | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle a new socket connection | ||||
|    * @param socket The incoming socket connection | ||||
|    */ | ||||
|   public abstract handleConnection(socket: plugins.net.Socket): void; | ||||
|    | ||||
|   /** | ||||
|    * Handle an HTTP request | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    */ | ||||
|   public abstract handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void; | ||||
|    | ||||
|   /** | ||||
|    * Get a target from the configuration, supporting round-robin selection | ||||
|    * @returns A resolved target object with host and port | ||||
|    */ | ||||
|   protected getTargetFromConfig(): { host: string, port: number } { | ||||
|     const { target } = this.config; | ||||
|      | ||||
|     // Handle round-robin host selection | ||||
|     if (Array.isArray(target.host)) { | ||||
|       if (target.host.length === 0) { | ||||
|         throw new Error('No target hosts specified'); | ||||
|       } | ||||
|        | ||||
|       // Simple round-robin selection | ||||
|       const randomIndex = Math.floor(Math.random() * target.host.length); | ||||
|       return { | ||||
|         host: target.host[randomIndex], | ||||
|         port: target.port | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     // Single host | ||||
|     return { | ||||
|       host: target.host, | ||||
|       port: target.port | ||||
|     }; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Redirect an HTTP request to HTTPS | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    */ | ||||
|   protected redirectToHttps(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { | ||||
|     const host = req.headers.host || ''; | ||||
|     const path = req.url || '/'; | ||||
|     const redirectUrl = `https://${host}${path}`; | ||||
|      | ||||
|     res.writeHead(301, { | ||||
|       'Location': redirectUrl, | ||||
|       'Cache-Control': 'no-cache' | ||||
|     }); | ||||
|     res.end(`Redirecting to ${redirectUrl}`); | ||||
|      | ||||
|     this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, { | ||||
|       statusCode: 301, | ||||
|       headers: { 'Location': redirectUrl }, | ||||
|       size: 0 | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Apply custom headers from configuration | ||||
|    * @param headers The original headers | ||||
|    * @param variables Variables to replace in the headers | ||||
|    * @returns The headers with custom values applied | ||||
|    */ | ||||
|   protected applyCustomHeaders( | ||||
|     headers: Record<string, string | string[] | undefined>, | ||||
|     variables: Record<string, string> | ||||
|   ): Record<string, string | string[] | undefined> { | ||||
|     const customHeaders = this.config.advanced?.headers || {}; | ||||
|     const result = { ...headers }; | ||||
|      | ||||
|     // Apply custom headers with variable substitution | ||||
|     for (const [key, value] of Object.entries(customHeaders)) { | ||||
|       let processedValue = value; | ||||
|        | ||||
|       // Replace variables in the header value | ||||
|       for (const [varName, varValue] of Object.entries(variables)) { | ||||
|         processedValue = processedValue.replace(`{${varName}}`, varValue); | ||||
|       } | ||||
|        | ||||
|       result[key] = processedValue; | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Get the timeout for this connection from configuration | ||||
|    * @returns Timeout in milliseconds | ||||
|    */ | ||||
|   protected getTimeout(): number { | ||||
|     return this.config.advanced?.timeout || 60000; // Default: 60 seconds | ||||
|   } | ||||
| } | ||||
							
								
								
									
										140
									
								
								ts/forwarding/handlers/http-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								ts/forwarding/handlers/http-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,140 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { ForwardingHandler } from './base-handler.js'; | ||||
| import type { ForwardConfig } from '../config/forwarding-types.js'; | ||||
| import { ForwardingHandlerEvents } from '../config/forwarding-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Handler for HTTP-only forwarding | ||||
|  */ | ||||
| export class HttpForwardingHandler extends ForwardingHandler { | ||||
|   /** | ||||
|    * Create a new HTTP forwarding handler | ||||
|    * @param config The forwarding configuration | ||||
|    */ | ||||
|   constructor(config: ForwardConfig) { | ||||
|     super(config); | ||||
|      | ||||
|     // Validate that this is an HTTP-only configuration | ||||
|     if (config.type !== 'http-only') { | ||||
|       throw new Error(`Invalid configuration type for HttpForwardingHandler: ${config.type}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle a raw socket connection | ||||
|    * HTTP handler doesn't do much with raw sockets as it mainly processes | ||||
|    * parsed HTTP requests | ||||
|    */ | ||||
|   public handleConnection(socket: plugins.net.Socket): void { | ||||
|     // For HTTP, we mainly handle parsed requests, but we can still set up | ||||
|     // some basic connection tracking | ||||
|     const remoteAddress = socket.remoteAddress || 'unknown'; | ||||
|      | ||||
|     socket.on('close', (hadError) => { | ||||
|       this.emit(ForwardingHandlerEvents.DISCONNECTED, { | ||||
|         remoteAddress, | ||||
|         hadError | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     socket.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: error.message | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     this.emit(ForwardingHandlerEvents.CONNECTED, { | ||||
|       remoteAddress | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle an HTTP request | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    */ | ||||
|   public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { | ||||
|     // Get the target from configuration | ||||
|     const target = this.getTargetFromConfig(); | ||||
|      | ||||
|     // Create a custom headers object with variables for substitution | ||||
|     const variables = { | ||||
|       clientIp: req.socket.remoteAddress || 'unknown' | ||||
|     }; | ||||
|      | ||||
|     // Prepare headers, merging with any custom headers from config | ||||
|     const headers = this.applyCustomHeaders(req.headers, variables); | ||||
|      | ||||
|     // Create the proxy request options | ||||
|     const options = { | ||||
|       hostname: target.host, | ||||
|       port: target.port, | ||||
|       path: req.url, | ||||
|       method: req.method, | ||||
|       headers | ||||
|     }; | ||||
|      | ||||
|     // Create the proxy request | ||||
|     const proxyReq = plugins.http.request(options, (proxyRes) => { | ||||
|       // Copy status code and headers from the proxied response | ||||
|       res.writeHead(proxyRes.statusCode || 500, proxyRes.headers); | ||||
|        | ||||
|       // Pipe the proxy response to the client response | ||||
|       proxyRes.pipe(res); | ||||
|        | ||||
|       // Track bytes for logging | ||||
|       let responseSize = 0; | ||||
|       proxyRes.on('data', (chunk) => { | ||||
|         responseSize += chunk.length; | ||||
|       }); | ||||
|        | ||||
|       proxyRes.on('end', () => { | ||||
|         this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, { | ||||
|           statusCode: proxyRes.statusCode, | ||||
|           headers: proxyRes.headers, | ||||
|           size: responseSize | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Handle errors in the proxy request | ||||
|     proxyReq.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress: req.socket.remoteAddress, | ||||
|         error: `Proxy request error: ${error.message}` | ||||
|       }); | ||||
|        | ||||
|       // Send an error response if headers haven't been sent yet | ||||
|       if (!res.headersSent) { | ||||
|         res.writeHead(502, { 'Content-Type': 'text/plain' }); | ||||
|         res.end(`Error forwarding request: ${error.message}`); | ||||
|       } else { | ||||
|         // Just end the response if headers have already been sent | ||||
|         res.end(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Track request details for logging | ||||
|     let requestSize = 0; | ||||
|     req.on('data', (chunk) => { | ||||
|       requestSize += chunk.length; | ||||
|     }); | ||||
|      | ||||
|     // Log the request | ||||
|     this.emit(ForwardingHandlerEvents.HTTP_REQUEST, { | ||||
|       method: req.method, | ||||
|       url: req.url, | ||||
|       headers: req.headers, | ||||
|       remoteAddress: req.socket.remoteAddress, | ||||
|       target: `${target.host}:${target.port}` | ||||
|     }); | ||||
|      | ||||
|     // Pipe the client request to the proxy request | ||||
|     if (req.readable) { | ||||
|       req.pipe(proxyReq); | ||||
|     } else { | ||||
|       proxyReq.end(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										182
									
								
								ts/forwarding/handlers/https-passthrough-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								ts/forwarding/handlers/https-passthrough-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { ForwardingHandler } from './base-handler.js'; | ||||
| import type { ForwardConfig } from '../config/forwarding-types.js'; | ||||
| import { ForwardingHandlerEvents } from '../config/forwarding-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Handler for HTTPS passthrough (SNI forwarding without termination) | ||||
|  */ | ||||
| export class HttpsPassthroughHandler extends ForwardingHandler { | ||||
|   /** | ||||
|    * Create a new HTTPS passthrough handler | ||||
|    * @param config The forwarding configuration | ||||
|    */ | ||||
|   constructor(config: ForwardConfig) { | ||||
|     super(config); | ||||
|      | ||||
|     // Validate that this is an HTTPS passthrough configuration | ||||
|     if (config.type !== 'https-passthrough') { | ||||
|       throw new Error(`Invalid configuration type for HttpsPassthroughHandler: ${config.type}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle a TLS/SSL socket connection by forwarding it without termination | ||||
|    * @param clientSocket The incoming socket from the client | ||||
|    */ | ||||
|   public handleConnection(clientSocket: plugins.net.Socket): void { | ||||
|     // Get the target from configuration | ||||
|     const target = this.getTargetFromConfig(); | ||||
|      | ||||
|     // Log the connection | ||||
|     const remoteAddress = clientSocket.remoteAddress || 'unknown'; | ||||
|     const remotePort = clientSocket.remotePort || 0; | ||||
|      | ||||
|     this.emit(ForwardingHandlerEvents.CONNECTED, { | ||||
|       remoteAddress, | ||||
|       remotePort, | ||||
|       target: `${target.host}:${target.port}` | ||||
|     }); | ||||
|      | ||||
|     // Create a connection to the target server | ||||
|     const serverSocket = plugins.net.connect(target.port, target.host); | ||||
|      | ||||
|     // Handle errors on the server socket | ||||
|     serverSocket.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: `Target connection error: ${error.message}` | ||||
|       }); | ||||
|        | ||||
|       // Close the client socket if it's still open | ||||
|       if (!clientSocket.destroyed) { | ||||
|         clientSocket.destroy(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Handle errors on the client socket | ||||
|     clientSocket.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: `Client connection error: ${error.message}` | ||||
|       }); | ||||
|        | ||||
|       // Close the server socket if it's still open | ||||
|       if (!serverSocket.destroyed) { | ||||
|         serverSocket.destroy(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Track data transfer for logging | ||||
|     let bytesSent = 0; | ||||
|     let bytesReceived = 0; | ||||
|      | ||||
|     // Forward data from client to server | ||||
|     clientSocket.on('data', (data) => { | ||||
|       bytesSent += data.length; | ||||
|        | ||||
|       // Check if server socket is writable | ||||
|       if (serverSocket.writable) { | ||||
|         const flushed = serverSocket.write(data); | ||||
|          | ||||
|         // Handle backpressure | ||||
|         if (!flushed) { | ||||
|           clientSocket.pause(); | ||||
|           serverSocket.once('drain', () => { | ||||
|             clientSocket.resume(); | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       this.emit(ForwardingHandlerEvents.DATA_FORWARDED, { | ||||
|         direction: 'outbound', | ||||
|         bytes: data.length, | ||||
|         total: bytesSent | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Forward data from server to client | ||||
|     serverSocket.on('data', (data) => { | ||||
|       bytesReceived += data.length; | ||||
|        | ||||
|       // Check if client socket is writable | ||||
|       if (clientSocket.writable) { | ||||
|         const flushed = clientSocket.write(data); | ||||
|          | ||||
|         // Handle backpressure | ||||
|         if (!flushed) { | ||||
|           serverSocket.pause(); | ||||
|           clientSocket.once('drain', () => { | ||||
|             serverSocket.resume(); | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       this.emit(ForwardingHandlerEvents.DATA_FORWARDED, { | ||||
|         direction: 'inbound', | ||||
|         bytes: data.length, | ||||
|         total: bytesReceived | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Handle connection close | ||||
|     const handleClose = () => { | ||||
|       if (!clientSocket.destroyed) { | ||||
|         clientSocket.destroy(); | ||||
|       } | ||||
|        | ||||
|       if (!serverSocket.destroyed) { | ||||
|         serverSocket.destroy(); | ||||
|       } | ||||
|        | ||||
|       this.emit(ForwardingHandlerEvents.DISCONNECTED, { | ||||
|         remoteAddress, | ||||
|         bytesSent, | ||||
|         bytesReceived | ||||
|       }); | ||||
|     }; | ||||
|      | ||||
|     // Set up close handlers | ||||
|     clientSocket.on('close', handleClose); | ||||
|     serverSocket.on('close', handleClose); | ||||
|      | ||||
|     // Set timeouts | ||||
|     const timeout = this.getTimeout(); | ||||
|     clientSocket.setTimeout(timeout); | ||||
|     serverSocket.setTimeout(timeout); | ||||
|      | ||||
|     // Handle timeouts | ||||
|     clientSocket.on('timeout', () => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: 'Client connection timeout' | ||||
|       }); | ||||
|       handleClose(); | ||||
|     }); | ||||
|      | ||||
|     serverSocket.on('timeout', () => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: 'Server connection timeout' | ||||
|       }); | ||||
|       handleClose(); | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle an HTTP request - HTTPS passthrough doesn't support HTTP | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    */ | ||||
|   public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { | ||||
|     // HTTPS passthrough doesn't support HTTP requests | ||||
|     res.writeHead(404, { 'Content-Type': 'text/plain' }); | ||||
|     res.end('HTTP not supported for this domain'); | ||||
|      | ||||
|     this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, { | ||||
|       statusCode: 404, | ||||
|       headers: { 'Content-Type': 'text/plain' }, | ||||
|       size: 'HTTP not supported for this domain'.length | ||||
|     }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										264
									
								
								ts/forwarding/handlers/https-terminate-to-http-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								ts/forwarding/handlers/https-terminate-to-http-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,264 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { ForwardingHandler } from './base-handler.js'; | ||||
| import type { ForwardConfig } from '../config/forwarding-types.js'; | ||||
| import { ForwardingHandlerEvents } from '../config/forwarding-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Handler for HTTPS termination with HTTP backend | ||||
|  */ | ||||
| export class HttpsTerminateToHttpHandler extends ForwardingHandler { | ||||
|   private tlsServer: plugins.tls.Server | null = null; | ||||
|   private secureContext: plugins.tls.SecureContext | null = null; | ||||
|    | ||||
|   /** | ||||
|    * Create a new HTTPS termination with HTTP backend handler | ||||
|    * @param config The forwarding configuration | ||||
|    */ | ||||
|   constructor(config: ForwardConfig) { | ||||
|     super(config); | ||||
|      | ||||
|     // Validate that this is an HTTPS terminate to HTTP configuration | ||||
|     if (config.type !== 'https-terminate-to-http') { | ||||
|       throw new Error(`Invalid configuration type for HttpsTerminateToHttpHandler: ${config.type}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Initialize the handler, setting up TLS context | ||||
|    */ | ||||
|   public async initialize(): Promise<void> { | ||||
|     // We need to load or create TLS certificates | ||||
|     if (this.config.https?.customCert) { | ||||
|       // Use custom certificate from configuration | ||||
|       this.secureContext = plugins.tls.createSecureContext({ | ||||
|         key: this.config.https.customCert.key, | ||||
|         cert: this.config.https.customCert.cert | ||||
|       }); | ||||
|        | ||||
|       this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, { | ||||
|         source: 'config', | ||||
|         domain: this.config.target.host | ||||
|       }); | ||||
|     } else if (this.config.acme?.enabled) { | ||||
|       // Request certificate through ACME if needed | ||||
|       this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, { | ||||
|         domain: Array.isArray(this.config.target.host)  | ||||
|           ? this.config.target.host[0]  | ||||
|           : this.config.target.host, | ||||
|         useProduction: this.config.acme.production || false | ||||
|       }); | ||||
|        | ||||
|       // In a real implementation, we would wait for the certificate to be issued | ||||
|       // For now, we'll use a dummy context | ||||
|       this.secureContext = plugins.tls.createSecureContext({ | ||||
|         key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----', | ||||
|         cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----' | ||||
|       }); | ||||
|     } else { | ||||
|       throw new Error('HTTPS termination requires either a custom certificate or ACME enabled'); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Set the secure context for TLS termination | ||||
|    * Called when a certificate is available | ||||
|    * @param context The secure context | ||||
|    */ | ||||
|   public setSecureContext(context: plugins.tls.SecureContext): void { | ||||
|     this.secureContext = context; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle a TLS/SSL socket connection by terminating TLS and forwarding to HTTP backend | ||||
|    * @param clientSocket The incoming socket from the client | ||||
|    */ | ||||
|   public handleConnection(clientSocket: plugins.net.Socket): void { | ||||
|     // Make sure we have a secure context | ||||
|     if (!this.secureContext) { | ||||
|       clientSocket.destroy(new Error('TLS secure context not initialized')); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     const remoteAddress = clientSocket.remoteAddress || 'unknown'; | ||||
|     const remotePort = clientSocket.remotePort || 0; | ||||
|      | ||||
|     // Create a TLS socket using our secure context | ||||
|     const tlsSocket = new plugins.tls.TLSSocket(clientSocket, { | ||||
|       secureContext: this.secureContext, | ||||
|       isServer: true, | ||||
|       server: this.tlsServer || undefined | ||||
|     }); | ||||
|      | ||||
|     this.emit(ForwardingHandlerEvents.CONNECTED, { | ||||
|       remoteAddress, | ||||
|       remotePort, | ||||
|       tls: true | ||||
|     }); | ||||
|      | ||||
|     // Handle TLS errors | ||||
|     tlsSocket.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: `TLS error: ${error.message}` | ||||
|       }); | ||||
|        | ||||
|       if (!tlsSocket.destroyed) { | ||||
|         tlsSocket.destroy(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // The TLS socket will now emit HTTP traffic that can be processed | ||||
|     // In a real implementation, we would create an HTTP parser and handle | ||||
|     // the requests here, but for simplicity, we'll just log the data | ||||
|      | ||||
|     let dataBuffer = Buffer.alloc(0); | ||||
|      | ||||
|     tlsSocket.on('data', (data) => { | ||||
|       // Append to buffer | ||||
|       dataBuffer = Buffer.concat([dataBuffer, data]); | ||||
|        | ||||
|       // Very basic HTTP parsing - in a real implementation, use http-parser | ||||
|       if (dataBuffer.includes(Buffer.from('\r\n\r\n'))) { | ||||
|         const target = this.getTargetFromConfig(); | ||||
|          | ||||
|         // Simple example: forward the data to an HTTP server | ||||
|         const socket = plugins.net.connect(target.port, target.host, () => { | ||||
|           socket.write(dataBuffer); | ||||
|           dataBuffer = Buffer.alloc(0); | ||||
|            | ||||
|           // Set up bidirectional data flow | ||||
|           tlsSocket.pipe(socket); | ||||
|           socket.pipe(tlsSocket); | ||||
|         }); | ||||
|          | ||||
|         socket.on('error', (error) => { | ||||
|           this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|             remoteAddress, | ||||
|             error: `Target connection error: ${error.message}` | ||||
|           }); | ||||
|            | ||||
|           if (!tlsSocket.destroyed) { | ||||
|             tlsSocket.destroy(); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Handle close | ||||
|     tlsSocket.on('close', () => { | ||||
|       this.emit(ForwardingHandlerEvents.DISCONNECTED, { | ||||
|         remoteAddress | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Set timeout | ||||
|     const timeout = this.getTimeout(); | ||||
|     tlsSocket.setTimeout(timeout); | ||||
|      | ||||
|     tlsSocket.on('timeout', () => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: 'TLS connection timeout' | ||||
|       }); | ||||
|        | ||||
|       if (!tlsSocket.destroyed) { | ||||
|         tlsSocket.destroy(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle an HTTP request by forwarding to the HTTP backend | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    */ | ||||
|   public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { | ||||
|     // Check if we should redirect to HTTPS | ||||
|     if (this.config.http?.redirectToHttps) { | ||||
|       this.redirectToHttps(req, res); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Get the target from configuration | ||||
|     const target = this.getTargetFromConfig(); | ||||
|      | ||||
|     // Create custom headers with variable substitution | ||||
|     const variables = { | ||||
|       clientIp: req.socket.remoteAddress || 'unknown' | ||||
|     }; | ||||
|      | ||||
|     // Prepare headers, merging with any custom headers from config | ||||
|     const headers = this.applyCustomHeaders(req.headers, variables); | ||||
|      | ||||
|     // Create the proxy request options | ||||
|     const options = { | ||||
|       hostname: target.host, | ||||
|       port: target.port, | ||||
|       path: req.url, | ||||
|       method: req.method, | ||||
|       headers | ||||
|     }; | ||||
|      | ||||
|     // Create the proxy request | ||||
|     const proxyReq = plugins.http.request(options, (proxyRes) => { | ||||
|       // Copy status code and headers from the proxied response | ||||
|       res.writeHead(proxyRes.statusCode || 500, proxyRes.headers); | ||||
|        | ||||
|       // Pipe the proxy response to the client response | ||||
|       proxyRes.pipe(res); | ||||
|        | ||||
|       // Track response size for logging | ||||
|       let responseSize = 0; | ||||
|       proxyRes.on('data', (chunk) => { | ||||
|         responseSize += chunk.length; | ||||
|       }); | ||||
|        | ||||
|       proxyRes.on('end', () => { | ||||
|         this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, { | ||||
|           statusCode: proxyRes.statusCode, | ||||
|           headers: proxyRes.headers, | ||||
|           size: responseSize | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Handle errors in the proxy request | ||||
|     proxyReq.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress: req.socket.remoteAddress, | ||||
|         error: `Proxy request error: ${error.message}` | ||||
|       }); | ||||
|        | ||||
|       // Send an error response if headers haven't been sent yet | ||||
|       if (!res.headersSent) { | ||||
|         res.writeHead(502, { 'Content-Type': 'text/plain' }); | ||||
|         res.end(`Error forwarding request: ${error.message}`); | ||||
|       } else { | ||||
|         // Just end the response if headers have already been sent | ||||
|         res.end(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Track request details for logging | ||||
|     let requestSize = 0; | ||||
|     req.on('data', (chunk) => { | ||||
|       requestSize += chunk.length; | ||||
|     }); | ||||
|      | ||||
|     // Log the request | ||||
|     this.emit(ForwardingHandlerEvents.HTTP_REQUEST, { | ||||
|       method: req.method, | ||||
|       url: req.url, | ||||
|       headers: req.headers, | ||||
|       remoteAddress: req.socket.remoteAddress, | ||||
|       target: `${target.host}:${target.port}` | ||||
|     }); | ||||
|      | ||||
|     // Pipe the client request to the proxy request | ||||
|     if (req.readable) { | ||||
|       req.pipe(proxyReq); | ||||
|     } else { | ||||
|       proxyReq.end(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										292
									
								
								ts/forwarding/handlers/https-terminate-to-https-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								ts/forwarding/handlers/https-terminate-to-https-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,292 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { ForwardingHandler } from './base-handler.js'; | ||||
| import type { ForwardConfig } from '../config/forwarding-types.js'; | ||||
| import { ForwardingHandlerEvents } from '../config/forwarding-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Handler for HTTPS termination with HTTPS backend | ||||
|  */ | ||||
| export class HttpsTerminateToHttpsHandler extends ForwardingHandler { | ||||
|   private secureContext: plugins.tls.SecureContext | null = null; | ||||
|    | ||||
|   /** | ||||
|    * Create a new HTTPS termination with HTTPS backend handler | ||||
|    * @param config The forwarding configuration | ||||
|    */ | ||||
|   constructor(config: ForwardConfig) { | ||||
|     super(config); | ||||
|      | ||||
|     // Validate that this is an HTTPS terminate to HTTPS configuration | ||||
|     if (config.type !== 'https-terminate-to-https') { | ||||
|       throw new Error(`Invalid configuration type for HttpsTerminateToHttpsHandler: ${config.type}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Initialize the handler, setting up TLS context | ||||
|    */ | ||||
|   public async initialize(): Promise<void> { | ||||
|     // We need to load or create TLS certificates for termination | ||||
|     if (this.config.https?.customCert) { | ||||
|       // Use custom certificate from configuration | ||||
|       this.secureContext = plugins.tls.createSecureContext({ | ||||
|         key: this.config.https.customCert.key, | ||||
|         cert: this.config.https.customCert.cert | ||||
|       }); | ||||
|        | ||||
|       this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, { | ||||
|         source: 'config', | ||||
|         domain: this.config.target.host | ||||
|       }); | ||||
|     } else if (this.config.acme?.enabled) { | ||||
|       // Request certificate through ACME if needed | ||||
|       this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, { | ||||
|         domain: Array.isArray(this.config.target.host)  | ||||
|           ? this.config.target.host[0]  | ||||
|           : this.config.target.host, | ||||
|         useProduction: this.config.acme.production || false | ||||
|       }); | ||||
|        | ||||
|       // In a real implementation, we would wait for the certificate to be issued | ||||
|       // For now, we'll use a dummy context | ||||
|       this.secureContext = plugins.tls.createSecureContext({ | ||||
|         key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----', | ||||
|         cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----' | ||||
|       }); | ||||
|     } else { | ||||
|       throw new Error('HTTPS termination requires either a custom certificate or ACME enabled'); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Set the secure context for TLS termination | ||||
|    * Called when a certificate is available | ||||
|    * @param context The secure context | ||||
|    */ | ||||
|   public setSecureContext(context: plugins.tls.SecureContext): void { | ||||
|     this.secureContext = context; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle a TLS/SSL socket connection by terminating TLS and creating a new TLS connection to backend | ||||
|    * @param clientSocket The incoming socket from the client | ||||
|    */ | ||||
|   public handleConnection(clientSocket: plugins.net.Socket): void { | ||||
|     // Make sure we have a secure context | ||||
|     if (!this.secureContext) { | ||||
|       clientSocket.destroy(new Error('TLS secure context not initialized')); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     const remoteAddress = clientSocket.remoteAddress || 'unknown'; | ||||
|     const remotePort = clientSocket.remotePort || 0; | ||||
|      | ||||
|     // Create a TLS socket using our secure context | ||||
|     const tlsSocket = new plugins.tls.TLSSocket(clientSocket, { | ||||
|       secureContext: this.secureContext, | ||||
|       isServer: true | ||||
|     }); | ||||
|      | ||||
|     this.emit(ForwardingHandlerEvents.CONNECTED, { | ||||
|       remoteAddress, | ||||
|       remotePort, | ||||
|       tls: true | ||||
|     }); | ||||
|      | ||||
|     // Handle TLS errors | ||||
|     tlsSocket.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: `TLS error: ${error.message}` | ||||
|       }); | ||||
|        | ||||
|       if (!tlsSocket.destroyed) { | ||||
|         tlsSocket.destroy(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // The TLS socket will now emit HTTP traffic that can be processed | ||||
|     // In a real implementation, we would create an HTTP parser and handle | ||||
|     // the requests here, but for simplicity, we'll just forward the data | ||||
|      | ||||
|     // Get the target from configuration | ||||
|     const target = this.getTargetFromConfig(); | ||||
|      | ||||
|     // Set up the connection to the HTTPS backend | ||||
|     const connectToBackend = () => { | ||||
|       const backendSocket = plugins.tls.connect({ | ||||
|         host: target.host, | ||||
|         port: target.port, | ||||
|         // In a real implementation, we would configure TLS options | ||||
|         rejectUnauthorized: false // For testing only, never use in production | ||||
|       }, () => { | ||||
|         this.emit(ForwardingHandlerEvents.DATA_FORWARDED, { | ||||
|           direction: 'outbound', | ||||
|           target: `${target.host}:${target.port}`, | ||||
|           tls: true | ||||
|         }); | ||||
|          | ||||
|         // Set up bidirectional data flow | ||||
|         tlsSocket.pipe(backendSocket); | ||||
|         backendSocket.pipe(tlsSocket); | ||||
|       }); | ||||
|        | ||||
|       backendSocket.on('error', (error) => { | ||||
|         this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|           remoteAddress, | ||||
|           error: `Backend connection error: ${error.message}` | ||||
|         }); | ||||
|          | ||||
|         if (!tlsSocket.destroyed) { | ||||
|           tlsSocket.destroy(); | ||||
|         } | ||||
|       }); | ||||
|        | ||||
|       // Handle close | ||||
|       backendSocket.on('close', () => { | ||||
|         if (!tlsSocket.destroyed) { | ||||
|           tlsSocket.destroy(); | ||||
|         } | ||||
|       }); | ||||
|        | ||||
|       // Set timeout | ||||
|       const timeout = this.getTimeout(); | ||||
|       backendSocket.setTimeout(timeout); | ||||
|        | ||||
|       backendSocket.on('timeout', () => { | ||||
|         this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|           remoteAddress, | ||||
|           error: 'Backend connection timeout' | ||||
|         }); | ||||
|          | ||||
|         if (!backendSocket.destroyed) { | ||||
|           backendSocket.destroy(); | ||||
|         } | ||||
|       }); | ||||
|     }; | ||||
|      | ||||
|     // Wait for the TLS handshake to complete before connecting to backend | ||||
|     tlsSocket.on('secure', () => { | ||||
|       connectToBackend(); | ||||
|     }); | ||||
|      | ||||
|     // Handle close | ||||
|     tlsSocket.on('close', () => { | ||||
|       this.emit(ForwardingHandlerEvents.DISCONNECTED, { | ||||
|         remoteAddress | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Set timeout | ||||
|     const timeout = this.getTimeout(); | ||||
|     tlsSocket.setTimeout(timeout); | ||||
|      | ||||
|     tlsSocket.on('timeout', () => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress, | ||||
|         error: 'TLS connection timeout' | ||||
|       }); | ||||
|        | ||||
|       if (!tlsSocket.destroyed) { | ||||
|         tlsSocket.destroy(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle an HTTP request by forwarding to the HTTPS backend | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    */ | ||||
|   public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { | ||||
|     // Check if we should redirect to HTTPS | ||||
|     if (this.config.http?.redirectToHttps) { | ||||
|       this.redirectToHttps(req, res); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Get the target from configuration | ||||
|     const target = this.getTargetFromConfig(); | ||||
|      | ||||
|     // Create custom headers with variable substitution | ||||
|     const variables = { | ||||
|       clientIp: req.socket.remoteAddress || 'unknown' | ||||
|     }; | ||||
|      | ||||
|     // Prepare headers, merging with any custom headers from config | ||||
|     const headers = this.applyCustomHeaders(req.headers, variables); | ||||
|      | ||||
|     // Create the proxy request options | ||||
|     const options = { | ||||
|       hostname: target.host, | ||||
|       port: target.port, | ||||
|       path: req.url, | ||||
|       method: req.method, | ||||
|       headers, | ||||
|       // In a real implementation, we would configure TLS options | ||||
|       rejectUnauthorized: false // For testing only, never use in production | ||||
|     }; | ||||
|      | ||||
|     // Create the proxy request using HTTPS | ||||
|     const proxyReq = plugins.https.request(options, (proxyRes) => { | ||||
|       // Copy status code and headers from the proxied response | ||||
|       res.writeHead(proxyRes.statusCode || 500, proxyRes.headers); | ||||
|        | ||||
|       // Pipe the proxy response to the client response | ||||
|       proxyRes.pipe(res); | ||||
|        | ||||
|       // Track response size for logging | ||||
|       let responseSize = 0; | ||||
|       proxyRes.on('data', (chunk) => { | ||||
|         responseSize += chunk.length; | ||||
|       }); | ||||
|        | ||||
|       proxyRes.on('end', () => { | ||||
|         this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, { | ||||
|           statusCode: proxyRes.statusCode, | ||||
|           headers: proxyRes.headers, | ||||
|           size: responseSize | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Handle errors in the proxy request | ||||
|     proxyReq.on('error', (error) => { | ||||
|       this.emit(ForwardingHandlerEvents.ERROR, { | ||||
|         remoteAddress: req.socket.remoteAddress, | ||||
|         error: `Proxy request error: ${error.message}` | ||||
|       }); | ||||
|        | ||||
|       // Send an error response if headers haven't been sent yet | ||||
|       if (!res.headersSent) { | ||||
|         res.writeHead(502, { 'Content-Type': 'text/plain' }); | ||||
|         res.end(`Error forwarding request: ${error.message}`); | ||||
|       } else { | ||||
|         // Just end the response if headers have already been sent | ||||
|         res.end(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Track request details for logging | ||||
|     let requestSize = 0; | ||||
|     req.on('data', (chunk) => { | ||||
|       requestSize += chunk.length; | ||||
|     }); | ||||
|      | ||||
|     // Log the request | ||||
|     this.emit(ForwardingHandlerEvents.HTTP_REQUEST, { | ||||
|       method: req.method, | ||||
|       url: req.url, | ||||
|       headers: req.headers, | ||||
|       remoteAddress: req.socket.remoteAddress, | ||||
|       target: `${target.host}:${target.port}` | ||||
|     }); | ||||
|      | ||||
|     // Pipe the client request to the proxy request | ||||
|     if (req.readable) { | ||||
|       req.pipe(proxyReq); | ||||
|     } else { | ||||
|       proxyReq.end(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										9
									
								
								ts/forwarding/handlers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								ts/forwarding/handlers/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| /** | ||||
|  * Forwarding handler implementations | ||||
|  */ | ||||
|  | ||||
| export { ForwardingHandler } from './base-handler.js'; | ||||
| export { HttpForwardingHandler } from './http-handler.js'; | ||||
| export { HttpsPassthroughHandler } from './https-passthrough-handler.js'; | ||||
| export { HttpsTerminateToHttpHandler } from './https-terminate-to-http-handler.js'; | ||||
| export { HttpsTerminateToHttpsHandler } from './https-terminate-to-https-handler.js'; | ||||
							
								
								
									
										34
									
								
								ts/forwarding/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								ts/forwarding/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| /** | ||||
|  * Forwarding system module | ||||
|  * Provides a flexible and type-safe way to configure and manage various forwarding strategies | ||||
|  */ | ||||
|  | ||||
| // Export types and configuration | ||||
| export * from './config/forwarding-types.js'; | ||||
| export * from './config/domain-config.js'; | ||||
| export * from './config/domain-manager.js'; | ||||
|  | ||||
| // Export handlers | ||||
| export { ForwardingHandler } from './handlers/base-handler.js'; | ||||
| export * from './handlers/http-handler.js'; | ||||
| export * from './handlers/https-passthrough-handler.js'; | ||||
| export * from './handlers/https-terminate-to-http-handler.js'; | ||||
| export * from './handlers/https-terminate-to-https-handler.js'; | ||||
|  | ||||
| // Export factory | ||||
| export * from './factory/forwarding-factory.js'; | ||||
|  | ||||
| // Helper functions as a convenience object | ||||
| import { | ||||
|   httpOnly, | ||||
|   tlsTerminateToHttp, | ||||
|   tlsTerminateToHttps, | ||||
|   httpsPassthrough | ||||
| } from './config/forwarding-types.js'; | ||||
|  | ||||
| export const helpers = { | ||||
|   httpOnly, | ||||
|   tlsTerminateToHttp, | ||||
|   tlsTerminateToHttps, | ||||
|   httpsPassthrough | ||||
| }; | ||||
							
								
								
									
										8
									
								
								ts/http/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								ts/http/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| /** | ||||
|  * HTTP functionality module | ||||
|  */ | ||||
|  | ||||
| // Export submodules | ||||
| export * from './port80/index.js'; | ||||
| export * from './router/index.js'; | ||||
| export * from './redirects/index.js'; | ||||
							
								
								
									
										106
									
								
								ts/http/models/http-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								ts/http/models/http-types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type {  | ||||
|   ForwardConfig, | ||||
|   DomainOptions, | ||||
|   AcmeOptions | ||||
| } from '../../certificate/models/certificate-types.js'; | ||||
|  | ||||
| /** | ||||
|  * HTTP-specific event types | ||||
|  */ | ||||
| export enum HttpEvents { | ||||
|   REQUEST_RECEIVED = 'request-received', | ||||
|   REQUEST_FORWARDED = 'request-forwarded', | ||||
|   REQUEST_HANDLED = 'request-handled', | ||||
|   REQUEST_ERROR = 'request-error', | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * HTTP status codes as an enum for better type safety | ||||
|  */ | ||||
| export enum HttpStatus { | ||||
|   OK = 200, | ||||
|   MOVED_PERMANENTLY = 301, | ||||
|   FOUND = 302, | ||||
|   TEMPORARY_REDIRECT = 307, | ||||
|   PERMANENT_REDIRECT = 308, | ||||
|   BAD_REQUEST = 400, | ||||
|   NOT_FOUND = 404, | ||||
|   METHOD_NOT_ALLOWED = 405, | ||||
|   INTERNAL_SERVER_ERROR = 500, | ||||
|   NOT_IMPLEMENTED = 501, | ||||
|   SERVICE_UNAVAILABLE = 503, | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Represents a domain configuration with certificate status information | ||||
|  */ | ||||
| export interface DomainCertificate { | ||||
|   options: DomainOptions; | ||||
|   certObtained: boolean; | ||||
|   obtainingInProgress: boolean; | ||||
|   certificate?: string; | ||||
|   privateKey?: string; | ||||
|   expiryDate?: Date; | ||||
|   lastRenewalAttempt?: Date; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Base error class for HTTP-related errors | ||||
|  */ | ||||
| export class HttpError extends Error { | ||||
|   constructor(message: string) { | ||||
|     super(message); | ||||
|     this.name = 'HttpError'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error related to certificate operations | ||||
|  */ | ||||
| export class CertificateError extends HttpError { | ||||
|   constructor( | ||||
|     message: string, | ||||
|     public readonly domain: string, | ||||
|     public readonly isRenewal: boolean = false | ||||
|   ) { | ||||
|     super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`); | ||||
|     this.name = 'CertificateError'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error related to server operations | ||||
|  */ | ||||
| export class ServerError extends HttpError { | ||||
|   constructor(message: string, public readonly code?: string) { | ||||
|     super(message); | ||||
|     this.name = 'ServerError'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Redirect configuration for HTTP requests | ||||
|  */ | ||||
| export interface RedirectConfig { | ||||
|   source: string;           // Source path or pattern | ||||
|   destination: string;      // Destination URL | ||||
|   type: HttpStatus;         // Redirect status code | ||||
|   preserveQuery?: boolean;  // Whether to preserve query parameters | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * HTTP router configuration | ||||
|  */ | ||||
| export interface RouterConfig { | ||||
|   routes: Array<{ | ||||
|     path: string; | ||||
|     handler: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void; | ||||
|   }>; | ||||
|   notFoundHandler?: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void; | ||||
| } | ||||
|  | ||||
| // Backward compatibility interfaces | ||||
| export { HttpError as Port80HandlerError }; | ||||
| export { CertificateError as CertError }; | ||||
| export type IDomainCertificate = DomainCertificate; | ||||
							
								
								
									
										221
									
								
								ts/http/port80/challenge-responder.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								ts/http/port80/challenge-responder.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,221 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { IncomingMessage, ServerResponse } from 'http'; | ||||
| import {  | ||||
|   CertificateEvents  | ||||
| } from '../../certificate/events/certificate-events.js'; | ||||
| import type { | ||||
|   CertificateData, | ||||
|   CertificateFailure, | ||||
|   CertificateExpiring | ||||
| } from '../../certificate/models/certificate-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Handles ACME HTTP-01 challenge responses | ||||
|  */ | ||||
| export class ChallengeResponder extends plugins.EventEmitter { | ||||
|   private smartAcme: plugins.smartacme.SmartAcme | null = null; | ||||
|   private http01Handler: plugins.smartacme.handlers.Http01MemoryHandler | null = null; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new challenge responder | ||||
|    * @param useProduction Whether to use production ACME servers | ||||
|    * @param email Account email for ACME | ||||
|    * @param certificateStore Directory to store certificates | ||||
|    */ | ||||
|   constructor( | ||||
|     private readonly useProduction: boolean = false, | ||||
|     private readonly email: string = 'admin@example.com', | ||||
|     private readonly certificateStore: string = './certs' | ||||
|   ) { | ||||
|     super(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Initialize the ACME client | ||||
|    */ | ||||
|   public async initialize(): Promise<void> { | ||||
|     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); | ||||
|  | ||||
|       // 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(); | ||||
|     } catch (error) { | ||||
|       throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Ensure certificate store directory exists | ||||
|    */ | ||||
|   private async ensureCertificateStore(): Promise<void> { | ||||
|     try { | ||||
|       await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true }); | ||||
|     } catch (error) { | ||||
|       throw new Error(`Failed to create certificate store: ${error instanceof Error ? error.message : String(error)}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle HTTP request and check if it's an ACME challenge | ||||
|    * @param req HTTP request | ||||
|    * @param res HTTP response | ||||
|    * @returns true if the request was handled as an ACME challenge | ||||
|    */ | ||||
|   public handleRequest(req: IncomingMessage, res: ServerResponse): boolean { | ||||
|     if (!this.http01Handler) { | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     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; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Invalid ACME challenge | ||||
|       res.writeHead(404); | ||||
|       res.end('Not found'); | ||||
|       return true; | ||||
|     } | ||||
|      | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request a certificate for a domain | ||||
|    * @param domain Domain name | ||||
|    * @param isRenewal Whether this is a renewal | ||||
|    */ | ||||
|   public async requestCertificate(domain: string, isRenewal: boolean = false): Promise<CertificateData> { | ||||
|     if (!this.smartAcme) { | ||||
|       throw new Error('ACME client not initialized'); | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const result = await this.smartAcme.getCertificate(domain); | ||||
|        | ||||
|       const certData: CertificateData = { | ||||
|         domain, | ||||
|         certificate: result.cert, | ||||
|         privateKey: result.key, | ||||
|         expiryDate: new Date(result.expiryDate), | ||||
|       }; | ||||
|        | ||||
|       // Emit appropriate event | ||||
|       if (isRenewal) { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData); | ||||
|       } else { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData); | ||||
|       } | ||||
|        | ||||
|       return certData; | ||||
|     } catch (error) { | ||||
|       // Construct failure object | ||||
|       const failure: CertificateFailure = { | ||||
|         domain, | ||||
|         error: error instanceof Error ? error.message : String(error), | ||||
|         isRenewal, | ||||
|       }; | ||||
|        | ||||
|       // Emit failure event | ||||
|       this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); | ||||
|        | ||||
|       throw new Error(`Failed to ${isRenewal ? 'renew' : 'obtain'} certificate for ${domain}: ${ | ||||
|         error instanceof Error ? error.message : String(error) | ||||
|       }`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if a certificate is expiring soon | ||||
|    * @param domain Domain name | ||||
|    * @param certificate Certificate data | ||||
|    * @param thresholdDays Days before expiry to trigger a renewal | ||||
|    */ | ||||
|   public checkCertificateExpiry( | ||||
|     domain: string, | ||||
|     certificate: CertificateData, | ||||
|     thresholdDays: number = 30 | ||||
|   ): void { | ||||
|     if (!certificate.expiryDate) { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     const now = new Date(); | ||||
|     const expiryDate = certificate.expiryDate; | ||||
|     const daysDifference = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); | ||||
|      | ||||
|     if (daysDifference <= thresholdDays) { | ||||
|       const expiryInfo: CertificateExpiring = { | ||||
|         domain, | ||||
|         expiryDate, | ||||
|         daysRemaining: daysDifference, | ||||
|       }; | ||||
|        | ||||
|       this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiryInfo); | ||||
|        | ||||
|       // Automatically attempt renewal if expiring | ||||
|       if (this.smartAcme) { | ||||
|         this.requestCertificate(domain, true).catch(error => { | ||||
|           console.error(`Failed to auto-renew certificate for ${domain}:`, error); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										3
									
								
								ts/http/port80/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/http/port80/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * Port 80 handling | ||||
|  */ | ||||
| @@ -1,57 +1,33 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { IncomingMessage, ServerResponse } from 'http'; | ||||
| import { Port80HandlerEvents } from '../common/types.js'; | ||||
| import { CertificateEvents } from '../../certificate/events/certificate-events.js'; | ||||
| import type { | ||||
|   IForwardConfig, | ||||
|   IDomainOptions, | ||||
|   ICertificateData, | ||||
|   ICertificateFailure, | ||||
|   ICertificateExpiring, | ||||
|   IAcmeOptions | ||||
| } from '../common/types.js'; | ||||
| // (fs and path I/O moved to CertProvisioner) | ||||
|   ForwardConfig, | ||||
|   DomainOptions, | ||||
|   CertificateData, | ||||
|   CertificateFailure, | ||||
|   CertificateExpiring, | ||||
|   AcmeOptions | ||||
| } from '../../certificate/models/certificate-types.js'; | ||||
| import { | ||||
|   HttpEvents, | ||||
|   HttpStatus, | ||||
|   HttpError, | ||||
|   CertificateError, | ||||
|   ServerError, | ||||
|   DomainCertificate | ||||
| } from '../models/http-types.js'; | ||||
| import { ChallengeResponder } from './challenge-responder.js'; | ||||
|  | ||||
| /** | ||||
|  * Custom error classes for better error handling | ||||
|  */ | ||||
| export class Port80HandlerError extends Error { | ||||
|   constructor(message: string) { | ||||
|     super(message); | ||||
|     this.name = 'Port80HandlerError'; | ||||
|   } | ||||
| // Re-export for backward compatibility | ||||
| export { | ||||
|   HttpError as Port80HandlerError, | ||||
|   CertificateError, | ||||
|   ServerError | ||||
| } | ||||
|  | ||||
| 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; | ||||
| } | ||||
| // Port80Handler events enum for backward compatibility | ||||
| export const Port80HandlerEvents = CertificateEvents; | ||||
|  | ||||
| /** | ||||
|  * Configuration options for the Port80Handler | ||||
| @@ -64,25 +40,22 @@ interface IDomainCertificate { | ||||
|  * Now with glob pattern support for domain matching | ||||
|  */ | ||||
| export class Port80Handler extends plugins.EventEmitter { | ||||
|   private domainCertificates: Map<string, IDomainCertificate>; | ||||
|   // SmartAcme instance for certificate management | ||||
|   private smartAcme: plugins.smartacme.SmartAcme | null = null; | ||||
|   private smartAcmeHttp01Handler!: plugins.smartacme.handlers.Http01MemoryHandler; | ||||
|   private domainCertificates: Map<string, DomainCertificate>; | ||||
|   private challengeResponder: ChallengeResponder | 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<IAcmeOptions>; | ||||
|   private options: Required<AcmeOptions>; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new Port80Handler | ||||
|    * @param options Configuration options | ||||
|    */ | ||||
|   constructor(options: IAcmeOptions = {}) { | ||||
|   constructor(options: AcmeOptions = {}) { | ||||
|     super(); | ||||
|     this.domainCertificates = new Map<string, IDomainCertificate>(); | ||||
|      | ||||
|     this.domainCertificates = new Map<string, DomainCertificate>(); | ||||
|  | ||||
|     // Default options | ||||
|     this.options = { | ||||
|       port: options.port ?? 80, | ||||
| @@ -97,6 +70,32 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|       autoRenew: options.autoRenew ?? true, | ||||
|       domainForwards: options.domainForwards ?? [] | ||||
|     }; | ||||
|  | ||||
|     // Initialize challenge responder | ||||
|     if (this.options.enabled) { | ||||
|       this.challengeResponder = new ChallengeResponder( | ||||
|         this.options.useProduction, | ||||
|         this.options.accountEmail, | ||||
|         this.options.certificateStore | ||||
|       ); | ||||
|  | ||||
|       // Forward certificate events from the challenge responder | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_ISSUED, (data: CertificateData) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_ISSUED, data); | ||||
|       }); | ||||
|  | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_RENEWED, (data: CertificateData) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_RENEWED, data); | ||||
|       }); | ||||
|  | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_FAILED, (error: CertificateFailure) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_FAILED, error); | ||||
|       }); | ||||
|  | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_EXPIRING, (expiry: CertificateExpiring) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiry); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|   | ||||
							
								
								
									
										3
									
								
								ts/http/redirects/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/http/redirects/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * HTTP redirects | ||||
|  */ | ||||
							
								
								
									
										3
									
								
								ts/http/router/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/http/router/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * HTTP routing | ||||
|  */ | ||||
							
								
								
									
										18
									
								
								ts/index.ts
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								ts/index.ts
									
									
									
									
									
								
							| @@ -1,12 +1,22 @@ | ||||
| /** | ||||
|  * SmartProxy main module exports | ||||
|  */ | ||||
|  | ||||
| // Legacy exports (to maintain backward compatibility) | ||||
| export * from './nfttablesproxy/classes.nftablesproxy.js'; | ||||
| export * from './networkproxy/index.js'; | ||||
| export * from './port80handler/classes.port80handler.js'; | ||||
| export * from './redirect/classes.redirect.js'; | ||||
| export * from './smartproxy/classes.smartproxy.js'; | ||||
| export * from './smartproxy/classes.pp.snihandler.js'; | ||||
| // Original: export * from './smartproxy/classes.pp.snihandler.js' | ||||
| // Now we export from the new module | ||||
| export { SniHandler } from './tls/sni/sni-handler.js'; | ||||
| export * from './smartproxy/classes.pp.interfaces.js'; | ||||
|  | ||||
| export * from './common/types.js'; | ||||
| // Core types and utilities | ||||
| export * from './core/models/common-types.js'; | ||||
|  | ||||
| // Export forwarding system | ||||
| export * as forwarding from './smartproxy/forwarding/index.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'; | ||||
| @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'; | ||||
| import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './classes.np.types.js'; | ||||
| import { Port80Handler } from '../port80handler/classes.port80handler.js'; | ||||
| import { Port80HandlerEvents } from '../common/types.js'; | ||||
| import { buildPort80Handler } from '../common/acmeFactory.js'; | ||||
| import { buildPort80Handler } from '../certificate/acme/acme-factory.js'; | ||||
| import { subscribeToPort80Handler } from '../common/eventUtils.js'; | ||||
| import type { IDomainOptions } from '../common/types.js'; | ||||
|  | ||||
|   | ||||
							
								
								
									
										8
									
								
								ts/proxies/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								ts/proxies/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| /** | ||||
|  * Proxy implementations module | ||||
|  */ | ||||
|  | ||||
| // Export submodules | ||||
| export * from './smart-proxy/index.js'; | ||||
| export * from './network-proxy/index.js'; | ||||
| export * from './nftables-proxy/index.js'; | ||||
							
								
								
									
										3
									
								
								ts/proxies/network-proxy/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/proxies/network-proxy/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * NetworkProxy implementation | ||||
|  */ | ||||
							
								
								
									
										3
									
								
								ts/proxies/network-proxy/models/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/proxies/network-proxy/models/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * NetworkProxy models | ||||
|  */ | ||||
							
								
								
									
										3
									
								
								ts/proxies/nftables-proxy/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/proxies/nftables-proxy/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * NfTablesProxy implementation | ||||
|  */ | ||||
							
								
								
									
										3
									
								
								ts/proxies/smart-proxy/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/proxies/smart-proxy/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * SmartProxy implementation | ||||
|  */ | ||||
							
								
								
									
										3
									
								
								ts/proxies/smart-proxy/models/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/proxies/smart-proxy/models/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * SmartProxy models | ||||
|  */ | ||||
| @@ -3,7 +3,7 @@ import { NetworkProxy } from '../networkproxy/classes.np.networkproxy.js'; | ||||
| import { Port80Handler } from '../port80handler/classes.port80handler.js'; | ||||
| import { Port80HandlerEvents } from '../common/types.js'; | ||||
| import { subscribeToPort80Handler } from '../common/eventUtils.js'; | ||||
| import type { ICertificateData } from '../common/types.js'; | ||||
| import type { CertificateData } from '../certificate/models/certificate-types.js'; | ||||
| import type { IConnectionRecord, ISmartProxyOptions, IDomainConfig } from './classes.pp.interfaces.js'; | ||||
|  | ||||
| /** | ||||
| @@ -66,7 +66,7 @@ export class NetworkProxyBridge { | ||||
|   /** | ||||
|    * Handle certificate issuance or renewal events | ||||
|    */ | ||||
|   private handleCertificateEvent(data: ICertificateData): void { | ||||
|   private handleCertificateEvent(data: CertificateData): void { | ||||
|     if (!this.networkProxy) return; | ||||
|      | ||||
|     console.log(`Received certificate for ${data.domain} from Port80Handler, updating NetworkProxy`); | ||||
| @@ -99,7 +99,7 @@ export class NetworkProxyBridge { | ||||
|   /** | ||||
|    * Apply an external (static) certificate into NetworkProxy | ||||
|    */ | ||||
|   public applyExternalCertificate(data: ICertificateData): void { | ||||
|   public applyExternalCertificate(data: CertificateData): void { | ||||
|     if (!this.networkProxy) { | ||||
|       console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`); | ||||
|       return; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import type { ISmartProxyOptions } from './classes.pp.interfaces.js'; | ||||
| import { SniHandler } from './classes.pp.snihandler.js'; | ||||
| import { SniHandler } from '../tls/sni/sni-handler.js'; | ||||
|  | ||||
| /** | ||||
|  * Interface for connection information used for SNI extraction | ||||
|   | ||||
| @@ -9,9 +9,9 @@ import { TimeoutManager } from './classes.pp.timeoutmanager.js'; | ||||
| import { PortRangeManager } from './classes.pp.portrangemanager.js'; | ||||
| import { ConnectionHandler } from './classes.pp.connectionhandler.js'; | ||||
| import { Port80Handler } from '../port80handler/classes.port80handler.js'; | ||||
| import { CertProvisioner } from './classes.pp.certprovisioner.js'; | ||||
| import type { ICertificateData } from '../common/types.js'; | ||||
| import { buildPort80Handler } from '../common/acmeFactory.js'; | ||||
| import { CertProvisioner } from '../certificate/providers/cert-provisioner.js'; | ||||
| import type { CertificateData } from '../certificate/models/certificate-types.js'; | ||||
| import { buildPort80Handler } from '../certificate/acme/acme-factory.js'; | ||||
| import type { ForwardingType } from './types/forwarding.types.js'; | ||||
| import { createPort80HandlerOptions } from '../common/port80-adapter.js'; | ||||
|  | ||||
| @@ -470,7 +470,7 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|           } else { | ||||
|             // Static certificate (e.g., DNS-01 provisioned) supports wildcards | ||||
|             const certObj = provision as plugins.tsclass.network.ICert; | ||||
|             const certData: ICertificateData = { | ||||
|             const certData: CertificateData = { | ||||
|               domain: certObj.domainName, | ||||
|               certificate: certObj.publicKey, | ||||
|               privateKey: certObj.privateKey, | ||||
|   | ||||
							
								
								
									
										3
									
								
								ts/tls/alerts/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/tls/alerts/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * TLS alerts | ||||
|  */ | ||||
| @@ -1,52 +1,53 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { TlsAlertLevel, TlsAlertDescription, TlsVersion } from '../utils/tls-utils.js'; | ||||
|  | ||||
| /** | ||||
|  * TlsAlert class for managing TLS alert messages | ||||
|  * TlsAlert class for creating and sending TLS alert messages | ||||
|  */ | ||||
| export class TlsAlert { | ||||
|   // TLS Alert Levels | ||||
|   static readonly LEVEL_WARNING = 0x01; | ||||
|   static readonly LEVEL_FATAL = 0x02; | ||||
|    | ||||
|   // TLS Alert Description Codes - RFC 8446 (TLS 1.3) / RFC 5246 (TLS 1.2) | ||||
|   static readonly CLOSE_NOTIFY = 0x00; | ||||
|   static readonly UNEXPECTED_MESSAGE = 0x0A; | ||||
|   static readonly BAD_RECORD_MAC = 0x14; | ||||
|   static readonly DECRYPTION_FAILED = 0x15; // TLS 1.0 only | ||||
|   static readonly RECORD_OVERFLOW = 0x16; | ||||
|   static readonly DECOMPRESSION_FAILURE = 0x1E; // TLS 1.2 and below | ||||
|   static readonly HANDSHAKE_FAILURE = 0x28; | ||||
|   static readonly NO_CERTIFICATE = 0x29; // SSLv3 only | ||||
|   static readonly BAD_CERTIFICATE = 0x2A; | ||||
|   static readonly UNSUPPORTED_CERTIFICATE = 0x2B; | ||||
|   static readonly CERTIFICATE_REVOKED = 0x2C; | ||||
|   static readonly CERTIFICATE_EXPIRED = 0x2F; | ||||
|   static readonly CERTIFICATE_UNKNOWN = 0x30; | ||||
|   static readonly ILLEGAL_PARAMETER = 0x2F; | ||||
|   static readonly UNKNOWN_CA = 0x30; | ||||
|   static readonly ACCESS_DENIED = 0x31; | ||||
|   static readonly DECODE_ERROR = 0x32; | ||||
|   static readonly DECRYPT_ERROR = 0x33; | ||||
|   static readonly EXPORT_RESTRICTION = 0x3C; // TLS 1.0 only | ||||
|   static readonly PROTOCOL_VERSION = 0x46; | ||||
|   static readonly INSUFFICIENT_SECURITY = 0x47; | ||||
|   static readonly INTERNAL_ERROR = 0x50; | ||||
|   static readonly INAPPROPRIATE_FALLBACK = 0x56; | ||||
|   static readonly USER_CANCELED = 0x5A; | ||||
|   static readonly NO_RENEGOTIATION = 0x64; // TLS 1.2 and below | ||||
|   static readonly MISSING_EXTENSION = 0x6D; // TLS 1.3 | ||||
|   static readonly UNSUPPORTED_EXTENSION = 0x6E; // TLS 1.3 | ||||
|   static readonly CERTIFICATE_REQUIRED = 0x6F; // TLS 1.3 | ||||
|   static readonly UNRECOGNIZED_NAME = 0x70; | ||||
|   static readonly BAD_CERTIFICATE_STATUS_RESPONSE = 0x71; | ||||
|   static readonly BAD_CERTIFICATE_HASH_VALUE = 0x72; // TLS 1.2 and below | ||||
|   static readonly UNKNOWN_PSK_IDENTITY = 0x73; | ||||
|   static readonly CERTIFICATE_REQUIRED_1_3 = 0x74; // TLS 1.3 | ||||
|   static readonly NO_APPLICATION_PROTOCOL = 0x78; | ||||
|   // Use enum values from TlsAlertLevel | ||||
|   static readonly LEVEL_WARNING = TlsAlertLevel.WARNING; | ||||
|   static readonly LEVEL_FATAL = TlsAlertLevel.FATAL; | ||||
|  | ||||
|   // Use enum values from TlsAlertDescription | ||||
|   static readonly CLOSE_NOTIFY = TlsAlertDescription.CLOSE_NOTIFY; | ||||
|   static readonly UNEXPECTED_MESSAGE = TlsAlertDescription.UNEXPECTED_MESSAGE; | ||||
|   static readonly BAD_RECORD_MAC = TlsAlertDescription.BAD_RECORD_MAC; | ||||
|   static readonly DECRYPTION_FAILED = TlsAlertDescription.DECRYPTION_FAILED; | ||||
|   static readonly RECORD_OVERFLOW = TlsAlertDescription.RECORD_OVERFLOW; | ||||
|   static readonly DECOMPRESSION_FAILURE = TlsAlertDescription.DECOMPRESSION_FAILURE; | ||||
|   static readonly HANDSHAKE_FAILURE = TlsAlertDescription.HANDSHAKE_FAILURE; | ||||
|   static readonly NO_CERTIFICATE = TlsAlertDescription.NO_CERTIFICATE; | ||||
|   static readonly BAD_CERTIFICATE = TlsAlertDescription.BAD_CERTIFICATE; | ||||
|   static readonly UNSUPPORTED_CERTIFICATE = TlsAlertDescription.UNSUPPORTED_CERTIFICATE; | ||||
|   static readonly CERTIFICATE_REVOKED = TlsAlertDescription.CERTIFICATE_REVOKED; | ||||
|   static readonly CERTIFICATE_EXPIRED = TlsAlertDescription.CERTIFICATE_EXPIRED; | ||||
|   static readonly CERTIFICATE_UNKNOWN = TlsAlertDescription.CERTIFICATE_UNKNOWN; | ||||
|   static readonly ILLEGAL_PARAMETER = TlsAlertDescription.ILLEGAL_PARAMETER; | ||||
|   static readonly UNKNOWN_CA = TlsAlertDescription.UNKNOWN_CA; | ||||
|   static readonly ACCESS_DENIED = TlsAlertDescription.ACCESS_DENIED; | ||||
|   static readonly DECODE_ERROR = TlsAlertDescription.DECODE_ERROR; | ||||
|   static readonly DECRYPT_ERROR = TlsAlertDescription.DECRYPT_ERROR; | ||||
|   static readonly EXPORT_RESTRICTION = TlsAlertDescription.EXPORT_RESTRICTION; | ||||
|   static readonly PROTOCOL_VERSION = TlsAlertDescription.PROTOCOL_VERSION; | ||||
|   static readonly INSUFFICIENT_SECURITY = TlsAlertDescription.INSUFFICIENT_SECURITY; | ||||
|   static readonly INTERNAL_ERROR = TlsAlertDescription.INTERNAL_ERROR; | ||||
|   static readonly INAPPROPRIATE_FALLBACK = TlsAlertDescription.INAPPROPRIATE_FALLBACK; | ||||
|   static readonly USER_CANCELED = TlsAlertDescription.USER_CANCELED; | ||||
|   static readonly NO_RENEGOTIATION = TlsAlertDescription.NO_RENEGOTIATION; | ||||
|   static readonly MISSING_EXTENSION = TlsAlertDescription.MISSING_EXTENSION; | ||||
|   static readonly UNSUPPORTED_EXTENSION = TlsAlertDescription.UNSUPPORTED_EXTENSION; | ||||
|   static readonly CERTIFICATE_REQUIRED = TlsAlertDescription.CERTIFICATE_REQUIRED; | ||||
|   static readonly UNRECOGNIZED_NAME = TlsAlertDescription.UNRECOGNIZED_NAME; | ||||
|   static readonly BAD_CERTIFICATE_STATUS_RESPONSE = TlsAlertDescription.BAD_CERTIFICATE_STATUS_RESPONSE; | ||||
|   static readonly BAD_CERTIFICATE_HASH_VALUE = TlsAlertDescription.BAD_CERTIFICATE_HASH_VALUE; | ||||
|   static readonly UNKNOWN_PSK_IDENTITY = TlsAlertDescription.UNKNOWN_PSK_IDENTITY; | ||||
|   static readonly CERTIFICATE_REQUIRED_1_3 = TlsAlertDescription.CERTIFICATE_REQUIRED_1_3; | ||||
|   static readonly NO_APPLICATION_PROTOCOL = TlsAlertDescription.NO_APPLICATION_PROTOCOL; | ||||
|    | ||||
|   /** | ||||
|    * Create a TLS alert buffer with the specified level and description code | ||||
|    *  | ||||
|    * | ||||
|    * @param level Alert level (warning or fatal) | ||||
|    * @param description Alert description code | ||||
|    * @param tlsVersion TLS version bytes (default is TLS 1.2: 0x0303) | ||||
| @@ -55,7 +56,7 @@ export class TlsAlert { | ||||
|   static create( | ||||
|     level: number, | ||||
|     description: number, | ||||
|     tlsVersion: [number, number] = [0x03, 0x03] | ||||
|     tlsVersion: [number, number] = [TlsVersion.TLS1_2[0], TlsVersion.TLS1_2[1]] | ||||
|   ): Buffer { | ||||
|     return Buffer.from([ | ||||
|       0x15, // Alert record type | ||||
|   | ||||
							
								
								
									
										33
									
								
								ts/tls/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								ts/tls/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| /** | ||||
|  * TLS module providing SNI extraction, TLS alerts, and other TLS-related utilities | ||||
|  */ | ||||
|  | ||||
| // Export TLS alert functionality | ||||
| export * from './alerts/tls-alert.js'; | ||||
|  | ||||
| // Export SNI handling | ||||
| export * from './sni/sni-handler.js'; | ||||
| export * from './sni/sni-extraction.js'; | ||||
| export * from './sni/client-hello-parser.js'; | ||||
|  | ||||
| // Export TLS utilities | ||||
| export * from './utils/tls-utils.js'; | ||||
|  | ||||
| // Create a namespace for SNI utilities | ||||
| import { SniHandler } from './sni/sni-handler.js'; | ||||
| import { SniExtraction } from './sni/sni-extraction.js'; | ||||
| import { ClientHelloParser } from './sni/client-hello-parser.js'; | ||||
|  | ||||
| // Export utility objects for convenience | ||||
| export const SNI = { | ||||
|   // Main handler class (for backward compatibility) | ||||
|   Handler: SniHandler, | ||||
|  | ||||
|   // Utility classes | ||||
|   Extraction: SniExtraction, | ||||
|   Parser: ClientHelloParser, | ||||
|  | ||||
|   // Convenience functions | ||||
|   extractSNI: SniHandler.extractSNI, | ||||
|   processTlsPacket: SniHandler.processTlsPacket, | ||||
| }; | ||||
							
								
								
									
										629
									
								
								ts/tls/sni/client-hello-parser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										629
									
								
								ts/tls/sni/client-hello-parser.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,629 @@ | ||||
| import { Buffer } from 'buffer'; | ||||
| import {  | ||||
|   TlsRecordType, | ||||
|   TlsHandshakeType, | ||||
|   TlsExtensionType, | ||||
|   TlsUtils | ||||
| } from '../utils/tls-utils.js'; | ||||
|  | ||||
| /** | ||||
|  * Interface for logging functions used by the parser | ||||
|  */ | ||||
| export type LoggerFunction = (message: string) => void; | ||||
|  | ||||
| /** | ||||
|  * Result of a session resumption check | ||||
|  */ | ||||
| export interface SessionResumptionResult { | ||||
|   isResumption: boolean; | ||||
|   hasSNI: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Information about parsed TLS extensions | ||||
|  */ | ||||
| export interface ExtensionInfo { | ||||
|   type: number; | ||||
|   length: number; | ||||
|   data: Buffer; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Result of a ClientHello parse operation | ||||
|  */ | ||||
| export interface ClientHelloParseResult { | ||||
|   isValid: boolean; | ||||
|   version?: [number, number]; | ||||
|   random?: Buffer; | ||||
|   sessionId?: Buffer; | ||||
|   hasSessionId: boolean; | ||||
|   cipherSuites?: Buffer; | ||||
|   compressionMethods?: Buffer; | ||||
|   extensions: ExtensionInfo[]; | ||||
|   serverNameList?: string[]; | ||||
|   hasSessionTicket: boolean; | ||||
|   hasPsk: boolean; | ||||
|   hasEarlyData: boolean; | ||||
|   error?: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fragment tracking information | ||||
|  */ | ||||
| export interface FragmentTrackingInfo { | ||||
|   buffer: Buffer; | ||||
|   timestamp: number; | ||||
|   connectionId: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Class for parsing TLS ClientHello messages | ||||
|  */ | ||||
| export class ClientHelloParser { | ||||
|   // Buffer for handling fragmented ClientHello messages | ||||
|   private static fragmentedBuffers: Map<string, FragmentTrackingInfo> = new Map(); | ||||
|   private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup | ||||
|  | ||||
|   /** | ||||
|    * Clean up expired fragments | ||||
|    */ | ||||
|   private static cleanupExpiredFragments(): void { | ||||
|     const now = Date.now(); | ||||
|     for (const [connectionId, info] of this.fragmentedBuffers.entries()) { | ||||
|       if (now - info.timestamp > this.fragmentTimeout) { | ||||
|         this.fragmentedBuffers.delete(connectionId); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handles potential fragmented ClientHello messages by buffering and reassembling | ||||
|    * TLS record fragments that might span multiple TCP packets. | ||||
|    * | ||||
|    * @param buffer The current buffer fragment | ||||
|    * @param connectionId Unique identifier for the connection | ||||
|    * @param logger Optional logging function | ||||
|    * @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed | ||||
|    */ | ||||
|   public static handleFragmentedClientHello( | ||||
|     buffer: Buffer, | ||||
|     connectionId: string, | ||||
|     logger?: LoggerFunction | ||||
|   ): Buffer | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     // Periodically clean up expired fragments | ||||
|     this.cleanupExpiredFragments(); | ||||
|  | ||||
|     // Check if we've seen this connection before | ||||
|     if (!this.fragmentedBuffers.has(connectionId)) { | ||||
|       // New connection, start with this buffer | ||||
|       this.fragmentedBuffers.set(connectionId, { | ||||
|         buffer, | ||||
|         timestamp: Date.now(), | ||||
|         connectionId | ||||
|       }); | ||||
|  | ||||
|       // Evaluate if this buffer already contains a complete ClientHello | ||||
|       try { | ||||
|         if (buffer.length >= 5) { | ||||
|           // Get the record length from TLS header | ||||
|           const recordLength = (buffer[3] << 8) + buffer[4] + 5; // +5 for the TLS record header itself | ||||
|           log(`Initial buffer size: ${buffer.length}, expected record length: ${recordLength}`); | ||||
|  | ||||
|           // Check if this buffer already contains a complete TLS record | ||||
|           if (buffer.length >= recordLength) { | ||||
|             log(`Initial buffer contains complete ClientHello, length: ${buffer.length}`); | ||||
|             return buffer; | ||||
|           } | ||||
|         } else { | ||||
|           log( | ||||
|             `Initial buffer too small (${buffer.length} bytes), needs at least 5 bytes for TLS header` | ||||
|           ); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         log(`Error checking initial buffer completeness: ${e}`); | ||||
|       } | ||||
|  | ||||
|       log(`Started buffering connection ${connectionId}, initial size: ${buffer.length}`); | ||||
|       return undefined; // Need more fragments | ||||
|     } else { | ||||
|       // Existing connection, append this buffer | ||||
|       const existingInfo = this.fragmentedBuffers.get(connectionId)!; | ||||
|       const newBuffer = Buffer.concat([existingInfo.buffer, buffer]); | ||||
|        | ||||
|       // Update the buffer and timestamp | ||||
|       this.fragmentedBuffers.set(connectionId, { | ||||
|         ...existingInfo, | ||||
|         buffer: newBuffer, | ||||
|         timestamp: Date.now() | ||||
|       }); | ||||
|  | ||||
|       log(`Appended to buffer for ${connectionId}, new size: ${newBuffer.length}`); | ||||
|  | ||||
|       // Check if we now have a complete ClientHello | ||||
|       try { | ||||
|         if (newBuffer.length >= 5) { | ||||
|           // Get the record length from TLS header | ||||
|           const recordLength = (newBuffer[3] << 8) + newBuffer[4] + 5; // +5 for the TLS record header itself | ||||
|           log( | ||||
|             `Reassembled buffer size: ${newBuffer.length}, expected record length: ${recordLength}` | ||||
|           ); | ||||
|  | ||||
|           // Check if we have a complete TLS record now | ||||
|           if (newBuffer.length >= recordLength) { | ||||
|             log( | ||||
|               `Assembled complete ClientHello, length: ${newBuffer.length}, needed: ${recordLength}` | ||||
|             ); | ||||
|  | ||||
|             // Extract the complete TLS record (might be followed by more data) | ||||
|             const completeRecord = newBuffer.slice(0, recordLength); | ||||
|  | ||||
|             // Check if this record is indeed a ClientHello (type 1) at position 5 | ||||
|             if ( | ||||
|               completeRecord.length > 5 && | ||||
|               completeRecord[5] === TlsHandshakeType.CLIENT_HELLO | ||||
|             ) { | ||||
|               log(`Verified record is a ClientHello handshake message`); | ||||
|  | ||||
|               // Complete message received, remove from tracking | ||||
|               this.fragmentedBuffers.delete(connectionId); | ||||
|               return completeRecord; | ||||
|             } else { | ||||
|               log(`Record is complete but not a ClientHello handshake, continuing to buffer`); | ||||
|               // This might be another TLS record type preceding the ClientHello | ||||
|  | ||||
|               // Try checking for a ClientHello starting at the end of this record | ||||
|               if (newBuffer.length > recordLength + 5) { | ||||
|                 const nextRecordType = newBuffer[recordLength]; | ||||
|                 log( | ||||
|                   `Next record type: ${nextRecordType} (looking for ${TlsRecordType.HANDSHAKE})` | ||||
|                 ); | ||||
|  | ||||
|                 if (nextRecordType === TlsRecordType.HANDSHAKE) { | ||||
|                   const handshakeType = newBuffer[recordLength + 5]; | ||||
|                   log( | ||||
|                     `Next handshake type: ${handshakeType} (looking for ${TlsHandshakeType.CLIENT_HELLO})` | ||||
|                   ); | ||||
|  | ||||
|                   if (handshakeType === TlsHandshakeType.CLIENT_HELLO) { | ||||
|                     // Found a ClientHello in the next record, return the entire buffer | ||||
|                     log(`Found ClientHello in subsequent record, returning full buffer`); | ||||
|                     this.fragmentedBuffers.delete(connectionId); | ||||
|                     return newBuffer; | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } catch (e) { | ||||
|         log(`Error checking reassembled buffer completeness: ${e}`); | ||||
|       } | ||||
|  | ||||
|       return undefined; // Still need more fragments | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Parses a TLS ClientHello message and extracts all components | ||||
|    *  | ||||
|    * @param buffer The buffer containing the ClientHello message | ||||
|    * @param logger Optional logging function | ||||
|    * @returns Parsed ClientHello or undefined if parsing failed | ||||
|    */ | ||||
|   public static parseClientHello( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): ClientHelloParseResult { | ||||
|     const log = logger || (() => {}); | ||||
|     const result: ClientHelloParseResult = { | ||||
|       isValid: false, | ||||
|       hasSessionId: false, | ||||
|       extensions: [], | ||||
|       hasSessionTicket: false, | ||||
|       hasPsk: false, | ||||
|       hasEarlyData: false | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       // Check basic validity | ||||
|       if (buffer.length < 5) { | ||||
|         result.error = 'Buffer too small for TLS record header'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Check record type (must be HANDSHAKE) | ||||
|       if (buffer[0] !== TlsRecordType.HANDSHAKE) { | ||||
|         result.error = `Not a TLS handshake record: ${buffer[0]}`; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Get TLS version from record header | ||||
|       const majorVersion = buffer[1]; | ||||
|       const minorVersion = buffer[2]; | ||||
|       result.version = [majorVersion, minorVersion]; | ||||
|       log(`TLS record version: ${majorVersion}.${minorVersion}`); | ||||
|  | ||||
|       // Parse record length (bytes 3-4, big-endian) | ||||
|       const recordLength = (buffer[3] << 8) + buffer[4]; | ||||
|       log(`Record length: ${recordLength}`); | ||||
|  | ||||
|       // Validate record length against buffer size | ||||
|       if (buffer.length < recordLength + 5) { | ||||
|         result.error = 'Buffer smaller than expected record length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Start of handshake message in the buffer | ||||
|       let pos = 5; | ||||
|  | ||||
|       // Check handshake type (must be CLIENT_HELLO) | ||||
|       if (buffer[pos] !== TlsHandshakeType.CLIENT_HELLO) { | ||||
|         result.error = `Not a ClientHello message: ${buffer[pos]}`; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Skip handshake type (1 byte) | ||||
|       pos += 1; | ||||
|  | ||||
|       // Parse handshake length (3 bytes, big-endian) | ||||
|       const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2]; | ||||
|       log(`Handshake length: ${handshakeLength}`); | ||||
|  | ||||
|       // Skip handshake length (3 bytes) | ||||
|       pos += 3; | ||||
|  | ||||
|       // Check client version (2 bytes) | ||||
|       const clientMajorVersion = buffer[pos]; | ||||
|       const clientMinorVersion = buffer[pos + 1]; | ||||
|       log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`); | ||||
|  | ||||
|       // Skip client version (2 bytes) | ||||
|       pos += 2; | ||||
|  | ||||
|       // Extract client random (32 bytes) | ||||
|       if (pos + 32 > buffer.length) { | ||||
|         result.error = 'Buffer too small for client random'; | ||||
|         return result; | ||||
|       } | ||||
|        | ||||
|       result.random = buffer.slice(pos, pos + 32); | ||||
|       log(`Client random: ${result.random.toString('hex')}`); | ||||
|  | ||||
|       // Skip client random (32 bytes) | ||||
|       pos += 32; | ||||
|  | ||||
|       // Parse session ID | ||||
|       if (pos + 1 > buffer.length) { | ||||
|         result.error = 'Buffer too small for session ID length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       const sessionIdLength = buffer[pos]; | ||||
|       log(`Session ID length: ${sessionIdLength}`); | ||||
|       pos += 1; | ||||
|        | ||||
|       result.hasSessionId = sessionIdLength > 0; | ||||
|        | ||||
|       if (sessionIdLength > 0) { | ||||
|         if (pos + sessionIdLength > buffer.length) { | ||||
|           result.error = 'Buffer too small for session ID'; | ||||
|           return result; | ||||
|         } | ||||
|          | ||||
|         result.sessionId = buffer.slice(pos, pos + sessionIdLength); | ||||
|         log(`Session ID: ${result.sessionId.toString('hex')}`); | ||||
|       } | ||||
|  | ||||
|       // Skip session ID | ||||
|       pos += sessionIdLength; | ||||
|  | ||||
|       // Check if we have enough bytes left for cipher suites | ||||
|       if (pos + 2 > buffer.length) { | ||||
|         result.error = 'Buffer too small for cipher suites length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Parse cipher suites length (2 bytes, big-endian) | ||||
|       const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|       log(`Cipher suites length: ${cipherSuitesLength}`); | ||||
|       pos += 2; | ||||
|        | ||||
|       // Extract cipher suites | ||||
|       if (pos + cipherSuitesLength > buffer.length) { | ||||
|         result.error = 'Buffer too small for cipher suites'; | ||||
|         return result; | ||||
|       } | ||||
|        | ||||
|       result.cipherSuites = buffer.slice(pos, pos + cipherSuitesLength); | ||||
|  | ||||
|       // Skip cipher suites | ||||
|       pos += cipherSuitesLength; | ||||
|  | ||||
|       // Check if we have enough bytes left for compression methods | ||||
|       if (pos + 1 > buffer.length) { | ||||
|         result.error = 'Buffer too small for compression methods length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Parse compression methods length (1 byte) | ||||
|       const compressionMethodsLength = buffer[pos]; | ||||
|       log(`Compression methods length: ${compressionMethodsLength}`); | ||||
|       pos += 1; | ||||
|        | ||||
|       // Extract compression methods | ||||
|       if (pos + compressionMethodsLength > buffer.length) { | ||||
|         result.error = 'Buffer too small for compression methods'; | ||||
|         return result; | ||||
|       } | ||||
|        | ||||
|       result.compressionMethods = buffer.slice(pos, pos + compressionMethodsLength); | ||||
|  | ||||
|       // Skip compression methods | ||||
|       pos += compressionMethodsLength; | ||||
|  | ||||
|       // Check if we have enough bytes for extensions length | ||||
|       if (pos + 2 > buffer.length) { | ||||
|         // No extensions present - this is valid for older TLS versions | ||||
|         result.isValid = true; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Parse extensions length (2 bytes, big-endian) | ||||
|       const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|       log(`Extensions length: ${extensionsLength}`); | ||||
|       pos += 2; | ||||
|  | ||||
|       // Extensions end position | ||||
|       const extensionsEnd = pos + extensionsLength; | ||||
|  | ||||
|       // Check if extensions length is valid | ||||
|       if (extensionsEnd > buffer.length) { | ||||
|         result.error = 'Extensions length exceeds buffer size'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Iterate through extensions | ||||
|       const serverNames: string[] = []; | ||||
|        | ||||
|       while (pos + 4 <= extensionsEnd) { | ||||
|         // Parse extension type (2 bytes, big-endian) | ||||
|         const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|         log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`); | ||||
|         pos += 2; | ||||
|  | ||||
|         // Parse extension length (2 bytes, big-endian) | ||||
|         const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|         log(`Extension length: ${extensionLength}`); | ||||
|         pos += 2; | ||||
|          | ||||
|         // Extract extension data | ||||
|         if (pos + extensionLength > extensionsEnd) { | ||||
|           result.error = `Extension ${extensionType} data exceeds bounds`; | ||||
|           return result; | ||||
|         } | ||||
|          | ||||
|         const extensionData = buffer.slice(pos, pos + extensionLength); | ||||
|          | ||||
|         // Record all extensions | ||||
|         result.extensions.push({ | ||||
|           type: extensionType, | ||||
|           length: extensionLength, | ||||
|           data: extensionData | ||||
|         }); | ||||
|          | ||||
|         // Track specific extension types | ||||
|         if (extensionType === TlsExtensionType.SERVER_NAME) { | ||||
|           // Server Name Indication (SNI) | ||||
|           this.parseServerNameExtension(extensionData, serverNames, logger); | ||||
|         } else if (extensionType === TlsExtensionType.SESSION_TICKET) { | ||||
|           // Session ticket | ||||
|           result.hasSessionTicket = true; | ||||
|         } else if (extensionType === TlsExtensionType.PRE_SHARED_KEY) { | ||||
|           // TLS 1.3 PSK | ||||
|           result.hasPsk = true; | ||||
|         } else if (extensionType === TlsExtensionType.EARLY_DATA) { | ||||
|           // TLS 1.3 Early Data (0-RTT) | ||||
|           result.hasEarlyData = true; | ||||
|         } | ||||
|          | ||||
|         // Move to next extension | ||||
|         pos += extensionLength; | ||||
|       } | ||||
|        | ||||
|       // Store any server names found | ||||
|       if (serverNames.length > 0) { | ||||
|         result.serverNameList = serverNames; | ||||
|       } | ||||
|        | ||||
|       // Mark as valid if we get here | ||||
|       result.isValid = true; | ||||
|       return result; | ||||
|     } catch (error) { | ||||
|       const errorMessage = error instanceof Error ? error.message : String(error); | ||||
|       log(`Error parsing ClientHello: ${errorMessage}`); | ||||
|       result.error = errorMessage; | ||||
|       return result; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Parses the server name extension data and extracts hostnames | ||||
|    *  | ||||
|    * @param data Extension data buffer | ||||
|    * @param serverNames Array to populate with found server names | ||||
|    * @param logger Optional logging function | ||||
|    * @returns true if parsing succeeded | ||||
|    */ | ||||
|   private static parseServerNameExtension( | ||||
|     data: Buffer, | ||||
|     serverNames: string[], | ||||
|     logger?: LoggerFunction | ||||
|   ): boolean { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     try { | ||||
|       // Need at least 2 bytes for server name list length | ||||
|       if (data.length < 2) { | ||||
|         log('SNI extension too small for server name list length'); | ||||
|         return false; | ||||
|       } | ||||
|        | ||||
|       // Parse server name list length (2 bytes) | ||||
|       const listLength = (data[0] << 8) + data[1]; | ||||
|        | ||||
|       // Skip to first name entry | ||||
|       let pos = 2; | ||||
|        | ||||
|       // End of list | ||||
|       const listEnd = pos + listLength; | ||||
|        | ||||
|       // Validate length | ||||
|       if (listEnd > data.length) { | ||||
|         log('SNI server name list exceeds extension data'); | ||||
|         return false; | ||||
|       } | ||||
|        | ||||
|       // Process all name entries | ||||
|       while (pos + 3 <= listEnd) { | ||||
|         // Name type (1 byte) | ||||
|         const nameType = data[pos]; | ||||
|         pos += 1; | ||||
|          | ||||
|         // For hostname, type must be 0 | ||||
|         if (nameType !== 0) { | ||||
|           // Skip this entry | ||||
|           if (pos + 2 <= listEnd) { | ||||
|             const nameLength = (data[pos] << 8) + data[pos + 1]; | ||||
|             pos += 2 + nameLength; | ||||
|             continue; | ||||
|           } else { | ||||
|             log('Malformed SNI entry'); | ||||
|             return false; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         // Parse hostname length (2 bytes) | ||||
|         if (pos + 2 > listEnd) { | ||||
|           log('SNI extension truncated'); | ||||
|           return false; | ||||
|         } | ||||
|          | ||||
|         const nameLength = (data[pos] << 8) + data[pos + 1]; | ||||
|         pos += 2; | ||||
|          | ||||
|         // Extract hostname | ||||
|         if (pos + nameLength > listEnd) { | ||||
|           log('SNI hostname truncated'); | ||||
|           return false; | ||||
|         } | ||||
|          | ||||
|         // Extract the hostname as UTF-8 | ||||
|         try { | ||||
|           const hostname = data.slice(pos, pos + nameLength).toString('utf8'); | ||||
|           log(`Found SNI hostname: ${hostname}`); | ||||
|           serverNames.push(hostname); | ||||
|         } catch (err) { | ||||
|           log(`Error extracting hostname: ${err}`); | ||||
|         } | ||||
|          | ||||
|         // Move to next entry | ||||
|         pos += nameLength; | ||||
|       } | ||||
|        | ||||
|       return serverNames.length > 0; | ||||
|     } catch (error) { | ||||
|       log(`Error parsing SNI extension: ${error}`); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Determines if a ClientHello contains session resumption indicators | ||||
|    *  | ||||
|    * @param buffer The ClientHello buffer | ||||
|    * @param logger Optional logging function | ||||
|    * @returns Session resumption result | ||||
|    */ | ||||
|   public static hasSessionResumption( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): SessionResumptionResult { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     if (!TlsUtils.isClientHello(buffer)) { | ||||
|       return { isResumption: false, hasSNI: false }; | ||||
|     } | ||||
|      | ||||
|     const parseResult = this.parseClientHello(buffer, logger); | ||||
|     if (!parseResult.isValid) { | ||||
|       log(`ClientHello parse failed: ${parseResult.error}`); | ||||
|       return { isResumption: false, hasSNI: false }; | ||||
|     } | ||||
|      | ||||
|     // Check resumption indicators | ||||
|     const hasSessionId = parseResult.hasSessionId; | ||||
|     const hasSessionTicket = parseResult.hasSessionTicket; | ||||
|     const hasPsk = parseResult.hasPsk; | ||||
|     const hasEarlyData = parseResult.hasEarlyData; | ||||
|      | ||||
|     // Check for SNI | ||||
|     const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0; | ||||
|      | ||||
|     // Consider it a resumption if any resumption mechanism is present | ||||
|     const isResumption = hasSessionTicket || hasPsk || hasEarlyData ||  | ||||
|                          (hasSessionId && !hasPsk); // Legacy resumption | ||||
|      | ||||
|     // Log details | ||||
|     if (isResumption) { | ||||
|       log( | ||||
|         'Session resumption detected: ' + | ||||
|         (hasSessionTicket ? 'session ticket, ' : '') + | ||||
|         (hasPsk ? 'PSK, ' : '') + | ||||
|         (hasEarlyData ? 'early data, ' : '') + | ||||
|         (hasSessionId ? 'session ID' : '') + | ||||
|         (hasSNI ? ', with SNI' : ', without SNI') | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     return { isResumption, hasSNI }; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Checks if a ClientHello appears to be from a tab reactivation  | ||||
|    *  | ||||
|    * @param buffer The ClientHello buffer  | ||||
|    * @param logger Optional logging function | ||||
|    * @returns true if it appears to be a tab reactivation | ||||
|    */ | ||||
|   public static isTabReactivationHandshake( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): boolean { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     if (!TlsUtils.isClientHello(buffer)) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Parse the ClientHello | ||||
|     const parseResult = this.parseClientHello(buffer, logger); | ||||
|     if (!parseResult.isValid) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Tab reactivation pattern: session identifier + (ticket or PSK) but no SNI | ||||
|     const hasSessionId = parseResult.hasSessionId; | ||||
|     const hasSessionTicket = parseResult.hasSessionTicket; | ||||
|     const hasPsk = parseResult.hasPsk; | ||||
|     const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0; | ||||
|      | ||||
|     if ((hasSessionId && (hasSessionTicket || hasPsk)) && !hasSNI) { | ||||
|       log('Detected tab reactivation pattern: session resumption without SNI'); | ||||
|       return true; | ||||
|     } | ||||
|      | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										3
									
								
								ts/tls/sni/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/tls/sni/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * SNI handling | ||||
|  */ | ||||
							
								
								
									
										353
									
								
								ts/tls/sni/sni-extraction.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								ts/tls/sni/sni-extraction.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,353 @@ | ||||
| import { Buffer } from 'buffer'; | ||||
| import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js'; | ||||
| import { | ||||
|   ClientHelloParser, | ||||
|   type LoggerFunction | ||||
| } from './client-hello-parser.js'; | ||||
|  | ||||
| /** | ||||
|  * Connection tracking information | ||||
|  */ | ||||
| export interface ConnectionInfo { | ||||
|   sourceIp: string; | ||||
|   sourcePort: number; | ||||
|   destIp: string; | ||||
|   destPort: number; | ||||
|   timestamp?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Utilities for extracting SNI information from TLS handshakes | ||||
|  */ | ||||
| export class SniExtraction { | ||||
|   /** | ||||
|    * Extracts the SNI (Server Name Indication) from a TLS ClientHello message. | ||||
|    *  | ||||
|    * @param buffer The buffer containing the TLS ClientHello message | ||||
|    * @param logger Optional logging function | ||||
|    * @returns The extracted server name or undefined if not found | ||||
|    */ | ||||
|   public static extractSNI(buffer: Buffer, logger?: LoggerFunction): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     try { | ||||
|       // Parse the ClientHello | ||||
|       const parseResult = ClientHelloParser.parseClientHello(buffer, logger); | ||||
|       if (!parseResult.isValid) { | ||||
|         log(`Failed to parse ClientHello: ${parseResult.error}`); | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Check if ServerName extension was found | ||||
|       if (parseResult.serverNameList && parseResult.serverNameList.length > 0) { | ||||
|         // Use the first hostname (most common case) | ||||
|         const serverName = parseResult.serverNameList[0]; | ||||
|         log(`Found SNI: ${serverName}`); | ||||
|         return serverName; | ||||
|       } | ||||
|        | ||||
|       log('No SNI extension found in ClientHello'); | ||||
|       return undefined; | ||||
|     } catch (error) { | ||||
|       log(`Error extracting SNI: ${error instanceof Error ? error.message : String(error)}`); | ||||
|       return undefined; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello. | ||||
|    *  | ||||
|    * In TLS 1.3, when a client attempts to resume a session, it may include | ||||
|    * the server name in the PSK identity hint rather than in the SNI extension. | ||||
|    *  | ||||
|    * @param buffer The buffer containing the TLS ClientHello message | ||||
|    * @param logger Optional logging function | ||||
|    * @returns The extracted server name or undefined if not found | ||||
|    */ | ||||
|   public static extractSNIFromPSKExtension( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     try { | ||||
|       // Ensure this is a ClientHello | ||||
|       if (!TlsUtils.isClientHello(buffer)) { | ||||
|         log('Not a ClientHello message'); | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Parse the ClientHello to find PSK extension | ||||
|       const parseResult = ClientHelloParser.parseClientHello(buffer, logger); | ||||
|       if (!parseResult.isValid || !parseResult.extensions) { | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Find the PSK extension | ||||
|       const pskExtension = parseResult.extensions.find(ext =>  | ||||
|         ext.type === TlsExtensionType.PRE_SHARED_KEY); | ||||
|        | ||||
|       if (!pskExtension) { | ||||
|         log('No PSK extension found'); | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Parse the PSK extension data | ||||
|       const data = pskExtension.data; | ||||
|        | ||||
|       // PSK extension structure: | ||||
|       // 2 bytes: identities list length | ||||
|       if (data.length < 2) return undefined; | ||||
|        | ||||
|       const identitiesLength = (data[0] << 8) + data[1]; | ||||
|       let pos = 2; | ||||
|        | ||||
|       // End of identities list | ||||
|       const identitiesEnd = pos + identitiesLength; | ||||
|       if (identitiesEnd > data.length) return undefined; | ||||
|        | ||||
|       // Process each PSK identity | ||||
|       while (pos + 2 <= identitiesEnd) { | ||||
|         // Identity length (2 bytes) | ||||
|         if (pos + 2 > identitiesEnd) break; | ||||
|          | ||||
|         const identityLength = (data[pos] << 8) + data[pos + 1]; | ||||
|         pos += 2; | ||||
|          | ||||
|         if (pos + identityLength > identitiesEnd) break; | ||||
|          | ||||
|         // Try to extract hostname from identity | ||||
|         // Chrome often embeds the hostname in the PSK identity | ||||
|         // This is a heuristic as there's no standard format | ||||
|         if (identityLength > 0) { | ||||
|           const identity = data.slice(pos, pos + identityLength); | ||||
|            | ||||
|           // Skip identity bytes | ||||
|           pos += identityLength; | ||||
|            | ||||
|           // Skip obfuscated ticket age (4 bytes) | ||||
|           if (pos + 4 <= identitiesEnd) { | ||||
|             pos += 4; | ||||
|           } else { | ||||
|             break; | ||||
|           } | ||||
|            | ||||
|           // Try to parse the identity as UTF-8 | ||||
|           try { | ||||
|             const identityStr = identity.toString('utf8'); | ||||
|             log(`PSK identity: ${identityStr}`); | ||||
|              | ||||
|             // Check if the identity contains hostname hints | ||||
|             // Chrome often embeds the hostname in a known format | ||||
|             // Try to extract using common patterns | ||||
|              | ||||
|             // Pattern 1: Look for domain name pattern | ||||
|             const domainPattern = | ||||
|               /([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?/i; | ||||
|             const domainMatch = identityStr.match(domainPattern); | ||||
|             if (domainMatch && domainMatch[0]) { | ||||
|               log(`Found domain in PSK identity: ${domainMatch[0]}`); | ||||
|               return domainMatch[0]; | ||||
|             } | ||||
|              | ||||
|             // Pattern 2: Chrome sometimes uses a specific format with delimiters | ||||
|             // This is a heuristic approach since the format isn't standardized | ||||
|             const parts = identityStr.split('|'); | ||||
|             if (parts.length > 1) { | ||||
|               for (const part of parts) { | ||||
|                 if (part.includes('.') && !part.includes('/')) { | ||||
|                   const possibleDomain = part.trim(); | ||||
|                   if (/^[a-z0-9.-]+$/i.test(possibleDomain)) { | ||||
|                     log(`Found possible domain in PSK delimiter format: ${possibleDomain}`); | ||||
|                     return possibleDomain; | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } catch (e) { | ||||
|             log('Failed to parse PSK identity as UTF-8'); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       log('No hostname found in PSK extension'); | ||||
|       return undefined; | ||||
|     } catch (error) { | ||||
|       log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`); | ||||
|       return undefined; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Main entry point for SNI extraction with support for fragmented messages | ||||
|    * and session resumption edge cases. | ||||
|    *  | ||||
|    * @param buffer The buffer containing TLS data | ||||
|    * @param connectionInfo Connection tracking information | ||||
|    * @param logger Optional logging function | ||||
|    * @param cachedSni Optional previously cached SNI value | ||||
|    * @returns The extracted server name or undefined | ||||
|    */ | ||||
|   public static extractSNIWithResumptionSupport( | ||||
|     buffer: Buffer, | ||||
|     connectionInfo?: ConnectionInfo, | ||||
|     logger?: LoggerFunction, | ||||
|     cachedSni?: string | ||||
|   ): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     // Log buffer details for debugging | ||||
|     if (logger) { | ||||
|       log(`Buffer size: ${buffer.length} bytes`); | ||||
|       log(`Buffer starts with: ${buffer.slice(0, Math.min(10, buffer.length)).toString('hex')}`); | ||||
|        | ||||
|       if (buffer.length >= 5) { | ||||
|         const recordType = buffer[0]; | ||||
|         const majorVersion = buffer[1]; | ||||
|         const minorVersion = buffer[2]; | ||||
|         const recordLength = (buffer[3] << 8) + buffer[4]; | ||||
|          | ||||
|         log( | ||||
|           `TLS Record: type=${recordType}, version=${majorVersion}.${minorVersion}, length=${recordLength}` | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Check if we need to handle fragmented packets | ||||
|     let processBuffer = buffer; | ||||
|     if (connectionInfo) { | ||||
|       const connectionId = TlsUtils.createConnectionId(connectionInfo); | ||||
|       const reassembledBuffer = ClientHelloParser.handleFragmentedClientHello( | ||||
|         buffer, | ||||
|         connectionId, | ||||
|         logger | ||||
|       ); | ||||
|        | ||||
|       if (!reassembledBuffer) { | ||||
|         log(`Waiting for more fragments on connection ${connectionId}`); | ||||
|         return undefined; // Need more fragments to complete ClientHello | ||||
|       } | ||||
|        | ||||
|       processBuffer = reassembledBuffer; | ||||
|       log(`Using reassembled buffer of length ${processBuffer.length}`); | ||||
|     } | ||||
|      | ||||
|     // First try the standard SNI extraction | ||||
|     const standardSni = this.extractSNI(processBuffer, logger); | ||||
|     if (standardSni) { | ||||
|       log(`Found standard SNI: ${standardSni}`); | ||||
|       return standardSni; | ||||
|     } | ||||
|      | ||||
|     // Check for session resumption when standard SNI extraction fails | ||||
|     if (TlsUtils.isClientHello(processBuffer)) { | ||||
|       const resumptionInfo = ClientHelloParser.hasSessionResumption(processBuffer, logger); | ||||
|        | ||||
|       if (resumptionInfo.isResumption) { | ||||
|         log(`Detected session resumption in ClientHello without standard SNI`); | ||||
|          | ||||
|         // Try to extract SNI from PSK extension | ||||
|         const pskSni = this.extractSNIFromPSKExtension(processBuffer, logger); | ||||
|         if (pskSni) { | ||||
|           log(`Extracted SNI from PSK extension: ${pskSni}`); | ||||
|           return pskSni; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // If cached SNI was provided, use it for application data packets | ||||
|     if (cachedSni && TlsUtils.isTlsApplicationData(buffer)) { | ||||
|       log(`Using provided cached SNI for application data: ${cachedSni}`); | ||||
|       return cachedSni; | ||||
|     } | ||||
|      | ||||
|     return undefined; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Unified method for processing a TLS packet and extracting SNI. | ||||
|    * Main entry point for SNI extraction that handles all edge cases. | ||||
|    *  | ||||
|    * @param buffer The buffer containing TLS data | ||||
|    * @param connectionInfo Connection tracking information | ||||
|    * @param logger Optional logging function | ||||
|    * @param cachedSni Optional previously cached SNI value | ||||
|    * @returns The extracted server name or undefined | ||||
|    */ | ||||
|   public static processTlsPacket( | ||||
|     buffer: Buffer, | ||||
|     connectionInfo: ConnectionInfo, | ||||
|     logger?: LoggerFunction, | ||||
|     cachedSni?: string | ||||
|   ): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     // Add timestamp if not provided | ||||
|     if (!connectionInfo.timestamp) { | ||||
|       connectionInfo.timestamp = Date.now(); | ||||
|     } | ||||
|      | ||||
|     // Check if this is a TLS handshake or application data | ||||
|     if (!TlsUtils.isTlsHandshake(buffer) && !TlsUtils.isTlsApplicationData(buffer)) { | ||||
|       log('Not a TLS handshake or application data packet'); | ||||
|       return undefined; | ||||
|     } | ||||
|      | ||||
|     // Create connection ID for tracking | ||||
|     const connectionId = TlsUtils.createConnectionId(connectionInfo); | ||||
|     log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`); | ||||
|      | ||||
|     // Handle application data with cached SNI (for connection racing) | ||||
|     if (TlsUtils.isTlsApplicationData(buffer)) { | ||||
|       // If explicit cachedSni was provided, use it | ||||
|       if (cachedSni) { | ||||
|         log(`Using provided cached SNI for application data: ${cachedSni}`); | ||||
|         return cachedSni; | ||||
|       } | ||||
|        | ||||
|       log('Application data packet without cached SNI, cannot determine hostname'); | ||||
|       return undefined; | ||||
|     } | ||||
|      | ||||
|     // Enhanced session resumption detection | ||||
|     if (TlsUtils.isClientHello(buffer)) { | ||||
|       const resumptionInfo = ClientHelloParser.hasSessionResumption(buffer, logger); | ||||
|        | ||||
|       if (resumptionInfo.isResumption) { | ||||
|         log(`Session resumption detected in TLS packet`); | ||||
|          | ||||
|         // Always try standard SNI extraction first | ||||
|         const standardSni = this.extractSNI(buffer, logger); | ||||
|         if (standardSni) { | ||||
|           log(`Found standard SNI in session resumption: ${standardSni}`); | ||||
|           return standardSni; | ||||
|         } | ||||
|          | ||||
|         // Enhanced session resumption SNI extraction | ||||
|         // Try extracting from PSK identity | ||||
|         const pskSni = this.extractSNIFromPSKExtension(buffer, logger); | ||||
|         if (pskSni) { | ||||
|           log(`Extracted SNI from PSK extension: ${pskSni}`); | ||||
|           return pskSni; | ||||
|         } | ||||
|          | ||||
|         log(`Session resumption without extractable SNI`); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // For handshake messages, try the full extraction process | ||||
|     const sni = this.extractSNIWithResumptionSupport(buffer, connectionInfo, logger); | ||||
|      | ||||
|     if (sni) { | ||||
|       log(`Successfully extracted SNI: ${sni}`); | ||||
|       return sni; | ||||
|     } | ||||
|      | ||||
|     // If we couldn't extract an SNI, check if this is a valid ClientHello | ||||
|     if (TlsUtils.isClientHello(buffer)) { | ||||
|       log('Valid ClientHello detected, but no SNI extracted - might need more data'); | ||||
|     } | ||||
|      | ||||
|     return undefined; | ||||
|   } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										3
									
								
								ts/tls/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/tls/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * TLS utilities | ||||
|  */ | ||||
							
								
								
									
										201
									
								
								ts/tls/utils/tls-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								ts/tls/utils/tls-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * TLS record types as defined in various RFCs | ||||
|  */ | ||||
| export enum TlsRecordType { | ||||
|   CHANGE_CIPHER_SPEC = 20, | ||||
|   ALERT = 21, | ||||
|   HANDSHAKE = 22, | ||||
|   APPLICATION_DATA = 23, | ||||
|   HEARTBEAT = 24, // RFC 6520 | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TLS handshake message types | ||||
|  */ | ||||
| export enum TlsHandshakeType { | ||||
|   HELLO_REQUEST = 0, | ||||
|   CLIENT_HELLO = 1, | ||||
|   SERVER_HELLO = 2, | ||||
|   NEW_SESSION_TICKET = 4, | ||||
|   ENCRYPTED_EXTENSIONS = 8, // TLS 1.3 | ||||
|   CERTIFICATE = 11, | ||||
|   SERVER_KEY_EXCHANGE = 12, | ||||
|   CERTIFICATE_REQUEST = 13, | ||||
|   SERVER_HELLO_DONE = 14, | ||||
|   CERTIFICATE_VERIFY = 15, | ||||
|   CLIENT_KEY_EXCHANGE = 16, | ||||
|   FINISHED = 20, | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TLS extension types | ||||
|  */ | ||||
| export enum TlsExtensionType { | ||||
|   SERVER_NAME = 0, // SNI | ||||
|   MAX_FRAGMENT_LENGTH = 1, | ||||
|   CLIENT_CERTIFICATE_URL = 2, | ||||
|   TRUSTED_CA_KEYS = 3, | ||||
|   TRUNCATED_HMAC = 4, | ||||
|   STATUS_REQUEST = 5, // OCSP | ||||
|   SUPPORTED_GROUPS = 10, // Previously named "elliptic_curves" | ||||
|   EC_POINT_FORMATS = 11, | ||||
|   SIGNATURE_ALGORITHMS = 13, | ||||
|   APPLICATION_LAYER_PROTOCOL_NEGOTIATION = 16, // ALPN | ||||
|   SIGNED_CERTIFICATE_TIMESTAMP = 18, // Certificate Transparency | ||||
|   PADDING = 21, | ||||
|   SESSION_TICKET = 35, | ||||
|   PRE_SHARED_KEY = 41, // TLS 1.3 | ||||
|   EARLY_DATA = 42, // TLS 1.3 0-RTT | ||||
|   SUPPORTED_VERSIONS = 43, // TLS 1.3 | ||||
|   COOKIE = 44, // TLS 1.3 | ||||
|   PSK_KEY_EXCHANGE_MODES = 45, // TLS 1.3 | ||||
|   CERTIFICATE_AUTHORITIES = 47, // TLS 1.3 | ||||
|   POST_HANDSHAKE_AUTH = 49, // TLS 1.3 | ||||
|   SIGNATURE_ALGORITHMS_CERT = 50, // TLS 1.3 | ||||
|   KEY_SHARE = 51, // TLS 1.3 | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TLS alert levels | ||||
|  */ | ||||
| export enum TlsAlertLevel { | ||||
|   WARNING = 1, | ||||
|   FATAL = 2, | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TLS alert description codes | ||||
|  */ | ||||
| export enum TlsAlertDescription { | ||||
|   CLOSE_NOTIFY = 0, | ||||
|   UNEXPECTED_MESSAGE = 10, | ||||
|   BAD_RECORD_MAC = 20, | ||||
|   DECRYPTION_FAILED = 21, // TLS 1.0 only | ||||
|   RECORD_OVERFLOW = 22, | ||||
|   DECOMPRESSION_FAILURE = 30, // TLS 1.2 and below | ||||
|   HANDSHAKE_FAILURE = 40, | ||||
|   NO_CERTIFICATE = 41, // SSLv3 only | ||||
|   BAD_CERTIFICATE = 42, | ||||
|   UNSUPPORTED_CERTIFICATE = 43, | ||||
|   CERTIFICATE_REVOKED = 44, | ||||
|   CERTIFICATE_EXPIRED = 45, | ||||
|   CERTIFICATE_UNKNOWN = 46, | ||||
|   ILLEGAL_PARAMETER = 47, | ||||
|   UNKNOWN_CA = 48, | ||||
|   ACCESS_DENIED = 49, | ||||
|   DECODE_ERROR = 50, | ||||
|   DECRYPT_ERROR = 51, | ||||
|   EXPORT_RESTRICTION = 60, // TLS 1.0 only | ||||
|   PROTOCOL_VERSION = 70, | ||||
|   INSUFFICIENT_SECURITY = 71, | ||||
|   INTERNAL_ERROR = 80, | ||||
|   INAPPROPRIATE_FALLBACK = 86, | ||||
|   USER_CANCELED = 90, | ||||
|   NO_RENEGOTIATION = 100, // TLS 1.2 and below | ||||
|   MISSING_EXTENSION = 109, // TLS 1.3 | ||||
|   UNSUPPORTED_EXTENSION = 110, // TLS 1.3 | ||||
|   CERTIFICATE_REQUIRED = 111, // TLS 1.3 | ||||
|   UNRECOGNIZED_NAME = 112, | ||||
|   BAD_CERTIFICATE_STATUS_RESPONSE = 113, | ||||
|   BAD_CERTIFICATE_HASH_VALUE = 114, // TLS 1.2 and below | ||||
|   UNKNOWN_PSK_IDENTITY = 115, | ||||
|   CERTIFICATE_REQUIRED_1_3 = 116, // TLS 1.3 | ||||
|   NO_APPLICATION_PROTOCOL = 120, | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TLS version codes (major.minor) | ||||
|  */ | ||||
| export const TlsVersion = { | ||||
|   SSL3: [0x03, 0x00], | ||||
|   TLS1_0: [0x03, 0x01], | ||||
|   TLS1_1: [0x03, 0x02], | ||||
|   TLS1_2: [0x03, 0x03], | ||||
|   TLS1_3: [0x03, 0x04], | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Utility functions for TLS protocol operations | ||||
|  */ | ||||
| export class TlsUtils { | ||||
|   /** | ||||
|    * Checks if a buffer contains a TLS handshake record | ||||
|    * @param buffer The buffer to check | ||||
|    * @returns true if the buffer starts with a TLS handshake record | ||||
|    */ | ||||
|   public static isTlsHandshake(buffer: Buffer): boolean { | ||||
|     return buffer.length > 0 && buffer[0] === TlsRecordType.HANDSHAKE; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Checks if a buffer contains TLS application data | ||||
|    * @param buffer The buffer to check | ||||
|    * @returns true if the buffer starts with a TLS application data record | ||||
|    */ | ||||
|   public static isTlsApplicationData(buffer: Buffer): boolean { | ||||
|     return buffer.length > 0 && buffer[0] === TlsRecordType.APPLICATION_DATA; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Checks if a buffer contains a TLS alert record | ||||
|    * @param buffer The buffer to check | ||||
|    * @returns true if the buffer starts with a TLS alert record | ||||
|    */ | ||||
|   public static isTlsAlert(buffer: Buffer): boolean { | ||||
|     return buffer.length > 0 && buffer[0] === TlsRecordType.ALERT; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Checks if a buffer contains a TLS ClientHello message | ||||
|    * @param buffer The buffer to check | ||||
|    * @returns true if the buffer appears to be a ClientHello message | ||||
|    */ | ||||
|   public static isClientHello(buffer: Buffer): boolean { | ||||
|     // Minimum ClientHello size (TLS record header + handshake header) | ||||
|     if (buffer.length < 9) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Check record type (must be TLS_HANDSHAKE_RECORD_TYPE) | ||||
|     if (buffer[0] !== TlsRecordType.HANDSHAKE) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Skip version and length in TLS record header (5 bytes total) | ||||
|     // Check handshake type at byte 5 (must be CLIENT_HELLO) | ||||
|     return buffer[5] === TlsHandshakeType.CLIENT_HELLO; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Gets the record length from a TLS record header | ||||
|    * @param buffer Buffer containing a TLS record | ||||
|    * @returns The record length if the buffer is valid, -1 otherwise | ||||
|    */ | ||||
|   public static getTlsRecordLength(buffer: Buffer): number { | ||||
|     if (buffer.length < 5) { | ||||
|       return -1; | ||||
|     } | ||||
|      | ||||
|     // Bytes 3-4 contain the record length (big-endian) | ||||
|     return (buffer[3] << 8) + buffer[4]; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Creates a connection ID based on source/destination information | ||||
|    * Used to track fragmented ClientHello messages across multiple packets | ||||
|    * | ||||
|    * @param connectionInfo Object containing connection identifiers | ||||
|    * @returns A string ID for the connection | ||||
|    */ | ||||
|   public static createConnectionId(connectionInfo: { | ||||
|     sourceIp?: string; | ||||
|     sourcePort?: number; | ||||
|     destIp?: string; | ||||
|     destPort?: number; | ||||
|   }): string { | ||||
|     const { sourceIp, sourcePort, destIp, destPort } = connectionInfo; | ||||
|     return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`; | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user