fix(metrics): improve metrics

This commit is contained in:
Juergen Kunz
2025-06-22 22:28:37 +00:00
parent de1269665a
commit 131a454b28
16 changed files with 1389 additions and 502 deletions

View File

@ -1,11 +1,10 @@
import * as plugins from '../../plugins.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import { SecurityManager } from './security-manager.js';
import { TimeoutManager } from './timeout-manager.js';
import type { IConnectionRecord } from './models/interfaces.js';
import { logger } from '../../core/utils/logger.js';
import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
import { cleanupSocket } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* Manages connection lifecycle, tracking, and cleanup with performance optimizations
@ -29,17 +28,15 @@ export class ConnectionManager extends LifecycleComponent {
private cleanupTimer: NodeJS.Timeout | null = null;
constructor(
private settings: ISmartProxyOptions,
private securityManager: SecurityManager,
private timeoutManager: TimeoutManager
private smartProxy: SmartProxy
) {
super();
// Set reasonable defaults for connection limits
this.maxConnections = settings.defaults?.security?.maxConnections || 10000;
this.maxConnections = smartProxy.settings.defaults?.security?.maxConnections || 10000;
// Start inactivity check timer if not disabled
if (!settings.disableInactivityCheck) {
if (!smartProxy.settings.disableInactivityCheck) {
this.startInactivityCheckTimer();
}
}
@ -108,10 +105,10 @@ export class ConnectionManager extends LifecycleComponent {
*/
public trackConnection(connectionId: string, record: IConnectionRecord): void {
this.connectionRecords.set(connectionId, record);
this.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
this.smartProxy.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
// Schedule inactivity check
if (!this.settings.disableInactivityCheck) {
if (!this.smartProxy.settings.disableInactivityCheck) {
this.scheduleInactivityCheck(connectionId, record);
}
}
@ -120,14 +117,14 @@ export class ConnectionManager extends LifecycleComponent {
* Schedule next inactivity check for a connection
*/
private scheduleInactivityCheck(connectionId: string, record: IConnectionRecord): void {
let timeout = this.settings.inactivityTimeout!;
let timeout = this.smartProxy.settings.inactivityTimeout!;
if (record.hasKeepAlive) {
if (this.settings.keepAliveTreatment === 'immortal') {
if (this.smartProxy.settings.keepAliveTreatment === 'immortal') {
// Don't schedule check for immortal connections
return;
} else if (this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
} else if (this.smartProxy.settings.keepAliveTreatment === 'extended') {
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
timeout = timeout * multiplier;
}
}
@ -172,7 +169,7 @@ export class ConnectionManager extends LifecycleComponent {
* Initiates cleanup once for a connection
*/
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Connection cleanup initiated`, {
connectionId: record.id,
remoteIP: record.remoteIP,
@ -253,7 +250,12 @@ export class ConnectionManager extends LifecycleComponent {
this.nextInactivityCheck.delete(record.id);
// Track connection termination
this.securityManager.removeConnectionByIP(record.remoteIP, record.id);
this.smartProxy.securityManager.removeConnectionByIP(record.remoteIP, record.id);
// Remove from metrics tracking
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.removeConnection(record.id);
}
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
@ -334,7 +336,7 @@ export class ConnectionManager extends LifecycleComponent {
this.connectionRecords.delete(record.id);
// Log connection details
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info',
`Connection terminated: ${record.remoteIP}:${record.localPort} (${reason}) - ` +
`${plugins.prettyMs(duration)}, IN: ${record.bytesReceived}B, OUT: ${record.bytesSent}B`,
@ -414,7 +416,7 @@ export class ConnectionManager extends LifecycleComponent {
*/
public handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
return () => {
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Connection closed on ${side} side`, {
connectionId: record.id,
side,
@ -553,9 +555,9 @@ export class ConnectionManager extends LifecycleComponent {
const inactivityTime = now - record.lastActivity;
// Use extended timeout for extended-treatment keep-alive connections
let effectiveTimeout = this.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
let effectiveTimeout = this.smartProxy.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}

View File

@ -1,14 +1,15 @@
import * as plugins from '../../plugins.js';
import { HttpProxy } from '../http-proxy/index.js';
import { setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import type { IConnectionRecord } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
import type { SmartProxy } from './smart-proxy.js';
export class HttpProxyBridge {
private httpProxy: HttpProxy | null = null;
constructor(private settings: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {}
/**
* Get the HttpProxy instance
@ -21,18 +22,18 @@ export class HttpProxyBridge {
* Initialize HttpProxy instance
*/
public async initialize(): Promise<void> {
if (!this.httpProxy && this.settings.useHttpProxy && this.settings.useHttpProxy.length > 0) {
if (!this.httpProxy && this.smartProxy.settings.useHttpProxy && this.smartProxy.settings.useHttpProxy.length > 0) {
const httpProxyOptions: any = {
port: this.settings.httpProxyPort!,
port: this.smartProxy.settings.httpProxyPort!,
portProxyIntegration: true,
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info'
logLevel: this.smartProxy.settings.enableDetailedLogging ? 'debug' : 'info'
};
this.httpProxy = new HttpProxy(httpProxyOptions);
console.log(`Initialized HttpProxy on port ${this.settings.httpProxyPort}`);
console.log(`Initialized HttpProxy on port ${this.smartProxy.settings.httpProxyPort}`);
// Apply route configurations to HttpProxy
await this.syncRoutesToHttpProxy(this.settings.routes || []);
await this.syncRoutesToHttpProxy(this.smartProxy.settings.routes || []);
}
}
@ -51,7 +52,7 @@ export class HttpProxyBridge {
: [route.match.ports];
return routePorts.some(port =>
this.settings.useHttpProxy?.includes(port)
this.smartProxy.settings.useHttpProxy?.includes(port)
);
})
.map(route => this.routeToHttpProxyConfig(route));

View File

@ -1,258 +1,341 @@
import * as plugins from '../../plugins.js';
import type { SmartProxy } from './smart-proxy.js';
import type { IProxyStats, IProxyStatsExtended } from './models/metrics-types.js';
import type {
IMetrics,
IThroughputData,
IThroughputHistoryPoint,
IByteTracker
} from './models/metrics-types.js';
import { ThroughputTracker } from './throughput-tracker.js';
import { logger } from '../../core/utils/logger.js';
/**
* Collects and computes metrics for SmartProxy on-demand
* Collects and provides metrics for SmartProxy with clean API
*/
export class MetricsCollector implements IProxyStatsExtended {
// RPS tracking (the only state we need to maintain)
export class MetricsCollector implements IMetrics {
// Throughput tracking
private throughputTracker: ThroughputTracker;
// Request tracking
private requestTimestamps: number[] = [];
private readonly RPS_WINDOW_SIZE = 60000; // 1 minute window
private readonly MAX_TIMESTAMPS = 5000; // Maximum timestamps to keep
private totalRequests: number = 0;
// Optional caching for performance
private cachedMetrics: {
timestamp: number;
connectionsByRoute?: Map<string, number>;
connectionsByIP?: Map<string, number>;
} = { timestamp: 0 };
// Connection byte tracking for per-route/IP metrics
private connectionByteTrackers = new Map<string, IByteTracker>();
private readonly CACHE_TTL = 1000; // 1 second cache
// RxJS subscription for connection events
// Subscriptions
private samplingInterval?: NodeJS.Timeout;
private connectionSubscription?: plugins.smartrx.rxjs.Subscription;
// Configuration
private readonly sampleIntervalMs: number;
private readonly retentionSeconds: number;
constructor(
private smartProxy: SmartProxy
private smartProxy: SmartProxy,
config?: {
sampleIntervalMs?: number;
retentionSeconds?: number;
}
) {
// Subscription will be set up in start() method
this.sampleIntervalMs = config?.sampleIntervalMs || 1000;
this.retentionSeconds = config?.retentionSeconds || 3600;
this.throughputTracker = new ThroughputTracker(this.retentionSeconds);
}
/**
* Get the current number of active connections
*/
public getActiveConnections(): number {
return this.smartProxy.connectionManager.getConnectionCount();
}
/**
* Get connection counts grouped by route name
*/
public getConnectionsByRoute(): Map<string, number> {
const now = Date.now();
// Connection metrics implementation
public connections = {
active: (): number => {
return this.smartProxy.connectionManager.getConnectionCount();
},
// Return cached value if fresh
if (this.cachedMetrics.connectionsByRoute &&
now - this.cachedMetrics.timestamp < this.CACHE_TTL) {
return new Map(this.cachedMetrics.connectionsByRoute);
}
// Compute fresh value
const routeCounts = new Map<string, number>();
const connections = this.smartProxy.connectionManager.getConnections();
if (this.smartProxy.settings?.enableDetailedLogging) {
logger.log('debug', `MetricsCollector: Computing route connections`, {
totalConnections: connections.size,
component: 'metrics'
});
}
for (const [_, record] of connections) {
// Try different ways to get the route name
const routeName = (record as any).routeName ||
record.routeConfig?.name ||
(record.routeConfig as any)?.routeName ||
'unknown';
total: (): number => {
const stats = this.smartProxy.connectionManager.getTerminationStats();
let total = this.smartProxy.connectionManager.getConnectionCount();
if (this.smartProxy.settings?.enableDetailedLogging) {
logger.log('debug', `MetricsCollector: Connection route info`, {
connectionId: record.id,
routeName,
hasRouteConfig: !!record.routeConfig,
routeConfigName: record.routeConfig?.name,
routeConfigKeys: record.routeConfig ? Object.keys(record.routeConfig) : [],
component: 'metrics'
for (const reason in stats.incoming) {
total += stats.incoming[reason];
}
return total;
},
byRoute: (): Map<string, number> => {
const routeCounts = new Map<string, number>();
const connections = this.smartProxy.connectionManager.getConnections();
for (const [_, record] of connections) {
const routeName = (record as any).routeName ||
record.routeConfig?.name ||
'unknown';
const current = routeCounts.get(routeName) || 0;
routeCounts.set(routeName, current + 1);
}
return routeCounts;
},
byIP: (): Map<string, number> => {
const ipCounts = new Map<string, number>();
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
const ip = record.remoteIP;
const current = ipCounts.get(ip) || 0;
ipCounts.set(ip, current + 1);
}
return ipCounts;
},
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
const ipCounts = this.connections.byIP();
return Array.from(ipCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, count]) => ({ ip, count }));
}
};
// Throughput metrics implementation
public throughput = {
instant: (): IThroughputData => {
return this.throughputTracker.getRate(1);
},
recent: (): IThroughputData => {
return this.throughputTracker.getRate(10);
},
average: (): IThroughputData => {
return this.throughputTracker.getRate(60);
},
custom: (seconds: number): IThroughputData => {
return this.throughputTracker.getRate(seconds);
},
history: (seconds: number): Array<IThroughputHistoryPoint> => {
return this.throughputTracker.getHistory(seconds);
},
byRoute: (windowSeconds: number = 60): Map<string, IThroughputData> => {
const routeThroughput = new Map<string, IThroughputData>();
const now = Date.now();
const windowStart = now - (windowSeconds * 1000);
// Aggregate bytes by route from trackers
const routeBytes = new Map<string, { in: number; out: number }>();
for (const [_, tracker] of this.connectionByteTrackers) {
if (tracker.lastUpdate > windowStart) {
const current = routeBytes.get(tracker.routeName) || { in: 0, out: 0 };
current.in += tracker.bytesIn;
current.out += tracker.bytesOut;
routeBytes.set(tracker.routeName, current);
}
}
// Convert to rates
for (const [route, bytes] of routeBytes) {
routeThroughput.set(route, {
in: Math.round(bytes.in / windowSeconds),
out: Math.round(bytes.out / windowSeconds)
});
}
const current = routeCounts.get(routeName) || 0;
routeCounts.set(routeName, current + 1);
}
return routeThroughput;
},
// Cache and return
this.cachedMetrics.connectionsByRoute = routeCounts;
this.cachedMetrics.timestamp = now;
return new Map(routeCounts);
}
byIP: (windowSeconds: number = 60): Map<string, IThroughputData> => {
const ipThroughput = new Map<string, IThroughputData>();
const now = Date.now();
const windowStart = now - (windowSeconds * 1000);
// Aggregate bytes by IP from trackers
const ipBytes = new Map<string, { in: number; out: number }>();
for (const [_, tracker] of this.connectionByteTrackers) {
if (tracker.lastUpdate > windowStart) {
const current = ipBytes.get(tracker.remoteIP) || { in: 0, out: 0 };
current.in += tracker.bytesIn;
current.out += tracker.bytesOut;
ipBytes.set(tracker.remoteIP, current);
}
}
// Convert to rates
for (const [ip, bytes] of ipBytes) {
ipThroughput.set(ip, {
in: Math.round(bytes.in / windowSeconds),
out: Math.round(bytes.out / windowSeconds)
});
}
return ipThroughput;
}
};
// Request metrics implementation
public requests = {
perSecond: (): number => {
const now = Date.now();
const oneSecondAgo = now - 1000;
// Clean old timestamps
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > now - 60000);
// Count requests in last second
const recentRequests = this.requestTimestamps.filter(ts => ts > oneSecondAgo);
return recentRequests.length;
},
perMinute: (): number => {
const now = Date.now();
const oneMinuteAgo = now - 60000;
// Count requests in last minute
const recentRequests = this.requestTimestamps.filter(ts => ts > oneMinuteAgo);
return recentRequests.length;
},
total: (): number => {
return this.totalRequests;
}
};
// Totals implementation
public totals = {
bytesIn: (): number => {
let total = 0;
// Sum from all active connections
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
total += record.bytesReceived;
}
// TODO: Add historical data from terminated connections
return total;
},
bytesOut: (): number => {
let total = 0;
// Sum from all active connections
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
total += record.bytesSent;
}
// TODO: Add historical data from terminated connections
return total;
},
connections: (): number => {
return this.connections.total();
}
};
// Percentiles implementation (placeholder for now)
public percentiles = {
connectionDuration: (): { p50: number; p95: number; p99: number } => {
// TODO: Implement percentile calculations
return { p50: 0, p95: 0, p99: 0 };
},
bytesTransferred: (): {
in: { p50: number; p95: number; p99: number };
out: { p50: number; p95: number; p99: number };
} => {
// TODO: Implement percentile calculations
return {
in: { p50: 0, p95: 0, p99: 0 },
out: { p50: 0, p95: 0, p99: 0 }
};
}
};
/**
* Get connection counts grouped by IP address
* Record a new request
*/
public getConnectionsByIP(): Map<string, number> {
const now = Date.now();
// Return cached value if fresh
if (this.cachedMetrics.connectionsByIP &&
now - this.cachedMetrics.timestamp < this.CACHE_TTL) {
return new Map(this.cachedMetrics.connectionsByIP);
}
// Compute fresh value
const ipCounts = new Map<string, number>();
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
const ip = record.remoteIP;
const current = ipCounts.get(ip) || 0;
ipCounts.set(ip, current + 1);
}
// Cache and return
this.cachedMetrics.connectionsByIP = ipCounts;
this.cachedMetrics.timestamp = now;
return new Map(ipCounts);
}
/**
* Get the total number of connections since proxy start
*/
public getTotalConnections(): number {
// Get from termination stats
const stats = this.smartProxy.connectionManager.getTerminationStats();
let total = this.smartProxy.connectionManager.getConnectionCount(); // Add active connections
// Add all terminated connections
for (const reason in stats.incoming) {
total += stats.incoming[reason];
}
return total;
}
/**
* Get the current requests per second rate
*/
public getRequestsPerSecond(): number {
const now = Date.now();
const windowStart = now - this.RPS_WINDOW_SIZE;
// Clean old timestamps
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > windowStart);
// Calculate RPS based on window
const requestsInWindow = this.requestTimestamps.length;
return requestsInWindow / (this.RPS_WINDOW_SIZE / 1000);
}
/**
* Record a new request for RPS tracking
*/
public recordRequest(): void {
public recordRequest(connectionId: string, routeName: string, remoteIP: string): void {
const now = Date.now();
this.requestTimestamps.push(now);
this.totalRequests++;
// Prevent unbounded growth - clean up more aggressively
if (this.requestTimestamps.length > this.MAX_TIMESTAMPS) {
// Keep only timestamps within the window
const cutoff = now - this.RPS_WINDOW_SIZE;
// Initialize byte tracker for this connection
this.connectionByteTrackers.set(connectionId, {
connectionId,
routeName,
remoteIP,
bytesIn: 0,
bytesOut: 0,
lastUpdate: now
});
// Cleanup old request timestamps (keep last minute only)
if (this.requestTimestamps.length > 1000) {
const cutoff = now - 60000;
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > cutoff);
}
}
/**
* Get total throughput (bytes transferred)
* Record bytes transferred for a connection
*/
public getThroughput(): { bytesIn: number; bytesOut: number } {
let bytesIn = 0;
let bytesOut = 0;
public recordBytes(connectionId: string, bytesIn: number, bytesOut: number): void {
// Update global throughput tracker
this.throughputTracker.recordBytes(bytesIn, bytesOut);
// Sum bytes from all active connections
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
bytesIn += record.bytesReceived;
bytesOut += record.bytesSent;
// Update connection-specific tracker
const tracker = this.connectionByteTrackers.get(connectionId);
if (tracker) {
tracker.bytesIn += bytesIn;
tracker.bytesOut += bytesOut;
tracker.lastUpdate = Date.now();
}
return { bytesIn, bytesOut };
}
/**
* Get throughput rate (bytes per second) for last minute
* Clean up tracking for a closed connection
*/
public getThroughputRate(): { bytesInPerSec: number; bytesOutPerSec: number } {
const now = Date.now();
let recentBytesIn = 0;
let recentBytesOut = 0;
// Calculate bytes transferred in last minute from active connections
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
const connectionAge = now - record.incomingStartTime;
if (connectionAge < 60000) { // Connection started within last minute
recentBytesIn += record.bytesReceived;
recentBytesOut += record.bytesSent;
} else {
// For older connections, estimate rate based on average
const rate = connectionAge / 60000;
recentBytesIn += record.bytesReceived / rate;
recentBytesOut += record.bytesSent / rate;
}
}
return {
bytesInPerSec: Math.round(recentBytesIn / 60),
bytesOutPerSec: Math.round(recentBytesOut / 60)
};
public removeConnection(connectionId: string): void {
this.connectionByteTrackers.delete(connectionId);
}
/**
* Get top IPs by connection count
*/
public getTopIPs(limit: number = 10): Array<{ ip: string; connections: number }> {
const ipCounts = this.getConnectionsByIP();
const sorted = Array.from(ipCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, connections]) => ({ ip, connections }));
return sorted;
}
/**
* Check if an IP has reached the connection limit
*/
public isIPBlocked(ip: string, maxConnectionsPerIP: number): boolean {
const ipCounts = this.getConnectionsByIP();
const currentConnections = ipCounts.get(ip) || 0;
return currentConnections >= maxConnectionsPerIP;
}
/**
* Clean up old request timestamps
*/
private cleanupOldRequests(): void {
const cutoff = Date.now() - this.RPS_WINDOW_SIZE;
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > cutoff);
}
/**
* Start the metrics collector and set up subscriptions
* Start the metrics collector
*/
public start(): void {
if (!this.smartProxy.routeConnectionHandler) {
throw new Error('MetricsCollector: RouteConnectionHandler not available');
}
// Subscribe to the newConnectionSubject from RouteConnectionHandler
// Start periodic sampling
this.samplingInterval = setInterval(() => {
this.throughputTracker.takeSample();
// Clean up old connection trackers (connections closed more than 5 minutes ago)
const cutoff = Date.now() - 300000;
for (const [id, tracker] of this.connectionByteTrackers) {
if (tracker.lastUpdate < cutoff) {
this.connectionByteTrackers.delete(id);
}
}
}, this.sampleIntervalMs);
// Subscribe to new connections
this.connectionSubscription = this.smartProxy.routeConnectionHandler.newConnectionSubject.subscribe({
next: (record) => {
this.recordRequest();
const routeName = record.routeConfig?.name || 'unknown';
this.recordRequest(record.id, routeName, record.remoteIP);
// Optional: Log connection details
if (this.smartProxy.settings?.enableDetailedLogging) {
logger.log('debug', `MetricsCollector: New connection recorded`, {
connectionId: record.id,
remoteIP: record.remoteIP,
routeName: record.routeConfig?.name || 'unknown',
routeName,
component: 'metrics'
});
}
@ -269,9 +352,14 @@ export class MetricsCollector implements IProxyStatsExtended {
}
/**
* Stop the metrics collector and clean up resources
* Stop the metrics collector
*/
public stop(): void {
if (this.samplingInterval) {
clearInterval(this.samplingInterval);
this.samplingInterval = undefined;
}
if (this.connectionSubscription) {
this.connectionSubscription.unsubscribe();
this.connectionSubscription = undefined;
@ -281,7 +369,7 @@ export class MetricsCollector implements IProxyStatsExtended {
}
/**
* Alias for stop() for backward compatibility
* Alias for stop() for compatibility
*/
public destroy(): void {
this.stop();

View File

@ -1,54 +1,106 @@
/**
* Interface for proxy statistics and metrics
* Interface for throughput sample data
*/
export interface IProxyStats {
/**
* Get the current number of active connections
*/
getActiveConnections(): number;
/**
* Get connection counts grouped by route name
*/
getConnectionsByRoute(): Map<string, number>;
/**
* Get connection counts grouped by IP address
*/
getConnectionsByIP(): Map<string, number>;
/**
* Get the total number of connections since proxy start
*/
getTotalConnections(): number;
/**
* Get the current requests per second rate
*/
getRequestsPerSecond(): number;
/**
* Get total throughput (bytes transferred)
*/
getThroughput(): { bytesIn: number; bytesOut: number };
export interface IThroughputSample {
timestamp: number;
bytesIn: number;
bytesOut: number;
}
/**
* Extended interface for additional metrics helpers
* Interface for throughput data
*/
export interface IProxyStatsExtended extends IProxyStats {
/**
* Get throughput rate (bytes per second) for last minute
*/
getThroughputRate(): { bytesInPerSec: number; bytesOutPerSec: number };
export interface IThroughputData {
in: number;
out: number;
}
/**
* Interface for time-series throughput data
*/
export interface IThroughputHistoryPoint {
timestamp: number;
in: number;
out: number;
}
/**
* Main metrics interface with clean, grouped API
*/
export interface IMetrics {
// Connection metrics
connections: {
active(): number;
total(): number;
byRoute(): Map<string, number>;
byIP(): Map<string, number>;
topIPs(limit?: number): Array<{ ip: string; count: number }>;
};
/**
* Get top IPs by connection count
*/
getTopIPs(limit?: number): Array<{ ip: string; connections: number }>;
// Throughput metrics (bytes per second)
throughput: {
instant(): IThroughputData; // Last 1 second
recent(): IThroughputData; // Last 10 seconds
average(): IThroughputData; // Last 60 seconds
custom(seconds: number): IThroughputData;
history(seconds: number): Array<IThroughputHistoryPoint>;
byRoute(windowSeconds?: number): Map<string, IThroughputData>;
byIP(windowSeconds?: number): Map<string, IThroughputData>;
};
/**
* Check if an IP has reached the connection limit
*/
isIPBlocked(ip: string, maxConnectionsPerIP: number): boolean;
// Request metrics
requests: {
perSecond(): number;
perMinute(): number;
total(): number;
};
// Cumulative totals
totals: {
bytesIn(): number;
bytesOut(): number;
connections(): number;
};
// Performance metrics
percentiles: {
connectionDuration(): { p50: number; p95: number; p99: number };
bytesTransferred(): {
in: { p50: number; p95: number; p99: number };
out: { p50: number; p95: number; p99: number };
};
};
}
/**
* Configuration for metrics collection
*/
export interface IMetricsConfig {
enabled: boolean;
// Sampling configuration
sampleIntervalMs: number; // Default: 1000 (1 second)
retentionSeconds: number; // Default: 3600 (1 hour)
// Performance tuning
enableDetailedTracking: boolean; // Per-connection byte history
enablePercentiles: boolean; // Calculate percentiles
cacheResultsMs: number; // Cache expensive calculations
// Export configuration
prometheusEnabled: boolean;
prometheusPath: string; // Default: /metrics
prometheusPrefix: string; // Default: smartproxy_
}
/**
* Internal interface for connection byte tracking
*/
export interface IByteTracker {
connectionId: string;
routeName: string;
remoteIP: string;
bytesIn: number;
bytesOut: number;
lastUpdate: number;
}

View File

@ -10,7 +10,7 @@ import type {
TPortRange,
INfTablesOptions
} from './models/route-types.js';
import type { ISmartProxyOptions } from './models/interfaces.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* Manages NFTables rules based on SmartProxy route configurations
@ -25,9 +25,9 @@ export class NFTablesManager {
/**
* Creates a new NFTablesManager
*
* @param options The SmartProxy options
* @param smartProxy The SmartProxy instance
*/
constructor(private options: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {}
/**
* Provision NFTables rules for a route
@ -166,10 +166,10 @@ export class NFTablesManager {
protocol: action.nftables?.protocol || 'tcp',
preserveSourceIP: action.nftables?.preserveSourceIP !== undefined ?
action.nftables.preserveSourceIP :
this.options.preserveSourceIP,
this.smartProxy.settings.preserveSourceIP,
useIPSets: action.nftables?.useIPSets !== false,
useAdvancedNAT: action.nftables?.useAdvancedNAT,
enableLogging: this.options.enableDetailedLogging,
enableLogging: this.smartProxy.settings.enableDetailedLogging,
deleteOnExit: true,
tableName: action.nftables?.tableName || 'smartproxy'
};

View File

@ -1,8 +1,7 @@
import * as plugins from '../../plugins.js';
import type { ISmartProxyOptions } from './models/interfaces.js';
import { RouteConnectionHandler } from './route-connection-handler.js';
import { logger } from '../../core/utils/logger.js';
import { cleanupSocket } from '../../core/utils/socket-utils.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* PortManager handles the dynamic creation and removal of port listeners
@ -16,8 +15,6 @@ import { cleanupSocket } from '../../core/utils/socket-utils.js';
*/
export class PortManager {
private servers: Map<number, plugins.net.Server> = new Map();
private settings: ISmartProxyOptions;
private routeConnectionHandler: RouteConnectionHandler;
private isShuttingDown: boolean = false;
// Track how many routes are using each port
private portRefCounts: Map<number, number> = new Map();
@ -25,16 +22,11 @@ export class PortManager {
/**
* Create a new PortManager
*
* @param settings The SmartProxy settings
* @param routeConnectionHandler The handler for new connections
* @param smartProxy The SmartProxy instance
*/
constructor(
settings: ISmartProxyOptions,
routeConnectionHandler: RouteConnectionHandler
) {
this.settings = settings;
this.routeConnectionHandler = routeConnectionHandler;
}
private smartProxy: SmartProxy
) {}
/**
* Start listening on a specific port
@ -70,7 +62,7 @@ export class PortManager {
}
// Delegate to route connection handler
this.routeConnectionHandler.handleConnection(socket);
this.smartProxy.routeConnectionHandler.handleConnection(socket);
}).on('error', (err: Error) => {
try {
logger.log('error', `Server Error on port ${port}: ${err.message}`, {
@ -86,7 +78,7 @@ export class PortManager {
// Start listening on the port
return new Promise<void>((resolve, reject) => {
server.listen(port, () => {
const isHttpProxyPort = this.settings.useHttpProxy?.includes(port);
const isHttpProxyPort = this.smartProxy.settings.useHttpProxy?.includes(port);
try {
logger.log('info', `SmartProxy -> OK: Now listening on port ${port}${
isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''

View File

@ -4,23 +4,16 @@ import { logger } from '../../core/utils/logger.js';
// Route checking functions have been removed
import type { IRouteConfig, IRouteAction } from './models/route-types.js';
import type { IRouteContext } from '../../core/models/route-context.js';
import { ConnectionManager } from './connection-manager.js';
import { SecurityManager } from './security-manager.js';
import { TlsManager } from './tls-manager.js';
import { HttpProxyBridge } from './http-proxy-bridge.js';
import { TimeoutManager } from './timeout-manager.js';
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
import { ProxyProtocolParser } from '../../core/utils/proxy-protocol.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* Handles new connection processing and setup logic with support for route-based configuration
*/
export class RouteConnectionHandler {
private settings: ISmartProxyOptions;
// Note: Route context caching was considered but not implemented
// as route contexts are lightweight and should be created fresh
// for each connection to ensure accurate context data
@ -29,16 +22,8 @@ export class RouteConnectionHandler {
public newConnectionSubject = new plugins.smartrx.rxjs.Subject<IConnectionRecord>();
constructor(
settings: ISmartProxyOptions,
private connectionManager: ConnectionManager,
private securityManager: SecurityManager,
private tlsManager: TlsManager,
private httpProxyBridge: HttpProxyBridge,
private timeoutManager: TimeoutManager,
private routeManager: RouteManager
) {
this.settings = settings;
}
private smartProxy: SmartProxy
) {}
/**
@ -93,7 +78,7 @@ export class RouteConnectionHandler {
const wrappedSocket = new WrappedSocket(socket);
// If this is from a trusted proxy, log it
if (this.settings.proxyIPs?.includes(remoteIP)) {
if (this.smartProxy.settings.proxyIPs?.includes(remoteIP)) {
logger.log('debug', `Connection from trusted proxy ${remoteIP}, PROXY protocol parsing will be enabled`, {
remoteIP,
component: 'route-handler'
@ -102,7 +87,7 @@ export class RouteConnectionHandler {
// Validate IP against rate limits and connection limits
// Note: For wrapped sockets, this will use the underlying socket IP until PROXY protocol is parsed
const ipValidation = this.securityManager.validateIP(wrappedSocket.remoteAddress || '');
const ipValidation = this.smartProxy.securityManager.validateIP(wrappedSocket.remoteAddress || '');
if (!ipValidation.allowed) {
logger.log('warn', `Connection rejected`, { remoteIP: wrappedSocket.remoteAddress, reason: ipValidation.reason, component: 'route-handler' });
cleanupSocket(wrappedSocket.socket, `rejected-${ipValidation.reason}`, { immediate: true });
@ -110,7 +95,7 @@ export class RouteConnectionHandler {
}
// Create a new connection record with the wrapped socket
const record = this.connectionManager.createConnection(wrappedSocket);
const record = this.smartProxy.connectionManager.createConnection(wrappedSocket);
if (!record) {
// Connection was rejected due to limit - socket already destroyed by connection manager
return;
@ -122,15 +107,15 @@ export class RouteConnectionHandler {
// Apply socket optimizations (apply to underlying socket)
const underlyingSocket = wrappedSocket.socket;
underlyingSocket.setNoDelay(this.settings.noDelay);
underlyingSocket.setNoDelay(this.smartProxy.settings.noDelay);
// Apply keep-alive settings if enabled
if (this.settings.keepAlive) {
underlyingSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
if (this.smartProxy.settings.keepAlive) {
underlyingSocket.setKeepAlive(true, this.smartProxy.settings.keepAliveInitialDelay);
record.hasKeepAlive = true;
// Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) {
if (this.smartProxy.settings.enableKeepAliveProbes) {
try {
// These are platform-specific and may not be available
if ('setKeepAliveProbes' in underlyingSocket) {
@ -141,34 +126,34 @@ export class RouteConnectionHandler {
}
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('warn', `Enhanced TCP keep-alive settings not supported`, { connectionId, error: err, component: 'route-handler' });
}
}
}
}
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info',
`New connection from ${remoteIP} on port ${localPort}. ` +
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
`Active connections: ${this.connectionManager.getConnectionCount()}`,
`Active connections: ${this.smartProxy.connectionManager.getConnectionCount()}`,
{
connectionId,
remoteIP,
localPort,
keepAlive: record.hasKeepAlive ? 'Enabled' : 'Disabled',
activeConnections: this.connectionManager.getConnectionCount(),
activeConnections: this.smartProxy.connectionManager.getConnectionCount(),
component: 'route-handler'
}
);
} else {
logger.log('info',
`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionManager.getConnectionCount()}`,
`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.smartProxy.connectionManager.getConnectionCount()}`,
{
remoteIP,
localPort,
activeConnections: this.connectionManager.getConnectionCount(),
activeConnections: this.smartProxy.connectionManager.getConnectionCount(),
component: 'route-handler'
}
);
@ -187,10 +172,10 @@ export class RouteConnectionHandler {
let initialDataReceived = false;
// Check if any routes on this port require TLS handling
const allRoutes = this.routeManager.getRoutes();
const allRoutes = this.smartProxy.routeManager.getRoutes();
const needsTlsHandling = allRoutes.some(route => {
// Check if route matches this port
const matchesPort = this.routeManager.getRoutesForPort(localPort).includes(route);
const matchesPort = this.smartProxy.routeManager.getRoutesForPort(localPort).includes(route);
return matchesPort &&
route.action.type === 'forward' &&
@ -229,7 +214,7 @@ export class RouteConnectionHandler {
}
// Always cleanup the connection record
this.connectionManager.cleanupConnection(record, reason);
this.smartProxy.connectionManager.cleanupConnection(record, reason);
},
undefined, // Use default timeout handler
'immediate-route-client'
@ -244,9 +229,9 @@ export class RouteConnectionHandler {
// Set an initial timeout for handshake data
let initialTimeout: NodeJS.Timeout | null = setTimeout(() => {
if (!initialDataReceived) {
logger.log('warn', `No initial data received from ${record.remoteIP} after ${this.settings.initialDataTimeout}ms for connection ${connectionId}`, {
logger.log('warn', `No initial data received from ${record.remoteIP} after ${this.smartProxy.settings.initialDataTimeout}ms for connection ${connectionId}`, {
connectionId,
timeout: this.settings.initialDataTimeout,
timeout: this.smartProxy.settings.initialDataTimeout,
remoteIP: record.remoteIP,
component: 'route-handler'
});
@ -260,14 +245,14 @@ export class RouteConnectionHandler {
});
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'initial_timeout';
this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout');
this.smartProxy.connectionManager.incrementTerminationStat('incoming', 'initial_timeout');
}
socket.end();
this.connectionManager.cleanupConnection(record, 'initial_timeout');
this.smartProxy.connectionManager.cleanupConnection(record, 'initial_timeout');
}
}, 30000);
}
}, this.settings.initialDataTimeout!);
}, this.smartProxy.settings.initialDataTimeout!);
// Make sure timeout doesn't keep the process alive
if (initialTimeout.unref) {
@ -275,7 +260,7 @@ export class RouteConnectionHandler {
}
// Set up error handler
socket.on('error', this.connectionManager.handleError('incoming', record));
socket.on('error', this.smartProxy.connectionManager.handleError('incoming', record));
// Add close/end handlers to catch immediate disconnections
socket.once('close', () => {
@ -289,7 +274,7 @@ export class RouteConnectionHandler {
clearTimeout(initialTimeout);
initialTimeout = null;
}
this.connectionManager.cleanupConnection(record, 'closed_before_data');
this.smartProxy.connectionManager.cleanupConnection(record, 'closed_before_data');
}
});
@ -311,7 +296,7 @@ export class RouteConnectionHandler {
// Handler for processing initial data (after potential PROXY protocol)
const processInitialData = (chunk: Buffer) => {
// Block non-TLS connections on port 443
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
if (!this.smartProxy.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
logger.log('warn', `Non-TLS connection ${connectionId} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
connectionId,
message: 'Terminating connection - only TLS traffic is allowed on standard HTTPS port.',
@ -319,20 +304,20 @@ export class RouteConnectionHandler {
});
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'non_tls_blocked';
this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked');
this.smartProxy.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked');
}
socket.end();
this.connectionManager.cleanupConnection(record, 'non_tls_blocked');
this.smartProxy.connectionManager.cleanupConnection(record, 'non_tls_blocked');
return;
}
// Check if this looks like a TLS handshake
let serverName = '';
if (this.tlsManager.isTlsHandshake(chunk)) {
if (this.smartProxy.tlsManager.isTlsHandshake(chunk)) {
record.isTLS = true;
// Check for ClientHello to extract SNI
if (this.tlsManager.isClientHello(chunk)) {
if (this.smartProxy.tlsManager.isClientHello(chunk)) {
// Create connection info for SNI extraction
const connInfo = {
sourceIp: record.remoteIP,
@ -342,20 +327,20 @@ export class RouteConnectionHandler {
};
// Extract SNI
serverName = this.tlsManager.extractSNI(chunk, connInfo) || '';
serverName = this.smartProxy.tlsManager.extractSNI(chunk, connInfo) || '';
// Lock the connection to the negotiated SNI
record.lockedDomain = serverName;
// Check if we should reject connections without SNI
if (!serverName && this.settings.allowSessionTicket === false) {
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${connectionId}; sending TLS alert`, {
connectionId,
component: 'route-handler'
});
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
this.connectionManager.incrementTerminationStat(
this.smartProxy.connectionManager.incrementTerminationStat(
'incoming',
'session_ticket_blocked_no_sni'
);
@ -369,11 +354,11 @@ export class RouteConnectionHandler {
} catch {
socket.end();
}
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
this.smartProxy.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
return;
}
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `TLS connection with SNI`, {
connectionId,
serverName: serverName || '(empty)',
@ -399,7 +384,7 @@ export class RouteConnectionHandler {
record.hasReceivedInitialData = true;
// Check if this is from a trusted proxy and might have PROXY protocol
if (this.settings.proxyIPs?.includes(socket.remoteAddress || '') && this.settings.acceptProxyProtocol !== false) {
if (this.smartProxy.settings.proxyIPs?.includes(socket.remoteAddress || '') && this.smartProxy.settings.acceptProxyProtocol !== false) {
// Check if this starts with PROXY protocol
if (chunk.toString('ascii', 0, Math.min(6, chunk.length)).startsWith('PROXY ')) {
try {
@ -463,7 +448,7 @@ export class RouteConnectionHandler {
const remoteIP = record.remoteIP;
// Check if this is an HTTP proxy port
const isHttpProxyPort = this.settings.useHttpProxy?.includes(localPort);
const isHttpProxyPort = this.smartProxy.settings.useHttpProxy?.includes(localPort);
// For HTTP proxy ports without TLS, skip domain check since domain info comes from HTTP headers
const skipDomainCheck = isHttpProxyPort && !record.isTLS;
@ -482,7 +467,7 @@ export class RouteConnectionHandler {
};
// Find matching route
const routeMatch = this.routeManager.findMatchingRoute(routeContext);
const routeMatch = this.smartProxy.routeManager.findMatchingRoute(routeContext);
if (!routeMatch) {
logger.log('warn', `No route found for ${serverName || 'connection'} on port ${localPort} (connection: ${connectionId})`, {
@ -499,10 +484,10 @@ export class RouteConnectionHandler {
});
// Check default security settings
const defaultSecuritySettings = this.settings.defaults?.security;
const defaultSecuritySettings = this.smartProxy.settings.defaults?.security;
if (defaultSecuritySettings) {
if (defaultSecuritySettings.ipAllowList && defaultSecuritySettings.ipAllowList.length > 0) {
const isAllowed = this.securityManager.isIPAuthorized(
const isAllowed = this.smartProxy.securityManager.isIPAuthorized(
remoteIP,
defaultSecuritySettings.ipAllowList,
defaultSecuritySettings.ipBlockList || []
@ -515,17 +500,17 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'ip_blocked');
this.smartProxy.connectionManager.cleanupConnection(record, 'ip_blocked');
return;
}
}
}
// Setup direct connection with default settings
if (this.settings.defaults?.target) {
if (this.smartProxy.settings.defaults?.target) {
// Use defaults from configuration
const targetHost = this.settings.defaults.target.host;
const targetPort = this.settings.defaults.target.port;
const targetHost = this.smartProxy.settings.defaults.target.host;
const targetPort = this.smartProxy.settings.defaults.target.port;
return this.setupDirectConnection(
socket,
@ -543,7 +528,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'no_default_target');
this.smartProxy.connectionManager.cleanupConnection(record, 'no_default_target');
return;
}
}
@ -551,7 +536,7 @@ export class RouteConnectionHandler {
// A matching route was found
const route = routeMatch.route;
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Route matched`, {
connectionId,
routeName: route.name || 'unnamed',
@ -565,7 +550,7 @@ export class RouteConnectionHandler {
if (route.security) {
// Check IP allow/block lists
if (route.security.ipAllowList || route.security.ipBlockList) {
const isIPAllowed = this.securityManager.isIPAuthorized(
const isIPAllowed = this.smartProxy.securityManager.isIPAuthorized(
remoteIP,
route.security.ipAllowList || [],
route.security.ipBlockList || []
@ -579,7 +564,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'route_ip_blocked');
this.smartProxy.connectionManager.cleanupConnection(record, 'route_ip_blocked');
return;
}
}
@ -588,7 +573,7 @@ export class RouteConnectionHandler {
if (route.security.maxConnections !== undefined) {
// TODO: Implement per-route connection tracking
// For now, log that this feature is not yet implemented
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('warn', `Route ${route.name} has maxConnections=${route.security.maxConnections} configured but per-route connection limits are not yet implemented`, {
connectionId,
routeName: route.name,
@ -633,7 +618,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'unknown_action');
this.smartProxy.connectionManager.cleanupConnection(record, 'unknown_action');
}
}
@ -658,7 +643,7 @@ export class RouteConnectionHandler {
// The application should NOT interfere with these connections
// Log the connection for monitoring purposes
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `NFTables forwarding (kernel-level)`, {
connectionId: record.id,
source: `${record.remoteIP}:${socket.remotePort}`,
@ -680,7 +665,7 @@ export class RouteConnectionHandler {
// Additional NFTables-specific logging if configured
if (action.nftables) {
const nftConfig = action.nftables;
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `NFTables config`, {
connectionId: record.id,
protocol: nftConfig.protocol || 'tcp',
@ -701,7 +686,7 @@ export class RouteConnectionHandler {
// Set up cleanup when the socket eventually closes
socket.once('close', () => {
this.connectionManager.cleanupConnection(record, 'nftables_closed');
this.smartProxy.connectionManager.cleanupConnection(record, 'nftables_closed');
});
return;
@ -714,7 +699,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'missing_target');
this.smartProxy.connectionManager.cleanupConnection(record, 'missing_target');
return;
}
@ -738,7 +723,7 @@ export class RouteConnectionHandler {
if (typeof action.target.host === 'function') {
try {
targetHost = action.target.host(routeContext);
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, {
connectionId,
targetHost: Array.isArray(targetHost) ? targetHost.join(', ') : targetHost,
@ -752,7 +737,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'host_mapping_error');
this.smartProxy.connectionManager.cleanupConnection(record, 'host_mapping_error');
return;
}
} else {
@ -769,7 +754,7 @@ export class RouteConnectionHandler {
if (typeof action.target.port === 'function') {
try {
targetPort = action.target.port(routeContext);
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, {
connectionId,
sourcePort: record.localPort,
@ -786,7 +771,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'port_mapping_error');
this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error');
return;
}
} else if (action.target.port === 'preserve') {
@ -805,7 +790,7 @@ export class RouteConnectionHandler {
switch (action.tls.mode) {
case 'passthrough':
// For TLS passthrough, just forward directly
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using TLS passthrough to ${selectedHost}:${targetPort} for connection ${connectionId}`, {
connectionId,
targetHost: selectedHost,
@ -827,8 +812,8 @@ export class RouteConnectionHandler {
case 'terminate':
case 'terminate-and-reencrypt':
// For TLS termination, use HttpProxy
if (this.httpProxyBridge.getHttpProxy()) {
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.httpProxyBridge.getHttpProxy()) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host} for connection ${connectionId}`, {
connectionId,
targetHost: action.target.host,
@ -838,13 +823,13 @@ export class RouteConnectionHandler {
// If we have an initial chunk with TLS data, start processing it
if (initialChunk && record.isTLS) {
this.httpProxyBridge.forwardToHttpProxy(
this.smartProxy.httpProxyBridge.forwardToHttpProxy(
connectionId,
socket,
record,
initialChunk,
this.settings.httpProxyPort || 8443,
(reason) => this.connectionManager.cleanupConnection(record, reason)
this.smartProxy.settings.httpProxyPort || 8443,
(reason) => this.smartProxy.connectionManager.cleanupConnection(record, reason)
);
return;
}
@ -855,7 +840,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'tls_error');
this.smartProxy.connectionManager.cleanupConnection(record, 'tls_error');
return;
} else {
logger.log('error', `HttpProxy not available for TLS termination for connection ${connectionId}`, {
@ -863,29 +848,29 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.end();
this.connectionManager.cleanupConnection(record, 'no_http_proxy');
this.smartProxy.connectionManager.cleanupConnection(record, 'no_http_proxy');
return;
}
}
} else {
// No TLS settings - check if this port should use HttpProxy
const isHttpProxyPort = this.settings.useHttpProxy?.includes(record.localPort);
const isHttpProxyPort = this.smartProxy.settings.useHttpProxy?.includes(record.localPort);
// Debug logging
if (this.settings.enableDetailedLogging) {
logger.log('debug', `Checking HttpProxy forwarding: port=${record.localPort}, useHttpProxy=${JSON.stringify(this.settings.useHttpProxy)}, isHttpProxyPort=${isHttpProxyPort}, hasHttpProxy=${!!this.httpProxyBridge.getHttpProxy()}`, {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('debug', `Checking HttpProxy forwarding: port=${record.localPort}, useHttpProxy=${JSON.stringify(this.smartProxy.settings.useHttpProxy)}, isHttpProxyPort=${isHttpProxyPort}, hasHttpProxy=${!!this.smartProxy.httpProxyBridge.getHttpProxy()}`, {
connectionId,
localPort: record.localPort,
useHttpProxy: this.settings.useHttpProxy,
useHttpProxy: this.smartProxy.settings.useHttpProxy,
isHttpProxyPort,
hasHttpProxy: !!this.httpProxyBridge.getHttpProxy(),
hasHttpProxy: !!this.smartProxy.httpProxyBridge.getHttpProxy(),
component: 'route-handler'
});
}
if (isHttpProxyPort && this.httpProxyBridge.getHttpProxy()) {
if (isHttpProxyPort && this.smartProxy.httpProxyBridge.getHttpProxy()) {
// Forward non-TLS connections to HttpProxy if configured
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using HttpProxy for non-TLS connection ${connectionId} on port ${record.localPort}`, {
connectionId,
port: record.localPort,
@ -893,18 +878,18 @@ export class RouteConnectionHandler {
});
}
this.httpProxyBridge.forwardToHttpProxy(
this.smartProxy.httpProxyBridge.forwardToHttpProxy(
connectionId,
socket,
record,
initialChunk,
this.settings.httpProxyPort || 8443,
(reason) => this.connectionManager.cleanupConnection(record, reason)
this.smartProxy.settings.httpProxyPort || 8443,
(reason) => this.smartProxy.connectionManager.cleanupConnection(record, reason)
);
return;
} else {
// Basic forwarding
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using basic forwarding to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host}:${action.target.port} for connection ${connectionId}`, {
connectionId,
targetHost: action.target.host,
@ -977,7 +962,7 @@ export class RouteConnectionHandler {
component: 'route-handler'
});
socket.destroy();
this.connectionManager.cleanupConnection(record, 'missing_handler');
this.smartProxy.connectionManager.cleanupConnection(record, 'missing_handler');
return;
}
@ -1052,7 +1037,7 @@ export class RouteConnectionHandler {
if (!socket.destroyed) {
socket.destroy();
}
this.connectionManager.cleanupConnection(record, 'handler_error');
this.smartProxy.connectionManager.cleanupConnection(record, 'handler_error');
});
} else {
// For sync handlers, emit on next tick
@ -1074,7 +1059,7 @@ export class RouteConnectionHandler {
if (!socket.destroyed) {
socket.destroy();
}
this.connectionManager.cleanupConnection(record, 'handler_error');
this.smartProxy.connectionManager.cleanupConnection(record, 'handler_error');
}
}
@ -1095,19 +1080,19 @@ export class RouteConnectionHandler {
// Determine target host and port if not provided
const finalTargetHost =
targetHost || record.targetHost || this.settings.defaults?.target?.host || 'localhost';
targetHost || record.targetHost || this.smartProxy.settings.defaults?.target?.host || 'localhost';
// Determine target port
const finalTargetPort =
targetPort ||
record.targetPort ||
(overridePort !== undefined ? overridePort : this.settings.defaults?.target?.port || 443);
(overridePort !== undefined ? overridePort : this.smartProxy.settings.defaults?.target?.port || 443);
// Update record with final target information
record.targetHost = finalTargetHost;
record.targetPort = finalTargetPort;
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Setting up direct connection ${connectionId} to ${finalTargetHost}:${finalTargetPort}`, {
connectionId,
targetHost: finalTargetHost,
@ -1123,7 +1108,7 @@ export class RouteConnectionHandler {
};
// Preserve source IP if configured
if (this.settings.defaults?.preserveSourceIP || this.settings.preserveSourceIP) {
if (this.smartProxy.settings.defaults?.preserveSourceIP || this.smartProxy.settings.preserveSourceIP) {
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
}
@ -1132,13 +1117,18 @@ export class RouteConnectionHandler {
record.bytesReceived += initialChunk.length;
record.pendingData.push(Buffer.from(initialChunk));
record.pendingDataSize = initialChunk.length;
// Record bytes for metrics
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.recordBytes(record.id, initialChunk.length, 0);
}
}
// Create the target socket with immediate error handling
const targetSocket = createSocketWithErrorHandler({
port: finalTargetPort,
host: finalTargetHost,
timeout: this.settings.connectionTimeout || 30000, // Connection timeout (default: 30s)
timeout: this.smartProxy.settings.connectionTimeout || 30000, // Connection timeout (default: 30s)
onError: (error) => {
// Connection failed - clean up everything immediately
// Check if connection record is still valid (client might have disconnected)
@ -1188,10 +1178,10 @@ export class RouteConnectionHandler {
}
// Clean up the connection record - this is critical!
this.connectionManager.cleanupConnection(record, `connection_failed_${(error as any).code || 'unknown'}`);
this.smartProxy.connectionManager.cleanupConnection(record, `connection_failed_${(error as any).code || 'unknown'}`);
},
onConnect: async () => {
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
connectionId,
targetHost: finalTargetHost,
@ -1204,11 +1194,11 @@ export class RouteConnectionHandler {
targetSocket.removeAllListeners('error');
// Add the normal error handler for established connections
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
targetSocket.on('error', this.smartProxy.connectionManager.handleError('outgoing', record));
// Check if we should send PROXY protocol header
const shouldSendProxyProtocol = record.routeConfig?.action?.sendProxyProtocol ||
this.settings.sendProxyProtocol;
this.smartProxy.settings.sendProxyProtocol;
if (shouldSendProxyProtocol) {
try {
@ -1260,7 +1250,7 @@ export class RouteConnectionHandler {
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
);
@ -1274,7 +1264,7 @@ export class RouteConnectionHandler {
error: err.message,
component: 'route-handler'
});
return this.connectionManager.cleanupConnection(record, 'write_error');
return this.smartProxy.connectionManager.cleanupConnection(record, 'write_error');
}
});
@ -1290,22 +1280,32 @@ export class RouteConnectionHandler {
setupBidirectionalForwarding(incomingSocket, targetSocket, {
onClientData: (chunk) => {
record.bytesReceived += chunk.length;
this.timeoutManager.updateActivity(record);
this.smartProxy.timeoutManager.updateActivity(record);
// Record bytes for metrics
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.recordBytes(record.id, chunk.length, 0);
}
},
onServerData: (chunk) => {
record.bytesSent += chunk.length;
this.timeoutManager.updateActivity(record);
this.smartProxy.timeoutManager.updateActivity(record);
// Record bytes for metrics
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.recordBytes(record.id, 0, chunk.length);
}
},
onCleanup: (reason) => {
this.connectionManager.cleanupConnection(record, reason);
this.smartProxy.connectionManager.cleanupConnection(record, reason);
},
enableHalfOpen: false // Default: close both when one closes (required for proxy chains)
});
// Apply timeouts if keep-alive is enabled
if (record.hasKeepAlive) {
socket.setTimeout(this.settings.socketTimeout || 3600000);
targetSocket.setTimeout(this.settings.socketTimeout || 3600000);
socket.setTimeout(this.smartProxy.settings.socketTimeout || 3600000);
targetSocket.setTimeout(this.smartProxy.settings.socketTimeout || 3600000);
}
// Log successful connection
@ -1333,11 +1333,11 @@ export class RouteConnectionHandler {
};
// Create a renegotiation handler function
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
const renegotiationHandler = this.smartProxy.tlsManager.createRenegotiationHandler(
connectionId,
serverName,
connInfo,
(_connectionId, reason) => this.connectionManager.cleanupConnection(record, reason)
(_connectionId, reason) => this.smartProxy.connectionManager.cleanupConnection(record, reason)
);
// Store the handler in the connection record so we can remove it during cleanup
@ -1346,7 +1346,7 @@ export class RouteConnectionHandler {
// Add the handler to the socket
socket.on('data', renegotiationHandler);
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `TLS renegotiation handler installed for connection ${connectionId} with SNI ${serverName}`, {
connectionId,
serverName,
@ -1356,13 +1356,13 @@ export class RouteConnectionHandler {
}
// Set connection timeout
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
record.cleanupTimer = this.smartProxy.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
logger.log('warn', `Connection ${connectionId} from ${record.remoteIP} exceeded max lifetime, forcing cleanup`, {
connectionId,
remoteIP: record.remoteIP,
component: 'route-handler'
});
this.connectionManager.cleanupConnection(record, reason);
this.smartProxy.connectionManager.cleanupConnection(record, reason);
});
// Mark TLS handshake as complete for TLS connections
@ -1377,14 +1377,14 @@ export class RouteConnectionHandler {
record.outgoingStartTime = Date.now();
// Apply socket optimizations
targetSocket.setNoDelay(this.settings.noDelay);
targetSocket.setNoDelay(this.smartProxy.settings.noDelay);
// Apply keep-alive settings if enabled
if (this.settings.keepAlive) {
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
if (this.smartProxy.settings.keepAlive) {
targetSocket.setKeepAlive(true, this.smartProxy.settings.keepAliveInitialDelay);
// Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) {
if (this.smartProxy.settings.enableKeepAliveProbes) {
try {
if ('setKeepAliveProbes' in targetSocket) {
(targetSocket as any).setKeepAliveProbes(10);
@ -1394,7 +1394,7 @@ export class RouteConnectionHandler {
}
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('warn', `Enhanced TCP keep-alive not supported for outgoing socket on connection ${connectionId}: ${err}`, {
connectionId,
error: err,
@ -1406,16 +1406,16 @@ export class RouteConnectionHandler {
}
// Setup error handlers for incoming socket
socket.on('error', this.connectionManager.handleError('incoming', record));
socket.on('error', this.smartProxy.connectionManager.handleError('incoming', record));
// Handle timeouts with keep-alive awareness
socket.on('timeout', () => {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
logger.log('warn', `Timeout event on incoming keep-alive connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}. Connection preserved.`, {
logger.log('warn', `Timeout event on incoming keep-alive connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000)}. Connection preserved.`, {
connectionId,
remoteIP: record.remoteIP,
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
timeout: plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000),
status: 'Connection preserved',
component: 'route-handler'
});
@ -1423,26 +1423,26 @@ export class RouteConnectionHandler {
}
// For non-keep-alive connections, proceed with normal cleanup
logger.log('warn', `Timeout on incoming side for connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`, {
logger.log('warn', `Timeout on incoming side for connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000)}`, {
connectionId,
remoteIP: record.remoteIP,
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
timeout: plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000),
component: 'route-handler'
});
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'timeout';
this.connectionManager.incrementTerminationStat('incoming', 'timeout');
this.smartProxy.connectionManager.incrementTerminationStat('incoming', 'timeout');
}
this.connectionManager.cleanupConnection(record, 'timeout_incoming');
this.smartProxy.connectionManager.cleanupConnection(record, 'timeout_incoming');
});
targetSocket.on('timeout', () => {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
logger.log('warn', `Timeout event on outgoing keep-alive connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}. Connection preserved.`, {
logger.log('warn', `Timeout event on outgoing keep-alive connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000)}. Connection preserved.`, {
connectionId,
remoteIP: record.remoteIP,
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
timeout: plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000),
status: 'Connection preserved',
component: 'route-handler'
});
@ -1450,20 +1450,20 @@ export class RouteConnectionHandler {
}
// For non-keep-alive connections, proceed with normal cleanup
logger.log('warn', `Timeout on outgoing side for connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`, {
logger.log('warn', `Timeout on outgoing side for connection ${connectionId} from ${record.remoteIP} after ${plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000)}`, {
connectionId,
remoteIP: record.remoteIP,
timeout: plugins.prettyMs(this.settings.socketTimeout || 3600000),
timeout: plugins.prettyMs(this.smartProxy.settings.socketTimeout || 3600000),
component: 'route-handler'
});
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'timeout';
this.connectionManager.incrementTerminationStat('outgoing', 'timeout');
this.smartProxy.connectionManager.incrementTerminationStat('outgoing', 'timeout');
}
this.connectionManager.cleanupConnection(record, 'timeout_outgoing');
this.smartProxy.connectionManager.cleanupConnection(record, 'timeout_outgoing');
});
// Apply socket timeouts
this.timeoutManager.applySocketTimeouts(record);
this.smartProxy.timeoutManager.applySocketTimeouts(record);
}
}

View File

@ -1,5 +1,5 @@
import * as plugins from '../../plugins.js';
import type { ISmartProxyOptions } from './models/interfaces.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* Handles security aspects like IP tracking, rate limiting, and authorization
@ -8,7 +8,7 @@ export class SecurityManager {
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
constructor(private settings: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {}
/**
* Get connections count by IP
@ -36,7 +36,7 @@ export class SecurityManager {
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
return timestamps.length <= this.smartProxy.settings.connectionRateLimitPerMinute!;
}
/**
@ -137,23 +137,23 @@ export class SecurityManager {
public validateIP(ip: string): { allowed: boolean; reason?: string } {
// Check connection count limit
if (
this.settings.maxConnectionsPerIP &&
this.getConnectionCountByIP(ip) >= this.settings.maxConnectionsPerIP
this.smartProxy.settings.maxConnectionsPerIP &&
this.getConnectionCountByIP(ip) >= this.smartProxy.settings.maxConnectionsPerIP
) {
return {
allowed: false,
reason: `Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded`
reason: `Maximum connections per IP (${this.smartProxy.settings.maxConnectionsPerIP}) exceeded`
};
}
// Check connection rate limit
if (
this.settings.connectionRateLimitPerMinute &&
this.smartProxy.settings.connectionRateLimitPerMinute &&
!this.checkConnectionRate(ip)
) {
return {
allowed: false,
reason: `Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded`
reason: `Connection rate limit (${this.smartProxy.settings.connectionRateLimitPerMinute}/min) exceeded`
};
}

View File

@ -29,7 +29,7 @@ import { AcmeStateManager } from './acme-state-manager.js';
// Import metrics collector
import { MetricsCollector } from './metrics-collector.js';
import type { IProxyStats } from './models/metrics-types.js';
import type { IMetrics } from './models/metrics-types.js';
/**
* SmartProxy - Pure route-based API
@ -52,24 +52,24 @@ export class SmartProxy extends plugins.EventEmitter {
// Component managers
public connectionManager: ConnectionManager;
private securityManager: SecurityManager;
private tlsManager: TlsManager;
private httpProxyBridge: HttpProxyBridge;
private timeoutManager: TimeoutManager;
public routeManager: RouteManager; // Made public for route management
public routeConnectionHandler: RouteConnectionHandler; // Made public for metrics
private nftablesManager: NFTablesManager;
public securityManager: SecurityManager;
public tlsManager: TlsManager;
public httpProxyBridge: HttpProxyBridge;
public timeoutManager: TimeoutManager;
public routeManager: RouteManager;
public routeConnectionHandler: RouteConnectionHandler;
public nftablesManager: NFTablesManager;
// Certificate manager for ACME and static certificates
private certManager: SmartCertManager | null = null;
public certManager: SmartCertManager | null = null;
// Global challenge route tracking
private globalChallengeRouteActive: boolean = false;
private routeUpdateLock: any = null; // Will be initialized as AsyncMutex
private acmeStateManager: AcmeStateManager;
public acmeStateManager: AcmeStateManager;
// Metrics collector
private metricsCollector: MetricsCollector;
public metricsCollector: MetricsCollector;
// Track port usage across route updates
private portUsageMap: Map<number, Set<string>> = new Map();
@ -161,13 +161,9 @@ export class SmartProxy extends plugins.EventEmitter {
}
// Initialize component managers
this.timeoutManager = new TimeoutManager(this.settings);
this.securityManager = new SecurityManager(this.settings);
this.connectionManager = new ConnectionManager(
this.settings,
this.securityManager,
this.timeoutManager
);
this.timeoutManager = new TimeoutManager(this);
this.securityManager = new SecurityManager(this);
this.connectionManager = new ConnectionManager(this);
// Create the route manager with SharedRouteManager API
// Create a logger adapter to match ILogger interface
@ -186,25 +182,17 @@ export class SmartProxy extends plugins.EventEmitter {
// Create other required components
this.tlsManager = new TlsManager(this.settings);
this.httpProxyBridge = new HttpProxyBridge(this.settings);
this.tlsManager = new TlsManager(this);
this.httpProxyBridge = new HttpProxyBridge(this);
// Initialize connection handler with route support
this.routeConnectionHandler = new RouteConnectionHandler(
this.settings,
this.connectionManager,
this.securityManager,
this.tlsManager,
this.httpProxyBridge,
this.timeoutManager,
this.routeManager
);
this.routeConnectionHandler = new RouteConnectionHandler(this);
// Initialize port manager
this.portManager = new PortManager(this.settings, this.routeConnectionHandler);
this.portManager = new PortManager(this);
// Initialize NFTablesManager
this.nftablesManager = new NFTablesManager(this.settings);
this.nftablesManager = new NFTablesManager(this);
// Initialize route update mutex for synchronization
this.routeUpdateLock = new Mutex();
@ -922,11 +910,11 @@ export class SmartProxy extends plugins.EventEmitter {
}
/**
* Get proxy statistics and metrics
* Get proxy metrics with clean API
*
* @returns IProxyStats interface with various metrics methods
* @returns IMetrics interface with grouped metrics methods
*/
public getStats(): IProxyStats {
public getMetrics(): IMetrics {
return this.metricsCollector;
}

View File

@ -0,0 +1,144 @@
import type { IThroughputSample, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js';
/**
* Tracks throughput data using time-series sampling
*/
export class ThroughputTracker {
private samples: IThroughputSample[] = [];
private readonly maxSamples: number;
private accumulatedBytesIn: number = 0;
private accumulatedBytesOut: number = 0;
private lastSampleTime: number = 0;
constructor(retentionSeconds: number = 3600) {
// Keep samples for the retention period at 1 sample per second
this.maxSamples = retentionSeconds;
}
/**
* Record bytes transferred (called on every data transfer)
*/
public recordBytes(bytesIn: number, bytesOut: number): void {
this.accumulatedBytesIn += bytesIn;
this.accumulatedBytesOut += bytesOut;
}
/**
* Take a sample of accumulated bytes (called every second)
*/
public takeSample(): void {
const now = Date.now();
// Record accumulated bytes since last sample
this.samples.push({
timestamp: now,
bytesIn: this.accumulatedBytesIn,
bytesOut: this.accumulatedBytesOut
});
// Reset accumulators
this.accumulatedBytesIn = 0;
this.accumulatedBytesOut = 0;
this.lastSampleTime = now;
// Maintain circular buffer - remove oldest samples
if (this.samples.length > this.maxSamples) {
this.samples.shift();
}
}
/**
* Get throughput rate over specified window (bytes per second)
*/
public getRate(windowSeconds: number): IThroughputData {
if (this.samples.length === 0) {
return { in: 0, out: 0 };
}
const now = Date.now();
const windowStart = now - (windowSeconds * 1000);
// Find samples within the window
const relevantSamples = this.samples.filter(s => s.timestamp > windowStart);
if (relevantSamples.length === 0) {
return { in: 0, out: 0 };
}
// Sum bytes in the window
const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0);
const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0);
// Calculate actual window duration (might be less than requested if not enough data)
const actualWindowSeconds = Math.min(
windowSeconds,
(now - relevantSamples[0].timestamp) / 1000
);
// Avoid division by zero
if (actualWindowSeconds === 0) {
return { in: 0, out: 0 };
}
return {
in: Math.round(totalBytesIn / actualWindowSeconds),
out: Math.round(totalBytesOut / actualWindowSeconds)
};
}
/**
* Get throughput history for specified duration
*/
public getHistory(durationSeconds: number): IThroughputHistoryPoint[] {
const now = Date.now();
const startTime = now - (durationSeconds * 1000);
// Filter samples within duration
const relevantSamples = this.samples.filter(s => s.timestamp > startTime);
// Convert to history points with per-second rates
const history: IThroughputHistoryPoint[] = [];
for (let i = 0; i < relevantSamples.length; i++) {
const sample = relevantSamples[i];
// For the first sample or samples after gaps, we can't calculate rate
if (i === 0 || sample.timestamp - relevantSamples[i - 1].timestamp > 2000) {
history.push({
timestamp: sample.timestamp,
in: sample.bytesIn,
out: sample.bytesOut
});
} else {
// Calculate rate based on time since previous sample
const prevSample = relevantSamples[i - 1];
const timeDelta = (sample.timestamp - prevSample.timestamp) / 1000;
history.push({
timestamp: sample.timestamp,
in: Math.round(sample.bytesIn / timeDelta),
out: Math.round(sample.bytesOut / timeDelta)
});
}
}
return history;
}
/**
* Clear all samples
*/
public clear(): void {
this.samples = [];
this.accumulatedBytesIn = 0;
this.accumulatedBytesOut = 0;
this.lastSampleTime = 0;
}
/**
* Get sample count for debugging
*/
public getSampleCount(): number {
return this.samples.length;
}
}

View File

@ -1,10 +1,11 @@
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import type { IConnectionRecord } from './models/interfaces.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* Manages timeouts and inactivity tracking for connections
*/
export class TimeoutManager {
constructor(private settings: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {}
/**
* Ensure timeout values don't exceed Node.js max safe integer
@ -41,16 +42,16 @@ export class TimeoutManager {
* Calculate effective inactivity timeout based on connection type
*/
public getEffectiveInactivityTimeout(record: IConnectionRecord): number {
let effectiveTimeout = this.settings.inactivityTimeout || 14400000; // 4 hours default
let effectiveTimeout = this.smartProxy.settings.inactivityTimeout || 14400000; // 4 hours default
// For immortal keep-alive connections, use an extremely long timeout
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, apply multiplier
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
@ -63,23 +64,23 @@ export class TimeoutManager {
public getEffectiveMaxLifetime(record: IConnectionRecord): number {
// Use route-specific timeout if available from the routeConfig
const baseTimeout = record.routeConfig?.action.advanced?.timeout ||
this.settings.maxConnectionLifetime ||
this.smartProxy.settings.maxConnectionLifetime ||
86400000; // 24 hours default
// For immortal keep-alive connections, use an extremely long lifetime
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, use the extended lifetime setting
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
return this.ensureSafeTimeout(
this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
this.smartProxy.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
);
}
// Apply randomization if enabled
if (this.settings.enableRandomizedTimeouts) {
if (this.smartProxy.settings.enableRandomizedTimeouts) {
return this.randomizeTimeout(baseTimeout);
}
@ -127,7 +128,7 @@ export class TimeoutManager {
effectiveTimeout: number;
} {
// Skip for connections with inactivity check disabled
if (this.settings.disableInactivityCheck) {
if (this.smartProxy.settings.disableInactivityCheck) {
return {
isInactive: false,
shouldWarn: false,
@ -137,7 +138,7 @@ export class TimeoutManager {
}
// Skip for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
return {
isInactive: false,
shouldWarn: false,
@ -171,7 +172,7 @@ export class TimeoutManager {
*/
public applySocketTimeouts(record: IConnectionRecord): void {
// Skip for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
// Disable timeouts completely for immortal connections
record.incoming.setTimeout(0);
if (record.outgoing) {
@ -181,7 +182,7 @@ export class TimeoutManager {
}
// Apply normal timeouts
const timeout = this.ensureSafeTimeout(this.settings.socketTimeout || 3600000); // 1 hour default
const timeout = this.ensureSafeTimeout(this.smartProxy.settings.socketTimeout || 3600000); // 1 hour default
record.incoming.setTimeout(timeout);
if (record.outgoing) {
record.outgoing.setTimeout(timeout);

View File

@ -1,6 +1,6 @@
import * as plugins from '../../plugins.js';
import type { ISmartProxyOptions } from './models/interfaces.js';
import { SniHandler } from '../../tls/sni/sni-handler.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* Interface for connection information used for SNI extraction
@ -16,7 +16,7 @@ interface IConnectionInfo {
* Manages TLS-related operations including SNI extraction and validation
*/
export class TlsManager {
constructor(private settings: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {}
/**
* Check if a data chunk appears to be a TLS handshake
@ -44,7 +44,7 @@ export class TlsManager {
return SniHandler.processTlsPacket(
chunk,
connInfo,
this.settings.enableTlsDebugLogging || false,
this.smartProxy.settings.enableTlsDebugLogging || false,
previousDomain
);
}
@ -58,19 +58,19 @@ export class TlsManager {
hasSNI: boolean
): { shouldBlock: boolean; reason?: string } {
// Skip if session tickets are allowed
if (this.settings.allowSessionTicket !== false) {
if (this.smartProxy.settings.allowSessionTicket !== false) {
return { shouldBlock: false };
}
// Check for session resumption attempt
const resumptionInfo = SniHandler.hasSessionResumption(
chunk,
this.settings.enableTlsDebugLogging || false
this.smartProxy.settings.enableTlsDebugLogging || false
);
// If this is a resumption attempt without SNI, block it
if (resumptionInfo.isResumption && !hasSNI && !resumptionInfo.hasSNI) {
if (this.settings.enableTlsDebugLogging) {
if (this.smartProxy.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Session resumption detected without SNI and allowSessionTicket=false. ` +
`Terminating connection to force new TLS handshake.`
@ -104,7 +104,7 @@ export class TlsManager {
const newSNI = SniHandler.extractSNIWithResumptionSupport(
chunk,
connInfo,
this.settings.enableTlsDebugLogging || false
this.smartProxy.settings.enableTlsDebugLogging || false
);
// Skip if no SNI was found
@ -112,14 +112,14 @@ export class TlsManager {
// Check for SNI mismatch
if (newSNI !== expectedDomain) {
if (this.settings.enableTlsDebugLogging) {
if (this.smartProxy.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Renegotiation with different SNI: ${expectedDomain} -> ${newSNI}. ` +
`Terminating connection - SNI domain switching is not allowed.`
);
}
return { hasMismatch: true, extractedSNI: newSNI };
} else if (this.settings.enableTlsDebugLogging) {
} else if (this.smartProxy.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.`
);
@ -175,13 +175,13 @@ export class TlsManager {
// Check for session resumption
const resumptionInfo = SniHandler.hasSessionResumption(
chunk,
this.settings.enableTlsDebugLogging || false
this.smartProxy.settings.enableTlsDebugLogging || false
);
// Extract SNI
const sni = SniHandler.extractSNI(
chunk,
this.settings.enableTlsDebugLogging || false
this.smartProxy.settings.enableTlsDebugLogging || false
);
// Update result