fix(routing): unify route based architecture
This commit is contained in:
376
ts/core/utils/event-system.ts
Normal file
376
ts/core/utils/event-system.ts
Normal file
@ -0,0 +1,376 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
ICertificateData,
|
||||
ICertificateFailure,
|
||||
ICertificateExpiring
|
||||
} from '../models/common-types.js';
|
||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import { Port80HandlerEvents } from '../models/common-types.js';
|
||||
|
||||
/**
|
||||
* Standardized event names used throughout the system
|
||||
*/
|
||||
export enum ProxyEvents {
|
||||
// Certificate events
|
||||
CERTIFICATE_ISSUED = 'certificate:issued',
|
||||
CERTIFICATE_RENEWED = 'certificate:renewed',
|
||||
CERTIFICATE_FAILED = 'certificate:failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate:expiring',
|
||||
|
||||
// Component lifecycle events
|
||||
COMPONENT_STARTED = 'component:started',
|
||||
COMPONENT_STOPPED = 'component:stopped',
|
||||
|
||||
// Connection events
|
||||
CONNECTION_ESTABLISHED = 'connection:established',
|
||||
CONNECTION_CLOSED = 'connection:closed',
|
||||
CONNECTION_ERROR = 'connection:error',
|
||||
|
||||
// Request events
|
||||
REQUEST_RECEIVED = 'request:received',
|
||||
REQUEST_COMPLETED = 'request:completed',
|
||||
REQUEST_ERROR = 'request:error',
|
||||
|
||||
// Route events
|
||||
ROUTE_MATCHED = 'route:matched',
|
||||
ROUTE_UPDATED = 'route:updated',
|
||||
ROUTE_ERROR = 'route:error',
|
||||
|
||||
// Security events
|
||||
SECURITY_BLOCKED = 'security:blocked',
|
||||
SECURITY_BREACH_ATTEMPT = 'security:breach-attempt',
|
||||
|
||||
// TLS events
|
||||
TLS_HANDSHAKE_STARTED = 'tls:handshake-started',
|
||||
TLS_HANDSHAKE_COMPLETED = 'tls:handshake-completed',
|
||||
TLS_HANDSHAKE_FAILED = 'tls:handshake-failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Component types for event metadata
|
||||
*/
|
||||
export enum ComponentType {
|
||||
SMART_PROXY = 'smart-proxy',
|
||||
NETWORK_PROXY = 'network-proxy',
|
||||
NFTABLES_PROXY = 'nftables-proxy',
|
||||
PORT80_HANDLER = 'port80-handler',
|
||||
CERTIFICATE_MANAGER = 'certificate-manager',
|
||||
ROUTE_MANAGER = 'route-manager',
|
||||
CONNECTION_MANAGER = 'connection-manager',
|
||||
TLS_MANAGER = 'tls-manager',
|
||||
SECURITY_MANAGER = 'security-manager'
|
||||
}
|
||||
|
||||
/**
|
||||
* Base event data interface
|
||||
*/
|
||||
export interface IEventData {
|
||||
timestamp: number;
|
||||
componentType: ComponentType;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate event data
|
||||
*/
|
||||
export interface ICertificateEventData extends IEventData, ICertificateData {
|
||||
isRenewal?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate failure event data
|
||||
*/
|
||||
export interface ICertificateFailureEventData extends IEventData, ICertificateFailure {}
|
||||
|
||||
/**
|
||||
* Certificate expiring event data
|
||||
*/
|
||||
export interface ICertificateExpiringEventData extends IEventData, ICertificateExpiring {}
|
||||
|
||||
/**
|
||||
* Component lifecycle event data
|
||||
*/
|
||||
export interface IComponentEventData extends IEventData {
|
||||
name: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection event data
|
||||
*/
|
||||
export interface IConnectionEventData extends IEventData {
|
||||
connectionId: string;
|
||||
clientIp: string;
|
||||
serverIp?: string;
|
||||
port: number;
|
||||
isTls?: boolean;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request event data
|
||||
*/
|
||||
export interface IRequestEventData extends IEventData {
|
||||
connectionId: string;
|
||||
requestId: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
statusCode?: number;
|
||||
duration?: number;
|
||||
routeId?: string;
|
||||
routeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route event data
|
||||
*/
|
||||
export interface IRouteEventData extends IEventData {
|
||||
route: IRouteConfig;
|
||||
context?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security event data
|
||||
*/
|
||||
export interface ISecurityEventData extends IEventData {
|
||||
clientIp: string;
|
||||
reason: string;
|
||||
routeId?: string;
|
||||
routeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS event data
|
||||
*/
|
||||
export interface ITlsEventData extends IEventData {
|
||||
connectionId: string;
|
||||
domain?: string;
|
||||
clientIp: string;
|
||||
tlsVersion?: string;
|
||||
cipherSuite?: string;
|
||||
sniHostname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for event system
|
||||
*/
|
||||
export interface IEventLogger {
|
||||
info: (message: string, ...args: any[]) => void;
|
||||
warn: (message: string, ...args: any[]) => void;
|
||||
error: (message: string, ...args: any[]) => void;
|
||||
debug?: (message: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler type
|
||||
*/
|
||||
export type EventHandler<T> = (data: T) => void;
|
||||
|
||||
/**
|
||||
* Helper class to standardize event emission and handling
|
||||
* across all system components
|
||||
*/
|
||||
export class EventSystem {
|
||||
private emitter: plugins.EventEmitter;
|
||||
private componentType: ComponentType;
|
||||
private componentId: string;
|
||||
private logger?: IEventLogger;
|
||||
|
||||
constructor(
|
||||
componentType: ComponentType,
|
||||
componentId: string = '',
|
||||
logger?: IEventLogger
|
||||
) {
|
||||
this.emitter = new plugins.EventEmitter();
|
||||
this.componentType = componentType;
|
||||
this.componentId = componentId;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate issued event
|
||||
*/
|
||||
public emitCertificateIssued(data: Omit<ICertificateEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Certificate issued for ${data.domain}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_ISSUED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate renewed event
|
||||
*/
|
||||
public emitCertificateRenewed(data: Omit<ICertificateEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Certificate renewed for ${data.domain}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_RENEWED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate failed event
|
||||
*/
|
||||
public emitCertificateFailed(data: Omit<ICertificateFailureEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateFailureEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.error?.(`Certificate issuance failed for ${data.domain}: ${data.error}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_FAILED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate expiring event
|
||||
*/
|
||||
public emitCertificateExpiring(data: Omit<ICertificateExpiringEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateExpiringEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.warn?.(`Certificate expiring for ${data.domain} in ${data.daysRemaining} days`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_EXPIRING, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a component started event
|
||||
*/
|
||||
public emitComponentStarted(name: string, version?: string): void {
|
||||
const eventData: IComponentEventData = {
|
||||
name,
|
||||
version,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Component ${name} started${version ? ` (v${version})` : ''}`);
|
||||
this.emitter.emit(ProxyEvents.COMPONENT_STARTED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a component stopped event
|
||||
*/
|
||||
public emitComponentStopped(name: string): void {
|
||||
const eventData: IComponentEventData = {
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Component ${name} stopped`);
|
||||
this.emitter.emit(ProxyEvents.COMPONENT_STOPPED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a connection established event
|
||||
*/
|
||||
public emitConnectionEstablished(data: Omit<IConnectionEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IConnectionEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Connection ${data.connectionId} established from ${data.clientIp} on port ${data.port}`);
|
||||
this.emitter.emit(ProxyEvents.CONNECTION_ESTABLISHED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a connection closed event
|
||||
*/
|
||||
public emitConnectionClosed(data: Omit<IConnectionEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IConnectionEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Connection ${data.connectionId} closed`);
|
||||
this.emitter.emit(ProxyEvents.CONNECTION_CLOSED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a route matched event
|
||||
*/
|
||||
public emitRouteMatched(data: Omit<IRouteEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IRouteEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Route matched: ${data.route.name || data.route.id || 'unnamed'}`);
|
||||
this.emitter.emit(ProxyEvents.ROUTE_MATCHED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event
|
||||
*/
|
||||
public on<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.on(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event once
|
||||
*/
|
||||
public once<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.once(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event
|
||||
*/
|
||||
public off<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.off(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Port80Handler events to standard proxy events
|
||||
*/
|
||||
public subscribePort80HandlerEvents(handler: any): void {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => {
|
||||
this.emitCertificateIssued({
|
||||
...data,
|
||||
isRenewal: false,
|
||||
source: 'port80handler'
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => {
|
||||
this.emitCertificateRenewed({
|
||||
...data,
|
||||
isRenewal: true,
|
||||
source: 'port80handler'
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (data: ICertificateFailure) => {
|
||||
this.emitCertificateFailed(data);
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data: ICertificateExpiring) => {
|
||||
this.emitCertificateExpiring(data);
|
||||
});
|
||||
}
|
||||
}
|
@ -5,3 +5,10 @@
|
||||
export * from './event-utils.js';
|
||||
export * from './validation-utils.js';
|
||||
export * from './ip-utils.js';
|
||||
export * from './template-utils.js';
|
||||
export * from './route-manager.js';
|
||||
export * from './route-utils.js';
|
||||
export * from './security-utils.js';
|
||||
export * from './shared-security-manager.js';
|
||||
export * from './event-system.js';
|
||||
export * from './websocket-utils.js';
|
||||
|
489
ts/core/utils/route-manager.ts
Normal file
489
ts/core/utils/route-manager.ts
Normal file
@ -0,0 +1,489 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
IRouteConfig,
|
||||
IRouteMatch,
|
||||
IRouteAction,
|
||||
TPortRange,
|
||||
IRouteContext
|
||||
} from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import {
|
||||
matchDomain,
|
||||
matchRouteDomain,
|
||||
matchPath,
|
||||
matchIpPattern,
|
||||
matchIpCidr,
|
||||
ipToNumber,
|
||||
isIpAuthorized,
|
||||
calculateRouteSpecificity
|
||||
} from './route-utils.js';
|
||||
|
||||
/**
|
||||
* Result of route matching
|
||||
*/
|
||||
export interface IRouteMatchResult {
|
||||
route: IRouteConfig;
|
||||
// Additional match parameters (path, query, etc.)
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for RouteManager
|
||||
*/
|
||||
export interface ILogger {
|
||||
info: (message: string, ...args: any[]) => void;
|
||||
warn: (message: string, ...args: any[]) => void;
|
||||
error: (message: string, ...args: any[]) => void;
|
||||
debug?: (message: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared RouteManager used by both SmartProxy and NetworkProxy
|
||||
*
|
||||
* This provides a unified implementation for route management,
|
||||
* route matching, and port handling.
|
||||
*/
|
||||
export class SharedRouteManager extends plugins.EventEmitter {
|
||||
private routes: IRouteConfig[] = [];
|
||||
private portMap: Map<number, IRouteConfig[]> = new Map();
|
||||
private logger: ILogger;
|
||||
private enableDetailedLogging: boolean;
|
||||
|
||||
/**
|
||||
* Memoization cache for expanded port ranges
|
||||
*/
|
||||
private portRangeCache: Map<string, number[]> = new Map();
|
||||
|
||||
constructor(options: {
|
||||
logger?: ILogger;
|
||||
enableDetailedLogging?: boolean;
|
||||
routes?: IRouteConfig[];
|
||||
}) {
|
||||
super();
|
||||
|
||||
// Set up logger (use console if not provided)
|
||||
this.logger = options.logger || {
|
||||
info: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: options.enableDetailedLogging ? console.log : undefined
|
||||
};
|
||||
|
||||
this.enableDetailedLogging = options.enableDetailedLogging || false;
|
||||
|
||||
// Initialize routes if provided
|
||||
if (options.routes) {
|
||||
this.updateRoutes(options.routes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routes with new configuration
|
||||
*/
|
||||
public updateRoutes(routes: IRouteConfig[] = []): void {
|
||||
// Sort routes by priority (higher first)
|
||||
this.routes = [...(routes || [])].sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
|
||||
// Rebuild port mapping for fast lookups
|
||||
this.rebuildPortMap();
|
||||
|
||||
this.logger.info(`Updated RouteManager with ${this.routes.length} routes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all routes
|
||||
*/
|
||||
public getRoutes(): IRouteConfig[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the port mapping for fast lookups
|
||||
* Also logs information about the ports being listened on
|
||||
*/
|
||||
private rebuildPortMap(): void {
|
||||
this.portMap.clear();
|
||||
this.portRangeCache.clear(); // Clear cache when rebuilding
|
||||
|
||||
// Track ports for logging
|
||||
const portToRoutesMap = new Map<number, string[]>();
|
||||
|
||||
for (const route of this.routes) {
|
||||
const ports = this.expandPortRange(route.match.ports);
|
||||
|
||||
// Skip if no ports were found
|
||||
if (ports.length === 0) {
|
||||
this.logger.warn(`Route ${route.name || 'unnamed'} has no valid ports to listen on`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const port of ports) {
|
||||
// Add to portMap for routing
|
||||
if (!this.portMap.has(port)) {
|
||||
this.portMap.set(port, []);
|
||||
}
|
||||
this.portMap.get(port)!.push(route);
|
||||
|
||||
// Add to tracking for logging
|
||||
if (!portToRoutesMap.has(port)) {
|
||||
portToRoutesMap.set(port, []);
|
||||
}
|
||||
portToRoutesMap.get(port)!.push(route.name || 'unnamed');
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary of ports and routes
|
||||
const totalPorts = this.portMap.size;
|
||||
const totalRoutes = this.routes.length;
|
||||
this.logger.info(`Route manager configured with ${totalRoutes} routes across ${totalPorts} ports`);
|
||||
|
||||
// Log port details if detailed logging is enabled
|
||||
if (this.enableDetailedLogging) {
|
||||
for (const [port, routes] of this.portMap.entries()) {
|
||||
this.logger.info(`Port ${port}: ${routes.length} routes (${portToRoutesMap.get(port)!.join(', ')})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a port range specification into an array of individual ports
|
||||
* Uses caching to improve performance for frequently used port ranges
|
||||
*
|
||||
* @public - Made public to allow external code to interpret port ranges
|
||||
*/
|
||||
public expandPortRange(portRange: TPortRange): number[] {
|
||||
// For simple number, return immediately
|
||||
if (typeof portRange === 'number') {
|
||||
return [portRange];
|
||||
}
|
||||
|
||||
// Create a cache key for this port range
|
||||
const cacheKey = JSON.stringify(portRange);
|
||||
|
||||
// Check if we have a cached result
|
||||
if (this.portRangeCache.has(cacheKey)) {
|
||||
return this.portRangeCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
// Process the port range
|
||||
let result: number[] = [];
|
||||
|
||||
if (Array.isArray(portRange)) {
|
||||
// Handle array of port objects or numbers
|
||||
result = portRange.flatMap(item => {
|
||||
if (typeof item === 'number') {
|
||||
return [item];
|
||||
} else if (typeof item === 'object' && 'from' in item && 'to' in item) {
|
||||
// Handle port range object - check valid range
|
||||
if (item.from > item.to) {
|
||||
this.logger.warn(`Invalid port range: from (${item.from}) > to (${item.to})`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle port range object
|
||||
const ports: number[] = [];
|
||||
for (let p = item.from; p <= item.to; p++) {
|
||||
ports.push(p);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
this.portRangeCache.set(cacheKey, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ports that should be listened on
|
||||
* This method automatically infers all required ports from route configurations
|
||||
*/
|
||||
public getListeningPorts(): number[] {
|
||||
// Return the unique set of ports from all routes
|
||||
return Array.from(this.portMap.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all routes for a given port
|
||||
*/
|
||||
public getRoutesForPort(port: number): IRouteConfig[] {
|
||||
return this.portMap.get(port) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the matching route for a connection
|
||||
*/
|
||||
public findMatchingRoute(context: IRouteContext): IRouteMatchResult | null {
|
||||
// Get routes for this port if using port-based filtering
|
||||
const routesToCheck = context.port
|
||||
? (this.portMap.get(context.port) || [])
|
||||
: this.routes;
|
||||
|
||||
// Find the first matching route based on priority order
|
||||
for (const route of routesToCheck) {
|
||||
if (this.matchesRoute(route, context)) {
|
||||
return { route };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route matches the given context
|
||||
*/
|
||||
private matchesRoute(route: IRouteConfig, context: IRouteContext): boolean {
|
||||
// Skip disabled routes
|
||||
if (route.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check port match if provided in context
|
||||
if (context.port !== undefined) {
|
||||
const ports = this.expandPortRange(route.match.ports);
|
||||
if (!ports.includes(context.port)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check domain match if specified
|
||||
if (route.match.domains && context.domain) {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
if (!domains.some(domainPattern => this.matchDomain(domainPattern, context.domain!))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check path match if specified
|
||||
if (route.match.path && context.path) {
|
||||
if (!this.matchPath(route.match.path, context.path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check client IP match if specified
|
||||
if (route.match.clientIp && context.clientIp) {
|
||||
if (!route.match.clientIp.some(ip => this.matchIpPattern(ip, context.clientIp))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check TLS version match if specified
|
||||
if (route.match.tlsVersion && context.tlsVersion) {
|
||||
if (!route.match.tlsVersion.includes(context.tlsVersion)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check header match if specified
|
||||
if (route.match.headers && context.headers) {
|
||||
for (const [headerName, expectedValue] of Object.entries(route.match.headers)) {
|
||||
const actualValue = context.headers[headerName.toLowerCase()];
|
||||
|
||||
// If header doesn't exist, no match
|
||||
if (actualValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match against string or regex
|
||||
if (typeof expectedValue === 'string') {
|
||||
if (actualValue !== expectedValue) {
|
||||
return false;
|
||||
}
|
||||
} else if (expectedValue instanceof RegExp) {
|
||||
if (!expectedValue.test(actualValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All criteria matched
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a domain pattern against a domain
|
||||
* @deprecated Use the matchDomain function from route-utils.js instead
|
||||
*/
|
||||
public matchDomain(pattern: string, domain: string): boolean {
|
||||
return matchDomain(pattern, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path pattern against a path
|
||||
* @deprecated Use the matchPath function from route-utils.js instead
|
||||
*/
|
||||
public matchPath(pattern: string, path: string): boolean {
|
||||
return matchPath(pattern, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP pattern against a pattern
|
||||
* @deprecated Use the matchIpPattern function from route-utils.js instead
|
||||
*/
|
||||
public matchIpPattern(pattern: string, ip: string): boolean {
|
||||
return matchIpPattern(pattern, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a CIDR pattern
|
||||
* @deprecated Use the matchIpCidr function from route-utils.js instead
|
||||
*/
|
||||
public matchIpCidr(cidr: string, ip: string): boolean {
|
||||
return matchIpCidr(cidr, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IP address to a numeric value
|
||||
* @deprecated Use the ipToNumber function from route-utils.js instead
|
||||
*/
|
||||
private ipToNumber(ip: string): number {
|
||||
return ipToNumber(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the route configuration and return any warnings
|
||||
*/
|
||||
public validateConfiguration(): string[] {
|
||||
const warnings: string[] = [];
|
||||
const duplicatePorts = new Map<number, number>();
|
||||
|
||||
// Check for routes with the same exact match criteria
|
||||
for (let i = 0; i < this.routes.length; i++) {
|
||||
for (let j = i + 1; j < this.routes.length; j++) {
|
||||
const route1 = this.routes[i];
|
||||
const route2 = this.routes[j];
|
||||
|
||||
// Check if route match criteria are the same
|
||||
if (this.areMatchesSimilar(route1.match, route2.match)) {
|
||||
warnings.push(
|
||||
`Routes "${route1.name || i}" and "${route2.name || j}" have similar match criteria. ` +
|
||||
`The route with higher priority (${Math.max(route1.priority || 0, route2.priority || 0)}) will be used.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for routes that may never be matched due to priority
|
||||
for (let i = 0; i < this.routes.length; i++) {
|
||||
const route = this.routes[i];
|
||||
const higherPriorityRoutes = this.routes.filter(r =>
|
||||
(r.priority || 0) > (route.priority || 0));
|
||||
|
||||
for (const higherRoute of higherPriorityRoutes) {
|
||||
if (this.isRouteShadowed(route, higherRoute)) {
|
||||
warnings.push(
|
||||
`Route "${route.name || i}" may never be matched because it is shadowed by ` +
|
||||
`higher priority route "${higherRoute.name || 'unnamed'}"`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two route matches are similar (potential conflict)
|
||||
*/
|
||||
private areMatchesSimilar(match1: IRouteMatch, match2: IRouteMatch): boolean {
|
||||
// Check port overlap
|
||||
const ports1 = new Set(this.expandPortRange(match1.ports));
|
||||
const ports2 = new Set(this.expandPortRange(match2.ports));
|
||||
|
||||
let havePortOverlap = false;
|
||||
for (const port of ports1) {
|
||||
if (ports2.has(port)) {
|
||||
havePortOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!havePortOverlap) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check domain overlap
|
||||
if (match1.domains && match2.domains) {
|
||||
const domains1 = Array.isArray(match1.domains) ? match1.domains : [match1.domains];
|
||||
const domains2 = Array.isArray(match2.domains) ? match2.domains : [match2.domains];
|
||||
|
||||
// Check if any domain pattern from match1 could match any from match2
|
||||
let haveDomainOverlap = false;
|
||||
for (const domain1 of domains1) {
|
||||
for (const domain2 of domains2) {
|
||||
if (domain1 === domain2 ||
|
||||
(domain1.includes('*') || domain2.includes('*'))) {
|
||||
haveDomainOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (haveDomainOverlap) break;
|
||||
}
|
||||
|
||||
if (!haveDomainOverlap) {
|
||||
return false;
|
||||
}
|
||||
} else if (match1.domains || match2.domains) {
|
||||
// One has domains, the other doesn't - they could overlap
|
||||
// The one with domains is more specific, so it's not exactly a conflict
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check path overlap
|
||||
if (match1.path && match2.path) {
|
||||
// This is a simplified check - in a real implementation,
|
||||
// you'd need to check if the path patterns could match the same paths
|
||||
return match1.path === match2.path ||
|
||||
match1.path.includes('*') ||
|
||||
match2.path.includes('*');
|
||||
} else if (match1.path || match2.path) {
|
||||
// One has a path, the other doesn't
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we get here, the matches have significant overlap
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is completely shadowed by a higher priority route
|
||||
*/
|
||||
private isRouteShadowed(route: IRouteConfig, higherPriorityRoute: IRouteConfig): boolean {
|
||||
// If they don't have similar match criteria, no shadowing occurs
|
||||
if (!this.areMatchesSimilar(route.match, higherPriorityRoute.match)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If higher priority route has more specific criteria, no shadowing
|
||||
const routeSpecificity = calculateRouteSpecificity(route.match);
|
||||
const higherRouteSpecificity = calculateRouteSpecificity(higherPriorityRoute.match);
|
||||
|
||||
if (higherRouteSpecificity > routeSpecificity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If higher priority route is equally or less specific but has higher priority,
|
||||
// it shadows the lower priority route
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route1 is more specific than route2
|
||||
* @deprecated Use the calculateRouteSpecificity function from route-utils.js instead
|
||||
*/
|
||||
private isRouteMoreSpecific(match1: IRouteMatch, match2: IRouteMatch): boolean {
|
||||
return calculateRouteSpecificity(match1) > calculateRouteSpecificity(match2);
|
||||
}
|
||||
}
|
293
ts/core/utils/route-utils.ts
Normal file
293
ts/core/utils/route-utils.ts
Normal file
@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Route matching utilities for SmartProxy components
|
||||
*
|
||||
* Contains shared logic for domain matching, path matching, and IP matching
|
||||
* to be used by different proxy components throughout the system.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Match a domain pattern against a domain
|
||||
*
|
||||
* @param pattern Domain pattern with optional wildcards (e.g., "*.example.com")
|
||||
* @param domain Domain to match against the pattern
|
||||
* @returns Whether the domain matches the pattern
|
||||
*/
|
||||
export function matchDomain(pattern: string, domain: string): boolean {
|
||||
// Handle exact match
|
||||
if (pattern === domain) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle wildcard pattern
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
return regex.test(domain);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match domains from a route against a given domain
|
||||
*
|
||||
* @param domains Array or single domain pattern to match against
|
||||
* @param domain Domain to match
|
||||
* @returns Whether the domain matches any of the patterns
|
||||
*/
|
||||
export function matchRouteDomain(domains: string | string[] | undefined, domain: string | undefined): boolean {
|
||||
// If no domains specified in the route, match all domains
|
||||
if (!domains) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no domain in the request, can't match domain-specific routes
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const patterns = Array.isArray(domains) ? domains : [domains];
|
||||
return patterns.some(pattern => matchDomain(pattern, domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path pattern against a path
|
||||
*
|
||||
* @param pattern Path pattern with optional wildcards
|
||||
* @param path Path to match against the pattern
|
||||
* @returns Whether the path matches the pattern
|
||||
*/
|
||||
export function matchPath(pattern: string, path: string): boolean {
|
||||
// Handle exact match
|
||||
if (pattern === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle simple wildcard at the end (like /api/*)
|
||||
if (pattern.endsWith('*')) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
// Handle more complex wildcard patterns
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*') // Convert * to .*
|
||||
.replace(/\//g, '\\/'); // Escape slashes
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CIDR notation into subnet and mask bits
|
||||
*
|
||||
* @param cidr CIDR string (e.g., "192.168.1.0/24")
|
||||
* @returns Object with subnet and bits, or null if invalid
|
||||
*/
|
||||
export function parseCidr(cidr: string): { subnet: string; bits: number } | null {
|
||||
try {
|
||||
const [subnet, bitsStr] = cidr.split('/');
|
||||
const bits = parseInt(bitsStr, 10);
|
||||
|
||||
if (isNaN(bits) || bits < 0 || bits > 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { subnet, bits };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IP address to a numeric value
|
||||
*
|
||||
* @param ip IPv4 address string (e.g., "192.168.1.1")
|
||||
* @returns Numeric representation of the IP
|
||||
*/
|
||||
export function ipToNumber(ip: string): number {
|
||||
// Handle IPv6-mapped IPv4 addresses (::ffff:192.168.1.1)
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7);
|
||||
}
|
||||
|
||||
const parts = ip.split('.').map(part => parseInt(part, 10));
|
||||
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a CIDR pattern
|
||||
*
|
||||
* @param cidr CIDR pattern (e.g., "192.168.1.0/24")
|
||||
* @param ip IP to match against the pattern
|
||||
* @returns Whether the IP is in the CIDR range
|
||||
*/
|
||||
export function matchIpCidr(cidr: string, ip: string): boolean {
|
||||
const parsed = parseCidr(cidr);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { subnet, bits } = parsed;
|
||||
|
||||
// Convert IP addresses to numeric values
|
||||
const ipNum = ipToNumber(ip);
|
||||
const subnetNum = ipToNumber(subnet);
|
||||
|
||||
// Calculate subnet mask
|
||||
const maskNum = ~(2 ** (32 - bits) - 1);
|
||||
|
||||
// Check if IP is in subnet
|
||||
return (ipNum & maskNum) === (subnetNum & maskNum);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP pattern against an IP
|
||||
*
|
||||
* @param pattern IP pattern (exact, CIDR, or with wildcards)
|
||||
* @param ip IP to match against the pattern
|
||||
* @returns Whether the IP matches the pattern
|
||||
*/
|
||||
export function matchIpPattern(pattern: string, ip: string): boolean {
|
||||
// Handle exact match
|
||||
if (pattern === ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle "all" wildcard
|
||||
if (pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle CIDR notation (e.g., 192.168.1.0/24)
|
||||
if (pattern.includes('/')) {
|
||||
return matchIpCidr(pattern, ip);
|
||||
}
|
||||
|
||||
// Handle glob pattern (e.g., 192.168.1.*)
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(ip);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against allowed and blocked IP patterns
|
||||
*
|
||||
* @param ip IP to check
|
||||
* @param allowedIps Array of allowed IP patterns
|
||||
* @param blockedIps Array of blocked IP patterns
|
||||
* @returns Whether the IP is allowed
|
||||
*/
|
||||
export function isIpAuthorized(
|
||||
ip: string,
|
||||
allowedIps: string[] = ['*'],
|
||||
blockedIps: string[] = []
|
||||
): boolean {
|
||||
// Check blocked IPs first
|
||||
if (blockedIps.length > 0) {
|
||||
for (const pattern of blockedIps) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
return false; // IP is blocked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are allowed IPs, check them
|
||||
if (allowedIps.length > 0) {
|
||||
// Special case: if '*' is in allowed IPs, all non-blocked IPs are allowed
|
||||
if (allowedIps.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const pattern of allowedIps) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
return true; // IP is allowed
|
||||
}
|
||||
}
|
||||
return false; // IP not in allowed list
|
||||
}
|
||||
|
||||
// No allowed IPs specified, so IP is allowed by default
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an HTTP header pattern against a header value
|
||||
*
|
||||
* @param pattern Expected header value (string or RegExp)
|
||||
* @param value Actual header value
|
||||
* @returns Whether the header matches the pattern
|
||||
*/
|
||||
export function matchHeader(pattern: string | RegExp, value: string): boolean {
|
||||
if (typeof pattern === 'string') {
|
||||
return pattern === value;
|
||||
} else if (pattern instanceof RegExp) {
|
||||
return pattern.test(value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate route specificity score
|
||||
* Higher score means more specific matching criteria
|
||||
*
|
||||
* @param match Match criteria to evaluate
|
||||
* @returns Numeric specificity score
|
||||
*/
|
||||
export function calculateRouteSpecificity(match: {
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
clientIp?: string[];
|
||||
tlsVersion?: string[];
|
||||
headers?: Record<string, string | RegExp>;
|
||||
}): number {
|
||||
let score = 0;
|
||||
|
||||
// Path is very specific
|
||||
if (match.path) {
|
||||
// More specific if it doesn't use wildcards
|
||||
score += match.path.includes('*') ? 3 : 4;
|
||||
}
|
||||
|
||||
// Domain is next most specific
|
||||
if (match.domains) {
|
||||
const domains = Array.isArray(match.domains) ? match.domains : [match.domains];
|
||||
// More domains or more specific domains (without wildcards) increase specificity
|
||||
score += domains.length;
|
||||
// Add bonus for exact domains (without wildcards)
|
||||
score += domains.some(d => !d.includes('*')) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Headers are quite specific
|
||||
if (match.headers) {
|
||||
score += Object.keys(match.headers).length * 2;
|
||||
}
|
||||
|
||||
// Client IP adds some specificity
|
||||
if (match.clientIp && match.clientIp.length > 0) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// TLS version adds minimal specificity
|
||||
if (match.tlsVersion && match.tlsVersion.length > 0) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
309
ts/core/utils/security-utils.ts
Normal file
309
ts/core/utils/security-utils.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
matchIpPattern,
|
||||
ipToNumber,
|
||||
matchIpCidr
|
||||
} from './route-utils.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 (matchIpPattern(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 (matchIpPattern(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;
|
||||
}
|
||||
}
|
333
ts/core/utils/shared-security-manager.ts
Normal file
333
ts/core/utils/shared-security-manager.ts
Normal file
@ -0,0 +1,333 @@
|
||||
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
|
||||
} 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 {
|
||||
return this.connectionsByIP.get(ip)?.connections.size || 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 {
|
||||
trackConnection(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 {
|
||||
removeConnection(ip, connectionId, this.connectionsByIP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client is allowed to access a specific route
|
||||
*
|
||||
* @param route - The route to check
|
||||
* @param context - The request context
|
||||
* @returns Whether access is allowed
|
||||
*/
|
||||
public isAllowed(route: IRouteConfig, context: IRouteContext): 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;
|
||||
}
|
||||
|
||||
// --- 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 || route.security.allowedIps;
|
||||
const ipBlockList = route.security.ipBlockList || route.security.blockedIps;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up caches to prevent memory leaks
|
||||
*/
|
||||
private cleanupCaches(): void {
|
||||
// Clean up rate limits
|
||||
cleanupExpiredRateLimits(this.rateLimits, this.logger);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
124
ts/core/utils/template-utils.ts
Normal file
124
ts/core/utils/template-utils.ts
Normal file
@ -0,0 +1,124 @@
|
||||
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);
|
||||
}
|
||||
}
|
81
ts/core/utils/websocket-utils.ts
Normal file
81
ts/core/utils/websocket-utils.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* WebSocket utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type for WebSocket RawData that can be different types in different environments
|
||||
* This matches the ws library's type definition
|
||||
*/
|
||||
export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
|
||||
|
||||
/**
|
||||
* 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: RawData): number {
|
||||
if (typeof data === 'string') {
|
||||
// For string data, get the byte length
|
||||
return Buffer.from(data, 'utf8').length;
|
||||
} else if (data instanceof Buffer) {
|
||||
// For Node.js Buffer
|
||||
return data.length;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
// For ArrayBuffer
|
||||
return data.byteLength;
|
||||
} else if (Array.isArray(data)) {
|
||||
// For array of buffers, sum their lengths
|
||||
return data.reduce((sum, chunk) => {
|
||||
if (chunk instanceof Buffer) {
|
||||
return sum + chunk.length;
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
return sum + chunk.byteLength;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
} else {
|
||||
// For other types, try to determine the size or return 0
|
||||
try {
|
||||
return Buffer.from(data).length;
|
||||
} catch (e) {
|
||||
console.warn('Could not determine message size', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: RawData): Buffer {
|
||||
if (typeof data === 'string') {
|
||||
return Buffer.from(data, 'utf8');
|
||||
} else if (data instanceof Buffer) {
|
||||
return data;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data);
|
||||
} else if (Array.isArray(data)) {
|
||||
// For array of buffers, concatenate them
|
||||
return Buffer.concat(data.map(chunk => {
|
||||
if (chunk instanceof Buffer) {
|
||||
return chunk;
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
return Buffer.from(chunk);
|
||||
}
|
||||
return Buffer.from(chunk);
|
||||
}));
|
||||
} else {
|
||||
// For other types, try to convert to Buffer or return empty Buffer
|
||||
try {
|
||||
return Buffer.from(data);
|
||||
} catch (e) {
|
||||
console.warn('Could not convert message to Buffer', e);
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user