514 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			514 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * Adaptive SMTP Logging System
 | |
|  * Automatically switches between logging modes based on server load (active connections)
 | |
|  * to maintain performance during high-concurrency scenarios
 | |
|  */
 | |
| 
 | |
| import * as plugins from '../../../../plugins.ts';
 | |
| import { logger } from '../../../../logger.ts';
 | |
| import { SecurityLogLevel, SecurityEventType } from '../constants.ts';
 | |
| import type { ISmtpSession } from '../interfaces.ts';
 | |
| import type { LogLevel, ISmtpLogOptions } from './logging.ts';
 | |
| 
 | |
| /**
 | |
|  * Log modes based on server load
 | |
|  */
 | |
| export enum LogMode {
 | |
|   VERBOSE = 'VERBOSE',     // < 20 connections: Full detailed logging
 | |
|   REDUCED = 'REDUCED',     // 20-40 connections: Limited command/response logging, full error logging
 | |
|   MINIMAL = 'MINIMAL'      // 40+ connections: Aggregated logging only, critical errors only
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Configuration for adaptive logging thresholds
 | |
|  */
 | |
| export interface IAdaptiveLogConfig {
 | |
|   verboseThreshold: number;     // Switch to REDUCED mode above this connection count
 | |
|   reducedThreshold: number;     // Switch to MINIMAL mode above this connection count
 | |
|   aggregationInterval: number;  // How often to flush aggregated logs (ms)
 | |
|   maxAggregatedEntries: number; // Max entries to hold before forced flush
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Aggregated log entry for batching similar events
 | |
|  */
 | |
| interface IAggregatedLogEntry {
 | |
|   type: 'connection' | 'command' | 'response' | 'error';
 | |
|   count: number;
 | |
|   firstSeen: number;
 | |
|   lastSeen: number;
 | |
|   sample: {
 | |
|     message: string;
 | |
|     level: LogLevel;
 | |
|     options?: ISmtpLogOptions;
 | |
|   };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Connection metadata for aggregation tracking
 | |
|  */
 | |
| interface IConnectionTracker {
 | |
|   activeConnections: number;
 | |
|   peakConnections: number;
 | |
|   totalConnections: number;
 | |
|   connectionsPerSecond: number;
 | |
|   lastConnectionTime: number;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Adaptive SMTP Logger that scales logging based on server load
 | |
|  */
 | |
| export class AdaptiveSmtpLogger {
 | |
|   private static instance: AdaptiveSmtpLogger;
 | |
|   private currentMode: LogMode = LogMode.VERBOSE;
 | |
|   private config: IAdaptiveLogConfig;
 | |
|   private aggregatedEntries: Map<string, IAggregatedLogEntry> = new Map();
 | |
|   private aggregationTimer: NodeJS.Timeout | null = null;
 | |
|   private connectionTracker: IConnectionTracker = {
 | |
|     activeConnections: 0,
 | |
|     peakConnections: 0,
 | |
|     totalConnections: 0,
 | |
|     connectionsPerSecond: 0,
 | |
|     lastConnectionTime: Date.now()
 | |
|   };
 | |
| 
 | |
|   private constructor(config?: Partial<IAdaptiveLogConfig>) {
 | |
|     this.config = {
 | |
|       verboseThreshold: 20,
 | |
|       reducedThreshold: 40,
 | |
|       aggregationInterval: 30000, // 30 seconds
 | |
|       maxAggregatedEntries: 100,
 | |
|       ...config
 | |
|     };
 | |
| 
 | |
|     this.startAggregationTimer();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get singleton instance
 | |
|    */
 | |
|   public static getInstance(config?: Partial<IAdaptiveLogConfig>): AdaptiveSmtpLogger {
 | |
|     if (!AdaptiveSmtpLogger.instance) {
 | |
|       AdaptiveSmtpLogger.instance = new AdaptiveSmtpLogger(config);
 | |
|     }
 | |
|     return AdaptiveSmtpLogger.instance;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Update active connection count and adjust log mode if needed
 | |
|    */
 | |
|   public updateConnectionCount(activeConnections: number): void {
 | |
|     this.connectionTracker.activeConnections = activeConnections;
 | |
|     this.connectionTracker.peakConnections = Math.max(
 | |
|       this.connectionTracker.peakConnections,
 | |
|       activeConnections
 | |
|     );
 | |
| 
 | |
|     const newMode = this.determineLogMode(activeConnections);
 | |
|     if (newMode !== this.currentMode) {
 | |
|       this.switchLogMode(newMode);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Track new connection for rate calculation
 | |
|    */
 | |
|   public trackConnection(): void {
 | |
|     this.connectionTracker.totalConnections++;
 | |
|     const now = Date.now();
 | |
|     const timeDiff = (now - this.connectionTracker.lastConnectionTime) / 1000;
 | |
|     if (timeDiff > 0) {
 | |
|       this.connectionTracker.connectionsPerSecond = 1 / timeDiff;
 | |
|     }
 | |
|     this.connectionTracker.lastConnectionTime = now;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get current logging mode
 | |
|    */
 | |
|   public getCurrentMode(): LogMode {
 | |
|     return this.currentMode;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get connection statistics
 | |
|    */
 | |
|   public getConnectionStats(): IConnectionTracker {
 | |
|     return { ...this.connectionTracker };
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Log a message with adaptive behavior
 | |
|    */
 | |
|   public log(level: LogLevel, message: string, options: ISmtpLogOptions = {}): void {
 | |
|     // Always log structured data
 | |
|     const errorInfo = options.error ? {
 | |
|       errorMessage: options.error.message,
 | |
|       errorStack: options.error.stack,
 | |
|       errorName: options.error.name
 | |
|     } : {};
 | |
|     
 | |
|     const logData = {
 | |
|       component: 'smtp-server',
 | |
|       logMode: this.currentMode,
 | |
|       activeConnections: this.connectionTracker.activeConnections,
 | |
|       ...options,
 | |
|       ...errorInfo
 | |
|     };
 | |
|     
 | |
|     if (logData.error) {
 | |
|       delete logData.error;
 | |
|     }
 | |
|     
 | |
|     logger.log(level, message, logData);
 | |
| 
 | |
|     // Adaptive console logging based on mode
 | |
|     switch (this.currentMode) {
 | |
|       case LogMode.VERBOSE:
 | |
|         // Full console logging
 | |
|         if (level === 'error' || level === 'warn') {
 | |
|           console[level](`[SMTP] ${message}`, logData);
 | |
|         }
 | |
|         break;
 | |
| 
 | |
|       case LogMode.REDUCED:
 | |
|         // Only errors and warnings to console
 | |
|         if (level === 'error' || level === 'warn') {
 | |
|           console[level](`[SMTP] ${message}`, logData);
 | |
|         }
 | |
|         break;
 | |
| 
 | |
|       case LogMode.MINIMAL:
 | |
|         // Only critical errors to console
 | |
|         if (level === 'error' && (message.includes('critical') || message.includes('security') || message.includes('crash'))) {
 | |
|           console[level](`[SMTP] ${message}`, logData);
 | |
|         }
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Log command with adaptive behavior
 | |
|    */
 | |
|   public logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void {
 | |
|     const clientInfo = {
 | |
|       remoteAddress: socket.remoteAddress,
 | |
|       remotePort: socket.remotePort,
 | |
|       secure: socket instanceof plugins.tls.TLSSocket,
 | |
|       sessionId: session?.id,
 | |
|       sessionState: session?.state
 | |
|     };
 | |
| 
 | |
|     switch (this.currentMode) {
 | |
|       case LogMode.VERBOSE:
 | |
|         this.log('info', `Command received: ${command}`, {
 | |
|           ...clientInfo,
 | |
|           command: command.split(' ')[0]?.toUpperCase()
 | |
|         });
 | |
|         console.log(`← ${command}`);
 | |
|         break;
 | |
| 
 | |
|       case LogMode.REDUCED:
 | |
|         // Aggregate commands instead of logging each one
 | |
|         this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo);
 | |
|         // Only show error commands
 | |
|         if (command.toUpperCase().startsWith('QUIT') || command.includes('error')) {
 | |
|           console.log(`← ${command}`);
 | |
|         }
 | |
|         break;
 | |
| 
 | |
|       case LogMode.MINIMAL:
 | |
|         // Only aggregate, no console output unless it's an error command
 | |
|         this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo);
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Log response with adaptive behavior
 | |
|    */
 | |
|   public logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
 | |
|     const clientInfo = {
 | |
|       remoteAddress: socket.remoteAddress,
 | |
|       remotePort: socket.remotePort,
 | |
|       secure: socket instanceof plugins.tls.TLSSocket
 | |
|     };
 | |
| 
 | |
|     const responseCode = response.substring(0, 3);
 | |
|     const isError = responseCode.startsWith('4') || responseCode.startsWith('5');
 | |
| 
 | |
|     switch (this.currentMode) {
 | |
|       case LogMode.VERBOSE:
 | |
|         if (responseCode.startsWith('2') || responseCode.startsWith('3')) {
 | |
|           this.log('debug', `Response sent: ${response}`, clientInfo);
 | |
|         } else if (responseCode.startsWith('4')) {
 | |
|           this.log('warn', `Temporary error response: ${response}`, clientInfo);
 | |
|         } else if (responseCode.startsWith('5')) {
 | |
|           this.log('error', `Permanent error response: ${response}`, clientInfo);
 | |
|         }
 | |
|         console.log(`→ ${response}`);
 | |
|         break;
 | |
| 
 | |
|       case LogMode.REDUCED:
 | |
|         // Log errors normally, aggregate success responses
 | |
|         if (isError) {
 | |
|           if (responseCode.startsWith('4')) {
 | |
|             this.log('warn', `Temporary error response: ${response}`, clientInfo);
 | |
|           } else {
 | |
|             this.log('error', `Permanent error response: ${response}`, clientInfo);
 | |
|           }
 | |
|           console.log(`→ ${response}`);
 | |
|         } else {
 | |
|           this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo);
 | |
|         }
 | |
|         break;
 | |
| 
 | |
|       case LogMode.MINIMAL:
 | |
|         // Only log critical errors
 | |
|         if (responseCode.startsWith('5')) {
 | |
|           this.log('error', `Permanent error response: ${response}`, clientInfo);
 | |
|           console.log(`→ ${response}`);
 | |
|         } else {
 | |
|           this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo);
 | |
|         }
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Log connection event with adaptive behavior
 | |
|    */
 | |
|   public logConnection(
 | |
|     socket: plugins.net.Socket | plugins.tls.TLSSocket,
 | |
|     eventType: 'connect' | 'close' | 'error',
 | |
|     session?: ISmtpSession,
 | |
|     error?: Error
 | |
|   ): void {
 | |
|     const clientInfo = {
 | |
|       remoteAddress: socket.remoteAddress,
 | |
|       remotePort: socket.remotePort,
 | |
|       secure: socket instanceof plugins.tls.TLSSocket,
 | |
|       sessionId: session?.id,
 | |
|       sessionState: session?.state
 | |
|     };
 | |
| 
 | |
|     if (eventType === 'connect') {
 | |
|       this.trackConnection();
 | |
|     }
 | |
| 
 | |
|     switch (this.currentMode) {
 | |
|       case LogMode.VERBOSE:
 | |
|         // Full connection logging
 | |
|         switch (eventType) {
 | |
|           case 'connect':
 | |
|             this.log('info', `New ${clientInfo.secure ? 'secure ' : ''}connection from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
 | |
|             break;
 | |
|           case 'close':
 | |
|             this.log('info', `Connection closed from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
 | |
|             break;
 | |
|           case 'error':
 | |
|             this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
 | |
|               ...clientInfo,
 | |
|               error
 | |
|             });
 | |
|             break;
 | |
|         }
 | |
|         break;
 | |
| 
 | |
|       case LogMode.REDUCED:
 | |
|         // Aggregate normal connections, log errors
 | |
|         if (eventType === 'error') {
 | |
|           this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
 | |
|             ...clientInfo,
 | |
|             error
 | |
|           });
 | |
|         } else {
 | |
|           this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo);
 | |
|         }
 | |
|         break;
 | |
| 
 | |
|       case LogMode.MINIMAL:
 | |
|         // Only aggregate, except for critical errors
 | |
|         if (eventType === 'error' && error && (error.message.includes('security') || error.message.includes('critical'))) {
 | |
|           this.log('error', `Critical connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
 | |
|             ...clientInfo,
 | |
|             error
 | |
|           });
 | |
|         } else {
 | |
|           this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo);
 | |
|         }
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Log security event (always logged regardless of mode)
 | |
|    */
 | |
|   public logSecurityEvent(
 | |
|     level: SecurityLogLevel,
 | |
|     type: SecurityEventType,
 | |
|     message: string,
 | |
|     details: Record<string, any>,
 | |
|     ipAddress?: string,
 | |
|     domain?: string,
 | |
|     success?: boolean
 | |
|   ): void {
 | |
|     const logLevel: LogLevel = level === SecurityLogLevel.DEBUG ? 'debug' :
 | |
|                                level === SecurityLogLevel.INFO ? 'info' :
 | |
|                                level === SecurityLogLevel.WARN ? 'warn' : 'error';
 | |
|     
 | |
|     // Security events are always logged in full detail
 | |
|     this.log(logLevel, message, {
 | |
|       component: 'smtp-security',
 | |
|       eventType: type,
 | |
|       success,
 | |
|       ipAddress,
 | |
|       domain,
 | |
|       ...details
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Determine appropriate log mode based on connection count
 | |
|    */
 | |
|   private determineLogMode(activeConnections: number): LogMode {
 | |
|     if (activeConnections >= this.config.reducedThreshold) {
 | |
|       return LogMode.MINIMAL;
 | |
|     } else if (activeConnections >= this.config.verboseThreshold) {
 | |
|       return LogMode.REDUCED;
 | |
|     } else {
 | |
|       return LogMode.VERBOSE;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Switch to a new log mode
 | |
|    */
 | |
|   private switchLogMode(newMode: LogMode): void {
 | |
|     const oldMode = this.currentMode;
 | |
|     this.currentMode = newMode;
 | |
| 
 | |
|     // Log the mode switch
 | |
|     console.log(`[SMTP] Adaptive logging switched from ${oldMode} to ${newMode} (${this.connectionTracker.activeConnections} active connections)`);
 | |
|     
 | |
|     this.log('info', `Adaptive logging mode changed to ${newMode}`, {
 | |
|       oldMode,
 | |
|       newMode,
 | |
|       activeConnections: this.connectionTracker.activeConnections,
 | |
|       peakConnections: this.connectionTracker.peakConnections,
 | |
|       totalConnections: this.connectionTracker.totalConnections
 | |
|     });
 | |
| 
 | |
|     // If switching to more verbose mode, flush aggregated entries
 | |
|     if ((oldMode === LogMode.MINIMAL && newMode !== LogMode.MINIMAL) ||
 | |
|         (oldMode === LogMode.REDUCED && newMode === LogMode.VERBOSE)) {
 | |
|       this.flushAggregatedEntries();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Add entry to aggregation buffer
 | |
|    */
 | |
|   private aggregateEntry(
 | |
|     type: 'connection' | 'command' | 'response' | 'error',
 | |
|     level: LogLevel,
 | |
|     message: string,
 | |
|     options?: ISmtpLogOptions
 | |
|   ): void {
 | |
|     const key = `${type}:${message}`;
 | |
|     const now = Date.now();
 | |
| 
 | |
|     if (this.aggregatedEntries.has(key)) {
 | |
|       const entry = this.aggregatedEntries.get(key)!;
 | |
|       entry.count++;
 | |
|       entry.lastSeen = now;
 | |
|     } else {
 | |
|       this.aggregatedEntries.set(key, {
 | |
|         type,
 | |
|         count: 1,
 | |
|         firstSeen: now,
 | |
|         lastSeen: now,
 | |
|         sample: { message, level, options }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     // Force flush if we have too many entries
 | |
|     if (this.aggregatedEntries.size >= this.config.maxAggregatedEntries) {
 | |
|       this.flushAggregatedEntries();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Start the aggregation timer
 | |
|    */
 | |
|   private startAggregationTimer(): void {
 | |
|     if (this.aggregationTimer) {
 | |
|       clearInterval(this.aggregationTimer);
 | |
|     }
 | |
| 
 | |
|     this.aggregationTimer = setInterval(() => {
 | |
|       this.flushAggregatedEntries();
 | |
|     }, this.config.aggregationInterval);
 | |
|     
 | |
|     // Unref the timer so it doesn't keep the process alive
 | |
|     if (this.aggregationTimer && typeof this.aggregationTimer.unref === 'function') {
 | |
|       this.aggregationTimer.unref();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Flush aggregated entries to logs
 | |
|    */
 | |
|   private flushAggregatedEntries(): void {
 | |
|     if (this.aggregatedEntries.size === 0) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const summary: Record<string, number> = {};
 | |
|     let totalAggregated = 0;
 | |
| 
 | |
|     for (const [key, entry] of this.aggregatedEntries.entries()) {
 | |
|       summary[entry.type] = (summary[entry.type] || 0) + entry.count;
 | |
|       totalAggregated += entry.count;
 | |
| 
 | |
|       // Log a sample of high-frequency entries
 | |
|       if (entry.count >= 10) {
 | |
|         this.log(entry.sample.level, `${entry.sample.message} (aggregated: ${entry.count} occurrences)`, {
 | |
|           ...entry.sample.options,
 | |
|           aggregated: true,
 | |
|           occurrences: entry.count,
 | |
|           timeSpan: entry.lastSeen - entry.firstSeen
 | |
|         });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Log aggregation summary
 | |
|     console.log(`[SMTP] Aggregated ${totalAggregated} log entries: ${JSON.stringify(summary)}`);
 | |
|     
 | |
|     this.log('info', 'Aggregated log summary', {
 | |
|       totalEntries: totalAggregated,
 | |
|       breakdown: summary,
 | |
|       logMode: this.currentMode,
 | |
|       activeConnections: this.connectionTracker.activeConnections
 | |
|     });
 | |
| 
 | |
|     // Clear aggregated entries
 | |
|     this.aggregatedEntries.clear();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Cleanup resources
 | |
|    */
 | |
|   public destroy(): void {
 | |
|     if (this.aggregationTimer) {
 | |
|       clearInterval(this.aggregationTimer);
 | |
|       this.aggregationTimer = null;
 | |
|     }
 | |
|     this.flushAggregatedEntries();
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Default instance for easy access
 | |
|  */
 | |
| export const adaptiveLogger = AdaptiveSmtpLogger.getInstance(); |