BREAKING CHANGE(ts-api,rustproxy): remove deprecated TypeScript protocol and utility exports while hardening QUIC, HTTP/3, WebSocket, and rate limiter cleanup paths

This commit is contained in:
2026-03-21 22:23:38 +00:00
parent 33fdf42a70
commit fc04a0210b
78 changed files with 331 additions and 10754 deletions

View File

@@ -1,3 +0,0 @@
/**
* Common event definitions
*/

View File

@@ -5,4 +5,3 @@
// Export submodules
export * from './models/index.js';
export * from './utils/index.js';
export * from './events/index.js';

View File

@@ -3,7 +3,6 @@
*/
export * from './common-types.js';
export * from './socket-augmentation.js';
export * from './route-context.js';
export * from './wrapped-socket.js';
export * from './socket-types.js';

View File

@@ -1,38 +0,0 @@
import * as plugins from '../../plugins.js';
// Augment the Node.js Socket type to include TLS-related properties
// This helps TypeScript understand properties that are dynamically added by Node.js
declare module 'net' {
interface Socket {
// TLS-related properties
encrypted?: boolean; // Indicates if the socket is encrypted (TLS/SSL)
authorizationError?: Error; // Authentication error if TLS handshake failed
// TLS-related methods
getTLSVersion?(): string; // Returns the TLS version (e.g., 'TLSv1.2', 'TLSv1.3')
getPeerCertificate?(detailed?: boolean): any; // Returns the peer's certificate
getSession?(): Buffer; // Returns the TLS session data
// Connection tracking properties (used by HttpProxy)
_connectionId?: string; // Unique identifier for the connection
_remoteIP?: string; // Remote IP address
_realRemoteIP?: string; // Real remote IP (when proxied)
}
}
// Export a utility function to check if a socket is a TLS socket
export function isTLSSocket(socket: plugins.net.Socket): boolean {
return 'encrypted' in socket && !!socket.encrypted;
}
// Export a utility function to safely get the TLS version
export function getTLSVersion(socket: plugins.net.Socket): string | null {
if (socket.getTLSVersion) {
try {
return socket.getTLSVersion();
} catch (e) {
return null;
}
}
return null;
}

View File

@@ -1,275 +0,0 @@
/**
* Async utility functions for SmartProxy
* Provides non-blocking alternatives to synchronous operations
*/
/**
* Delays execution for the specified number of milliseconds
* Non-blocking alternative to busy wait loops
* @param ms - Number of milliseconds to delay
* @returns Promise that resolves after the delay
*/
export async function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Retry an async operation with exponential backoff
* @param fn - The async function to retry
* @param options - Retry options
* @returns The result of the function or throws the last error
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
initialDelay?: number;
maxDelay?: number;
factor?: number;
onRetry?: (attempt: number, error: Error) => void;
} = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelay = 100,
maxDelay = 10000,
factor = 2,
onRetry
} = options;
let lastError: Error | null = null;
let currentDelay = initialDelay;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error: any) {
lastError = error;
if (attempt === maxAttempts) {
throw error;
}
if (onRetry) {
onRetry(attempt, error);
}
await delay(currentDelay);
currentDelay = Math.min(currentDelay * factor, maxDelay);
}
}
throw lastError || new Error('Retry failed');
}
/**
* Execute an async operation with a timeout
* @param fn - The async function to execute
* @param timeoutMs - Timeout in milliseconds
* @param timeoutError - Optional custom timeout error
* @returns The result of the function or throws timeout error
*/
export async function withTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
timeoutError?: Error
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(timeoutError || new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([fn(), timeoutPromise]);
}
/**
* Run multiple async operations in parallel with a concurrency limit
* @param items - Array of items to process
* @param fn - Async function to run for each item
* @param concurrency - Maximum number of concurrent operations
* @returns Array of results in the same order as input
*/
export async function parallelLimit<T, R>(
items: T[],
fn: (item: T, index: number) => Promise<R>,
concurrency: number
): Promise<R[]> {
const results: R[] = new Array(items.length);
const executing: Set<Promise<void>> = new Set();
for (let i = 0; i < items.length; i++) {
const promise = fn(items[i], i).then(result => {
results[i] = result;
executing.delete(promise);
});
executing.add(promise);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
}
/**
* Debounce an async function
* @param fn - The async function to debounce
* @param delayMs - Delay in milliseconds
* @returns Debounced function with cancel method
*/
export function debounceAsync<T extends (...args: any[]) => Promise<any>>(
fn: T,
delayMs: number
): T & { cancel: () => void } {
let timeoutId: NodeJS.Timeout | null = null;
let lastPromise: Promise<any> | null = null;
const debounced = ((...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
lastPromise = new Promise((resolve, reject) => {
timeoutId = setTimeout(async () => {
timeoutId = null;
try {
const result = await fn(...args);
resolve(result);
} catch (error) {
reject(error);
}
}, delayMs);
});
return lastPromise;
}) as any;
debounced.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return debounced as T & { cancel: () => void };
}
/**
* Create a mutex for ensuring exclusive access to a resource
*/
export class AsyncMutex {
private queue: Array<() => void> = [];
private locked = false;
async acquire(): Promise<() => void> {
if (!this.locked) {
this.locked = true;
return () => this.release();
}
return new Promise<() => void>(resolve => {
this.queue.push(() => {
resolve(() => this.release());
});
});
}
private release(): void {
const next = this.queue.shift();
if (next) {
next();
} else {
this.locked = false;
}
}
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
const release = await this.acquire();
try {
return await fn();
} finally {
release();
}
}
}
/**
* Circuit breaker for protecting against cascading failures
*/
export class CircuitBreaker {
private failureCount = 0;
private lastFailureTime = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private options: {
failureThreshold: number;
resetTimeout: number;
onStateChange?: (state: 'closed' | 'open' | 'half-open') => void;
}
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime > this.options.resetTimeout) {
this.setState('half-open');
} else {
throw new Error('Circuit breaker is open');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
if (this.state !== 'closed') {
this.setState('closed');
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.options.failureThreshold) {
this.setState('open');
}
}
private setState(state: 'closed' | 'open' | 'half-open'): void {
if (this.state !== state) {
this.state = state;
if (this.options.onStateChange) {
this.options.onStateChange(state);
}
}
}
isOpen(): boolean {
return this.state === 'open';
}
getState(): 'closed' | 'open' | 'half-open' {
return this.state;
}
recordSuccess(): void {
this.onSuccess();
}
recordFailure(): void {
this.onFailure();
}
}

View File

@@ -1,225 +0,0 @@
/**
* A binary heap implementation for efficient priority queue operations
* Supports O(log n) insert and extract operations
*/
export class BinaryHeap<T> {
private heap: T[] = [];
private keyMap?: Map<string, number>; // For efficient key-based lookups
constructor(
private compareFn: (a: T, b: T) => number,
private extractKey?: (item: T) => string
) {
if (extractKey) {
this.keyMap = new Map();
}
}
/**
* Get the current size of the heap
*/
public get size(): number {
return this.heap.length;
}
/**
* Check if the heap is empty
*/
public isEmpty(): boolean {
return this.heap.length === 0;
}
/**
* Peek at the top element without removing it
*/
public peek(): T | undefined {
return this.heap[0];
}
/**
* Insert a new item into the heap
* O(log n) time complexity
*/
public insert(item: T): void {
const index = this.heap.length;
this.heap.push(item);
if (this.keyMap && this.extractKey) {
const key = this.extractKey(item);
this.keyMap.set(key, index);
}
this.bubbleUp(index);
}
/**
* Extract the top element from the heap
* O(log n) time complexity
*/
public extract(): T | undefined {
if (this.heap.length === 0) return undefined;
if (this.heap.length === 1) {
const item = this.heap.pop()!;
if (this.keyMap && this.extractKey) {
this.keyMap.delete(this.extractKey(item));
}
return item;
}
const result = this.heap[0];
const lastItem = this.heap.pop()!;
this.heap[0] = lastItem;
if (this.keyMap && this.extractKey) {
this.keyMap.delete(this.extractKey(result));
this.keyMap.set(this.extractKey(lastItem), 0);
}
this.bubbleDown(0);
return result;
}
/**
* Extract an element that matches the predicate
* O(n) time complexity for search, O(log n) for extraction
*/
public extractIf(predicate: (item: T) => boolean): T | undefined {
const index = this.heap.findIndex(predicate);
if (index === -1) return undefined;
return this.extractAt(index);
}
/**
* Extract an element by its key (if extractKey was provided)
* O(log n) time complexity
*/
public extractByKey(key: string): T | undefined {
if (!this.keyMap || !this.extractKey) {
throw new Error('extractKey function must be provided to use key-based extraction');
}
const index = this.keyMap.get(key);
if (index === undefined) return undefined;
return this.extractAt(index);
}
/**
* Check if a key exists in the heap
* O(1) time complexity
*/
public hasKey(key: string): boolean {
if (!this.keyMap) return false;
return this.keyMap.has(key);
}
/**
* Get all elements as an array (does not modify heap)
* O(n) time complexity
*/
public toArray(): T[] {
return [...this.heap];
}
/**
* Clear the heap
*/
public clear(): void {
this.heap = [];
if (this.keyMap) {
this.keyMap.clear();
}
}
/**
* Extract element at specific index
*/
private extractAt(index: number): T {
const item = this.heap[index];
if (this.keyMap && this.extractKey) {
this.keyMap.delete(this.extractKey(item));
}
if (index === this.heap.length - 1) {
this.heap.pop();
return item;
}
const lastItem = this.heap.pop()!;
this.heap[index] = lastItem;
if (this.keyMap && this.extractKey) {
this.keyMap.set(this.extractKey(lastItem), index);
}
// Try bubbling up first
const parentIndex = Math.floor((index - 1) / 2);
if (parentIndex >= 0 && this.compareFn(this.heap[index], this.heap[parentIndex]) < 0) {
this.bubbleUp(index);
} else {
this.bubbleDown(index);
}
return item;
}
/**
* Bubble up element at given index to maintain heap property
*/
private bubbleUp(index: number): void {
while (index > 0) {
const parentIndex = Math.floor((index - 1) / 2);
if (this.compareFn(this.heap[index], this.heap[parentIndex]) >= 0) {
break;
}
this.swap(index, parentIndex);
index = parentIndex;
}
}
/**
* Bubble down element at given index to maintain heap property
*/
private bubbleDown(index: number): void {
const length = this.heap.length;
while (true) {
const leftChild = 2 * index + 1;
const rightChild = 2 * index + 2;
let smallest = index;
if (leftChild < length &&
this.compareFn(this.heap[leftChild], this.heap[smallest]) < 0) {
smallest = leftChild;
}
if (rightChild < length &&
this.compareFn(this.heap[rightChild], this.heap[smallest]) < 0) {
smallest = rightChild;
}
if (smallest === index) break;
this.swap(index, smallest);
index = smallest;
}
}
/**
* Swap two elements in the heap
*/
private swap(i: number, j: number): void {
const temp = this.heap[i];
this.heap[i] = this.heap[j];
this.heap[j] = temp;
if (this.keyMap && this.extractKey) {
this.keyMap.set(this.extractKey(this.heap[i]), i);
this.keyMap.set(this.extractKey(this.heap[j]), j);
}
}
}

