update
This commit is contained in:
@@ -123,7 +123,27 @@ export class CommandHandler implements ICommandHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log received command
|
// Handle command pipelining (RFC 2920)
|
||||||
|
// Multiple commands can be sent in a single TCP packet
|
||||||
|
if (commandLine.includes('\r\n') || commandLine.includes('\n')) {
|
||||||
|
// Split the commandLine into individual commands by newline
|
||||||
|
const commands = commandLine.split(/\r\n|\n/).filter(line => line.trim().length > 0);
|
||||||
|
|
||||||
|
if (commands.length > 1) {
|
||||||
|
SmtpLogger.debug(`Command pipelining detected: ${commands.length} commands`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
commandCount: commands.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process each command separately (recursively call processCommand)
|
||||||
|
for (const cmd of commands) {
|
||||||
|
this.processCommand(socket, cmd);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log received command (single command case)
|
||||||
SmtpLogger.logCommand(commandLine, socket, session);
|
SmtpLogger.logCommand(commandLine, socket, session);
|
||||||
|
|
||||||
// Extract command and arguments
|
// Extract command and arguments
|
||||||
@@ -187,6 +207,14 @@ export class CommandHandler implements ICommandHandler {
|
|||||||
this.handleHelp(socket, args);
|
this.handleHelp(socket, args);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case SmtpCommand.VRFY:
|
||||||
|
this.handleVrfy(socket, args);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SmtpCommand.EXPN:
|
||||||
|
this.handleExpn(socket, args);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Command not implemented`);
|
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Command not implemented`);
|
||||||
break;
|
break;
|
||||||
@@ -203,18 +231,103 @@ export class CommandHandler implements ICommandHandler {
|
|||||||
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||||
SmtpLogger.logResponse(response, socket);
|
SmtpLogger.logResponse(response, socket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error and destroy socket
|
// Attempt to recover from known transient errors
|
||||||
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
if (this.isRecoverableSocketError(error)) {
|
||||||
response,
|
this.handleSocketError(socket, error, response);
|
||||||
remoteAddress: socket.remoteAddress,
|
} else {
|
||||||
remotePort: socket.remotePort,
|
// Log error and destroy socket for non-recoverable errors
|
||||||
error: error instanceof Error ? error : new Error(String(error))
|
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
||||||
});
|
response,
|
||||||
|
remoteAddress: socket.remoteAddress,
|
||||||
|
remotePort: socket.remotePort,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error))
|
||||||
|
});
|
||||||
|
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a socket error is potentially recoverable
|
||||||
|
* @param error - The error that occurred
|
||||||
|
* @returns Whether the error is potentially recoverable
|
||||||
|
*/
|
||||||
|
private isRecoverableSocketError(error: unknown): boolean {
|
||||||
|
const recoverableErrorCodes = [
|
||||||
|
'EPIPE', // Broken pipe
|
||||||
|
'ECONNRESET', // Connection reset by peer
|
||||||
|
'ETIMEDOUT', // Connection timed out
|
||||||
|
'ECONNABORTED' // Connection aborted
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
'code' in error &&
|
||||||
|
typeof (error as any).code === 'string' &&
|
||||||
|
recoverableErrorCodes.includes((error as any).code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle recoverable socket errors with retry logic
|
||||||
|
* @param socket - Client socket
|
||||||
|
* @param error - The error that occurred
|
||||||
|
* @param response - The response that failed to send
|
||||||
|
*/
|
||||||
|
private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: unknown, response: string): void {
|
||||||
|
// Get the session for this socket
|
||||||
|
const session = this.sessionManager.getSession(socket);
|
||||||
|
if (!session) {
|
||||||
|
SmtpLogger.error(`Session not found when handling socket error`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get error details for logging
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorCode = error instanceof Error && 'code' in error ? (error as any).code : 'UNKNOWN';
|
||||||
|
|
||||||
|
SmtpLogger.warn(`Recoverable socket error (${errorCode}): ${errorMessage}`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
remoteAddress: session.remoteAddress,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if socket is already destroyed
|
||||||
|
if (socket.destroyed) {
|
||||||
|
SmtpLogger.info(`Socket already destroyed, cannot retry operation`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if socket is writeable
|
||||||
|
if (!socket.writable) {
|
||||||
|
SmtpLogger.info(`Socket no longer writable, aborting recovery attempt`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to retry the write operation after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (!socket.destroyed && socket.writable) {
|
||||||
|
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||||
|
SmtpLogger.info(`Successfully retried send operation after error`);
|
||||||
|
} else {
|
||||||
|
SmtpLogger.warn(`Socket no longer available for retry`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (retryError) {
|
||||||
|
SmtpLogger.error(`Retry attempt failed: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100); // Short delay before retry
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle EHLO command
|
* Handle EHLO command
|
||||||
* @param socket - Client socket
|
* @param socket - Client socket
|
||||||
@@ -321,19 +434,44 @@ export class CommandHandler implements ICommandHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check size parameter if provided
|
// Enhanced SIZE parameter handling
|
||||||
if (validation.params && validation.params.SIZE) {
|
if (validation.params && validation.params.SIZE) {
|
||||||
const size = parseInt(validation.params.SIZE, 10);
|
const size = parseInt(validation.params.SIZE, 10);
|
||||||
|
|
||||||
|
// Check for valid numeric format
|
||||||
if (isNaN(size)) {
|
if (isNaN(size)) {
|
||||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter`);
|
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter: not a number`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (size > this.options.size!) {
|
// Check for negative values
|
||||||
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message size exceeds limit`);
|
if (size < 0) {
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter: cannot be negative`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure reasonable minimum size (at least 100 bytes for headers)
|
||||||
|
if (size < 100) {
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter: too small (minimum 100 bytes)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against server maximum
|
||||||
|
if (size > this.options.size!) {
|
||||||
|
// Generate informative error with the server's limit
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message size exceeds limit of ${Math.floor(this.options.size! / 1024)} KB`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log large messages for monitoring
|
||||||
|
if (size > this.options.size! * 0.8) {
|
||||||
|
SmtpLogger.info(`Large message detected (${Math.floor(size / 1024)} KB)`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
remoteAddress: session.remoteAddress,
|
||||||
|
sizeBytes: size,
|
||||||
|
percentOfMax: Math.floor((size / this.options.size!) * 100)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset email data and recipients for new transaction
|
// Reset email data and recipients for new transaction
|
||||||
@@ -655,6 +793,74 @@ export class CommandHandler implements ICommandHandler {
|
|||||||
this.sendResponse(socket, `${SmtpResponseCode.HELP_MESSAGE} ${helpText}`);
|
this.sendResponse(socket, `${SmtpResponseCode.HELP_MESSAGE} ${helpText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle VRFY command (Verify user/mailbox)
|
||||||
|
* RFC 5321 Section 3.5.1: Server MAY respond with 252 to avoid disclosing sensitive information
|
||||||
|
* @param socket - Client socket
|
||||||
|
* @param args - Command arguments (username to verify)
|
||||||
|
*/
|
||||||
|
private handleVrfy(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||||
|
// Get the session for this socket
|
||||||
|
const session = this.sessionManager.getSession(socket);
|
||||||
|
if (!session) {
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session activity timestamp
|
||||||
|
this.sessionManager.updateSessionActivity(session);
|
||||||
|
|
||||||
|
const username = args.trim();
|
||||||
|
|
||||||
|
// Security best practice: Do not confirm or deny user existence
|
||||||
|
// Instead, respond with 252 "Cannot verify, but will attempt delivery"
|
||||||
|
// This prevents VRFY from being used for user enumeration attacks
|
||||||
|
if (!username) {
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} User name required`);
|
||||||
|
} else {
|
||||||
|
// Log the VRFY attempt
|
||||||
|
SmtpLogger.info(`VRFY command received for user: ${username}`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
remoteAddress: session.remoteAddress,
|
||||||
|
useTLS: session.useTLS
|
||||||
|
});
|
||||||
|
|
||||||
|
// Respond with ambiguous response for security
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.CANNOT_VRFY} Cannot VRFY user, but will accept message and attempt delivery`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle EXPN command (Expand mailing list)
|
||||||
|
* RFC 5321 Section 3.5.2: Server MAY disable this for security
|
||||||
|
* @param socket - Client socket
|
||||||
|
* @param args - Command arguments (mailing list to expand)
|
||||||
|
*/
|
||||||
|
private handleExpn(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||||
|
// Get the session for this socket
|
||||||
|
const session = this.sessionManager.getSession(socket);
|
||||||
|
if (!session) {
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session activity timestamp
|
||||||
|
this.sessionManager.updateSessionActivity(session);
|
||||||
|
|
||||||
|
const listname = args.trim();
|
||||||
|
|
||||||
|
// Log the EXPN attempt
|
||||||
|
SmtpLogger.info(`EXPN command received for list: ${listname}`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
remoteAddress: session.remoteAddress,
|
||||||
|
useTLS: session.useTLS
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable EXPN for security (best practice - RFC 5321 Section 3.5.2)
|
||||||
|
// EXPN allows enumeration of list members, which is a privacy concern
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} EXPN command is disabled for security reasons`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset session to after-EHLO state
|
* Reset session to after-EHLO state
|
||||||
* @param session - SMTP session to reset
|
* @param session - SMTP session to reset
|
||||||
|
@@ -13,6 +13,7 @@ import { getSocketDetails, formatMultilineResponse } from './utils/helpers.js';
|
|||||||
/**
|
/**
|
||||||
* Manager for SMTP connections
|
* Manager for SMTP connections
|
||||||
* Handles connection setup, event listeners, and lifecycle management
|
* Handles connection setup, event listeners, and lifecycle management
|
||||||
|
* Provides resource management, connection tracking, and monitoring
|
||||||
*/
|
*/
|
||||||
export class ConnectionManager implements IConnectionManager {
|
export class ConnectionManager implements IConnectionManager {
|
||||||
/**
|
/**
|
||||||
@@ -20,18 +21,50 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
*/
|
*/
|
||||||
private activeConnections: Set<plugins.net.Socket | plugins.tls.TLSSocket> = new Set();
|
private activeConnections: Set<plugins.net.Socket | plugins.tls.TLSSocket> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection tracking for resource management
|
||||||
|
*/
|
||||||
|
private connectionStats = {
|
||||||
|
totalConnections: 0,
|
||||||
|
activeConnections: 0,
|
||||||
|
peakConnections: 0,
|
||||||
|
rejectedConnections: 0,
|
||||||
|
closedConnections: 0,
|
||||||
|
erroredConnections: 0,
|
||||||
|
timedOutConnections: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-IP connection tracking for rate limiting
|
||||||
|
*/
|
||||||
|
private ipConnections: Map<string, {
|
||||||
|
count: number;
|
||||||
|
firstConnection: number;
|
||||||
|
lastConnection: number;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource monitoring interval
|
||||||
|
*/
|
||||||
|
private resourceCheckInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference to the session manager
|
* Reference to the session manager
|
||||||
*/
|
*/
|
||||||
private sessionManager: ISessionManager;
|
private sessionManager: ISessionManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMTP server options
|
* SMTP server options with enhanced resource controls
|
||||||
*/
|
*/
|
||||||
private options: {
|
private options: {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
maxConnections: number;
|
maxConnections: number;
|
||||||
socketTimeout: number;
|
socketTimeout: number;
|
||||||
|
maxConnectionsPerIP: number;
|
||||||
|
connectionRateLimit: number;
|
||||||
|
connectionRateWindow: number;
|
||||||
|
bufferSizeLimit: number;
|
||||||
|
resourceCheckInterval: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +73,7 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
private commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void;
|
private commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new connection manager
|
* Creates a new connection manager with enhanced resource management
|
||||||
* @param sessionManager - Session manager instance
|
* @param sessionManager - Session manager instance
|
||||||
* @param commandHandler - Command handler function
|
* @param commandHandler - Command handler function
|
||||||
* @param options - Connection manager options
|
* @param options - Connection manager options
|
||||||
@@ -52,26 +85,250 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
hostname?: string;
|
hostname?: string;
|
||||||
maxConnections?: number;
|
maxConnections?: number;
|
||||||
socketTimeout?: number;
|
socketTimeout?: number;
|
||||||
|
maxConnectionsPerIP?: number;
|
||||||
|
connectionRateLimit?: number;
|
||||||
|
connectionRateWindow?: number;
|
||||||
|
bufferSizeLimit?: number;
|
||||||
|
resourceCheckInterval?: number;
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
this.sessionManager = sessionManager;
|
this.sessionManager = sessionManager;
|
||||||
this.commandHandler = commandHandler;
|
this.commandHandler = commandHandler;
|
||||||
|
|
||||||
|
// Default values for resource management
|
||||||
|
const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
|
||||||
|
const DEFAULT_CONNECTION_RATE_LIMIT = 30; // connections per window
|
||||||
|
const DEFAULT_CONNECTION_RATE_WINDOW = 60 * 1000; // 60 seconds window
|
||||||
|
const DEFAULT_BUFFER_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MB
|
||||||
|
const DEFAULT_RESOURCE_CHECK_INTERVAL = 30 * 1000; // 30 seconds
|
||||||
|
|
||||||
this.options = {
|
this.options = {
|
||||||
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
|
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
|
||||||
maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS,
|
maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS,
|
||||||
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT
|
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT,
|
||||||
|
maxConnectionsPerIP: options.maxConnectionsPerIP || DEFAULT_MAX_CONNECTIONS_PER_IP,
|
||||||
|
connectionRateLimit: options.connectionRateLimit || DEFAULT_CONNECTION_RATE_LIMIT,
|
||||||
|
connectionRateWindow: options.connectionRateWindow || DEFAULT_CONNECTION_RATE_WINDOW,
|
||||||
|
bufferSizeLimit: options.bufferSizeLimit || DEFAULT_BUFFER_SIZE_LIMIT,
|
||||||
|
resourceCheckInterval: options.resourceCheckInterval || DEFAULT_RESOURCE_CHECK_INTERVAL
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Start resource monitoring
|
||||||
|
this.startResourceMonitoring();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a new connection
|
* Start resource monitoring interval to check resource usage
|
||||||
|
*/
|
||||||
|
private startResourceMonitoring(): void {
|
||||||
|
// Clear any existing interval
|
||||||
|
if (this.resourceCheckInterval) {
|
||||||
|
clearInterval(this.resourceCheckInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up new interval
|
||||||
|
this.resourceCheckInterval = setInterval(() => {
|
||||||
|
this.monitorResourceUsage();
|
||||||
|
}, this.options.resourceCheckInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor resource usage and log statistics
|
||||||
|
*/
|
||||||
|
private monitorResourceUsage(): void {
|
||||||
|
// Calculate memory usage
|
||||||
|
const memoryUsage = process.memoryUsage();
|
||||||
|
const memoryUsageMB = {
|
||||||
|
rss: Math.round(memoryUsage.rss / 1024 / 1024),
|
||||||
|
heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024),
|
||||||
|
heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024),
|
||||||
|
external: Math.round(memoryUsage.external / 1024 / 1024)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate connection rate metrics
|
||||||
|
const activeIPs = Array.from(this.ipConnections.entries())
|
||||||
|
.filter(([_, data]) => data.count > 0).length;
|
||||||
|
|
||||||
|
const highVolumeIPs = Array.from(this.ipConnections.entries())
|
||||||
|
.filter(([_, data]) => data.count > this.options.connectionRateLimit / 2).length;
|
||||||
|
|
||||||
|
// Log resource usage with more detailed metrics
|
||||||
|
SmtpLogger.info('Resource usage stats', {
|
||||||
|
connections: {
|
||||||
|
active: this.activeConnections.size,
|
||||||
|
total: this.connectionStats.totalConnections,
|
||||||
|
peak: this.connectionStats.peakConnections,
|
||||||
|
rejected: this.connectionStats.rejectedConnections,
|
||||||
|
closed: this.connectionStats.closedConnections,
|
||||||
|
errored: this.connectionStats.erroredConnections,
|
||||||
|
timedOut: this.connectionStats.timedOutConnections
|
||||||
|
},
|
||||||
|
memory: memoryUsageMB,
|
||||||
|
ipTracking: {
|
||||||
|
uniqueIPs: this.ipConnections.size,
|
||||||
|
activeIPs: activeIPs,
|
||||||
|
highVolumeIPs: highVolumeIPs
|
||||||
|
},
|
||||||
|
resourceLimits: {
|
||||||
|
maxConnections: this.options.maxConnections,
|
||||||
|
maxConnectionsPerIP: this.options.maxConnectionsPerIP,
|
||||||
|
connectionRateLimit: this.options.connectionRateLimit,
|
||||||
|
bufferSizeLimit: Math.round(this.options.bufferSizeLimit / 1024 / 1024) + 'MB'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for potential DoS conditions
|
||||||
|
if (highVolumeIPs > 3) {
|
||||||
|
SmtpLogger.warn(`Potential DoS detected: ${highVolumeIPs} IPs with high connection rates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assess memory usage trends
|
||||||
|
if (memoryUsageMB.heapUsed > 500) { // Over 500MB heap used
|
||||||
|
SmtpLogger.warn(`High memory usage detected: ${memoryUsageMB.heapUsed}MB heap used`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up expired IP rate limits and validate resource tracking
|
||||||
|
this.cleanupIpRateLimits();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired IP rate limits and perform additional resource monitoring
|
||||||
|
*/
|
||||||
|
private cleanupIpRateLimits(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowThreshold = now - this.options.connectionRateWindow;
|
||||||
|
let activeIps = 0;
|
||||||
|
let removedEntries = 0;
|
||||||
|
|
||||||
|
// Iterate through IP connections and manage entries
|
||||||
|
for (const [ip, data] of this.ipConnections.entries()) {
|
||||||
|
// If the last connection was before the window threshold + one extra window, remove the entry
|
||||||
|
if (data.lastConnection < windowThreshold - this.options.connectionRateWindow) {
|
||||||
|
// Remove stale entries to prevent memory growth
|
||||||
|
this.ipConnections.delete(ip);
|
||||||
|
removedEntries++;
|
||||||
|
}
|
||||||
|
// If last connection was before the window threshold, reset the count
|
||||||
|
else if (data.lastConnection < windowThreshold) {
|
||||||
|
if (data.count > 0) {
|
||||||
|
// Reset but keep the IP in the map with a zero count
|
||||||
|
this.ipConnections.set(ip, {
|
||||||
|
count: 0,
|
||||||
|
firstConnection: now,
|
||||||
|
lastConnection: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This IP is still active within the current window
|
||||||
|
activeIps++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log cleanup activity if significant changes occurred
|
||||||
|
if (removedEntries > 0) {
|
||||||
|
SmtpLogger.debug(`IP rate limit cleanup: removed ${removedEntries} stale entries, ${this.ipConnections.size} remaining, ${activeIps} active in current window`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for memory leaks in connection tracking
|
||||||
|
if (this.activeConnections.size > 0 && this.connectionStats.activeConnections !== this.activeConnections.size) {
|
||||||
|
SmtpLogger.warn(`Connection tracking inconsistency detected: stats.active=${this.connectionStats.activeConnections}, actual=${this.activeConnections.size}`);
|
||||||
|
// Fix the inconsistency
|
||||||
|
this.connectionStats.activeConnections = this.activeConnections.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and clean leaked resources if needed
|
||||||
|
this.validateResourceTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and repair resource tracking to prevent leaks
|
||||||
|
*/
|
||||||
|
private validateResourceTracking(): void {
|
||||||
|
// Prepare a detailed report if inconsistencies are found
|
||||||
|
const inconsistenciesFound = [];
|
||||||
|
|
||||||
|
// 1. Check active connections count matches activeConnections set size
|
||||||
|
if (this.connectionStats.activeConnections !== this.activeConnections.size) {
|
||||||
|
inconsistenciesFound.push({
|
||||||
|
issue: 'Active connection count mismatch',
|
||||||
|
stats: this.connectionStats.activeConnections,
|
||||||
|
actual: this.activeConnections.size,
|
||||||
|
action: 'Auto-corrected'
|
||||||
|
});
|
||||||
|
this.connectionStats.activeConnections = this.activeConnections.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for destroyed sockets in active connections
|
||||||
|
let destroyedSocketsCount = 0;
|
||||||
|
for (const socket of this.activeConnections) {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
destroyedSocketsCount++;
|
||||||
|
// This should not happen - remove destroyed sockets from tracking
|
||||||
|
this.activeConnections.delete(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destroyedSocketsCount > 0) {
|
||||||
|
inconsistenciesFound.push({
|
||||||
|
issue: 'Destroyed sockets in active list',
|
||||||
|
count: destroyedSocketsCount,
|
||||||
|
action: 'Removed from tracking'
|
||||||
|
});
|
||||||
|
// Update active connections count after cleanup
|
||||||
|
this.connectionStats.activeConnections = this.activeConnections.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for sessions without corresponding active connections
|
||||||
|
const sessionCount = this.sessionManager.getSessionCount();
|
||||||
|
if (sessionCount > this.activeConnections.size) {
|
||||||
|
inconsistenciesFound.push({
|
||||||
|
issue: 'Orphaned sessions',
|
||||||
|
sessions: sessionCount,
|
||||||
|
connections: this.activeConnections.size,
|
||||||
|
action: 'Session cleanup recommended'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any inconsistencies found, log a detailed report
|
||||||
|
if (inconsistenciesFound.length > 0) {
|
||||||
|
SmtpLogger.warn('Resource tracking inconsistencies detected and repaired', { inconsistencies: inconsistenciesFound });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a new connection with resource management
|
||||||
* @param socket - Client socket
|
* @param socket - Client socket
|
||||||
*/
|
*/
|
||||||
public handleNewConnection(socket: plugins.net.Socket): void {
|
public handleNewConnection(socket: plugins.net.Socket): void {
|
||||||
// Check if maximum connections reached
|
// Update connection stats
|
||||||
|
this.connectionStats.totalConnections++;
|
||||||
|
this.connectionStats.activeConnections = this.activeConnections.size + 1;
|
||||||
|
|
||||||
|
if (this.connectionStats.activeConnections > this.connectionStats.peakConnections) {
|
||||||
|
this.connectionStats.peakConnections = this.connectionStats.activeConnections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client IP
|
||||||
|
const remoteAddress = socket.remoteAddress || '0.0.0.0';
|
||||||
|
|
||||||
|
// Check rate limits by IP
|
||||||
|
if (this.isIPRateLimited(remoteAddress)) {
|
||||||
|
this.rejectConnection(socket, 'Rate limit exceeded');
|
||||||
|
this.connectionStats.rejectedConnections++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-IP connection limit
|
||||||
|
if (this.hasReachedIPConnectionLimit(remoteAddress)) {
|
||||||
|
this.rejectConnection(socket, 'Too many connections from this IP');
|
||||||
|
this.connectionStats.rejectedConnections++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if maximum global connections reached
|
||||||
if (this.hasReachedMaxConnections()) {
|
if (this.hasReachedMaxConnections()) {
|
||||||
this.rejectConnection(socket, 'Too many connections');
|
this.rejectConnection(socket, 'Too many connections');
|
||||||
|
this.connectionStats.rejectedConnections++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +339,27 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
socket.setKeepAlive(true);
|
socket.setKeepAlive(true);
|
||||||
socket.setTimeout(this.options.socketTimeout);
|
socket.setTimeout(this.options.socketTimeout);
|
||||||
|
|
||||||
|
// Explicitly set socket buffer sizes to prevent memory issues
|
||||||
|
socket.setNoDelay(true); // Disable Nagle's algorithm for better responsiveness
|
||||||
|
|
||||||
|
// Set limits on socket buffer size if supported by Node.js version
|
||||||
|
try {
|
||||||
|
// Here we set reasonable buffer limits to prevent memory exhaustion attacks
|
||||||
|
const highWaterMark = 64 * 1024; // 64 KB
|
||||||
|
if (typeof socket.// setReadableHighWaterMark === 'function') {
|
||||||
|
socket.// setReadableHighWaterMark(highWaterMark);
|
||||||
|
}
|
||||||
|
if (typeof socket.// setWritableHighWaterMark === 'function') {
|
||||||
|
socket.// setWritableHighWaterMark(highWaterMark);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors from older Node.js versions that don't support these methods
|
||||||
|
SmtpLogger.debug(`Could not set socket buffer limits: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track this IP connection
|
||||||
|
this.trackIPConnection(remoteAddress);
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
this.setupSocketEventHandlers(socket);
|
this.setupSocketEventHandlers(socket);
|
||||||
|
|
||||||
@@ -97,13 +375,117 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a new secure TLS connection
|
* Check if an IP has exceeded the rate limit
|
||||||
|
* @param ip - Client IP address
|
||||||
|
* @returns True if rate limited
|
||||||
|
*/
|
||||||
|
private isIPRateLimited(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const ipData = this.ipConnections.get(ip);
|
||||||
|
|
||||||
|
if (!ipData) {
|
||||||
|
return false; // No previous connections
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're within the rate window
|
||||||
|
const isWithinWindow = now - ipData.firstConnection <= this.options.connectionRateWindow;
|
||||||
|
|
||||||
|
// If within window and count exceeds limit, rate limit is applied
|
||||||
|
if (isWithinWindow && ipData.count >= this.options.connectionRateLimit) {
|
||||||
|
SmtpLogger.warn(`Rate limit exceeded for IP ${ip}: ${ipData.count} connections in ${Math.round((now - ipData.firstConnection) / 1000)}s`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a new connection from an IP
|
||||||
|
* @param ip - Client IP address
|
||||||
|
*/
|
||||||
|
private trackIPConnection(ip: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const ipData = this.ipConnections.get(ip);
|
||||||
|
|
||||||
|
if (!ipData) {
|
||||||
|
// First connection from this IP
|
||||||
|
this.ipConnections.set(ip, {
|
||||||
|
count: 1,
|
||||||
|
firstConnection: now,
|
||||||
|
lastConnection: now
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if we need to reset the window
|
||||||
|
if (now - ipData.lastConnection > this.options.connectionRateWindow) {
|
||||||
|
// Reset the window
|
||||||
|
this.ipConnections.set(ip, {
|
||||||
|
count: 1,
|
||||||
|
firstConnection: now,
|
||||||
|
lastConnection: now
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Increment within the current window
|
||||||
|
this.ipConnections.set(ip, {
|
||||||
|
count: ipData.count + 1,
|
||||||
|
firstConnection: ipData.firstConnection,
|
||||||
|
lastConnection: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP has reached its connection limit
|
||||||
|
* @param ip - Client IP address
|
||||||
|
* @returns True if limit reached
|
||||||
|
*/
|
||||||
|
private hasReachedIPConnectionLimit(ip: string): boolean {
|
||||||
|
let ipConnectionCount = 0;
|
||||||
|
|
||||||
|
// Count active connections from this IP
|
||||||
|
for (const socket of this.activeConnections) {
|
||||||
|
if (socket.remoteAddress === ip) {
|
||||||
|
ipConnectionCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipConnectionCount >= this.options.maxConnectionsPerIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a new secure TLS connection with resource management
|
||||||
* @param socket - Client TLS socket
|
* @param socket - Client TLS socket
|
||||||
*/
|
*/
|
||||||
public handleNewSecureConnection(socket: plugins.tls.TLSSocket): void {
|
public handleNewSecureConnection(socket: plugins.tls.TLSSocket): void {
|
||||||
// Check if maximum connections reached
|
// Update connection stats
|
||||||
|
this.connectionStats.totalConnections++;
|
||||||
|
this.connectionStats.activeConnections = this.activeConnections.size + 1;
|
||||||
|
|
||||||
|
if (this.connectionStats.activeConnections > this.connectionStats.peakConnections) {
|
||||||
|
this.connectionStats.peakConnections = this.connectionStats.activeConnections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client IP
|
||||||
|
const remoteAddress = socket.remoteAddress || '0.0.0.0';
|
||||||
|
|
||||||
|
// Check rate limits by IP
|
||||||
|
if (this.isIPRateLimited(remoteAddress)) {
|
||||||
|
this.rejectConnection(socket, 'Rate limit exceeded');
|
||||||
|
this.connectionStats.rejectedConnections++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-IP connection limit
|
||||||
|
if (this.hasReachedIPConnectionLimit(remoteAddress)) {
|
||||||
|
this.rejectConnection(socket, 'Too many connections from this IP');
|
||||||
|
this.connectionStats.rejectedConnections++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if maximum global connections reached
|
||||||
if (this.hasReachedMaxConnections()) {
|
if (this.hasReachedMaxConnections()) {
|
||||||
this.rejectConnection(socket, 'Too many connections');
|
this.rejectConnection(socket, 'Too many connections');
|
||||||
|
this.connectionStats.rejectedConnections++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +496,27 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
socket.setKeepAlive(true);
|
socket.setKeepAlive(true);
|
||||||
socket.setTimeout(this.options.socketTimeout);
|
socket.setTimeout(this.options.socketTimeout);
|
||||||
|
|
||||||
|
// Explicitly set socket buffer sizes to prevent memory issues
|
||||||
|
socket.setNoDelay(true); // Disable Nagle's algorithm for better responsiveness
|
||||||
|
|
||||||
|
// Set limits on socket buffer size if supported by Node.js version
|
||||||
|
try {
|
||||||
|
// Here we set reasonable buffer limits to prevent memory exhaustion attacks
|
||||||
|
const highWaterMark = 64 * 1024; // 64 KB
|
||||||
|
if (typeof socket.// setReadableHighWaterMark === 'function') {
|
||||||
|
socket.// setReadableHighWaterMark(highWaterMark);
|
||||||
|
}
|
||||||
|
if (typeof socket.// setWritableHighWaterMark === 'function') {
|
||||||
|
socket.// setWritableHighWaterMark(highWaterMark);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors from older Node.js versions that don't support these methods
|
||||||
|
SmtpLogger.debug(`Could not set socket buffer limits: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track this IP connection
|
||||||
|
this.trackIPConnection(remoteAddress);
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
this.setupSocketEventHandlers(socket);
|
this.setupSocketEventHandlers(socket);
|
||||||
|
|
||||||
@@ -128,7 +531,7 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up event handlers for a socket
|
* Set up event handlers for a socket with enhanced resource management
|
||||||
* @param socket - Client socket
|
* @param socket - Client socket
|
||||||
*/
|
*/
|
||||||
public setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
public setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||||
@@ -144,30 +547,96 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
if (existingErrorHandler) socket.removeListener('error', existingErrorHandler);
|
if (existingErrorHandler) socket.removeListener('error', existingErrorHandler);
|
||||||
if (existingTimeoutHandler) socket.removeListener('timeout', existingTimeoutHandler);
|
if (existingTimeoutHandler) socket.removeListener('timeout', existingTimeoutHandler);
|
||||||
|
|
||||||
// Data event - process incoming data from the client
|
// Data event - process incoming data from the client with resource limits
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
let totalBytesReceived = 0;
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
socket.on('data', (data) => {
|
||||||
// Get current session and update activity timestamp
|
try {
|
||||||
const session = this.sessionManager.getSession(socket);
|
// Get current session and update activity timestamp
|
||||||
if (session) {
|
const session = this.sessionManager.getSession(socket);
|
||||||
this.sessionManager.updateSessionActivity(session);
|
if (session) {
|
||||||
}
|
this.sessionManager.updateSessionActivity(session);
|
||||||
|
|
||||||
// Buffer incoming data
|
|
||||||
buffer += data.toString();
|
|
||||||
|
|
||||||
// Process complete lines
|
|
||||||
let lineEndPos;
|
|
||||||
while ((lineEndPos = buffer.indexOf(SMTP_DEFAULTS.CRLF)) !== -1) {
|
|
||||||
// Extract a complete line
|
|
||||||
const line = buffer.substring(0, lineEndPos);
|
|
||||||
buffer = buffer.substring(lineEndPos + 2); // +2 to skip CRLF
|
|
||||||
|
|
||||||
// Process non-empty lines
|
|
||||||
if (line.length > 0) {
|
|
||||||
// In DATA state, the command handler will process the data differently
|
|
||||||
this.commandHandler(socket, line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check buffer size limits to prevent memory attacks
|
||||||
|
totalBytesReceived += data.length;
|
||||||
|
|
||||||
|
if (buffer.length > this.options.bufferSizeLimit) {
|
||||||
|
// Buffer is too large, reject the connection
|
||||||
|
SmtpLogger.warn(`Buffer size limit exceeded: ${buffer.length} bytes for ${socket.remoteAddress}`);
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message too large, disconnecting`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impose a total transfer limit to prevent DoS
|
||||||
|
if (totalBytesReceived > this.options.bufferSizeLimit * 2) {
|
||||||
|
SmtpLogger.warn(`Total transfer limit exceeded: ${totalBytesReceived} bytes for ${socket.remoteAddress}`);
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Transfer limit exceeded, disconnecting`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert buffer to string safely with explicit encoding
|
||||||
|
const dataString = data.toString('utf8');
|
||||||
|
|
||||||
|
// Buffer incoming data
|
||||||
|
buffer += dataString;
|
||||||
|
|
||||||
|
// Process complete lines
|
||||||
|
let lineEndPos;
|
||||||
|
while ((lineEndPos = buffer.indexOf(SMTP_DEFAULTS.CRLF)) !== -1) {
|
||||||
|
// Extract a complete line
|
||||||
|
const line = buffer.substring(0, lineEndPos);
|
||||||
|
buffer = buffer.substring(lineEndPos + 2); // +2 to skip CRLF
|
||||||
|
|
||||||
|
// Check line length to prevent extremely long lines
|
||||||
|
if (line.length > 4096) { // 4KB line limit is reasonable for SMTP
|
||||||
|
SmtpLogger.warn(`Line length limit exceeded: ${line.length} bytes for ${socket.remoteAddress}`);
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Line too long, disconnecting`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process non-empty lines
|
||||||
|
if (line.length > 0) {
|
||||||
|
try {
|
||||||
|
// In DATA state, the command handler will process the data differently
|
||||||
|
this.commandHandler(socket, line);
|
||||||
|
} catch (cmdError) {
|
||||||
|
// Handle any errors in command processing
|
||||||
|
SmtpLogger.error(`Command handler error: ${cmdError instanceof Error ? cmdError.message : String(cmdError)}`);
|
||||||
|
|
||||||
|
// If there's a severe error, close the connection
|
||||||
|
if (cmdError instanceof Error &&
|
||||||
|
(cmdError.message.includes('fatal') || cmdError.message.includes('critical'))) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If buffer is getting too large without CRLF, it might be a DoS attempt
|
||||||
|
if (buffer.length > 10240) { // 10KB is a reasonable limit for a line without CRLF
|
||||||
|
SmtpLogger.warn(`Incomplete line too large: ${buffer.length} bytes for ${socket.remoteAddress}`);
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Incomplete line too large, disconnecting`);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle any unexpected errors during data processing
|
||||||
|
SmtpLogger.error(`Data handler error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add drain event handler to manage flow control
|
||||||
|
socket.on('drain', () => {
|
||||||
|
// Socket buffer has been emptied, resume data flow if needed
|
||||||
|
if (socket.isPaused()) {
|
||||||
|
socket.resume();
|
||||||
|
SmtpLogger.debug(`Resumed socket for ${socket.remoteAddress} after drain`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,17 +705,45 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
* @param hadError - Whether the socket was closed due to error
|
* @param hadError - Whether the socket was closed due to error
|
||||||
*/
|
*/
|
||||||
private handleSocketClose(socket: plugins.net.Socket | plugins.tls.TLSSocket, hadError: boolean): void {
|
private handleSocketClose(socket: plugins.net.Socket | plugins.tls.TLSSocket, hadError: boolean): void {
|
||||||
// Remove from active connections
|
try {
|
||||||
this.activeConnections.delete(socket);
|
// Update connection statistics
|
||||||
|
this.connectionStats.closedConnections++;
|
||||||
|
this.connectionStats.activeConnections = this.activeConnections.size - 1;
|
||||||
|
|
||||||
// Get the session before removing it
|
// Get socket details for logging
|
||||||
const session = this.sessionManager.getSession(socket);
|
const socketDetails = getSocketDetails(socket);
|
||||||
|
const socketId = `${socketDetails.remoteAddress}:${socketDetails.remotePort}`;
|
||||||
|
|
||||||
// Remove from session manager
|
// Log with appropriate level based on whether there was an error
|
||||||
this.sessionManager.removeSession(socket);
|
if (hadError) {
|
||||||
|
SmtpLogger.warn(`Socket closed with error: ${socketId}`);
|
||||||
|
} else {
|
||||||
|
SmtpLogger.debug(`Socket closed normally: ${socketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Log connection close
|
// Get the session before removing it
|
||||||
SmtpLogger.logConnection(socket, 'close', session);
|
const session = this.sessionManager.getSession(socket);
|
||||||
|
|
||||||
|
// Remove from active connections
|
||||||
|
this.activeConnections.delete(socket);
|
||||||
|
|
||||||
|
// Remove from session manager
|
||||||
|
this.sessionManager.removeSession(socket);
|
||||||
|
|
||||||
|
// Cancel any timeout ID stored in the session
|
||||||
|
if (session?.dataTimeoutId) {
|
||||||
|
clearTimeout(session.dataTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log connection close with session details if available
|
||||||
|
SmtpLogger.logConnection(socket, 'close', session);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle any unexpected errors during cleanup
|
||||||
|
SmtpLogger.error(`Error in handleSocketClose: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
|
||||||
|
// Ensure socket is removed from active connections even if an error occurs
|
||||||
|
this.activeConnections.delete(socket);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,15 +752,54 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
* @param error - Error object
|
* @param error - Error object
|
||||||
*/
|
*/
|
||||||
private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: Error): void {
|
private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: Error): void {
|
||||||
// Get the session
|
try {
|
||||||
const session = this.sessionManager.getSession(socket);
|
// Update connection statistics
|
||||||
|
this.connectionStats.erroredConnections++;
|
||||||
|
|
||||||
// Log the error
|
// Get socket details for context
|
||||||
SmtpLogger.logConnection(socket, 'error', session, error);
|
const socketDetails = getSocketDetails(socket);
|
||||||
|
const socketId = `${socketDetails.remoteAddress}:${socketDetails.remotePort}`;
|
||||||
|
|
||||||
// Close the socket if not already closed
|
// Get the session
|
||||||
if (!socket.destroyed) {
|
const session = this.sessionManager.getSession(socket);
|
||||||
socket.destroy();
|
|
||||||
|
// Detailed error logging with context information
|
||||||
|
SmtpLogger.error(`Socket error for ${socketId}: ${error.message}`, {
|
||||||
|
errorCode: (error as any).code,
|
||||||
|
errorStack: error.stack,
|
||||||
|
sessionId: session?.id,
|
||||||
|
sessionState: session?.state,
|
||||||
|
remoteAddress: socketDetails.remoteAddress,
|
||||||
|
remotePort: socketDetails.remotePort
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the error for connection tracking
|
||||||
|
SmtpLogger.logConnection(socket, 'error', session, error);
|
||||||
|
|
||||||
|
// Cancel any timeout ID stored in the session
|
||||||
|
if (session?.dataTimeoutId) {
|
||||||
|
clearTimeout(session.dataTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the socket if not already closed
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from active connections (cleanup after error)
|
||||||
|
this.activeConnections.delete(socket);
|
||||||
|
|
||||||
|
// Remove from session manager
|
||||||
|
this.sessionManager.removeSession(socket);
|
||||||
|
} catch (handlerError) {
|
||||||
|
// Meta-error handling (errors in the error handler)
|
||||||
|
SmtpLogger.error(`Error in handleSocketError: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`);
|
||||||
|
|
||||||
|
// Ensure socket is destroyed and removed from active connections
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
this.activeConnections.delete(socket);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,31 +808,77 @@ export class ConnectionManager implements IConnectionManager {
|
|||||||
* @param socket - Client socket
|
* @param socket - Client socket
|
||||||
*/
|
*/
|
||||||
private handleSocketTimeout(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
private handleSocketTimeout(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||||
// Get the session
|
|
||||||
const session = this.sessionManager.getSession(socket);
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
// Log the timeout
|
|
||||||
SmtpLogger.warn(`Socket timeout from ${session.remoteAddress}`, {
|
|
||||||
sessionId: session.id,
|
|
||||||
remoteAddress: session.remoteAddress,
|
|
||||||
state: session.state,
|
|
||||||
timeout: this.options.socketTimeout
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send timeout notification
|
|
||||||
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} Connection timeout - closing connection`);
|
|
||||||
} else {
|
|
||||||
// Log timeout without session context
|
|
||||||
const socketDetails = getSocketDetails(socket);
|
|
||||||
SmtpLogger.warn(`Socket timeout without session from ${socketDetails.remoteAddress}:${socketDetails.remotePort}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the socket
|
|
||||||
try {
|
try {
|
||||||
socket.end();
|
// Update connection statistics
|
||||||
} catch (error) {
|
this.connectionStats.timedOutConnections++;
|
||||||
SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
|
// Get socket details for context
|
||||||
|
const socketDetails = getSocketDetails(socket);
|
||||||
|
const socketId = `${socketDetails.remoteAddress}:${socketDetails.remotePort}`;
|
||||||
|
|
||||||
|
// Get the session
|
||||||
|
const session = this.sessionManager.getSession(socket);
|
||||||
|
|
||||||
|
// Get timing information for better debugging
|
||||||
|
const now = Date.now();
|
||||||
|
const idleTime = session?.lastActivity ? now - session.lastActivity : 'unknown';
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
// Log the timeout with extended details
|
||||||
|
SmtpLogger.warn(`Socket timeout from ${session.remoteAddress}`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
remoteAddress: session.remoteAddress,
|
||||||
|
state: session.state,
|
||||||
|
timeout: this.options.socketTimeout,
|
||||||
|
idleTime: idleTime,
|
||||||
|
emailState: session.envelope?.mailFrom ? 'has-sender' : 'no-sender',
|
||||||
|
recipientCount: session.envelope?.rcptTo?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel any timeout ID stored in the session
|
||||||
|
if (session.dataTimeoutId) {
|
||||||
|
clearTimeout(session.dataTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send timeout notification to client
|
||||||
|
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} Connection timeout - closing connection`);
|
||||||
|
} else {
|
||||||
|
// Log timeout without session context
|
||||||
|
SmtpLogger.warn(`Socket timeout without session from ${socketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the socket gracefully
|
||||||
|
try {
|
||||||
|
socket.end();
|
||||||
|
|
||||||
|
// Set a forced close timeout in case socket.end() doesn't close the connection
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
SmtpLogger.warn(`Forcing destroy of timed out socket: ${socketId}`);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}, 5000); // 5 second grace period for socket to end properly
|
||||||
|
} catch (error) {
|
||||||
|
SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
|
||||||
|
// Ensure socket is destroyed even if end() fails
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up resources
|
||||||
|
this.activeConnections.delete(socket);
|
||||||
|
this.sessionManager.removeSession(socket);
|
||||||
|
} catch (handlerError) {
|
||||||
|
// Handle any unexpected errors during timeout handling
|
||||||
|
SmtpLogger.error(`Error in handleSocketTimeout: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`);
|
||||||
|
|
||||||
|
// Ensure socket is destroyed and removed from tracking
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
this.activeConnections.delete(socket);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -455,14 +455,103 @@ export class DataHandler implements IDataHandler {
|
|||||||
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||||
SmtpLogger.logResponse(response, socket);
|
SmtpLogger.logResponse(response, socket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
// Attempt to recover from specific transient errors
|
||||||
response,
|
if (this.isRecoverableSocketError(error)) {
|
||||||
remoteAddress: socket.remoteAddress,
|
this.handleSocketError(socket, error, response);
|
||||||
remotePort: socket.remotePort,
|
} else {
|
||||||
error: error instanceof Error ? error : new Error(String(error))
|
// Log error for non-recoverable errors
|
||||||
});
|
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
||||||
|
response,
|
||||||
|
remoteAddress: socket.remoteAddress,
|
||||||
|
remotePort: socket.remotePort,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
socket.destroy();
|
/**
|
||||||
|
* Check if a socket error is potentially recoverable
|
||||||
|
* @param error - The error that occurred
|
||||||
|
* @returns Whether the error is potentially recoverable
|
||||||
|
*/
|
||||||
|
private isRecoverableSocketError(error: unknown): boolean {
|
||||||
|
const recoverableErrorCodes = [
|
||||||
|
'EPIPE', // Broken pipe
|
||||||
|
'ECONNRESET', // Connection reset by peer
|
||||||
|
'ETIMEDOUT', // Connection timed out
|
||||||
|
'ECONNABORTED' // Connection aborted
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
'code' in error &&
|
||||||
|
typeof (error as any).code === 'string' &&
|
||||||
|
recoverableErrorCodes.includes((error as any).code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle recoverable socket errors with retry logic
|
||||||
|
* @param socket - Client socket
|
||||||
|
* @param error - The error that occurred
|
||||||
|
* @param response - The response that failed to send
|
||||||
|
*/
|
||||||
|
private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: unknown, response: string): void {
|
||||||
|
// Get the session for this socket
|
||||||
|
const session = this.sessionManager.getSession(socket);
|
||||||
|
if (!session) {
|
||||||
|
SmtpLogger.error(`Session not found when handling socket error`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get error details for logging
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorCode = error instanceof Error && 'code' in error ? (error as any).code : 'UNKNOWN';
|
||||||
|
|
||||||
|
SmtpLogger.warn(`Recoverable socket error during data handling (${errorCode}): ${errorMessage}`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
remoteAddress: session.remoteAddress,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if socket is already destroyed
|
||||||
|
if (socket.destroyed) {
|
||||||
|
SmtpLogger.info(`Socket already destroyed, cannot retry data operation`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if socket is writeable
|
||||||
|
if (!socket.writable) {
|
||||||
|
SmtpLogger.info(`Socket no longer writable, aborting data recovery attempt`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to retry the write operation after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (!socket.destroyed && socket.writable) {
|
||||||
|
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||||
|
SmtpLogger.info(`Successfully retried data send operation after error`);
|
||||||
|
} else {
|
||||||
|
SmtpLogger.warn(`Socket no longer available for data retry`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (retryError) {
|
||||||
|
SmtpLogger.error(`Data retry attempt failed: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100); // Short delay before retry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -78,6 +78,41 @@ export class SmtpServer implements ISmtpServer {
|
|||||||
*/
|
*/
|
||||||
private running = false;
|
private running = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server recovery state
|
||||||
|
*/
|
||||||
|
private recoveryState = {
|
||||||
|
/**
|
||||||
|
* Whether recovery is in progress
|
||||||
|
*/
|
||||||
|
recovering: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of consecutive connection failures
|
||||||
|
*/
|
||||||
|
connectionFailures: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last recovery attempt timestamp
|
||||||
|
*/
|
||||||
|
lastRecoveryAttempt: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery cooldown in milliseconds
|
||||||
|
*/
|
||||||
|
recoveryCooldown: 5000,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum recovery attempts before giving up
|
||||||
|
*/
|
||||||
|
maxRecoveryAttempts: 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current recovery attempt
|
||||||
|
*/
|
||||||
|
currentRecoveryAttempt: 0
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new SMTP server
|
* Creates a new SMTP server
|
||||||
* @param config - Server configuration
|
* @param config - Server configuration
|
||||||
@@ -173,9 +208,14 @@ export class SmtpServer implements ISmtpServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up error handling
|
// Set up error handling with recovery
|
||||||
this.server.on('error', (err) => {
|
this.server.on('error', (err) => {
|
||||||
SmtpLogger.error(`SMTP server error: ${err.message}`, { error: err });
|
SmtpLogger.error(`SMTP server error: ${err.message}`, { error: err });
|
||||||
|
|
||||||
|
// Try to recover from specific errors
|
||||||
|
if (this.shouldAttemptRecovery(err)) {
|
||||||
|
this.attemptServerRecovery('standard', err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start listening
|
// Start listening
|
||||||
@@ -252,12 +292,17 @@ export class SmtpServer implements ISmtpServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global error handler for the secure server
|
// Global error handler for the secure server with recovery
|
||||||
this.secureServer.on('error', (err) => {
|
this.secureServer.on('error', (err) => {
|
||||||
SmtpLogger.error(`SMTP secure server error: ${err.message}`, {
|
SmtpLogger.error(`SMTP secure server error: ${err.message}`, {
|
||||||
error: err,
|
error: err,
|
||||||
stack: err.stack
|
stack: err.stack
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Try to recover from specific errors
|
||||||
|
if (this.shouldAttemptRecovery(err)) {
|
||||||
|
this.attemptServerRecovery('secure', err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start listening on secure port
|
// Start listening on secure port
|
||||||
@@ -445,4 +490,285 @@ export class SmtpServer implements ISmtpServer {
|
|||||||
public isRunning(): boolean {
|
public isRunning(): boolean {
|
||||||
return this.running;
|
return this.running;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we should attempt to recover from an error
|
||||||
|
* @param error - The error that occurred
|
||||||
|
* @returns Whether recovery should be attempted
|
||||||
|
*/
|
||||||
|
private shouldAttemptRecovery(error: Error): boolean {
|
||||||
|
// Skip recovery if we're already in recovery mode
|
||||||
|
if (this.recoveryState.recovering) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've reached the maximum number of recovery attempts
|
||||||
|
if (this.recoveryState.currentRecoveryAttempt >= this.recoveryState.maxRecoveryAttempts) {
|
||||||
|
SmtpLogger.warn('Maximum recovery attempts reached, not attempting further recovery');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed since the last recovery attempt
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.recoveryState.lastRecoveryAttempt < this.recoveryState.recoveryCooldown) {
|
||||||
|
SmtpLogger.warn('Recovery cooldown period not elapsed, skipping recovery attempt');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recoverable errors include:
|
||||||
|
// - EADDRINUSE: Address already in use (port conflict)
|
||||||
|
// - ECONNRESET: Connection reset by peer
|
||||||
|
// - EPIPE: Broken pipe
|
||||||
|
// - ETIMEDOUT: Connection timed out
|
||||||
|
const recoverableErrors = [
|
||||||
|
'EADDRINUSE',
|
||||||
|
'ECONNRESET',
|
||||||
|
'EPIPE',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'ECONNABORTED',
|
||||||
|
'EPROTO',
|
||||||
|
'EMFILE' // Too many open files
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if this is a recoverable error
|
||||||
|
const errorCode = (error as any).code;
|
||||||
|
return recoverableErrors.includes(errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to recover the server after a critical error
|
||||||
|
* @param serverType - The type of server to recover ('standard' or 'secure')
|
||||||
|
* @param error - The error that triggered recovery
|
||||||
|
*/
|
||||||
|
private async attemptServerRecovery(serverType: 'standard' | 'secure', error: Error): Promise<void> {
|
||||||
|
// Set recovery flag to prevent multiple simultaneous recovery attempts
|
||||||
|
if (this.recoveryState.recovering) {
|
||||||
|
SmtpLogger.warn('Recovery already in progress, skipping new recovery attempt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recoveryState.recovering = true;
|
||||||
|
this.recoveryState.lastRecoveryAttempt = Date.now();
|
||||||
|
this.recoveryState.currentRecoveryAttempt++;
|
||||||
|
|
||||||
|
SmtpLogger.info(`Attempting server recovery for ${serverType} server after error: ${error.message}`, {
|
||||||
|
attempt: this.recoveryState.currentRecoveryAttempt,
|
||||||
|
maxAttempts: this.recoveryState.maxRecoveryAttempts,
|
||||||
|
errorCode: (error as any).code
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine which server to restart
|
||||||
|
const isStandardServer = serverType === 'standard';
|
||||||
|
|
||||||
|
// Close the affected server
|
||||||
|
if (isStandardServer && this.server) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!this.server) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First try a clean shutdown
|
||||||
|
this.server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
SmtpLogger.warn(`Error during server close in recovery: ${err.message}`);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a timeout to force close
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server = null;
|
||||||
|
} else if (!isStandardServer && this.secureServer) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!this.secureServer) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First try a clean shutdown
|
||||||
|
this.secureServer.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
SmtpLogger.warn(`Error during secure server close in recovery: ${err.message}`);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a timeout to force close
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.secureServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short delay before restarting
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Clean up any lingering connections
|
||||||
|
this.connectionManager.closeAllConnections();
|
||||||
|
this.sessionManager.clearAllSessions();
|
||||||
|
|
||||||
|
// Restart the affected server
|
||||||
|
if (isStandardServer) {
|
||||||
|
// Create and start the standard server
|
||||||
|
this.server = plugins.net.createServer((socket) => {
|
||||||
|
// Check IP reputation before handling connection
|
||||||
|
this.securityHandler.checkIpReputation(socket)
|
||||||
|
.then(allowed => {
|
||||||
|
if (allowed) {
|
||||||
|
this.connectionManager.handleNewConnection(socket);
|
||||||
|
} else {
|
||||||
|
// Close connection if IP is not allowed
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, {
|
||||||
|
remoteAddress: socket.remoteAddress,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow connection on error (fail open)
|
||||||
|
this.connectionManager.handleNewConnection(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up error handling with recovery
|
||||||
|
this.server.on('error', (err) => {
|
||||||
|
SmtpLogger.error(`SMTP server error after recovery: ${err.message}`, { error: err });
|
||||||
|
|
||||||
|
// Try to recover again if needed
|
||||||
|
if (this.shouldAttemptRecovery(err)) {
|
||||||
|
this.attemptServerRecovery('standard', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start listening again
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (!this.server) {
|
||||||
|
reject(new Error('Server not initialized during recovery'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server.listen(this.options.port, this.options.host, () => {
|
||||||
|
SmtpLogger.info(`SMTP server recovered and listening on ${this.options.host || '0.0.0.0'}:${this.options.port}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only use error event for startup issues during recovery
|
||||||
|
this.server.once('error', (err) => {
|
||||||
|
SmtpLogger.error(`Failed to restart server during recovery: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (this.options.securePort && this.tlsHandler.isTlsEnabled()) {
|
||||||
|
// Try to recreate the secure server
|
||||||
|
try {
|
||||||
|
// Import the secure server creation utility
|
||||||
|
const { createSecureTlsServer } = await import('./secure-server.js');
|
||||||
|
|
||||||
|
// Create secure server with the certificates
|
||||||
|
this.secureServer = createSecureTlsServer({
|
||||||
|
key: this.options.key,
|
||||||
|
cert: this.options.cert,
|
||||||
|
ca: this.options.ca
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.secureServer) {
|
||||||
|
SmtpLogger.info(`Created secure TLS server for port ${this.options.securePort} during recovery`);
|
||||||
|
|
||||||
|
// Use explicit error handling for secure connections
|
||||||
|
this.secureServer.on('tlsClientError', (err, tlsSocket) => {
|
||||||
|
SmtpLogger.error(`TLS client error after recovery: ${err.message}`, {
|
||||||
|
error: err,
|
||||||
|
remoteAddress: tlsSocket.remoteAddress,
|
||||||
|
remotePort: tlsSocket.remotePort,
|
||||||
|
stack: err.stack
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the secure connection handler
|
||||||
|
this.secureServer.on('secureConnection', (socket) => {
|
||||||
|
// Check IP reputation before handling connection
|
||||||
|
this.securityHandler.checkIpReputation(socket)
|
||||||
|
.then(allowed => {
|
||||||
|
if (allowed) {
|
||||||
|
// Pass the connection to the connection manager
|
||||||
|
this.connectionManager.handleNewSecureConnection(socket);
|
||||||
|
} else {
|
||||||
|
// Close connection if IP is not allowed
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
SmtpLogger.error(`IP reputation check error after recovery: ${error instanceof Error ? error.message : String(error)}`, {
|
||||||
|
remoteAddress: socket.remoteAddress,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow connection on error (fail open)
|
||||||
|
this.connectionManager.handleNewSecureConnection(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler for the secure server with recovery
|
||||||
|
this.secureServer.on('error', (err) => {
|
||||||
|
SmtpLogger.error(`SMTP secure server error after recovery: ${err.message}`, {
|
||||||
|
error: err,
|
||||||
|
stack: err.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to recover again if needed
|
||||||
|
if (this.shouldAttemptRecovery(err)) {
|
||||||
|
this.attemptServerRecovery('secure', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start listening on secure port again
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (!this.secureServer) {
|
||||||
|
reject(new Error('Secure server not initialized during recovery'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.secureServer.listen(this.options.securePort, this.options.host, () => {
|
||||||
|
SmtpLogger.info(`SMTP secure server recovered and listening on ${this.options.host || '0.0.0.0'}:${this.options.securePort}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only use error event for startup issues during recovery
|
||||||
|
this.secureServer.once('error', (err) => {
|
||||||
|
SmtpLogger.error(`Failed to restart secure server during recovery: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
SmtpLogger.warn('Failed to create secure server during recovery');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
SmtpLogger.error(`Error setting up secure server during recovery: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovery successful
|
||||||
|
SmtpLogger.info('Server recovery completed successfully');
|
||||||
|
|
||||||
|
} catch (recoveryError) {
|
||||||
|
SmtpLogger.error(`Server recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`, {
|
||||||
|
error: recoveryError instanceof Error ? recoveryError : new Error(String(recoveryError)),
|
||||||
|
attempt: this.recoveryState.currentRecoveryAttempt,
|
||||||
|
maxAttempts: this.recoveryState.maxRecoveryAttempts
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Reset recovery flag
|
||||||
|
this.recoveryState.recovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user