From a459d77b6fcfd09259b4013764c737bc14a3db17 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 22 Jul 2025 10:35:39 +0000 Subject: [PATCH] update --- certs/static-route/meta.json | 6 +- test/test.connection-stability.ts | 286 ++++++++++++++++++ test/test.detection.ts | 20 +- ts/detection/detectors/tls-detector.ts | 39 +-- ts/detection/protocol-detector.ts | 36 ++- ts/proxies/smart-proxy/connection-manager.ts | 13 + .../smart-proxy/route-connection-handler.ts | 14 +- 7 files changed, 362 insertions(+), 52 deletions(-) create mode 100644 test/test.connection-stability.ts diff --git a/certs/static-route/meta.json b/certs/static-route/meta.json index ad004c2..019d7f8 100644 --- a/certs/static-route/meta.json +++ b/certs/static-route/meta.json @@ -1,5 +1,5 @@ { - "expiryDate": "2025-10-19T23:55:27.838Z", - "issueDate": "2025-07-21T23:55:27.838Z", - "savedAt": "2025-07-21T23:55:27.838Z" + "expiryDate": "2025-10-20T10:10:52.985Z", + "issueDate": "2025-07-22T10:10:52.985Z", + "savedAt": "2025-07-22T10:10:52.986Z" } \ No newline at end of file diff --git a/test/test.connection-stability.ts b/test/test.connection-stability.ts new file mode 100644 index 0000000..a10d7ff --- /dev/null +++ b/test/test.connection-stability.ts @@ -0,0 +1,286 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartproxy from '../ts/index.js'; +import * as net from 'net'; +import * as crypto from 'crypto'; + +tap.test('Connection Stability - Fragment Cleanup', async () => { + // Create a simple TCP server + const server = net.createServer(); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const serverPort = (server.address() as net.AddressInfo).port; + + // Configure a route + const routes: smartproxy.IRouteConfig[] = [{ + match: { + ports: 9000, + domains: '*' + }, + action: { + type: 'forward', + target: { + host: '127.0.0.1', + port: serverPort + } + } + }]; + + // Create SmartProxy instance with routes + const proxy = new smartproxy.SmartProxy({ + keepAliveTimeoutMs: 5000, + routes + }); + + await proxy.start(); + + // Test 1: Send fragmented TLS hello + const tlsHello = Buffer.concat([ + Buffer.from([0x16, 0x03, 0x03]), // TLS handshake, version 1.2 + Buffer.from([0x00, 0x50]), // Length: 80 bytes + Buffer.from([0x01]), // ClientHello + Buffer.from([0x00, 0x00, 0x4c]), // Handshake length + Buffer.from([0x03, 0x03]), // TLS 1.2 + crypto.randomBytes(32), // Random + Buffer.from([0x00]), // Session ID length + Buffer.from([0x00, 0x04]), // Cipher suites length + Buffer.from([0xc0, 0x2f, 0xc0, 0x30]), // Cipher suites + Buffer.from([0x01, 0x00]), // Compression methods + Buffer.from([0x00, 0x1f]), // Extensions length + // SNI extension + Buffer.from([0x00, 0x00]), // Server name extension + Buffer.from([0x00, 0x1b]), // Extension length + Buffer.from([0x00, 0x19]), // Server name list length + Buffer.from([0x00]), // Host name type + Buffer.from([0x00, 0x16]), // Name length + Buffer.from('test.example.com') // Server name + ]); + + // Function to check fragment manager size + const getFragmentCount = () => { + // Access the fragment manager through the singleton + const detector = (smartproxy.detection.ProtocolDetector as any).getInstance(); + const tlsFragments = detector.fragmentManager.getHandler('tls'); + const httpFragments = detector.fragmentManager.getHandler('http'); + return tlsFragments.size + httpFragments.size; + }; + + // Test fragmented connections + const connections: net.Socket[] = []; + + // Create multiple fragmented connections + for (let i = 0; i < 5; i++) { + const client = new net.Socket(); + connections.push(client); + + await new Promise((resolve, reject) => { + client.connect(9000, '127.0.0.1', () => { + // Send first fragment + client.write(tlsHello.subarray(0, 20)); + resolve(); + }); + client.on('error', reject); + }); + } + + // Give time for fragments to accumulate + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check that fragments are being tracked + const fragmentCount = getFragmentCount(); + expect(fragmentCount).toBeGreaterThan(0); + + // Send remaining fragments and close connections + for (const client of connections) { + client.write(tlsHello.subarray(20)); + client.end(); + } + + // Wait for connections to close + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check that fragments are cleaned up + const finalFragmentCount = getFragmentCount(); + expect(finalFragmentCount).toEqual(0); + + // Cleanup + await proxy.stop(); + server.close(); +}); + +tap.test('Connection Stability - Memory Leak Prevention', async () => { + // Create a simple echo server + const server = net.createServer((socket) => { + socket.pipe(socket); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const serverPort = (server.address() as net.AddressInfo).port; + + // Configure a route + const routes: smartproxy.IRouteConfig[] = [{ + match: { + ports: 9001, + domains: '*' + }, + action: { + type: 'forward', + target: { + host: '127.0.0.1', + port: serverPort + } + } + }]; + + // Create SmartProxy instance with routes + const proxy = new smartproxy.SmartProxy({ + keepAliveTimeoutMs: 5000, + routes + }); + + await proxy.start(); + + // Function to get active connection count + const getConnectionCount = () => { + const connectionManager = (proxy as any).connectionManager; + return connectionManager.getActiveConnectionCount(); + }; + + // Create many short-lived connections + const connectionPromises: Promise[] = []; + + for (let i = 0; i < 20; i++) { + const promise = new Promise((resolve, reject) => { + const client = new net.Socket(); + + client.connect(9001, '127.0.0.1', () => { + // Send some data + client.write('Hello World'); + + // Close after a short delay + setTimeout(() => { + client.end(); + }, 50); + }); + + client.on('close', () => resolve()); + client.on('error', reject); + }); + + connectionPromises.push(promise); + + // Stagger connection creation + await new Promise(resolve => setTimeout(resolve, 10)); + } + + // Wait for all connections to complete + await Promise.all(connectionPromises); + + // Give time for cleanup + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Check that all connections are cleaned up + const finalConnectionCount = getConnectionCount(); + expect(finalConnectionCount).toEqual(0); + + // Check fragment cleanup + const fragmentCount = (() => { + const detector = (smartproxy.detection.ProtocolDetector as any).getInstance(); + const tlsFragments = detector.fragmentManager.getHandler('tls'); + const httpFragments = detector.fragmentManager.getHandler('http'); + return tlsFragments.size + httpFragments.size; + })(); + + expect(fragmentCount).toEqual(0); + + // Cleanup + await proxy.stop(); + server.close(); +}); + +tap.test('Connection Stability - Rapid Connect/Disconnect', async () => { + // Create a server that immediately closes connections + const server = net.createServer((socket) => { + socket.end(); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const serverPort = (server.address() as net.AddressInfo).port; + + // Configure a route + const routes: smartproxy.IRouteConfig[] = [{ + match: { + ports: 9002, + domains: '*' + }, + action: { + type: 'forward', + target: { + host: '127.0.0.1', + port: serverPort + } + } + }]; + + // Create SmartProxy instance with routes + const proxy = new smartproxy.SmartProxy({ + keepAliveTimeoutMs: 5000, + routes + }); + + await proxy.start(); + + let errors = 0; + const connections: Promise[] = []; + + // Create many rapid connections + for (let i = 0; i < 50; i++) { + const promise = new Promise((resolve) => { + const client = new net.Socket(); + + client.on('error', () => { + errors++; + resolve(); + }); + + client.on('close', () => { + resolve(); + }); + + client.connect(9002, '127.0.0.1'); + }); + + connections.push(promise); + } + + // Wait for all to complete + await Promise.all(connections); + + // Give time for cleanup + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check that connections are cleaned up despite rapid connect/disconnect + const connectionManager = (proxy as any).connectionManager; + const finalConnectionCount = connectionManager.getActiveConnectionCount(); + expect(finalConnectionCount).toEqual(0); + + // Check fragment cleanup + const fragmentCount = (() => { + const detector = (smartproxy.detection.ProtocolDetector as any).getInstance(); + const tlsFragments = detector.fragmentManager.getHandler('tls'); + const httpFragments = detector.fragmentManager.getHandler('http'); + return tlsFragments.size + httpFragments.size; + })(); + + expect(fragmentCount).toEqual(0); + + // Cleanup + await proxy.stop(); + server.close(); +}); + +tap.start(); \ No newline at end of file diff --git a/test/test.detection.ts b/test/test.detection.ts index 1f43b08..57f5ff0 100644 --- a/test/test.detection.ts +++ b/test/test.detection.ts @@ -80,28 +80,38 @@ tap.test('Protocol Detection - Unknown Protocol', async () => { }); tap.test('Protocol Detection - Fragmented HTTP', async () => { - const connectionId = 'test-connection-1'; + // Create connection context + const context = smartproxy.detection.ProtocolDetector.createConnectionContext({ + sourceIp: '127.0.0.1', + sourcePort: 12345, + destIp: '127.0.0.1', + destPort: 80, + socketId: 'test-connection-1' + }); // First fragment const fragment1 = Buffer.from('GET /test HT'); - let result = await smartproxy.detection.ProtocolDetector.detectWithConnectionTracking( + let result = await smartproxy.detection.ProtocolDetector.detectWithContext( fragment1, - connectionId + context ); expect(result.protocol).toEqual('http'); expect(result.isComplete).toEqual(false); // Second fragment const fragment2 = Buffer.from('TP/1.1\r\nHost: example.com\r\n\r\n'); - result = await smartproxy.detection.ProtocolDetector.detectWithConnectionTracking( + result = await smartproxy.detection.ProtocolDetector.detectWithContext( fragment2, - connectionId + context ); expect(result.protocol).toEqual('http'); expect(result.isComplete).toEqual(true); expect(result.connectionInfo.method).toEqual('GET'); expect(result.connectionInfo.path).toEqual('/test'); expect(result.connectionInfo.domain).toEqual('example.com'); + + // Clean up fragments + smartproxy.detection.ProtocolDetector.cleanupConnection(context); }); tap.test('Protocol Detection - HTTP Methods', async () => { diff --git a/ts/detection/detectors/tls-detector.ts b/ts/detection/detectors/tls-detector.ts index 0f60641..4b5f9d0 100644 --- a/ts/detection/detectors/tls-detector.ts +++ b/ts/detection/detectors/tls-detector.ts @@ -5,7 +5,7 @@ // TLS detector doesn't need plugins imports import type { IProtocolDetector } from '../models/interfaces.js'; import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from '../models/detection-types.js'; -import { readUInt16BE, BufferAccumulator } from '../utils/buffer-utils.js'; +import { readUInt16BE } from '../utils/buffer-utils.js'; import { tlsVersionToString } from '../utils/parser-utils.js'; // Import from protocols @@ -24,17 +24,6 @@ export class TlsDetector implements IProtocolDetector { */ private static readonly MIN_TLS_HEADER_SIZE = 5; - /** - * Fragment tracking for incomplete handshakes - */ - private static fragmentedBuffers = new Map(); - - /** - * Create connection ID from context - */ - private createConnectionId(context: { sourceIp?: string; sourcePort?: number; destIp?: string; destPort?: number }): string { - return `${context.sourceIp || 'unknown'}:${context.sourcePort || 0}->${context.destIp || 'unknown'}:${context.destPort || 0}`; - } /** * Detect TLS protocol from buffer @@ -224,29 +213,11 @@ export class TlsDetector implements IProtocolDetector { */ detectWithContext( buffer: Buffer, - context: { sourceIp?: string; sourcePort?: number; destIp?: string; destPort?: number }, + _context: { sourceIp?: string; sourcePort?: number; destIp?: string; destPort?: number }, options?: IDetectionOptions ): IDetectionResult | null { - const connectionId = this.createConnectionId(context); - - // Get or create buffer accumulator for this connection - let accumulator = TlsDetector.fragmentedBuffers.get(connectionId); - if (!accumulator) { - accumulator = new BufferAccumulator(); - TlsDetector.fragmentedBuffers.set(connectionId, accumulator); - } - - // Add new data - accumulator.append(buffer); - - // Try detection on accumulated data - const result = this.detect(accumulator.getBuffer(), options); - - // If detection is complete or we have too much data, clean up - if (result?.isComplete || accumulator.length() > 65536) { - TlsDetector.fragmentedBuffers.delete(connectionId); - } - - return result; + // This method is deprecated - TLS detection should use the fragment manager + // from the parent detector system, not maintain its own fragments + return this.detect(buffer, options); } } \ No newline at end of file diff --git a/ts/detection/protocol-detector.ts b/ts/detection/protocol-detector.ts index 2caa52b..c6c3945 100644 --- a/ts/detection/protocol-detector.ts +++ b/ts/detection/protocol-detector.ts @@ -120,10 +120,28 @@ export class ProtocolDetector { }; } + const connectionId = DetectionFragmentManager.createConnectionId(context); + // First peek to determine protocol type if (this.tlsDetector.canHandle(buffer)) { - const result = this.tlsDetector.detectWithContext(buffer, context, options); + // Handle TLS with fragment accumulation + const handler = this.fragmentManager.getHandler('tls'); + const fragmentResult = handler.addFragment(connectionId, buffer); + + if (fragmentResult.error) { + handler.complete(connectionId); + return { + protocol: 'unknown', + connectionInfo: { protocol: 'unknown' }, + isComplete: true + }; + } + + const result = this.tlsDetector.detect(fragmentResult.buffer!, options); if (result) { + if (result.isComplete) { + handler.complete(connectionId); + } return result; } } @@ -173,13 +191,25 @@ export class ProtocolDetector { /** * Clean up old connection tracking entries * - * @param maxAge Maximum age in milliseconds (default: 30 seconds) + * @param _maxAge Maximum age in milliseconds (default: 30 seconds) */ - static cleanupConnections(maxAge: number = 30000): void { + static cleanupConnections(_maxAge: number = 30000): void { // Cleanup is now handled internally by the fragment manager this.getInstance().fragmentManager.cleanup(); } + /** + * Clean up fragments for a specific connection + */ + static cleanupConnection(context: IConnectionContext): void { + const instance = this.getInstance(); + const connectionId = DetectionFragmentManager.createConnectionId(context); + + // Clean up both TLS and HTTP fragments for this connection + instance.fragmentManager.getHandler('tls').complete(connectionId); + instance.fragmentManager.getHandler('http').complete(connectionId); + } + /** * Extract domain from connection info */ diff --git a/ts/proxies/smart-proxy/connection-manager.ts b/ts/proxies/smart-proxy/connection-manager.ts index 1f05086..3d41728 100644 --- a/ts/proxies/smart-proxy/connection-manager.ts +++ b/ts/proxies/smart-proxy/connection-manager.ts @@ -5,6 +5,7 @@ import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.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 { ProtocolDetector } from '../../detection/index.js'; import type { SmartProxy } from './smart-proxy.js'; /** @@ -323,6 +324,18 @@ export class ConnectionManager extends LifecycleComponent { this.smartProxy.metricsCollector.removeConnection(record.id); } + // Clean up protocol detection fragments + const context = ProtocolDetector.createConnectionContext({ + sourceIp: record.remoteIP, + sourcePort: record.incoming?.remotePort || 0, + destIp: record.incoming?.localAddress || '', + destPort: record.localPort, + socketId: record.id + }); + + // Clean up any pending detection fragments for this connection + ProtocolDetector.cleanupConnection(context); + if (record.cleanupTimer) { clearTimeout(record.cleanupTimer); record.cleanupTimer = undefined; diff --git a/ts/proxies/smart-proxy/route-connection-handler.ts b/ts/proxies/smart-proxy/route-connection-handler.ts index 8f88cb8..cce73ef 100644 --- a/ts/proxies/smart-proxy/route-connection-handler.ts +++ b/ts/proxies/smart-proxy/route-connection-handler.ts @@ -303,18 +303,18 @@ export class RouteConnectionHandler { // Handler for processing initial data (after potential PROXY protocol) const processInitialData = async (chunk: Buffer) => { - // Use ProtocolDetector to identify protocol - const connectionId = ProtocolDetector.createConnectionId({ + // Create connection context for protocol detection + const context = ProtocolDetector.createConnectionContext({ sourceIp: record.remoteIP, - sourcePort: socket.remotePort, - destIp: socket.localAddress, - destPort: socket.localPort, + sourcePort: socket.remotePort || 0, + destIp: socket.localAddress || '', + destPort: socket.localPort || 0, socketId: record.id }); - const detectionResult = await ProtocolDetector.detectWithConnectionTracking( + const detectionResult = await ProtocolDetector.detectWithContext( chunk, - connectionId, + context, { extractFullHeaders: false } // Only extract essential info for routing );