View File

@@ -1,425 +0,0 @@
import { LifecycleComponent } from './lifecycle-component.js';
import { BinaryHeap } from './binary-heap.js';
import { AsyncMutex } from './async-utils.js';
import { EventEmitter } from 'node:events';
/**
* Interface for pooled connection
*/
export interface IPooledConnection<T> {
id: string;
connection: T;
createdAt: number;
lastUsedAt: number;
useCount: number;
inUse: boolean;
metadata?: any;
}
/**
* Configuration options for the connection pool
*/
export interface IConnectionPoolOptions<T> {
minSize?: number;
maxSize?: number;
acquireTimeout?: number;
idleTimeout?: number;
maxUseCount?: number;
validateOnAcquire?: boolean;
validateOnReturn?: boolean;
queueTimeout?: number;
connectionFactory: () => Promise<T>;
connectionValidator?: (connection: T) => Promise<boolean>;
connectionDestroyer?: (connection: T) => Promise<void>;
onConnectionError?: (error: Error, connection?: T) => void;
}
/**
* Interface for queued acquire request
*/
interface IAcquireRequest<T> {
id: string;
priority: number;
timestamp: number;
resolve: (connection: IPooledConnection<T>) => void;
reject: (error: Error) => void;
timeoutHandle?: NodeJS.Timeout;
}
/**
* Enhanced connection pool with priority queue, backpressure, and lifecycle management
*/
export class EnhancedConnectionPool<T> extends LifecycleComponent {
private readonly options: Required<Omit<IConnectionPoolOptions<T>, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>> & Pick<IConnectionPoolOptions<T>, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>;
private readonly availableConnections: IPooledConnection<T>[] = [];
private readonly activeConnections: Map<string, IPooledConnection<T>> = new Map();
private readonly waitQueue: BinaryHeap<IAcquireRequest<T>>;
private readonly mutex = new AsyncMutex();
private readonly eventEmitter = new EventEmitter();
private connectionIdCounter = 0;
private requestIdCounter = 0;
private isClosing = false;
// Metrics
private metrics = {
connectionsCreated: 0,
connectionsDestroyed: 0,
connectionsAcquired: 0,
connectionsReleased: 0,
acquireTimeouts: 0,
validationFailures: 0,
queueHighWaterMark: 0,
};
constructor(options: IConnectionPoolOptions<T>) {
super();
this.options = {
minSize: 0,
maxSize: 10,
acquireTimeout: 30000,
idleTimeout: 300000, // 5 minutes
maxUseCount: Infinity,
validateOnAcquire: true,
validateOnReturn: false,
queueTimeout: 60000,
...options,
};
// Initialize priority queue (higher priority = extracted first)
this.waitQueue = new BinaryHeap<IAcquireRequest<T>>(
(a, b) => b.priority - a.priority || a.timestamp - b.timestamp,
(item) => item.id
);
// Start maintenance cycle
this.startMaintenance();
// Initialize minimum connections
this.initializeMinConnections();
}
/**
* Initialize minimum number of connections
*/
private async initializeMinConnections(): Promise<void> {
const promises: Promise<void>[] = [];
for (let i = 0; i < this.options.minSize; i++) {
promises.push(
this.createConnection()
.then(conn => {
this.availableConnections.push(conn);
})
.catch(err => {
if (this.options.onConnectionError) {
this.options.onConnectionError(err);
}
})
);
}
await Promise.all(promises);
}
/**
* Start maintenance timer for idle connection cleanup
*/
private startMaintenance(): void {
this.setInterval(() => {
this.performMaintenance();
}, 30000); // Every 30 seconds
}
/**
* Perform maintenance tasks
*/
private async performMaintenance(): Promise<void> {
await this.mutex.runExclusive(async () => {
const now = Date.now();
const toRemove: IPooledConnection<T>[] = [];
// Check for idle connections beyond minimum size
for (let i = this.availableConnections.length - 1; i >= 0; i--) {
const conn = this.availableConnections[i];
// Keep minimum connections
if (this.availableConnections.length <= this.options.minSize) {
break;
}
// Remove idle connections
if (now - conn.lastUsedAt > this.options.idleTimeout) {
toRemove.push(conn);
this.availableConnections.splice(i, 1);
}
}
// Destroy idle connections
for (const conn of toRemove) {
await this.destroyConnection(conn);
}
});
}
/**
* Acquire a connection from the pool
*/
public async acquire(priority: number = 0, timeout?: number): Promise<IPooledConnection<T>> {
if (this.isClosing) {
throw new Error('Connection pool is closing');
}
return this.mutex.runExclusive(async () => {
// Try to get an available connection
const connection = await this.tryAcquireConnection();
if (connection) {
return connection;
}
// Check if we can create a new connection
const totalConnections = this.availableConnections.length + this.activeConnections.size;
if (totalConnections < this.options.maxSize) {
try {
const newConnection = await this.createConnection();
return this.checkoutConnection(newConnection);
} catch (err) {
// Fall through to queue if creation fails
}
}
// Add to wait queue
return this.queueAcquireRequest(priority, timeout);
});
}
/**
* Try to acquire an available connection
*/
private async tryAcquireConnection(): Promise<IPooledConnection<T> | null> {
while (this.availableConnections.length > 0) {
const connection = this.availableConnections.shift()!;
// Check if connection exceeded max use count
if (connection.useCount >= this.options.maxUseCount) {
await this.destroyConnection(connection);
continue;
}
// Validate connection if required
if (this.options.validateOnAcquire && this.options.connectionValidator) {
try {
const isValid = await this.options.connectionValidator(connection.connection);
if (!isValid) {
this.metrics.validationFailures++;
await this.destroyConnection(connection);
continue;
}
} catch (err) {
this.metrics.validationFailures++;
await this.destroyConnection(connection);
continue;
}
}
return this.checkoutConnection(connection);
}
return null;
}
/**
* Checkout a connection for use
*/
private checkoutConnection(connection: IPooledConnection<T>): IPooledConnection<T> {
connection.inUse = true;
connection.lastUsedAt = Date.now();
connection.useCount++;
this.activeConnections.set(connection.id, connection);
this.metrics.connectionsAcquired++;
this.eventEmitter.emit('acquire', connection);
return connection;
}
/**
* Queue an acquire request
*/
private queueAcquireRequest(priority: number, timeout?: number): Promise<IPooledConnection<T>> {
return new Promise<IPooledConnection<T>>((resolve, reject) => {
const request: IAcquireRequest<T> = {
id: `req-${this.requestIdCounter++}`,
priority,
timestamp: Date.now(),
resolve,
reject,
};
// Set timeout
const timeoutMs = timeout || this.options.queueTimeout;
request.timeoutHandle = this.setTimeout(() => {
if (this.waitQueue.extractByKey(request.id)) {
this.metrics.acquireTimeouts++;
reject(new Error(`Connection acquire timeout after ${timeoutMs}ms`));
}
}, timeoutMs);
this.waitQueue.insert(request);
this.metrics.queueHighWaterMark = Math.max(
this.metrics.queueHighWaterMark,
this.waitQueue.size
);
this.eventEmitter.emit('enqueue', { queueSize: this.waitQueue.size });
});
}
/**
* Release a connection back to the pool
*/
public async release(connection: IPooledConnection<T>): Promise<void> {
return this.mutex.runExclusive(async () => {
if (!connection.inUse || !this.activeConnections.has(connection.id)) {
throw new Error('Connection is not active');
}
this.activeConnections.delete(connection.id);
connection.inUse = false;
connection.lastUsedAt = Date.now();
this.metrics.connectionsReleased++;
// Check if connection should be destroyed
if (connection.useCount >= this.options.maxUseCount) {
await this.destroyConnection(connection);
return;
}
// Validate on return if required
if (this.options.validateOnReturn && this.options.connectionValidator) {
try {
const isValid = await this.options.connectionValidator(connection.connection);
if (!isValid) {
await this.destroyConnection(connection);
return;
}
} catch (err) {
await this.destroyConnection(connection);
return;
}
}
// Check if there are waiting requests
const request = this.waitQueue.extract();
if (request) {
this.clearTimeout(request.timeoutHandle!);
request.resolve(this.checkoutConnection(connection));
this.eventEmitter.emit('dequeue', { queueSize: this.waitQueue.size });
} else {
// Return to available pool
this.availableConnections.push(connection);
this.eventEmitter.emit('release', connection);
}
});
}
/**
* Create a new connection
*/
private async createConnection(): Promise<IPooledConnection<T>> {
const rawConnection = await this.options.connectionFactory();
const connection: IPooledConnection<T> = {
id: `conn-${this.connectionIdCounter++}`,
connection: rawConnection,
createdAt: Date.now(),
lastUsedAt: Date.now(),
useCount: 0,
inUse: false,
};
this.metrics.connectionsCreated++;
this.eventEmitter.emit('create', connection);
return connection;
}
/**
* Destroy a connection
*/
private async destroyConnection(connection: IPooledConnection<T>): Promise<void> {
try {
if (this.options.connectionDestroyer) {
await this.options.connectionDestroyer(connection.connection);
}
this.metrics.connectionsDestroyed++;
this.eventEmitter.emit('destroy', connection);
} catch (err) {
if (this.options.onConnectionError) {
this.options.onConnectionError(err as Error, connection.connection);
}
}
}
/**
* Get current pool statistics
*/
public getStats() {
return {
available: this.availableConnections.length,
active: this.activeConnections.size,
waiting: this.waitQueue.size,
total: this.availableConnections.length + this.activeConnections.size,
...this.metrics,
};
}
/**
* Subscribe to pool events
*/
public on(event: string, listener: Function): void {
this.addEventListener(this.eventEmitter, event, listener);
}
/**
* Close the pool and cleanup resources
*/
protected async onCleanup(): Promise<void> {
this.isClosing = true;
// Clear the wait queue
while (!this.waitQueue.isEmpty()) {
const request = this.waitQueue.extract();
if (request) {
this.clearTimeout(request.timeoutHandle!);
request.reject(new Error('Connection pool is closing'));
}
}
// Wait for active connections to be released (with timeout)
const timeout = 30000;
const startTime = Date.now();
while (this.activeConnections.size > 0 && Date.now() - startTime < timeout) {
await new Promise(resolve => {
const timer = setTimeout(resolve, 100);
if (typeof timer.unref === 'function') {
timer.unref();
}
});
}
// Destroy all connections
const allConnections = [
...this.availableConnections,
...this.activeConnections.values(),
];
await Promise.all(allConnections.map(conn => this.destroyConnection(conn)));
this.availableConnections.length = 0;
this.activeConnections.clear();
}
}

