From 131a454b28ee37f911b0e605068e80fe87dc36f1 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 22 Jun 2025 22:28:37 +0000 Subject: [PATCH] fix(metrics): improve metrics --- certs/static-route/meta.json | 6 +- readme.hints.md | 0 readme.plan.md | 358 +++++++++++++ test/test.metrics-new.ts | 261 ++++++++++ ts/proxies/smart-proxy/connection-manager.ts | 44 +- ts/proxies/smart-proxy/http-proxy-bridge.ts | 17 +- ts/proxies/smart-proxy/metrics-collector.ts | 492 +++++++++++------- .../smart-proxy/models/metrics-types.ts | 142 +++-- ts/proxies/smart-proxy/nftables-manager.ts | 10 +- ts/proxies/smart-proxy/port-manager.ts | 20 +- .../smart-proxy/route-connection-handler.ts | 272 +++++----- ts/proxies/smart-proxy/security-manager.ts | 16 +- ts/proxies/smart-proxy/smart-proxy.ts | 56 +- ts/proxies/smart-proxy/throughput-tracker.ts | 144 +++++ ts/proxies/smart-proxy/timeout-manager.ts | 31 +- ts/proxies/smart-proxy/tls-manager.ts | 22 +- 16 files changed, 1389 insertions(+), 502 deletions(-) create mode 100644 readme.hints.md create mode 100644 readme.plan.md create mode 100644 test/test.metrics-new.ts create mode 100644 ts/proxies/smart-proxy/throughput-tracker.ts diff --git a/certs/static-route/meta.json b/certs/static-route/meta.json index 9335cea..021d8de 100644 --- a/certs/static-route/meta.json +++ b/certs/static-route/meta.json @@ -1,5 +1,5 @@ { - "expiryDate": "2025-09-03T17:57:28.583Z", - "issueDate": "2025-06-05T17:57:28.583Z", - "savedAt": "2025-06-05T17:57:28.583Z" + "expiryDate": "2025-09-20T22:26:01.547Z", + "issueDate": "2025-06-22T22:26:01.547Z", + "savedAt": "2025-06-22T22:26:01.548Z" } \ No newline at end of file diff --git a/readme.hints.md b/readme.hints.md new file mode 100644 index 0000000..e69de29 diff --git a/readme.plan.md b/readme.plan.md new file mode 100644 index 0000000..45884df --- /dev/null +++ b/readme.plan.md @@ -0,0 +1,358 @@ +# SmartProxy Metrics Improvement Plan + +## Overview + +The current `getThroughputRate()` implementation calculates cumulative throughput over a 60-second window rather than providing an actual rate, making metrics misleading for monitoring systems. This plan outlines a comprehensive redesign of the metrics system to provide accurate, time-series based metrics suitable for production monitoring. + +## 1. Core Issues with Current Implementation + +- **Cumulative vs Rate**: Current method accumulates all bytes from connections in the last minute rather than calculating actual throughput rate +- **No Time-Series Data**: Cannot track throughput changes over time +- **Inaccurate Estimates**: Attempting to estimate rates for older connections is fundamentally flawed +- **No Sliding Windows**: Cannot provide different time window views (1s, 10s, 60s, etc.) +- **Limited Granularity**: Only provides a single 60-second view + +## 2. Proposed Architecture + +### A. Time-Series Throughput Tracking + +```typescript +interface IThroughputSample { + timestamp: number; + bytesIn: number; + bytesOut: number; +} + +class ThroughputTracker { + private samples: IThroughputSample[] = []; + private readonly MAX_SAMPLES = 3600; // 1 hour at 1 sample/second + private lastSampleTime: number = 0; + private accumulatedBytesIn: number = 0; + private accumulatedBytesOut: number = 0; + + // Called on every data transfer + public recordBytes(bytesIn: number, bytesOut: number): void { + this.accumulatedBytesIn += bytesIn; + this.accumulatedBytesOut += bytesOut; + } + + // Called periodically (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; + + // Trim old samples + const cutoff = now - 3600000; // 1 hour + this.samples = this.samples.filter(s => s.timestamp > cutoff); + } + + // Get rate over specified window + public getRate(windowSeconds: number): { bytesInPerSec: number; bytesOutPerSec: number } { + const now = Date.now(); + const windowStart = now - (windowSeconds * 1000); + + const relevantSamples = this.samples.filter(s => s.timestamp > windowStart); + + if (relevantSamples.length === 0) { + return { bytesInPerSec: 0, bytesOutPerSec: 0 }; + } + + const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0); + const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0); + + const actualWindow = (now - relevantSamples[0].timestamp) / 1000; + + return { + bytesInPerSec: Math.round(totalBytesIn / actualWindow), + bytesOutPerSec: Math.round(totalBytesOut / actualWindow) + }; + } +} +``` + +### B. Connection-Level Byte Tracking + +```typescript +// In ConnectionRecord, add: +interface IConnectionRecord { + // ... existing fields ... + + // Byte counters with timestamps + bytesReceivedHistory: Array<{ timestamp: number; bytes: number }>; + bytesSentHistory: Array<{ timestamp: number; bytes: number }>; + + // For efficiency, could use circular buffer + lastBytesReceivedUpdate: number; + lastBytesSentUpdate: number; +} +``` + +### C. Enhanced Metrics Interface + +```typescript +interface IMetrics { + // Connection metrics + connections: { + active(): number; + total(): number; + byRoute(): Map; + byIP(): Map; + topIPs(limit?: number): Array<{ ip: string; count: number }>; + }; + + // Throughput metrics (bytes per second) + throughput: { + instant(): { in: number; out: number }; // Last 1 second + recent(): { in: number; out: number }; // Last 10 seconds + average(): { in: number; out: number }; // Last 60 seconds + custom(seconds: number): { in: number; out: number }; + history(seconds: number): Array<{ timestamp: number; in: number; out: number }>; + byRoute(windowSeconds?: number): Map; + byIP(windowSeconds?: number): Map; + }; + + // 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 }; + }; + }; +} +``` + +## 3. Implementation Plan + +### Current Status +- **Phase 1**: ~90% complete (core functionality implemented, tests need fixing) +- **Phase 2**: ~60% complete (main features done, percentiles pending) +- **Phase 3**: ~40% complete (basic optimizations in place) +- **Phase 4**: 0% complete (export formats not started) + +### Phase 1: Core Throughput Tracking (Week 1) +- [x] Implement `ThroughputTracker` class +- [x] Integrate byte recording into socket data handlers +- [x] Add periodic sampling (1-second intervals) +- [x] Update `getThroughputRate()` to use time-series data (replaced with new clean API) +- [ ] Add unit tests for throughput tracking + +### Phase 2: Enhanced Metrics (Week 2) +- [x] Add configurable time windows (1s, 10s, 60s, 5m, etc.) +- [ ] Implement percentile calculations +- [x] Add route-specific and IP-specific throughput tracking +- [x] Create historical data access methods +- [ ] Add integration tests + +### Phase 3: Performance Optimization (Week 3) +- [x] Use circular buffers for efficiency +- [ ] Implement data aggregation for longer time windows +- [x] Add configurable retention periods +- [ ] Optimize memory usage +- [ ] Add performance benchmarks + +### Phase 4: Export Formats (Week 4) +- [ ] Add Prometheus metric format with proper metric types +- [ ] Add StatsD format support +- [ ] Add JSON export with metadata +- [ ] Create OpenMetrics compatibility +- [ ] Add documentation and examples + +## 4. Key Design Decisions + +### A. Sampling Strategy +- **1-second samples** for fine-grained data +- **Aggregate to 1-minute** for longer retention +- **Keep 1 hour** of second-level data +- **Keep 24 hours** of minute-level data + +### B. Memory Management +- **Circular buffers** for fixed memory usage +- **Configurable retention** periods +- **Lazy aggregation** for older data +- **Efficient data structures** (typed arrays for samples) + +### C. Performance Considerations +- **Batch updates** during high throughput +- **Debounced calculations** for expensive metrics +- **Cached results** with TTL +- **Worker thread** option for heavy calculations + +## 5. Configuration Options + +```typescript +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_ +} +``` + +## 6. Example Usage + +```typescript +const proxy = new SmartProxy({ + metrics: { + enabled: true, + sampleIntervalMs: 1000, + enableDetailedTracking: true + } +}); + +// Get metrics instance +const metrics = proxy.getMetrics(); + +// Connection metrics +console.log(`Active connections: ${metrics.connections.active()}`); +console.log(`Total connections: ${metrics.connections.total()}`); + +// Throughput metrics +const instant = metrics.throughput.instant(); +console.log(`Current: ${instant.in} bytes/sec in, ${instant.out} bytes/sec out`); + +const recent = metrics.throughput.recent(); // Last 10 seconds +const average = metrics.throughput.average(); // Last 60 seconds + +// Custom time window +const custom = metrics.throughput.custom(30); // Last 30 seconds + +// Historical data for graphing +const history = metrics.throughput.history(300); // Last 5 minutes +history.forEach(point => { + console.log(`${new Date(point.timestamp)}: ${point.in} bytes/sec in, ${point.out} bytes/sec out`); +}); + +// Top routes by throughput +const routeThroughput = metrics.throughput.byRoute(60); +routeThroughput.forEach((stats, route) => { + console.log(`Route ${route}: ${stats.in} bytes/sec in, ${stats.out} bytes/sec out`); +}); + +// Request metrics +console.log(`RPS: ${metrics.requests.perSecond()}`); +console.log(`RPM: ${metrics.requests.perMinute()}`); + +// Totals +console.log(`Total bytes in: ${metrics.totals.bytesIn()}`); +console.log(`Total bytes out: ${metrics.totals.bytesOut()}`); +``` + +## 7. Prometheus Export Example + +``` +# HELP smartproxy_throughput_bytes_per_second Current throughput in bytes per second +# TYPE smartproxy_throughput_bytes_per_second gauge +smartproxy_throughput_bytes_per_second{direction="in",window="1s"} 1234567 +smartproxy_throughput_bytes_per_second{direction="out",window="1s"} 987654 +smartproxy_throughput_bytes_per_second{direction="in",window="10s"} 1134567 +smartproxy_throughput_bytes_per_second{direction="out",window="10s"} 887654 + +# HELP smartproxy_bytes_total Total bytes transferred +# TYPE smartproxy_bytes_total counter +smartproxy_bytes_total{direction="in"} 123456789 +smartproxy_bytes_total{direction="out"} 98765432 + +# HELP smartproxy_active_connections Current number of active connections +# TYPE smartproxy_active_connections gauge +smartproxy_active_connections 42 + +# HELP smartproxy_connection_duration_seconds Connection duration in seconds +# TYPE smartproxy_connection_duration_seconds histogram +smartproxy_connection_duration_seconds_bucket{le="0.1"} 100 +smartproxy_connection_duration_seconds_bucket{le="1"} 500 +smartproxy_connection_duration_seconds_bucket{le="10"} 800 +smartproxy_connection_duration_seconds_bucket{le="+Inf"} 850 +smartproxy_connection_duration_seconds_sum 4250 +smartproxy_connection_duration_seconds_count 850 +``` + +## 8. Migration Strategy + +### Breaking Changes +- Completely replace the old metrics API with the new clean design +- Remove all `get*` prefixed methods in favor of grouped properties +- Use simple `{ in, out }` objects instead of verbose property names +- Provide clear migration guide in documentation + +### Implementation Approach +1. Create new `ThroughputTracker` class for time-series data +2. Implement new `IMetrics` interface with clean API +3. Replace `MetricsCollector` implementation entirely +4. Update all references to use new API +5. Add comprehensive tests for accuracy validation + +## 9. Success Metrics + +- **Accuracy**: Throughput metrics accurate within 1% of actual +- **Performance**: < 1% CPU overhead for metrics collection +- **Memory**: < 10MB memory usage for 1 hour of data +- **Latency**: < 1ms to retrieve any metric +- **Reliability**: No metrics data loss under load + +## 10. Future Enhancements + +### Phase 5: Advanced Analytics +- Anomaly detection for traffic patterns +- Predictive analytics for capacity planning +- Correlation analysis between routes +- Real-time alerting integration + +### Phase 6: Distributed Metrics +- Metrics aggregation across multiple proxies +- Distributed time-series storage +- Cross-proxy analytics +- Global dashboard support + +## 11. Risks and Mitigations + +### Risk: Memory Usage +- **Mitigation**: Circular buffers and configurable retention +- **Monitoring**: Track memory usage per metric type + +### Risk: Performance Impact +- **Mitigation**: Efficient data structures and caching +- **Testing**: Load test with metrics enabled/disabled + +### Risk: Data Accuracy +- **Mitigation**: Atomic operations and proper synchronization +- **Validation**: Compare with external monitoring tools + +## Conclusion + +This plan transforms SmartProxy's metrics from a basic cumulative system to a comprehensive, time-series based monitoring solution suitable for production environments. The phased approach ensures minimal disruption while delivering immediate value through accurate throughput measurements. \ No newline at end of file diff --git a/test/test.metrics-new.ts b/test/test.metrics-new.ts new file mode 100644 index 0000000..dd2d77b --- /dev/null +++ b/test/test.metrics-new.ts @@ -0,0 +1,261 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../ts/plugins.js'; +import { SmartProxy } from '../ts/index.js'; +import * as net from 'net'; + +let smartProxyInstance: SmartProxy; +let echoServer: net.Server; +const echoServerPort = 9876; +const proxyPort = 8080; + +// Create an echo server for testing +tap.test('should create echo server for testing', async () => { + echoServer = net.createServer((socket) => { + socket.on('data', (data) => { + socket.write(data); // Echo back the data + }); + }); + + await new Promise((resolve) => { + echoServer.listen(echoServerPort, () => { + console.log(`Echo server listening on port ${echoServerPort}`); + resolve(); + }); + }); +}); + +tap.test('should create SmartProxy instance with new metrics', async () => { + smartProxyInstance = new SmartProxy({ + routes: [{ + name: 'test-route', + match: { + matchType: 'startsWith', + matchAgainst: 'domain', + value: ['*'], + ports: [proxyPort] // Add the port to match on + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: echoServerPort + }, + tls: { + mode: 'passthrough' + } + } + }], + defaultTarget: { + host: 'localhost', + port: echoServerPort + }, + metrics: { + enabled: true, + sampleIntervalMs: 100, // Sample every 100ms for faster testing + retentionSeconds: 60 + } + }); + + await smartProxyInstance.start(); +}); + +tap.test('should verify new metrics API structure', async () => { + const metrics = smartProxyInstance.getMetrics(); + + // Check API structure + expect(metrics).toHaveProperty('connections'); + expect(metrics).toHaveProperty('throughput'); + expect(metrics).toHaveProperty('requests'); + expect(metrics).toHaveProperty('totals'); + expect(metrics).toHaveProperty('percentiles'); + + // Check connections methods + expect(metrics.connections).toHaveProperty('active'); + expect(metrics.connections).toHaveProperty('total'); + expect(metrics.connections).toHaveProperty('byRoute'); + expect(metrics.connections).toHaveProperty('byIP'); + expect(metrics.connections).toHaveProperty('topIPs'); + + // Check throughput methods + expect(metrics.throughput).toHaveProperty('instant'); + expect(metrics.throughput).toHaveProperty('recent'); + expect(metrics.throughput).toHaveProperty('average'); + expect(metrics.throughput).toHaveProperty('custom'); + expect(metrics.throughput).toHaveProperty('history'); + expect(metrics.throughput).toHaveProperty('byRoute'); + expect(metrics.throughput).toHaveProperty('byIP'); +}); + +tap.test('should track throughput correctly', async (tools) => { + const metrics = smartProxyInstance.getMetrics(); + + // Initial state - no connections yet + expect(metrics.connections.active()).toEqual(0); + expect(metrics.throughput.instant()).toEqual({ in: 0, out: 0 }); + + // Create a test connection + const client = new net.Socket(); + + await new Promise((resolve, reject) => { + client.connect(proxyPort, 'localhost', () => { + console.log('Connected to proxy'); + resolve(); + }); + + client.on('error', reject); + }); + + // Send some data + const testData = Buffer.from('Hello, World!'.repeat(100)); // ~1.3KB + + await new Promise((resolve) => { + client.write(testData, () => { + console.log('Data sent'); + resolve(); + }); + }); + + // Wait for echo response + await new Promise((resolve) => { + client.once('data', (data) => { + console.log(`Received ${data.length} bytes back`); + resolve(); + }); + }); + + // Wait for metrics to be sampled + await tools.delayFor(200); + + // Check metrics + expect(metrics.connections.active()).toEqual(1); + expect(metrics.requests.total()).toBeGreaterThan(0); + + // Check throughput - should show bytes transferred + const instant = metrics.throughput.instant(); + console.log('Instant throughput:', instant); + + // Should have recorded some throughput + expect(instant.in).toBeGreaterThan(0); + expect(instant.out).toBeGreaterThan(0); + + // Check totals + expect(metrics.totals.bytesIn()).toBeGreaterThan(0); + expect(metrics.totals.bytesOut()).toBeGreaterThan(0); + + // Clean up + client.destroy(); + await tools.delayFor(100); + + // Verify connection was cleaned up + expect(metrics.connections.active()).toEqual(0); +}); + +tap.test('should track multiple connections and routes', async (tools) => { + const metrics = smartProxyInstance.getMetrics(); + + // Create multiple connections + const clients: net.Socket[] = []; + const connectionCount = 5; + + for (let i = 0; i < connectionCount; i++) { + const client = new net.Socket(); + + await new Promise((resolve, reject) => { + client.connect(proxyPort, 'localhost', () => { + resolve(); + }); + + client.on('error', reject); + }); + + clients.push(client); + } + + // Verify active connections + expect(metrics.connections.active()).toEqual(connectionCount); + + // Send data on each connection + const dataPromises = clients.map((client, index) => { + return new Promise((resolve) => { + const data = Buffer.from(`Connection ${index}: `.repeat(50)); + client.write(data, () => { + client.once('data', () => resolve()); + }); + }); + }); + + await Promise.all(dataPromises); + await tools.delayFor(200); + + // Check metrics by route + const routeConnections = metrics.connections.byRoute(); + console.log('Connections by route:', Array.from(routeConnections.entries())); + expect(routeConnections.get('test-route')).toEqual(connectionCount); + + // Check top IPs + const topIPs = metrics.connections.topIPs(5); + console.log('Top IPs:', topIPs); + expect(topIPs.length).toBeGreaterThan(0); + expect(topIPs[0].count).toEqual(connectionCount); + + // Clean up all connections + clients.forEach(client => client.destroy()); + await tools.delayFor(100); + + expect(metrics.connections.active()).toEqual(0); +}); + +tap.test('should provide throughput history', async (tools) => { + const metrics = smartProxyInstance.getMetrics(); + + // Create a connection and send data periodically + const client = new net.Socket(); + + await new Promise((resolve, reject) => { + client.connect(proxyPort, 'localhost', () => resolve()); + client.on('error', reject); + }); + + // Send data every 100ms for 1 second + for (let i = 0; i < 10; i++) { + const data = Buffer.from(`Packet ${i}: `.repeat(100)); + client.write(data); + await tools.delayFor(100); + } + + // Get throughput history + const history = metrics.throughput.history(2); // Last 2 seconds + console.log('Throughput history entries:', history.length); + console.log('Sample history entry:', history[0]); + + expect(history.length).toBeGreaterThan(0); + expect(history[0]).toHaveProperty('timestamp'); + expect(history[0]).toHaveProperty('in'); + expect(history[0]).toHaveProperty('out'); + + // Verify different time windows show different rates + const instant = metrics.throughput.instant(); + const recent = metrics.throughput.recent(); + const average = metrics.throughput.average(); + + console.log('Throughput windows:'); + console.log(' Instant (1s):', instant); + console.log(' Recent (10s):', recent); + console.log(' Average (60s):', average); + + // Clean up + client.destroy(); +}); + +tap.test('should clean up resources', async () => { + await smartProxyInstance.stop(); + + await new Promise((resolve) => { + echoServer.close(() => { + console.log('Echo server closed'); + resolve(); + }); + }); +}); + +tap.start(); \ No newline at end of file diff --git a/ts/proxies/smart-proxy/connection-manager.ts b/ts/proxies/smart-proxy/connection-manager.ts index 205ad00..4322899 100644 --- a/ts/proxies/smart-proxy/connection-manager.ts +++ b/ts/proxies/smart-proxy/connection-manager.ts @@ -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; } diff --git a/ts/proxies/smart-proxy/http-proxy-bridge.ts b/ts/proxies/smart-proxy/http-proxy-bridge.ts index dcb07ae..b585d97 100644 --- a/ts/proxies/smart-proxy/http-proxy-bridge.ts +++ b/ts/proxies/smart-proxy/http-proxy-bridge.ts @@ -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 { - 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)); diff --git a/ts/proxies/smart-proxy/metrics-collector.ts b/ts/proxies/smart-proxy/metrics-collector.ts index 377ccac..99da736 100644 --- a/ts/proxies/smart-proxy/metrics-collector.ts +++ b/ts/proxies/smart-proxy/metrics-collector.ts @@ -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; - connectionsByIP?: Map; - } = { timestamp: 0 }; + // Connection byte tracking for per-route/IP metrics + private connectionByteTrackers = new Map(); - 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 { - 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(); - 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 => { + const routeCounts = new Map(); + 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 => { + const ipCounts = new Map(); + + 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 => { + return this.throughputTracker.getHistory(seconds); + }, + + byRoute: (windowSeconds: number = 60): Map => { + const routeThroughput = new Map(); + const now = Date.now(); + const windowStart = now - (windowSeconds * 1000); + + // Aggregate bytes by route from trackers + const routeBytes = new Map(); + + 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 => { + const ipThroughput = new Map(); + const now = Date.now(); + const windowStart = now - (windowSeconds * 1000); + + // Aggregate bytes by IP from trackers + const ipBytes = new Map(); + + 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 { - 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(); - 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(); diff --git a/ts/proxies/smart-proxy/models/metrics-types.ts b/ts/proxies/smart-proxy/models/metrics-types.ts index 6dc2cb0..bfc054a 100644 --- a/ts/proxies/smart-proxy/models/metrics-types.ts +++ b/ts/proxies/smart-proxy/models/metrics-types.ts @@ -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; - - /** - * Get connection counts grouped by IP address - */ - getConnectionsByIP(): Map; - - /** - * 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; + byIP(): Map; + 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; + byRoute(windowSeconds?: number): Map; + byIP(windowSeconds?: number): Map; + }; - /** - * 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; } \ No newline at end of file diff --git a/ts/proxies/smart-proxy/nftables-manager.ts b/ts/proxies/smart-proxy/nftables-manager.ts index 9d28ae7..586ad84 100644 --- a/ts/proxies/smart-proxy/nftables-manager.ts +++ b/ts/proxies/smart-proxy/nftables-manager.ts @@ -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' }; diff --git a/ts/proxies/smart-proxy/port-manager.ts b/ts/proxies/smart-proxy/port-manager.ts index df68165..0e40c62 100644 --- a/ts/proxies/smart-proxy/port-manager.ts +++ b/ts/proxies/smart-proxy/port-manager.ts @@ -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 = new Map(); - private settings: ISmartProxyOptions; - private routeConnectionHandler: RouteConnectionHandler; private isShuttingDown: boolean = false; // Track how many routes are using each port private portRefCounts: Map = 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((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)' : '' diff --git a/ts/proxies/smart-proxy/route-connection-handler.ts b/ts/proxies/smart-proxy/route-connection-handler.ts index e5988e8..3075b86 100644 --- a/ts/proxies/smart-proxy/route-connection-handler.ts +++ b/ts/proxies/smart-proxy/route-connection-handler.ts @@ -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(); 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); } } \ No newline at end of file diff --git a/ts/proxies/smart-proxy/security-manager.ts b/ts/proxies/smart-proxy/security-manager.ts index d7598f1..217ac26 100644 --- a/ts/proxies/smart-proxy/security-manager.ts +++ b/ts/proxies/smart-proxy/security-manager.ts @@ -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> = new Map(); private connectionRateByIP: Map = 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` }; } diff --git a/ts/proxies/smart-proxy/smart-proxy.ts b/ts/proxies/smart-proxy/smart-proxy.ts index 94c9f84..f1c11ef 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -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> = 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; } diff --git a/ts/proxies/smart-proxy/throughput-tracker.ts b/ts/proxies/smart-proxy/throughput-tracker.ts new file mode 100644 index 0000000..0b9f30c --- /dev/null +++ b/ts/proxies/smart-proxy/throughput-tracker.ts @@ -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; + } +} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/timeout-manager.ts b/ts/proxies/smart-proxy/timeout-manager.ts index 2e6e645..ab0e462 100644 --- a/ts/proxies/smart-proxy/timeout-manager.ts +++ b/ts/proxies/smart-proxy/timeout-manager.ts @@ -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); diff --git a/ts/proxies/smart-proxy/tls-manager.ts b/ts/proxies/smart-proxy/tls-manager.ts index b32c11a..2b6e36e 100644 --- a/ts/proxies/smart-proxy/tls-manager.ts +++ b/ts/proxies/smart-proxy/tls-manager.ts @@ -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