View File

@@ -1,270 +0,0 @@
/**
* Async filesystem utilities for SmartProxy
* Provides non-blocking alternatives to synchronous filesystem operations
*/
import * as plugins from '../../plugins.js';
export class AsyncFileSystem {
/**
* Check if a file or directory exists
* @param path - Path to check
* @returns Promise resolving to true if exists, false otherwise
*/
static async exists(path: string): Promise<boolean> {
try {
await plugins.fs.promises.access(path);
return true;
} catch {
return false;
}
}
/**
* Ensure a directory exists, creating it if necessary
* @param dirPath - Directory path to ensure
* @returns Promise that resolves when directory is ensured
*/
static async ensureDir(dirPath: string): Promise<void> {
await plugins.fs.promises.mkdir(dirPath, { recursive: true });
}
/**
* Read a file as string
* @param filePath - Path to the file
* @param encoding - File encoding (default: utf8)
* @returns Promise resolving to file contents
*/
static async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
return plugins.fs.promises.readFile(filePath, encoding);
}
/**
* Read a file as buffer
* @param filePath - Path to the file
* @returns Promise resolving to file buffer
*/
static async readFileBuffer(filePath: string): Promise<Buffer> {
return plugins.fs.promises.readFile(filePath);
}
/**
* Write string data to a file
* @param filePath - Path to the file
* @param data - String data to write
* @param encoding - File encoding (default: utf8)
* @returns Promise that resolves when file is written
*/
static async writeFile(filePath: string, data: string, encoding: BufferEncoding = 'utf8'): Promise<void> {
// Ensure directory exists
const dir = plugins.path.dirname(filePath);
await this.ensureDir(dir);
await plugins.fs.promises.writeFile(filePath, data, encoding);
}
/**
* Write buffer data to a file
* @param filePath - Path to the file
* @param data - Buffer data to write
* @returns Promise that resolves when file is written
*/
static async writeFileBuffer(filePath: string, data: Buffer): Promise<void> {
const dir = plugins.path.dirname(filePath);
await this.ensureDir(dir);
await plugins.fs.promises.writeFile(filePath, data);
}
/**
* Remove a file
* @param filePath - Path to the file
* @returns Promise that resolves when file is removed
*/
static async remove(filePath: string): Promise<void> {
try {
await plugins.fs.promises.unlink(filePath);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
// File doesn't exist, which is fine
}
}
/**
* Remove a directory and all its contents
* @param dirPath - Path to the directory
* @returns Promise that resolves when directory is removed
*/
static async removeDir(dirPath: string): Promise<void> {
try {
await plugins.fs.promises.rm(dirPath, { recursive: true, force: true });
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}
/**
* Read JSON from a file
* @param filePath - Path to the JSON file
* @returns Promise resolving to parsed JSON
*/
static async readJSON<T = any>(filePath: string): Promise<T> {
const content = await this.readFile(filePath);
return JSON.parse(content);
}
/**
* Write JSON to a file
* @param filePath - Path to the file
* @param data - Data to write as JSON
* @param pretty - Whether to pretty-print JSON (default: true)
* @returns Promise that resolves when file is written
*/
static async writeJSON(filePath: string, data: any, pretty = true): Promise<void> {
const jsonString = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
await this.writeFile(filePath, jsonString);
}
/**
* Copy a file from source to destination
* @param source - Source file path
* @param destination - Destination file path
* @returns Promise that resolves when file is copied
*/
static async copyFile(source: string, destination: string): Promise<void> {
const destDir = plugins.path.dirname(destination);
await this.ensureDir(destDir);
await plugins.fs.promises.copyFile(source, destination);
}
/**
* Move/rename a file
* @param source - Source file path
* @param destination - Destination file path
* @returns Promise that resolves when file is moved
*/
static async moveFile(source: string, destination: string): Promise<void> {
const destDir = plugins.path.dirname(destination);
await this.ensureDir(destDir);
await plugins.fs.promises.rename(source, destination);
}
/**
* Get file stats
* @param filePath - Path to the file
* @returns Promise resolving to file stats or null if doesn't exist
*/
static async getStats(filePath: string): Promise<plugins.fs.Stats | null> {
try {
return await plugins.fs.promises.stat(filePath);
} catch (error: any) {
if (error.code === 'ENOENT') {
return null;
}
throw error;
}
}
/**
* List files in a directory
* @param dirPath - Directory path
* @returns Promise resolving to array of filenames
*/
static async listFiles(dirPath: string): Promise<string[]> {
try {
return await plugins.fs.promises.readdir(dirPath);
} catch (error: any) {
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
}
/**
* List files in a directory with full paths
* @param dirPath - Directory path
* @returns Promise resolving to array of full file paths
*/
static async listFilesFullPath(dirPath: string): Promise<string[]> {
const files = await this.listFiles(dirPath);
return files.map(file => plugins.path.join(dirPath, file));
}
/**
* Recursively list all files in a directory
* @param dirPath - Directory path
* @param fileList - Accumulator for file list (used internally)
* @returns Promise resolving to array of all file paths
*/
static async listFilesRecursive(dirPath: string, fileList: string[] = []): Promise<string[]> {
const files = await this.listFiles(dirPath);
for (const file of files) {
const filePath = plugins.path.join(dirPath, file);
const stats = await this.getStats(filePath);
if (stats?.isDirectory()) {
await this.listFilesRecursive(filePath, fileList);
} else if (stats?.isFile()) {
fileList.push(filePath);
}
}
return fileList;
}
/**
* Create a read stream for a file
* @param filePath - Path to the file
* @param options - Stream options
* @returns Read stream
*/
static createReadStream(filePath: string, options?: Parameters<typeof plugins.fs.createReadStream>[1]): plugins.fs.ReadStream {
return plugins.fs.createReadStream(filePath, options);
}
/**
* Create a write stream for a file
* @param filePath - Path to the file
* @param options - Stream options
* @returns Write stream
*/
static createWriteStream(filePath: string, options?: Parameters<typeof plugins.fs.createWriteStream>[1]): plugins.fs.WriteStream {
return plugins.fs.createWriteStream(filePath, options);
}
/**
* Ensure a file exists, creating an empty file if necessary
* @param filePath - Path to the file
* @returns Promise that resolves when file is ensured
*/
static async ensureFile(filePath: string): Promise<void> {
const exists = await this.exists(filePath);
if (!exists) {
await this.writeFile(filePath, '');
}
}
/**
* Check if a path is a directory
* @param path - Path to check
* @returns Promise resolving to true if directory, false otherwise
*/
static async isDirectory(path: string): Promise<boolean> {
const stats = await this.getStats(path);
return stats?.isDirectory() ?? false;
}
/**
* Check if a path is a file
* @param path - Path to check
* @returns Promise resolving to true if file, false otherwise
*/
static async isFile(path: string): Promise<boolean> {
const stats = await this.getStats(path);
return stats?.isFile() ?? false;
}
}

View File

@@ -2,16 +2,4 @@
* Core utility functions
*/
export * from './validation-utils.js';
export * from './ip-utils.js';
export * from './template-utils.js';
export * from './security-utils.js';
export * from './shared-security-manager.js';
export * from './websocket-utils.js';
export * from './logger.js';
export * from './async-utils.js';
export * from './fs-utils.js';
export * from './lifecycle-component.js';
export * from './binary-heap.js';
export * from './enhanced-connection-pool.js';
export * from './socket-utils.js';

View File

@@ -1,303 +0,0 @@
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;
// Check each pattern
for (const pattern of patterns) {
// Handle CIDR notation
if (pattern.includes('/')) {
if (this.matchCIDR(ip, pattern)) {
return true;
}
continue;
}
// Handle range notation
if (pattern.includes('-') && !pattern.includes('*')) {
if (this.matchIPRange(ip, pattern)) {
return true;
}
continue;
}
// Expand shorthand patterns for glob matching
let expandedPattern = pattern;
if (pattern.includes('*') && !pattern.includes(':')) {
const parts = pattern.split('.');
while (parts.length < 4) {
parts.push('*');
}
expandedPattern = parts.join('.');
}
// Normalize and check with minimatch
const normalizedPatterns = this.normalizeIP(expandedPattern);
for (const ipVariant of normalizedIPVariants) {
for (const normalizedPattern of normalizedPatterns) {
if (plugins.minimatch(ipVariant, normalizedPattern)) {
return true;
}
}
}
}
return false;
}
/**
* 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);
}
/**
* Check if an IP matches a CIDR notation
*
* @param ip The IP address to check
* @param cidr The CIDR notation (e.g., "192.168.1.0/24")
* @returns true if IP is within the CIDR range
*/
private static matchCIDR(ip: string, cidr: string): boolean {
if (!cidr.includes('/')) return false;
const [networkAddr, prefixStr] = cidr.split('/');
const prefix = parseInt(prefixStr, 10);
// Handle IPv4-mapped IPv6 in the IP being checked
let checkIP = ip;
if (checkIP.startsWith('::ffff:')) {
checkIP = checkIP.slice(7);
}
// Handle IPv6 CIDR
if (networkAddr.includes(':')) {
// TODO: Implement IPv6 CIDR matching
return false;
}
// IPv4 CIDR matching
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false;
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(networkAddr)) return false;
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false;
const ipParts = checkIP.split('.').map(Number);
const netParts = networkAddr.split('.').map(Number);
// Validate IP parts
for (const part of [...ipParts, ...netParts]) {
if (part < 0 || part > 255) return false;
}
// Convert to 32-bit integers
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
const netNum = (netParts[0] << 24) | (netParts[1] << 16) | (netParts[2] << 8) | netParts[3];
// Create mask
const mask = (-1 << (32 - prefix)) >>> 0;
// Check if IP is in network range
return (ipNum & mask) === (netNum & mask);
}
/**
* Check if an IP matches a range notation
*
* @param ip The IP address to check
* @param range The range notation (e.g., "192.168.1.1-192.168.1.100")
* @returns true if IP is within the range
*/
private static matchIPRange(ip: string, range: string): boolean {
if (!range.includes('-')) return false;
const [startIP, endIP] = range.split('-').map(s => s.trim());
// Handle IPv4-mapped IPv6 in the IP being checked
let checkIP = ip;
if (checkIP.startsWith('::ffff:')) {
checkIP = checkIP.slice(7);
}
// Only handle IPv4 for now
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false;
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(startIP)) return false;
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(endIP)) return false;
const ipParts = checkIP.split('.').map(Number);
const startParts = startIP.split('.').map(Number);
const endParts = endIP.split('.').map(Number);
// Validate parts
for (const part of [...ipParts, ...startParts, ...endParts]) {
if (part < 0 || part > 255) return false;
}
// Convert to 32-bit integers for comparison
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
const startNum = (startParts[0] << 24) | (startParts[1] << 16) | (startParts[2] << 8) | startParts[3];
const endNum = (endParts[0] << 24) | (endParts[1] << 16) | (endParts[2] << 8) | endParts[3];
// Convert to unsigned for proper comparison
const ipUnsigned = ipNum >>> 0;
const startUnsigned = startNum >>> 0;
const endUnsigned = endNum >>> 0;
return ipUnsigned >= startUnsigned && ipUnsigned <= endUnsigned;
}
/**
* 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;
}
}

View File

@@ -1,251 +0,0 @@
/**
* Base class for components that need proper resource lifecycle management
* Provides automatic cleanup of timers and event listeners to prevent memory leaks
*/
export abstract class LifecycleComponent {
private timers: Set<NodeJS.Timeout> = new Set();
private intervals: Set<NodeJS.Timeout> = new Set();
private listeners: Array<{
target: any;
event: string;
handler: Function;
actualHandler?: Function; // The actual handler registered (may be wrapped)
once?: boolean;
}> = [];
private childComponents: Set<LifecycleComponent> = new Set();
protected isShuttingDown = false;
private cleanupPromise?: Promise<void>;
/**
* Create a managed setTimeout that will be automatically cleaned up
*/
protected setTimeout(handler: Function, timeout: number): NodeJS.Timeout {
if (this.isShuttingDown) {
// Return a dummy timer if shutting down
const dummyTimer = setTimeout(() => {}, 0);
if (typeof dummyTimer.unref === 'function') {
dummyTimer.unref();
}
return dummyTimer;
}
const wrappedHandler = () => {
this.timers.delete(timer);
if (!this.isShuttingDown) {
handler();
}
};
const timer = setTimeout(wrappedHandler, timeout);
this.timers.add(timer);
// Allow process to exit even with timer
if (typeof timer.unref === 'function') {
timer.unref();
}
return timer;
}
/**
* Create a managed setInterval that will be automatically cleaned up
*/
protected setInterval(handler: Function, interval: number): NodeJS.Timeout {
if (this.isShuttingDown) {
// Return a dummy timer if shutting down
const dummyTimer = setInterval(() => {}, interval);
if (typeof dummyTimer.unref === 'function') {
dummyTimer.unref();
}
clearInterval(dummyTimer); // Clear immediately since we don't need it
return dummyTimer;
}
const wrappedHandler = () => {
if (!this.isShuttingDown) {
handler();
}
};
const timer = setInterval(wrappedHandler, interval);
this.intervals.add(timer);
// Allow process to exit even with timer
if (typeof timer.unref === 'function') {
timer.unref();
}
return timer;
}
/**
* Clear a managed timeout
*/
protected clearTimeout(timer: NodeJS.Timeout): void {
clearTimeout(timer);
this.timers.delete(timer);
}
/**
* Clear a managed interval
*/
protected clearInterval(timer: NodeJS.Timeout): void {
clearInterval(timer);
this.intervals.delete(timer);
}
/**
* Add a managed event listener that will be automatically removed on cleanup
*/
protected addEventListener(
target: any,
event: string,
handler: Function,
options?: { once?: boolean }
): void {
if (this.isShuttingDown) {
return;
}
// For 'once' listeners, we need to wrap the handler to remove it from our tracking
let actualHandler = handler;
if (options?.once) {
actualHandler = (...args: any[]) => {
// Call the original handler
handler(...args);
// Remove from our internal tracking
const index = this.listeners.findIndex(
l => l.target === target && l.event === event && l.handler === handler
);
if (index !== -1) {
this.listeners.splice(index, 1);
}
};
}
// Support both EventEmitter and DOM-style event targets
if (typeof target.on === 'function') {
if (options?.once) {
target.once(event, actualHandler);
} else {
target.on(event, actualHandler);
}
} else if (typeof target.addEventListener === 'function') {
target.addEventListener(event, actualHandler, options);
} else {
throw new Error('Target must support on() or addEventListener()');
}
// Store both the original handler and the actual handler registered
this.listeners.push({
target,
event,
handler,
actualHandler, // The handler that was actually registered (may be wrapped)
once: options?.once
});
}
/**
* Remove a specific event listener
*/
protected removeEventListener(target: any, event: string, handler: Function): void {
// Remove from target
if (typeof target.removeListener === 'function') {
target.removeListener(event, handler);
} else if (typeof target.removeEventListener === 'function') {
target.removeEventListener(event, handler);
}
// Remove from our tracking
const index = this.listeners.findIndex(
l => l.target === target && l.event === event && l.handler === handler
);
if (index !== -1) {
this.listeners.splice(index, 1);
}
}
/**
* Register a child component that should be cleaned up when this component is cleaned up
*/
protected registerChildComponent(component: LifecycleComponent): void {
this.childComponents.add(component);
}
/**
* Unregister a child component
*/
protected unregisterChildComponent(component: LifecycleComponent): void {
this.childComponents.delete(component);
}
/**
* Override this method to implement component-specific cleanup logic
*/
protected async onCleanup(): Promise<void> {
// Override in subclasses
}
/**
* Clean up all managed resources
*/
public async cleanup(): Promise<void> {
// Return existing cleanup promise if already cleaning up
if (this.cleanupPromise) {
return this.cleanupPromise;
}
this.cleanupPromise = this.performCleanup();
return this.cleanupPromise;
}
private async performCleanup(): Promise<void> {
this.isShuttingDown = true;
// First, clean up child components
const childCleanupPromises: Promise<void>[] = [];
for (const child of this.childComponents) {
childCleanupPromises.push(child.cleanup());
}
await Promise.all(childCleanupPromises);
this.childComponents.clear();
// Clear all timers
for (const timer of this.timers) {
clearTimeout(timer);
}
this.timers.clear();
// Clear all intervals
for (const timer of this.intervals) {
clearInterval(timer);
}
this.intervals.clear();
// Remove all event listeners
for (const { target, event, handler, actualHandler } of this.listeners) {
// Use actualHandler if available (for wrapped handlers), otherwise use the original handler
const handlerToRemove = actualHandler || handler;
// All listeners need to be removed, including 'once' listeners that might not have fired
if (typeof target.removeListener === 'function') {
target.removeListener(event, handlerToRemove);
} else if (typeof target.removeEventListener === 'function') {
target.removeEventListener(event, handlerToRemove);
}
}
this.listeners = [];
// Call subclass cleanup
await this.onCleanup();
}
/**
* Check if the component is shutting down
*/
protected isShuttingDownState(): boolean {
return this.isShuttingDown;
}
}

View File

@@ -1,370 +0,0 @@
import { logger } from './logger.js';
interface ILogEvent {
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: any;
count: number;
firstSeen: number;
lastSeen: number;
}
interface IAggregatedEvent {
key: string;
events: Map<string, ILogEvent>;
flushTimer?: NodeJS.Timeout;
}
/**
* Log deduplication utility to reduce log spam for repetitive events
*/
export class LogDeduplicator {
private globalFlushTimer?: NodeJS.Timeout;
private aggregatedEvents: Map<string, IAggregatedEvent> = new Map();
private flushInterval: number = 5000; // 5 seconds
private maxBatchSize: number = 100;
private rapidEventThreshold: number = 50; // Flush early if this many events in 1 second
private lastRapidCheck: number = Date.now();
constructor(flushInterval?: number) {
if (flushInterval) {
this.flushInterval = flushInterval;
}
// Set up global periodic flush to ensure logs are emitted regularly
this.globalFlushTimer = setInterval(() => {
this.flushAll();
}, this.flushInterval * 2); // Flush everything every 2x the normal interval
if (this.globalFlushTimer.unref) {
this.globalFlushTimer.unref();
}
}
/**
* Log a deduplicated event
* @param key - Aggregation key (e.g., 'connection-rejected', 'cleanup-batch')
* @param level - Log level
* @param message - Log message template
* @param data - Additional data
* @param dedupeKey - Deduplication key within the aggregation (e.g., IP address, reason)
*/
public log(
key: string,
level: 'info' | 'warn' | 'error' | 'debug',
message: string,
data?: any,
dedupeKey?: string
): void {
const eventKey = dedupeKey || message;
const now = Date.now();
if (!this.aggregatedEvents.has(key)) {
this.aggregatedEvents.set(key, {
key,
events: new Map(),
flushTimer: undefined
});
}
const aggregated = this.aggregatedEvents.get(key)!;
if (aggregated.events.has(eventKey)) {
const event = aggregated.events.get(eventKey)!;
event.count++;
event.lastSeen = now;
if (data) {
event.data = { ...event.data, ...data };
}
} else {
aggregated.events.set(eventKey, {
level,
message,
data,
count: 1,
firstSeen: now,
lastSeen: now
});
}
// Check for rapid events (many events in short time)
const totalEvents = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
// If we're getting flooded with events, flush more frequently
if (now - this.lastRapidCheck < 1000 && totalEvents >= this.rapidEventThreshold) {
this.flush(key);
this.lastRapidCheck = now;
} else if (aggregated.events.size >= this.maxBatchSize) {
// Check if we should flush due to size
this.flush(key);
} else if (!aggregated.flushTimer) {
// Schedule flush
aggregated.flushTimer = setTimeout(() => {
this.flush(key);
}, this.flushInterval);
if (aggregated.flushTimer.unref) {
aggregated.flushTimer.unref();
}
}
// Update rapid check time
if (now - this.lastRapidCheck >= 1000) {
this.lastRapidCheck = now;
}
}
/**
* Flush aggregated events for a specific key
*/
public flush(key: string): void {
const aggregated = this.aggregatedEvents.get(key);
if (!aggregated || aggregated.events.size === 0) {
return;
}
if (aggregated.flushTimer) {
clearTimeout(aggregated.flushTimer);
aggregated.flushTimer = undefined;
}
// Emit aggregated log based on the key
switch (key) {
case 'connection-rejected':
this.flushConnectionRejections(aggregated);
break;
case 'connection-cleanup':
this.flushConnectionCleanups(aggregated);
break;
case 'connection-terminated':
this.flushConnectionTerminations(aggregated);
break;
case 'ip-rejected':
this.flushIPRejections(aggregated);
break;
default:
this.flushGeneric(aggregated);
}
// Clear events
aggregated.events.clear();
}
/**
* Flush all pending events
*/
public flushAll(): void {
for (const key of this.aggregatedEvents.keys()) {
this.flush(key);
}
}
private flushConnectionRejections(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'unknown';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
logger.log('warn', `[SUMMARY] Rejected ${totalCount} connections in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
uniqueIPs: aggregated.events.size,
component: 'connection-dedup'
});
}
private flushConnectionCleanups(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'normal';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5) // Top 5 reasons
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
logger.log('info', `Cleaned up ${totalCount} connections`, {
reasons: reasonSummary,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'connection-dedup'
});
}
private flushConnectionTerminations(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
const byIP = new Map<string, number>();
let lastActiveCount = 0;
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'unknown';
const ip = event.data?.remoteIP || 'unknown';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
// Track by IP
if (ip !== 'unknown') {
byIP.set(ip, (byIP.get(ip) || 0) + event.count);
}
// Track the last active connection count
if (event.data?.activeConnections !== undefined) {
lastActiveCount = event.data.activeConnections;
}
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5) // Top 5 reasons
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
// Show top IPs if there are many different ones
let ipInfo = '';
if (byIP.size > 3) {
const topIPs = Array.from(byIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([ip, count]) => `${ip} (${count})`)
.join(', ');
ipInfo = `, from ${byIP.size} IPs (top: ${topIPs})`;
} else if (byIP.size > 0) {
ipInfo = `, IPs: ${Array.from(byIP.keys()).join(', ')}`;
}
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
// Special handling for localhost connections (HttpProxy)
const localhostCount = byIP.get('::ffff:127.0.0.1') || 0;
if (localhostCount > 0 && byIP.size === 1) {
// All connections are from localhost (HttpProxy)
logger.log('info', `[SUMMARY] ${totalCount} HttpProxy connections terminated in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
activeConnections: lastActiveCount,
component: 'connection-dedup'
});
} else {
logger.log('info', `[SUMMARY] ${totalCount} connections terminated in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
activeConnections: lastActiveCount,
uniqueReasons: byReason.size,
...(ipInfo ? { ips: ipInfo } : {}),
component: 'connection-dedup'
});
}
}
private flushIPRejections(aggregated: IAggregatedEvent): void {
const byIP = new Map<string, { count: number; reasons: Set<string> }>();
const allReasons = new Map<string, number>();
for (const [ip, event] of aggregated.events) {
if (!byIP.has(ip)) {
byIP.set(ip, { count: 0, reasons: new Set() });
}
const ipData = byIP.get(ip)!;
ipData.count += event.count;
if (event.data?.reason) {
ipData.reasons.add(event.data.reason);
// Track overall reason counts
allReasons.set(event.data.reason, (allReasons.get(event.data.reason) || 0) + event.count);
}
}
// Create reason summary
const reasonSummary = Array.from(allReasons.entries())
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
// Log top offenders
const topOffenders = Array.from(byIP.entries())
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 10)
.map(([ip, data]) => `${ip} (${data.count}x, ${Array.from(data.reasons).join('/')})`)
.join(', ');
const totalRejections = Array.from(byIP.values()).reduce((sum, data) => sum + data.count, 0);
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
logger.log('warn', `[SUMMARY] Rejected ${totalRejections} connections from ${byIP.size} IPs in ${Math.round(duration/1000)}s (${reasonSummary})`, {
topOffenders,
component: 'ip-dedup'
});
}
private flushGeneric(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const level = aggregated.events.values().next().value?.level || 'info';
// Special handling for IP cleanup events
if (aggregated.key === 'ip-cleanup') {
const totalCleaned = Array.from(aggregated.events.values()).reduce((sum, e) => {
return sum + (e.data?.cleanedIPs || 0) + (e.data?.cleanedRateLimits || 0);
}, 0);
if (totalCleaned > 0) {
logger.log(level as any, `IP tracking cleanup: removed ${totalCleaned} entries across ${totalCount} cleanup cycles`, {
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'log-dedup'
});
}
} else {
logger.log(level as any, `${aggregated.key}: ${totalCount} events`, {
uniqueEvents: aggregated.events.size,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'log-dedup'
});
}
}
/**
* Cleanup and stop deduplication
*/
public cleanup(): void {
this.flushAll();
if (this.globalFlushTimer) {
clearInterval(this.globalFlushTimer);
this.globalFlushTimer = undefined;
}
for (const aggregated of this.aggregatedEvents.values()) {
if (aggregated.flushTimer) {
clearTimeout(aggregated.flushTimer);
}
}
this.aggregatedEvents.clear();
}
}
// Global instance for connection-related log deduplication
export const connectionLogDeduplicator = new LogDeduplicator(5000); // 5 second batches
// Ensure logs are flushed on process exit.
// Only use beforeExit — do NOT call process.exit() from SIGINT/SIGTERM handlers
// as that kills the host process's graceful shutdown (e.g., dcrouter connection draining).
process.on('beforeExit', () => {
connectionLogDeduplicator.flushAll();
});
process.on('SIGINT', () => {
connectionLogDeduplicator.cleanup();
});
process.on('SIGTERM', () => {
connectionLogDeduplicator.cleanup();
});

View File

@@ -1,305 +0,0 @@
import * as plugins from '../../plugins.js';
import { IpMatcher } from '../routing/matchers/ip.js';
/**
* Security utilities for IP validation, rate limiting,
* authentication, and other security features
*/
/**
* Result of IP validation
*/
export interface IIpValidationResult {
allowed: boolean;
reason?: string;
}
/**
* IP connection tracking information
*/
export interface IIpConnectionInfo {
connections: Set<string>; // ConnectionIDs
timestamps: number[]; // Connection timestamps
ipVariants: string[]; // Normalized IP variants (e.g., ::ffff:127.0.0.1 and 127.0.0.1)
}
/**
* Rate limit tracking
*/
export interface IRateLimitInfo {
count: number;
expiry: number;
}
/**
* Logger interface for security utilities
*/
export interface ISecurityLogger {
info: (message: string, ...args: any[]) => void;
warn: (message: string, ...args: any[]) => void;
error: (message: string, ...args: any[]) => void;
debug?: (message: string, ...args: any[]) => void;
}
/**
* Normalize IP addresses for comparison
* Handles IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
*
* @param ip IP address to normalize
* @returns Array of equivalent IP representations
*/
export function 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 based on allow and block lists
*
* @param ip - The IP address to check
* @param allowedIPs - Array of allowed IP patterns
* @param blockedIPs - Array of blocked IP patterns
* @returns Whether the IP is authorized
*/
export function isIPAuthorized(
ip: string,
allowedIPs: string[] = ['*'],
blockedIPs: string[] = []
): boolean {
// Skip IP validation if no rules
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
return true;
}
// First check if IP is blocked - blocked IPs take precedence
if (blockedIPs.length > 0) {
for (const pattern of blockedIPs) {
if (IpMatcher.match(pattern, ip)) {
return false;
}
}
}
// If allowed IPs list has wildcard, all non-blocked IPs are allowed
if (allowedIPs.includes('*')) {
return true;
}
// Then check if IP is allowed in the explicit allow list
if (allowedIPs.length > 0) {
for (const pattern of allowedIPs) {
if (IpMatcher.match(pattern, ip)) {
return true;
}
}
// If allowedIPs is specified but no match, deny access
return false;
}
// Default allow if no explicit allow list
return true;
}
/**
* Check if an IP exceeds maximum connections
*
* @param ip - The IP address to check
* @param ipConnectionsMap - Map of IPs to connection info
* @param maxConnectionsPerIP - Maximum allowed connections per IP
* @returns Result with allowed status and reason if blocked
*/
export function checkMaxConnections(
ip: string,
ipConnectionsMap: Map<string, IIpConnectionInfo>,
maxConnectionsPerIP: number
): IIpValidationResult {
if (!ipConnectionsMap.has(ip)) {
return { allowed: true };
}
const connectionCount = ipConnectionsMap.get(ip)!.connections.size;
if (connectionCount >= maxConnectionsPerIP) {
return {
allowed: false,
reason: `Maximum connections per IP (${maxConnectionsPerIP}) exceeded`
};
}
return { allowed: true };
}
/**
* Check if an IP exceeds connection rate limit
*
* @param ip - The IP address to check
* @param ipConnectionsMap - Map of IPs to connection info
* @param rateLimit - Maximum connections per minute
* @returns Result with allowed status and reason if blocked
*/
export function checkConnectionRate(
ip: string,
ipConnectionsMap: Map<string, IIpConnectionInfo>,
rateLimit: number
): IIpValidationResult {
const now = Date.now();
const minute = 60 * 1000;
// Get or create connection info
if (!ipConnectionsMap.has(ip)) {
const info: IIpConnectionInfo = {
connections: new Set(),
timestamps: [now],
ipVariants: normalizeIP(ip)
};
ipConnectionsMap.set(ip, info);
return { allowed: true };
}
// Get timestamps and filter out entries older than 1 minute
const info = ipConnectionsMap.get(ip)!;
const timestamps = info.timestamps.filter(time => now - time < minute);
timestamps.push(now);
info.timestamps = timestamps;
// Check if rate exceeds limit
if (timestamps.length > rateLimit) {
return {
allowed: false,
reason: `Connection rate limit (${rateLimit}/min) exceeded`
};
}
return { allowed: true };
}
/**
* Track a connection for an IP
*
* @param ip - The IP address
* @param connectionId - The connection ID to track
* @param ipConnectionsMap - Map of IPs to connection info
*/
export function trackConnection(
ip: string,
connectionId: string,
ipConnectionsMap: Map<string, IIpConnectionInfo>
): void {
if (!ipConnectionsMap.has(ip)) {
ipConnectionsMap.set(ip, {
connections: new Set([connectionId]),
timestamps: [Date.now()],
ipVariants: normalizeIP(ip)
});
return;
}
const info = ipConnectionsMap.get(ip)!;
info.connections.add(connectionId);
}
/**
* Remove connection tracking for an IP
*
* @param ip - The IP address
* @param connectionId - The connection ID to remove
* @param ipConnectionsMap - Map of IPs to connection info
*/
export function removeConnection(
ip: string,
connectionId: string,
ipConnectionsMap: Map<string, IIpConnectionInfo>
): void {
if (!ipConnectionsMap.has(ip)) return;
const info = ipConnectionsMap.get(ip)!;
info.connections.delete(connectionId);
if (info.connections.size === 0) {
ipConnectionsMap.delete(ip);
}
}
/**
* Clean up expired rate limits
*
* @param rateLimits - Map of rate limits to clean up
* @param logger - Logger for debug messages
*/
export function cleanupExpiredRateLimits(
rateLimits: Map<string, Map<string, IRateLimitInfo>>,
logger?: ISecurityLogger
): void {
const now = Date.now();
let totalRemoved = 0;
for (const [routeId, routeLimits] of rateLimits.entries()) {
let removed = 0;
for (const [key, limit] of routeLimits.entries()) {
if (limit.expiry < now) {
routeLimits.delete(key);
removed++;
totalRemoved++;
}
}
if (removed > 0 && logger?.debug) {
logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`);
}
}
if (totalRemoved > 0 && logger?.info) {
logger.info(`Cleaned up ${totalRemoved} expired rate limits total`);
}
}
/**
* Generate basic auth header value from username and password
*
* @param username - The username
* @param password - The password
* @returns Base64 encoded basic auth string
*/
export function generateBasicAuthHeader(username: string, password: string): string {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
/**
* Parse basic auth header
*
* @param authHeader - The Authorization header value
* @returns Username and password, or null if invalid
*/
export function parseBasicAuthHeader(
authHeader: string
): { username: string; password: string } | null {
if (!authHeader || !authHeader.startsWith('Basic ')) {
return null;
}
try {
const base64 = authHeader.slice(6); // Remove 'Basic '
const decoded = Buffer.from(base64, 'base64').toString();
const [username, password] = decoded.split(':');
if (!username || !password) {
return null;
}
return { username, password };
} catch (err) {
return null;
}
}

View File

@@ -1,470 +0,0 @@
import * as plugins from '../../plugins.js';
import type { IRouteConfig, IRouteContext } from '../../proxies/smart-proxy/models/route-types.js';
import type {
IIpValidationResult,
IIpConnectionInfo,
ISecurityLogger,
IRateLimitInfo
} from './security-utils.js';
import {
isIPAuthorized,
checkMaxConnections,
checkConnectionRate,
trackConnection,
removeConnection,
cleanupExpiredRateLimits,
parseBasicAuthHeader,
normalizeIP
} from './security-utils.js';
/**
* Shared SecurityManager for use across proxy components
* Handles IP tracking, rate limiting, and authentication
*/
export class SharedSecurityManager {
// IP connection tracking
private connectionsByIP: Map<string, IIpConnectionInfo> = new Map();
// Route-specific rate limiting
private rateLimits: Map<string, Map<string, IRateLimitInfo>> = new Map();
// Cache IP filtering results to avoid constant regex matching
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
// Default limits
private maxConnectionsPerIP: number;
private connectionRateLimitPerMinute: number;
// Cache cleanup interval
private cleanupInterval: NodeJS.Timeout | null = null;
/**
* Create a new SharedSecurityManager
*
* @param options - Configuration options
* @param logger - Logger instance
*/
constructor(options: {
maxConnectionsPerIP?: number;
connectionRateLimitPerMinute?: number;
cleanupIntervalMs?: number;
routes?: IRouteConfig[];
}, private logger?: ISecurityLogger) {
this.maxConnectionsPerIP = options.maxConnectionsPerIP || 100;
this.connectionRateLimitPerMinute = options.connectionRateLimitPerMinute || 300;
// Set up logger with defaults if not provided
this.logger = logger || {
info: console.log,
warn: console.warn,
error: console.error
};
// Set up cache cleanup interval
const cleanupInterval = options.cleanupIntervalMs || 60000; // Default: 1 minute
this.cleanupInterval = setInterval(() => {
this.cleanupCaches();
}, cleanupInterval);
// Don't keep the process alive just for cleanup
if (this.cleanupInterval.unref) {
this.cleanupInterval.unref();
}
}
/**
* Get connections count by IP
*
* @param ip - The IP address to check
* @returns Number of connections from this IP
*/
public getConnectionCountByIP(ip: string): number {
// Check all normalized variants of the IP
const variants = normalizeIP(ip);
for (const variant of variants) {
const info = this.connectionsByIP.get(variant);
if (info) {
return info.connections.size;
}
}
return 0;
}
/**
* Track connection by IP
*
* @param ip - The IP address to track
* @param connectionId - The connection ID to associate
*/
public trackConnectionByIP(ip: string, connectionId: string): void {
// Check if any variant already exists
const variants = normalizeIP(ip);
let existingKey: string | null = null;
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
existingKey = variant;
break;
}
}
// Use existing key or the original IP
trackConnection(existingKey || ip, connectionId, this.connectionsByIP);
}
/**
* Remove connection tracking for an IP
*
* @param ip - The IP address to update
* @param connectionId - The connection ID to remove
*/
public removeConnectionByIP(ip: string, connectionId: string): void {
// Check all variants to find where the connection is tracked
const variants = normalizeIP(ip);
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
removeConnection(variant, connectionId, this.connectionsByIP);
break;
}
}
}
/**
* Check if IP is authorized based on route security settings
*
* @param ip - The IP address to check
* @param allowedIPs - List of allowed IP patterns
* @param blockedIPs - List of blocked IP patterns
* @returns Whether the IP is authorized
*/
public isIPAuthorized(
ip: string,
allowedIPs: string[] = ['*'],
blockedIPs: string[] = []
): boolean {
return isIPAuthorized(ip, allowedIPs, blockedIPs);
}
/**
* Validate IP against rate limits and connection limits
*
* @param ip - The IP address to validate
* @returns Result with allowed status and reason if blocked
*/
public validateIP(ip: string): IIpValidationResult {
// Check connection count limit
const connectionResult = checkMaxConnections(
ip,
this.connectionsByIP,
this.maxConnectionsPerIP
);
if (!connectionResult.allowed) {
return connectionResult;
}
// Check connection rate limit
const rateResult = checkConnectionRate(
ip,
this.connectionsByIP,
this.connectionRateLimitPerMinute
);
if (!rateResult.allowed) {
return rateResult;
}
return { allowed: true };
}
/**
* Atomically validate an IP and track the connection if allowed.
* This prevents race conditions where concurrent connections could bypass per-IP limits.
*
* @param ip - The IP address to validate
* @param connectionId - The connection ID to track if validation passes
* @returns Object with validation result and reason
*/
public validateAndTrackIP(ip: string, connectionId: string): IIpValidationResult {
// Check connection count limit BEFORE tracking
const connectionResult = checkMaxConnections(
ip,
this.connectionsByIP,
this.maxConnectionsPerIP
);
if (!connectionResult.allowed) {
return connectionResult;
}
// Check connection rate limit
const rateResult = checkConnectionRate(
ip,
this.connectionsByIP,
this.connectionRateLimitPerMinute
);
if (!rateResult.allowed) {
return rateResult;
}
// Validation passed - immediately track to prevent race conditions
this.trackConnectionByIP(ip, connectionId);
return { allowed: true };
}
/**
* Check if a client is allowed to access a specific route
*
* @param route - The route to check
* @param context - The request context
* @param routeConnectionCount - Current connection count for this route (optional)
* @returns Whether access is allowed
*/
public isAllowed(route: IRouteConfig, context: IRouteContext, routeConnectionCount?: number): boolean {
if (!route.security) {
return true; // No security restrictions
}
// --- IP filtering ---
if (!this.isClientIpAllowed(route, context.clientIp)) {
this.logger?.debug?.(`IP ${context.clientIp} is blocked for route ${route.name || 'unnamed'}`);
return false;
}
// --- Route-level connection limit ---
if (route.security.maxConnections !== undefined && routeConnectionCount !== undefined) {
if (routeConnectionCount >= route.security.maxConnections) {
this.logger?.debug?.(`Route connection limit (${route.security.maxConnections}) exceeded for route ${route.name || 'unnamed'}`);
return false;
}
}
// --- Rate limiting ---
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
return false;
}
return true;
}
/**
* Check if a client IP is allowed for a route
*
* @param route - The route to check
* @param clientIp - The client IP
* @returns Whether the IP is allowed
*/
private isClientIpAllowed(route: IRouteConfig, clientIp: string): boolean {
if (!route.security) {
return true; // No security restrictions
}
const routeId = route.id || route.name || 'unnamed';
// Check cache first
if (!this.ipFilterCache.has(routeId)) {
this.ipFilterCache.set(routeId, new Map());
}
const routeCache = this.ipFilterCache.get(routeId)!;
if (routeCache.has(clientIp)) {
return routeCache.get(clientIp)!;
}
// Check IP against route security settings
const ipAllowList = route.security.ipAllowList;
const ipBlockList = route.security.ipBlockList;
const allowed = this.isIPAuthorized(clientIp, ipAllowList, ipBlockList);
// Cache the result
routeCache.set(clientIp, allowed);
return allowed;
}
/**
* Check if request is within rate limit
*
* @param route - The route to check
* @param context - The request context
* @returns Whether the request is within rate limit
*/
private isWithinRateLimit(route: IRouteConfig, context: IRouteContext): boolean {
if (!route.security?.rateLimit?.enabled) {
return true;
}
const rateLimit = route.security.rateLimit;
const routeId = route.id || route.name || 'unnamed';
// Determine rate limit key (by IP, path, or header)
let key = context.clientIp; // Default to IP
if (rateLimit.keyBy === 'path' && context.path) {
key = `${context.clientIp}:${context.path}`;
} else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) {
const headerValue = context.headers[rateLimit.headerName.toLowerCase()];
if (headerValue) {
key = `${context.clientIp}:${headerValue}`;
}
}
// Get or create rate limit tracking for this route
if (!this.rateLimits.has(routeId)) {
this.rateLimits.set(routeId, new Map());
}
const routeLimits = this.rateLimits.get(routeId)!;
const now = Date.now();
// Get or create rate limit tracking for this key
let limit = routeLimits.get(key);
if (!limit || limit.expiry < now) {
// Create new rate limit or reset expired one
limit = {
count: 1,
expiry: now + (rateLimit.window * 1000)
};
routeLimits.set(key, limit);
return true;
}
// Increment the counter
limit.count++;
// Check if rate limit is exceeded
return limit.count <= rateLimit.maxRequests;
}
/**
* Validate HTTP Basic Authentication
*
* @param route - The route to check
* @param authHeader - The Authorization header
* @returns Whether authentication is valid
*/
public validateBasicAuth(route: IRouteConfig, authHeader?: string): boolean {
// Skip if basic auth not enabled for route
if (!route.security?.basicAuth?.enabled) {
return true;
}
// No auth header means auth failed
if (!authHeader) {
return false;
}
// Parse auth header
const credentials = parseBasicAuthHeader(authHeader);
if (!credentials) {
return false;
}
// Check credentials against configured users
const { username, password } = credentials;
const users = route.security.basicAuth.users;
return users.some(user =>
user.username === username && user.password === password
);
}
/**
* Verify a JWT token against route configuration
*
* @param route - The route to verify the token for
* @param token - The JWT token to verify
* @returns True if the token is valid, false otherwise
*/
public verifyJwtToken(route: IRouteConfig, token: string): boolean {
if (!route.security?.jwtAuth?.enabled) {
return true;
}
try {
const jwtAuth = route.security.jwtAuth;
// Verify structure (header.payload.signature)
const parts = token.split('.');
if (parts.length !== 3) {
return false;
}
// Decode payload
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
// Check expiration
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
return false;
}
// Check issuer
if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) {
return false;
}
// Check audience
if (jwtAuth.audience && payload.aud !== jwtAuth.audience) {
return false;
}
// Note: In a real implementation, you'd also verify the signature
// using the secret and algorithm specified in jwtAuth.
// This requires a proper JWT library for cryptographic verification.
return true;
} catch (err) {
this.logger?.error?.(`Error verifying JWT: ${err}`);
return false;
}
}
/**
* Clean up caches to prevent memory leaks
*/
private cleanupCaches(): void {
// Clean up rate limits
cleanupExpiredRateLimits(this.rateLimits, this.logger);
// Clean up IP connection tracking
let cleanedIPs = 0;
for (const [ip, info] of this.connectionsByIP.entries()) {
// Remove IPs with no active connections and no recent timestamps
if (info.connections.size === 0 && info.timestamps.length === 0) {
this.connectionsByIP.delete(ip);
cleanedIPs++;
}
}
if (cleanedIPs > 0 && this.logger?.debug) {
this.logger.debug(`Cleaned up ${cleanedIPs} IPs with no active connections`);
}
// IP filter cache doesn't need cleanup (tied to routes)
}
/**
* Clear all IP tracking data (for shutdown)
*/
public clearIPTracking(): void {
this.connectionsByIP.clear();
this.rateLimits.clear();
this.ipFilterCache.clear();
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
/**
* Update routes for security checking
*
* @param routes - New routes to use
*/
public setRoutes(routes: IRouteConfig[]): void {
// Only clear the IP filter cache - route-specific
this.ipFilterCache.clear();
}
}

View File

@@ -1,322 +0,0 @@
import * as plugins from '../../plugins.js';
export interface CleanupOptions {
immediate?: boolean; // Force immediate destruction
allowDrain?: boolean; // Allow write buffer to drain
gracePeriod?: number; // Ms to wait before force close
}
export interface SafeSocketOptions {
port: number;
host: string;
onError?: (error: Error) => void;
onConnect?: () => void;
timeout?: number;
}
/**
* Safely cleanup a socket by removing all listeners and destroying it
* @param socket The socket to cleanup
* @param socketName Optional name for logging
* @param options Cleanup options
*/
export function cleanupSocket(
socket: plugins.net.Socket | plugins.tls.TLSSocket | null,
socketName?: string,
options: CleanupOptions = {}
): Promise<void> {
if (!socket || socket.destroyed) return Promise.resolve();
return new Promise<void>((resolve) => {
const cleanup = () => {
try {
// Remove all event listeners
socket.removeAllListeners();
// Destroy if not already destroyed
if (!socket.destroyed) {
socket.destroy();
}
} catch (err) {
console.error(`Error cleaning up socket${socketName ? ` (${socketName})` : ''}: ${err}`);
}
resolve();
};
if (options.immediate) {
// Immediate cleanup (old behavior)
socket.unpipe();
cleanup();
} else if (options.allowDrain && socket.writable) {
// Allow pending writes to complete
socket.end(() => cleanup());
// Force cleanup after grace period
if (options.gracePeriod) {
setTimeout(() => {
if (!socket.destroyed) {
cleanup();
}
}, options.gracePeriod);
}
} else {
// Default: immediate cleanup
socket.unpipe();
cleanup();
}
});
}
/**
* Create independent cleanup handlers for paired sockets that support half-open connections
* @param clientSocket The client socket
* @param serverSocket The server socket
* @param onBothClosed Callback when both sockets are closed
* @returns Independent cleanup functions for each socket
*/
export function createIndependentSocketHandlers(
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
onBothClosed: (reason: string) => void,
options: { enableHalfOpen?: boolean } = {}
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
let clientClosed = false;
let serverClosed = false;
let clientReason = '';
let serverReason = '';
const checkBothClosed = () => {
if (clientClosed && serverClosed) {
onBothClosed(`client: ${clientReason}, server: ${serverReason}`);
}
};
const cleanupClient = async (reason: string) => {
if (clientClosed) return;
clientClosed = true;
clientReason = reason;
// Default behavior: close both sockets when one closes (required for proxy chains)
if (!serverClosed && !options.enableHalfOpen) {
serverSocket.destroy();
}
// Half-open support (opt-in only)
if (!serverClosed && serverSocket.writable && options.enableHalfOpen) {
// Half-close: stop reading from client, let server finish
clientSocket.pause();
clientSocket.unpipe(serverSocket);
await cleanupSocket(clientSocket, 'client', { allowDrain: true, gracePeriod: 5000 });
} else {
await cleanupSocket(clientSocket, 'client', { immediate: true });
}
checkBothClosed();
};
const cleanupServer = async (reason: string) => {
if (serverClosed) return;
serverClosed = true;
serverReason = reason;
// Default behavior: close both sockets when one closes (required for proxy chains)
if (!clientClosed && !options.enableHalfOpen) {
clientSocket.destroy();
}
// Half-open support (opt-in only)
if (!clientClosed && clientSocket.writable && options.enableHalfOpen) {
// Half-close: stop reading from server, let client finish
serverSocket.pause();
serverSocket.unpipe(clientSocket);
await cleanupSocket(serverSocket, 'server', { allowDrain: true, gracePeriod: 5000 });
} else {
await cleanupSocket(serverSocket, 'server', { immediate: true });
}
checkBothClosed();
};
return { cleanupClient, cleanupServer };
}
/**
* Setup socket error and close handlers with proper cleanup
* @param socket The socket to setup handlers for
* @param handleClose The cleanup function to call
* @param handleTimeout Optional custom timeout handler
* @param errorPrefix Optional prefix for error messages
*/
export function setupSocketHandlers(
socket: plugins.net.Socket | plugins.tls.TLSSocket,
handleClose: (reason: string) => void,
handleTimeout?: (socket: plugins.net.Socket | plugins.tls.TLSSocket) => void,
errorPrefix?: string
): void {
socket.on('error', (error) => {
const prefix = errorPrefix || 'Socket';
handleClose(`${prefix}_error: ${error.message}`);
});
socket.on('close', () => {
const prefix = errorPrefix || 'socket';
handleClose(`${prefix}_closed`);
});
socket.on('timeout', () => {
if (handleTimeout) {
handleTimeout(socket); // Custom timeout handling
} else {
// Default: just log, don't close
console.warn(`Socket timeout: ${errorPrefix || 'socket'}`);
}
});
}
/**
* Setup bidirectional data forwarding between two sockets with proper cleanup
* @param clientSocket The client/incoming socket
* @param serverSocket The server/outgoing socket
* @param handlers Object containing optional handlers for data and cleanup
* @returns Cleanup functions for both sockets
*/
export function setupBidirectionalForwarding(
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
handlers: {
onClientData?: (chunk: Buffer) => void;
onServerData?: (chunk: Buffer) => void;
onCleanup: (reason: string) => void;
enableHalfOpen?: boolean;
}
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
// Set up cleanup handlers
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
clientSocket,
serverSocket,
handlers.onCleanup,
{ enableHalfOpen: handlers.enableHalfOpen }
);
// Set up error and close handlers
setupSocketHandlers(clientSocket, cleanupClient, undefined, 'client');
setupSocketHandlers(serverSocket, cleanupServer, undefined, 'server');
// Set up data forwarding with backpressure handling
clientSocket.on('data', (chunk: Buffer) => {
if (handlers.onClientData) {
handlers.onClientData(chunk);
}
if (serverSocket.writable) {
const flushed = serverSocket.write(chunk);
// Handle backpressure
if (!flushed) {
clientSocket.pause();
serverSocket.once('drain', () => {
if (!clientSocket.destroyed) {
clientSocket.resume();
}
});
}
}
});
serverSocket.on('data', (chunk: Buffer) => {
if (handlers.onServerData) {
handlers.onServerData(chunk);
}
if (clientSocket.writable) {
const flushed = clientSocket.write(chunk);
// Handle backpressure
if (!flushed) {
serverSocket.pause();
clientSocket.once('drain', () => {
if (!serverSocket.destroyed) {
serverSocket.resume();
}
});
}
}
});
return { cleanupClient, cleanupServer };
}
/**
* Create a socket with immediate error handling to prevent crashes
* @param options Socket creation options
* @returns The created socket
*/
export function createSocketWithErrorHandler(options: SafeSocketOptions): plugins.net.Socket {
const { port, host, onError, onConnect, timeout } = options;
// Create socket with immediate error handler attachment
const socket = new plugins.net.Socket();
// Track if connected
let connected = false;
let connectionTimeout: NodeJS.Timeout | null = null;
// Attach error handler BEFORE connecting to catch immediate errors
socket.on('error', (error) => {
console.error(`Socket connection error to ${host}:${port}: ${error.message}`);
// Clear the connection timeout if it exists
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
if (onError) {
onError(error);
}
});
// Attach connect handler
const handleConnect = () => {
connected = true;
// Clear the connection timeout
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
// Set inactivity timeout if provided (after connection is established)
if (timeout) {
socket.setTimeout(timeout);
}
if (onConnect) {
onConnect();
}
};
socket.on('connect', handleConnect);
// Implement connection establishment timeout
if (timeout) {
connectionTimeout = setTimeout(() => {
if (!connected && !socket.destroyed) {
// Connection timed out - destroy the socket
const error = new Error(`Connection timeout after ${timeout}ms to ${host}:${port}`);
(error as any).code = 'ETIMEDOUT';
console.error(`Socket connection timeout to ${host}:${port} after ${timeout}ms`);
// Destroy the socket
socket.destroy();
// Call error handler
if (onError) {
onError(error);
}
}
}, timeout);
}
// Now attempt to connect - any immediate errors will be caught
socket.connect(port, host);
return socket;
}

View File

@@ -1,124 +0,0 @@
import type { IRouteContext } from '../models/route-context.js';
/**
* Utility class for resolving template variables in strings
*/
export class TemplateUtils {
/**
* Resolve template variables in a string using the route context
* Supports variables like {domain}, {path}, {clientIp}, etc.
*
* @param template The template string with {variables}
* @param context The route context with values
* @returns The resolved string
*/
public static resolveTemplateVariables(template: string, context: IRouteContext): string {
if (!template) {
return template;
}
// Replace variables with values from context
return template.replace(/\{([a-zA-Z0-9_\.]+)\}/g, (match, varName) => {
// Handle nested properties with dot notation (e.g., {headers.host})
if (varName.includes('.')) {
const parts = varName.split('.');
let current: any = context;
// Traverse nested object structure
for (const part of parts) {
if (current === undefined || current === null) {
return match; // Return original if path doesn't exist
}
current = current[part];
}
// Return the resolved value if it exists
if (current !== undefined && current !== null) {
return TemplateUtils.convertToString(current);
}
return match;
}
// Direct property access
const value = context[varName as keyof IRouteContext];
if (value === undefined) {
return match; // Keep the original {variable} if not found
}
// Convert value to string
return TemplateUtils.convertToString(value);
});
}
/**
* Safely convert a value to a string
*
* @param value Any value to convert to string
* @returns String representation or original match for complex objects
*/
private static convertToString(value: any): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value.toString();
}
if (Array.isArray(value)) {
return value.join(',');
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (e) {
return '[Object]';
}
}
return String(value);
}
/**
* Resolve template variables in header values
*
* @param headers Header object with potential template variables
* @param context Route context for variable resolution
* @returns New header object with resolved values
*/
public static resolveHeaderTemplates(
headers: Record<string, string>,
context: IRouteContext
): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
// Skip special directive headers (starting with !)
if (value.startsWith('!')) {
result[key] = value;
continue;
}
// Resolve template variables in the header value
result[key] = TemplateUtils.resolveTemplateVariables(value, context);
}
return result;
}
/**
* Check if a string contains template variables
*
* @param str String to check for template variables
* @returns True if string contains template variables
*/
public static containsTemplateVariables(str: string): boolean {
return !!str && /\{([a-zA-Z0-9_\.]+)\}/g.test(str);
}
}

View File

@@ -1,177 +0,0 @@
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-----');
}
}

View File

@@ -1,33 +0,0 @@
/**
* WebSocket utility functions
*
* This module provides smartproxy-specific WebSocket utilities
* and re-exports protocol utilities from the protocols module
*/
// Import and re-export from protocols
import { getMessageSize as protocolGetMessageSize, toBuffer as protocolToBuffer } from '../../protocols/websocket/index.js';
export type { RawData } from '../../protocols/websocket/index.js';
/**
* Get the length of a WebSocket message regardless of its type
* (handles all possible WebSocket message data types)
*
* @param data - The data message from WebSocket (could be any RawData type)
* @returns The length of the data in bytes
*/
export function getMessageSize(data: import('../../protocols/websocket/index.js').RawData): number {
// Delegate to protocol implementation
return protocolGetMessageSize(data);
}
/**
* Convert any raw WebSocket data to Buffer for consistent handling
*
* @param data - The data message from WebSocket (could be any RawData type)
* @returns A Buffer containing the data
*/
export function toBuffer(data: import('../../protocols/websocket/index.js').RawData): Buffer {
// Delegate to protocol implementation
return protocolToBuffer(data);
}