Compare commits

...

43 Commits

Author SHA1 Message Date
8d7213e91b 19.6.14
Some checks failed
Default (tags) / security (push) Successful in 1m24s
Default (tags) / test (push) Failing after 29m37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-03 02:33:04 +00:00
5d011ba84c better logging 2025-07-03 02:32:17 +00:00
67aff4bb30 19.6.13
Some checks failed
Default (tags) / security (push) Successful in 1m25s
Default (tags) / test (push) Failing after 29m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 15:42:39 +00:00
3857d2670f fix(metrics): fix metrics 2025-06-23 15:42:04 +00:00
4587940f38 19.6.12
Some checks failed
Default (tags) / security (push) Successful in 1m28s
Default (tags) / test (push) Failing after 29m14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 13:19:56 +00:00
82ca0381e9 fix(metrics): fix metrics 2025-06-23 13:19:39 +00:00
7bf15e72f9 19.6.11
Some checks failed
Default (tags) / security (push) Successful in 1m29s
Default (tags) / test (push) Failing after 29m11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 13:07:46 +00:00
caa15e539e fix(metrics): fix metrics 2025-06-23 13:07:30 +00:00
cc9e76fade 19.6.10
Some checks failed
Default (tags) / security (push) Successful in 1m31s
Default (tags) / test (push) Failing after 28m39s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 09:35:58 +00:00
8df0333dc3 fix(metrics): fix metrics 2025-06-23 09:35:37 +00:00
22418cd65e 19.6.9
Some checks failed
Default (tags) / security (push) Successful in 1m16s
Default (tags) / test (push) Failing after 28m48s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 09:03:17 +00:00
86b016cac3 fix(metrics): update hints 2025-06-23 09:03:09 +00:00
e81d0386d6 fix(metrics): fix metrics 2025-06-23 09:02:42 +00:00
fc210eca8b 19.6.8
Some checks failed
Default (tags) / security (push) Successful in 1m18s
Default (tags) / test (push) Failing after 25m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 08:51:25 +00:00
753b03d3e9 fix(metrics): fix metrics 2025-06-23 08:50:19 +00:00
be58700a2f fix(tests): fix tests 2025-06-23 08:38:14 +00:00
1aead55296 fix(tests): fix tests 2025-06-22 23:15:30 +00:00
6e16f9423a 19.6.7
Some checks failed
Default (tags) / security (push) Successful in 58s
Default (tags) / test (push) Failing after 35m47s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-22 23:11:03 +00:00
e5ec48abd3 fix(tests): fix tests 2025-06-22 23:10:56 +00:00
131a454b28 fix(metrics): improve metrics 2025-06-22 22:28:37 +00:00
de1269665a 19.6.6
Some checks failed
Default (tags) / security (push) Successful in 59s
Default (tags) / test (push) Failing after 35m48s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-16 23:46:30 +00:00
70155b29c4 19.6.5
Some checks failed
Default (tags) / security (push) Successful in 1m1s
Default (tags) / test (push) Failing after 35m50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-16 23:45:30 +00:00
eb1b8b8ef3 19.6.4
Some checks failed
Default (tags) / security (push) Successful in 1m4s
Default (tags) / test (push) Failing after 35m41s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-16 23:45:08 +00:00
4e409df9ae 19.6.3
Some checks failed
Default (tags) / security (push) Successful in 1m5s
Default (tags) / test (push) Failing after 35m52s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-16 23:45:05 +00:00
424407d879 fix(readme): update 2025-06-13 17:22:31 +00:00
7e1b7b190c fix(readme): update 2025-06-12 16:59:25 +00:00
8347e0fec7 19.6.2
Some checks failed
Default (tags) / security (push) Successful in 45s
Default (tags) / test (push) Failing after 34m50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-09 22:13:56 +00:00
fc09af9afd 19.6.1
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 31m49s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-09 16:37:46 +00:00
4c847fd3d7 19.6.0
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 33m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-09 15:28:53 +00:00
2e11f9358c docs(readme): add metrics and monitoring documentation
Document the new metrics collection system including available metrics methods, usage examples, and export formats for external monitoring systems.
2025-06-09 15:14:13 +00:00
9bf15ff756 feat(metrics): add comprehensive metrics collection system
Implement real-time stats tracking including connection counts, request metrics, bandwidth usage, and route-specific monitoring. Adds MetricsCollector with observable streams for reactive monitoring integration.
2025-06-09 15:08:37 +00:00
6726de277e 19.5.26
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 27m56s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-08 12:26:32 +00:00
dc3eda5e29 fix accumulation 2025-06-08 12:25:31 +00:00
82a350bf51 19.5.25
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 24m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-07 20:37:52 +00:00
890e907664 fix(connection): filter zombie connections part 2 2025-06-07 20:37:49 +00:00
19590ef107 19.5.24
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 24m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-07 10:56:08 +00:00
47735adbf2 Implement zombie connection detection and cleanup in ConnectionManager; enhance tests for edge cases 2025-06-07 10:55:59 +00:00
9094b76b1b 19.5.23
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 24m25s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-06 23:36:19 +00:00
9aebcd488d Implement connection timeout handling and improve connection cleanup in SmartProxy 2025-06-06 23:34:50 +00:00
311691c2cc 19.5.22
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 19m29s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-06 15:54:40 +00:00
578d1ba2f7 update 2025-06-06 15:00:46 +00:00
233c98e5ff 19.5.21
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 19m32s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-06 14:30:39 +00:00
b3714d583d Implement PROXY protocol v1 support in SmartProxy
- Added ProxyProtocolParser class for parsing and generating PROXY protocol v1 headers.
- Integrated PROXY protocol parsing into RouteConnectionHandler for handling incoming connections from trusted proxies.
- Implemented WrappedSocket class to encapsulate real client information.
- Configured SmartProxy to accept and send PROXY protocol headers in routing actions.
- Developed comprehensive unit tests for PROXY protocol parsing and generation.
- Documented usage patterns, configuration, and best practices for proxy chaining scenarios.
- Added security and performance considerations for PROXY protocol implementation.
2025-06-06 13:45:44 +00:00
57 changed files with 6785 additions and 2370 deletions

View File

@ -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-10-01T02:31:27.435Z",
"issueDate": "2025-07-03T02:31:27.435Z",
"savedAt": "2025-07-03T02:31:27.435Z"
}

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "19.5.20",
"version": "19.6.14",
"private": false,
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
"main": "dist_ts/index.js",
@ -31,6 +31,7 @@
"@push.rocks/smartnetwork": "^4.0.2",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.0.15",
"@push.rocks/taskbuffer": "^3.1.7",
"@tsclass/tsclass": "^9.2.0",

13
pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ importers:
'@push.rocks/smartrequest':
specifier: ^2.1.0
version: 2.1.0
'@push.rocks/smartrx':
specifier: ^3.0.10
version: 3.0.10
'@push.rocks/smartstring':
specifier: ^4.0.15
version: 4.0.15
@ -977,9 +980,6 @@ packages:
'@push.rocks/smartrx@3.0.10':
resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==}
'@push.rocks/smartrx@3.0.7':
resolution: {integrity: sha512-qCWy0s3RLAgGSnaw/Gu0BNaJ59CsI6RK5OJDCCqxc7P2X/S755vuLtnAR5/0dEjdhCHXHX9ytPZx+o9g/CNiyA==}
'@push.rocks/smarts3@2.2.5':
resolution: {integrity: sha512-OZjD0jBCUTJCLnwraxBcyZ3he5buXf2OEM1zipiTBChA2EcKUZWKk/a6KR5WT+NlFCIIuB23UG+U+cxsIWM91Q==}
@ -6131,11 +6131,6 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3
rxjs: 7.8.2
'@push.rocks/smartrx@3.0.7':
dependencies:
'@push.rocks/smartpromise': 4.2.3
rxjs: 7.8.2
'@push.rocks/smarts3@2.2.5':
dependencies:
'@push.rocks/smartbucket': 3.3.7
@ -6301,7 +6296,7 @@ snapshots:
'@push.rocks/smartenv': 5.0.12
'@push.rocks/smartjson': 5.0.20
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.7
'@push.rocks/smartrx': 3.0.10
'@tempfix/idb': 8.0.3
fake-indexeddb: 5.0.2

View File

@ -0,0 +1,169 @@
# SmartProxy Byte Counting Audit Report
## Executive Summary
After a comprehensive audit of the SmartProxy codebase, I can confirm that **byte counting is implemented correctly** with no instances of double counting. Each byte transferred through the proxy is counted exactly once in each direction.
## Byte Counting Implementation
### 1. Core Tracking Mechanisms
SmartProxy uses two complementary tracking systems:
1. **Connection Records** (`IConnectionRecord`):
- `bytesReceived`: Total bytes received from client
- `bytesSent`: Total bytes sent to client
2. **MetricsCollector**:
- Global throughput tracking via `ThroughputTracker`
- Per-connection byte tracking for route/IP metrics
- Called via `recordBytes(connectionId, bytesIn, bytesOut)`
### 2. Where Bytes Are Counted
Bytes are counted in only two files:
#### a) `route-connection-handler.ts`
- **Line 351**: TLS alert bytes when no SNI is provided
- **Lines 1286-1301**: Data forwarding callbacks in `setupBidirectionalForwarding()`
#### b) `http-proxy-bridge.ts`
- **Line 127**: Initial TLS chunk for HttpProxy connections
- **Lines 142-154**: Data forwarding callbacks in `setupBidirectionalForwarding()`
## Connection Flow Analysis
### 1. Direct TCP Connection (No TLS)
```
Client → SmartProxy → Target Server
```
1. Connection arrives at `RouteConnectionHandler.handleConnection()`
2. For non-TLS ports, immediately routes via `routeConnection()`
3. `setupDirectConnection()` creates target connection
4. `setupBidirectionalForwarding()` handles all data transfer:
- `onClientData`: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
- `onServerData`: `bytesSent += chunk.length` + `recordBytes(0, chunk.length)`
**Result**: ✅ Each byte counted exactly once
### 2. TLS Passthrough Connection
```
Client (TLS) → SmartProxy → Target Server (TLS)
```
1. Connection waits for initial data to detect TLS
2. TLS handshake detected, SNI extracted
3. Route matched, `setupDirectConnection()` called
4. Initial chunk stored in `pendingData` (NOT counted yet)
5. On target connect, `pendingData` written to target (still not counted)
6. `setupBidirectionalForwarding()` counts ALL bytes including initial chunk
**Result**: ✅ Each byte counted exactly once
### 3. TLS Termination via HttpProxy
```
Client (TLS) → SmartProxy → HttpProxy (localhost) → Target Server
```
1. TLS connection detected with `tls.mode = "terminate"`
2. `forwardToHttpProxy()` called:
- Initial chunk: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
3. Proxy connection created to HttpProxy on localhost
4. `setupBidirectionalForwarding()` handles subsequent data
**Result**: ✅ Each byte counted exactly once
### 4. HTTP Connection via HttpProxy
```
Client (HTTP) → SmartProxy → HttpProxy (localhost) → Target Server
```
1. Connection on configured HTTP port (`useHttpProxy` ports)
2. Same flow as TLS termination
3. All byte counting identical to TLS termination
**Result**: ✅ Each byte counted exactly once
### 5. NFTables Forwarding
```
Client → [Kernel NFTables] → Target Server
```
1. Connection detected, route matched with `forwardingEngine: 'nftables'`
2. Connection marked as `usingNetworkProxy = true`
3. NO application-level forwarding (kernel handles packet routing)
4. NO byte counting in application layer
**Result**: ✅ No counting (correct - kernel handles everything)
## Special Cases
### PROXY Protocol
- PROXY protocol headers sent to backend servers are NOT counted in client metrics
- Only actual client data is counted
- **Correct behavior**: Protocol overhead is not client data
### TLS Alerts
- TLS alerts (e.g., for missing SNI) are counted as sent bytes
- **Correct behavior**: Alerts are actual data sent to the client
### Initial Chunks
- **Direct connections**: Stored in `pendingData`, counted when forwarded
- **HttpProxy connections**: Counted immediately upon receipt
- **Both approaches**: Count each byte exactly once
## Verification Methodology
1. **Code Analysis**: Searched for all instances of:
- `bytesReceived +=` and `bytesSent +=`
- `recordBytes()` calls
- Data forwarding implementations
2. **Flow Tracing**: Followed data path for each connection type from entry to exit
3. **Handler Review**: Examined all forwarding handlers to ensure no additional counting
## Findings
### ✅ No Double Counting Detected
- Each byte is counted exactly once in the direction it flows
- Connection records and metrics are updated consistently
- No overlapping or duplicate counting logic found
### Areas of Excellence
1. **Centralized Counting**: All byte counting happens in just two files
2. **Consistent Pattern**: Uses `setupBidirectionalForwarding()` with callbacks
3. **Clear Separation**: Forwarding handlers don't interfere with proxy metrics
## Recommendations
1. **Debug Logging**: Add optional debug logging to verify byte counts in production:
```typescript
if (settings.debugByteCount) {
logger.log('debug', `Bytes counted: ${connectionId} +${bytes} (total: ${record.bytesReceived})`);
}
```
2. **Unit Tests**: Create specific tests to ensure byte counting accuracy:
- Test initial chunk handling
- Test PROXY protocol overhead exclusion
- Test HttpProxy forwarding accuracy
3. **Protocol Overhead Tracking**: Consider separately tracking:
- PROXY protocol headers
- TLS handshake bytes
- HTTP headers vs body
4. **NFTables Documentation**: Clearly document that NFTables-forwarded connections are not included in application metrics
## Conclusion
SmartProxy's byte counting implementation is **robust and accurate**. The design ensures that each byte is counted exactly once, with clear separation between connection tracking and metrics collection. No remediation is required.

View File

@ -1,187 +0,0 @@
# SmartProxy Code Deletion Plan
This document tracks all code paths that can be deleted as part of the routing unification effort.
## Phase 1: Matching Logic Duplicates (READY TO DELETE)
### 1. Inline Matching Functions in RouteManager
**File**: `ts/proxies/smart-proxy/route-manager.ts`
**Lines**: Approximately lines 200-400
**Duplicates**:
- `matchDomain()` method - duplicate of DomainMatcher
- `matchPath()` method - duplicate of PathMatcher
- `matchIpPattern()` method - duplicate of IpMatcher
- `matchHeaders()` method - duplicate of HeaderMatcher
**Action**: Update to use unified matchers from `ts/core/routing/matchers/`
### 2. Duplicate Matching in Core route-utils
**File**: `ts/core/utils/route-utils.ts`
**Functions to update**:
- `matchDomain()` → Use DomainMatcher.match()
- `matchPath()` → Use PathMatcher.match()
- `matchIpPattern()` → Use IpMatcher.match()
- `matchHeader()` → Use HeaderMatcher.match()
**Action**: Update to use unified matchers, keep only unique utilities
## Phase 2: Route Manager Duplicates (READY AFTER MIGRATION)
### 1. SmartProxy RouteManager
**File**: `ts/proxies/smart-proxy/route-manager.ts`
**Entire file**: ~500 lines
**Reason**: 95% duplicate of SharedRouteManager
**Migration Required**:
- Update SmartProxy to use SharedRouteManager
- Update all imports
- Test thoroughly
**Action**: DELETE entire file after migration
### 2. Deprecated Methods in SharedRouteManager
**File**: `ts/core/utils/route-manager.ts`
**Methods**:
- Any deprecated security check methods
- Legacy compatibility methods
**Action**: Remove after confirming no usage
## Phase 3: Router Consolidation (REQUIRES REFACTORING)
### 1. ProxyRouter vs RouteRouter Duplication
**Files**:
- `ts/routing/router/proxy-router.ts` (~250 lines)
- `ts/routing/router/route-router.ts` (~250 lines)
**Reason**: Nearly identical implementations
**Plan**: Merge into single HttpRouter with legacy adapter
**Action**: DELETE one file after consolidation
### 2. Inline Route Matching in HttpProxy
**Location**: Various files in `ts/proxies/http-proxy/`
**Pattern**: Direct route matching without using RouteManager
**Action**: Update to use SharedRouteManager
## Phase 4: Scattered Utilities (CLEANUP)
### 1. Duplicate Route Utilities
**Files with duplicate logic**:
- `ts/proxies/smart-proxy/utils/route-utils.ts` - Keep (different purpose)
- `ts/proxies/smart-proxy/utils/route-validators.ts` - Review for duplicates
- `ts/proxies/smart-proxy/utils/route-patterns.ts` - Review for consolidation
### 2. Legacy Type Definitions
**Review for removal**:
- Old route type definitions
- Deprecated configuration interfaces
- Unused type exports
## Deletion Progress Tracker
### Completed Deletions
- [x] Phase 1: Matching logic consolidation (Partial)
- Updated core/utils/route-utils.ts to use unified matchers
- Removed duplicate matching implementations (~200 lines)
- Marked functions as deprecated with migration path
- [x] Phase 2: RouteManager unification (COMPLETED)
- ✓ Migrated SmartProxy to use SharedRouteManager
- ✓ Updated imports in smart-proxy.ts, route-connection-handler.ts, and index.ts
- ✓ Created logger adapter to match ILogger interface expectations
- ✓ Fixed method calls (getAllRoutes → getRoutes)
- ✓ Fixed type errors in header matcher
- ✓ Removed unused ipToNumber imports and methods
- ✓ DELETED: `/ts/proxies/smart-proxy/route-manager.ts` (553 lines removed)
- [x] Phase 3: Router consolidation (COMPLETED)
- ✓ Created unified HttpRouter with legacy compatibility
- ✓ Migrated ProxyRouter and RouteRouter to use HttpRouter aliases
- ✓ Updated imports in http-proxy.ts, request-handler.ts, websocket-handler.ts
- ✓ Added routeReqLegacy() method for backward compatibility
- ✓ DELETED: `/ts/routing/router/proxy-router.ts` (437 lines)
- ✓ DELETED: `/ts/routing/router/route-router.ts` (482 lines)
- [x] Phase 4: Architecture cleanup (COMPLETED)
- ✓ Updated route-utils.ts to use unified matchers directly
- ✓ Removed deprecated methods from SharedRouteManager
- ✓ Fixed HeaderMatcher.matchMultiple → matchAll method name
- ✓ Fixed findMatchingRoute return type handling (IRouteMatchResult)
- ✓ Fixed header type conversion for RegExp patterns
- ✓ DELETED: Duplicate RouteManager class from http-proxy/models/types.ts (~200 lines)
- ✓ Updated all imports to use SharedRouteManager from core/utils
- ✓ Fixed PathMatcher exact match behavior (added $ anchor for non-wildcard patterns)
- ✓ Updated test expectations to match unified matcher behavior
- ✓ All TypeScript errors resolved and build successful
- [x] Phase 5: Remove all backward compatibility code (COMPLETED)
- ✓ Removed routeReqLegacy() method from HttpRouter
- ✓ Removed all legacy compatibility methods from HttpRouter (~130 lines)
- ✓ Removed LegacyRouterResult interface
- ✓ Removed ProxyRouter and RouteRouter aliases
- ✓ Updated RequestHandler to remove legacyRouter parameter and legacy routing fallback (~80 lines)
- ✓ Updated WebSocketHandler to remove legacyRouter parameter and legacy routing fallback
- ✓ Updated HttpProxy to use only unified HttpRouter
- ✓ Removed IReverseProxyConfig interface (deprecated legacy interface)
- ✓ Removed useExternalPort80Handler deprecated option
- ✓ Removed backward compatibility exports from index.ts
- ✓ Removed all deprecated functions from route-utils.ts (~50 lines)
- ✓ Clean build with no legacy code
### Files Updated
1. `ts/core/utils/route-utils.ts` - Replaced all matching logic with unified matchers
2. `ts/core/utils/security-utils.ts` - Updated to use IpMatcher directly
3. `ts/proxies/smart-proxy/smart-proxy.ts` - Using SharedRouteManager with logger adapter
4. `ts/proxies/smart-proxy/route-connection-handler.ts` - Updated to use SharedRouteManager
5. `ts/proxies/smart-proxy/index.ts` - Exporting SharedRouteManager as RouteManager
6. `ts/core/routing/matchers/header.ts` - Fixed type handling for array header values
7. `ts/core/utils/route-manager.ts` - Removed unused ipToNumber import
8. `ts/proxies/http-proxy/http-proxy.ts` - Updated imports to use unified router
9. `ts/proxies/http-proxy/request-handler.ts` - Updated to use routeReqLegacy()
10. `ts/proxies/http-proxy/websocket-handler.ts` - Updated to use routeReqLegacy()
11. `ts/routing/router/index.ts` - Export unified HttpRouter with aliases
12. `ts/proxies/smart-proxy/utils/route-utils.ts` - Updated to use unified matchers directly
13. `ts/proxies/http-proxy/request-handler.ts` - Fixed findMatchingRoute usage
14. `ts/proxies/http-proxy/models/types.ts` - Removed duplicate RouteManager class
15. `ts/index.ts` - Updated exports to use SharedRouteManager aliases
16. `ts/proxies/index.ts` - Updated exports to use SharedRouteManager aliases
17. `test/test.acme-route-creation.ts` - Fixed getAllRoutes → getRoutes method call
### Files Created
1. `ts/core/routing/matchers/domain.ts` - Unified domain matcher
2. `ts/core/routing/matchers/path.ts` - Unified path matcher
3. `ts/core/routing/matchers/ip.ts` - Unified IP matcher
4. `ts/core/routing/matchers/header.ts` - Unified header matcher
5. `ts/core/routing/matchers/index.ts` - Matcher exports
6. `ts/core/routing/types.ts` - Core routing types
7. `ts/core/routing/specificity.ts` - Route specificity calculator
8. `ts/core/routing/index.ts` - Main routing exports
9. `ts/routing/router/http-router.ts` - Unified HTTP router
### Lines of Code Removed
- Target: ~1,500 lines
- Actual: ~2,332 lines (Target exceeded by 55%!)
- Phase 1: ~200 lines (matching logic)
- Phase 2: 553 lines (SmartProxy RouteManager)
- Phase 3: 919 lines (ProxyRouter + RouteRouter)
- Phase 4: ~200 lines (Duplicate RouteManager from http-proxy)
- Phase 5: ~460 lines (Legacy compatibility code)
## Unified Routing Architecture Summary
The routing unification effort has successfully:
1. **Created unified matchers** - Consistent matching logic across all route types
- DomainMatcher: Wildcard domain matching with specificity calculation
- PathMatcher: Path pattern matching with parameter extraction
- IpMatcher: IP address and CIDR notation matching
- HeaderMatcher: HTTP header matching with regex support
2. **Consolidated route managers** - Single SharedRouteManager for all proxies
3. **Unified routers** - Single HttpRouter for all HTTP routing needs
4. **Removed ~2,332 lines of code** - Exceeded target by 55%
5. **Clean modern architecture** - No legacy code, no backward compatibility layers
## Safety Checklist Before Deletion
Before deleting any code:
1. ✓ All tests pass
2. ✓ No references to deleted code remain
3. ✓ Migration path tested
4. ✓ Performance benchmarks show no regression
5. ✓ Documentation updated
## Rollback Plan
If issues arise after deletion:
1. Git history preserves all deleted code
2. Each phase can be reverted independently
3. Feature flags can disable new code if needed

File diff suppressed because it is too large Load Diff

1024
readme.md

File diff suppressed because it is too large Load Diff

View File

@ -1,621 +1,45 @@
# PROXY Protocol Implementation Plan
# SmartProxy Connection Limiting Improvements Plan
## ⚠️ CRITICAL: Implementation Order
Command to re-read CLAUDE.md: `cat /home/philkunz/.claude/CLAUDE.md`
**Phase 1 (ProxyProtocolSocket/WrappedSocket) MUST be completed first!**
## Issues Identified
The ProxyProtocolSocket class is the foundation that enables all PROXY protocol functionality. No protocol parsing or integration can happen until this wrapper class is fully implemented and tested.
1. **HttpProxy Bypass**: Connections forwarded to HttpProxy for TLS termination only check global limits, not per-IP limits
2. **Missing Route-Level Connection Enforcement**: Routes can define `security.maxConnections` but it's never enforced
3. **Cleanup Queue Race Condition**: New connections can be added to cleanup queue while processing
4. **IP Tracking Memory Optimization**: IP entries remain in map even without active connections
1. **FIRST**: Implement ProxyProtocolSocket (the WrappedSocket)
2. **THEN**: Add PROXY protocol parser
3. **THEN**: Integrate with connection handlers
4. **FINALLY**: Add security and validation
## Implementation Steps
## Overview
Implement PROXY protocol support in SmartProxy to preserve client IP information through proxy chains, solving the connection limit accumulation issue where inner proxies see all connections as coming from the outer proxy's IP.
### 1. Fix HttpProxy Per-IP Validation ✓
- [x] Pass IP information to HttpProxy when forwarding connections
- [x] Add per-IP validation in HttpProxy connection handler
- [x] Ensure connection tracking is consistent between SmartProxy and HttpProxy
## Problem Statement
- In proxy chains, the inner proxy sees all connections from the outer proxy's IP
- This causes the inner proxy to hit per-IP connection limits (default: 100)
- Results in connection rejections while outer proxy accumulates connections
### 2. Implement Route-Level Connection Limits ✓
- [x] Add connection count tracking per route in ConnectionManager
- [x] Update SharedSecurityManager.isAllowed() to check route-specific maxConnections
- [x] Add route connection limit validation in route-connection-handler.ts
## Solution Design
### 3. Fix Cleanup Queue Race Condition ✓
- [x] Implement proper queue snapshotting before processing
- [x] Ensure new connections added during processing aren't missed
- [x] Add proper synchronization for cleanup operations
### 1. Core Features
### 4. Optimize IP Tracking Memory Usage ✓
- [x] Add periodic cleanup for IPs with no active connections
- [x] Implement expiry for rate limit timestamps
- [x] Add memory-efficient data structures for IP tracking
#### 1.1 PROXY Protocol Parsing
- Support PROXY protocol v1 (text format) initially
- Parse incoming PROXY headers to extract:
- Real client IP address
- Real client port
- Proxy IP address
- Proxy port
- Protocol (TCP4/TCP6)
### 5. Add Comprehensive Tests ✓
- [x] Test per-IP limits with HttpProxy forwarding
- [x] Test route-level connection limits
- [x] Test cleanup queue edge cases
- [x] Test memory usage with many unique IPs
#### 1.2 PROXY Protocol Generation
- Add ability to send PROXY protocol headers when forwarding connections
- Configurable per route or target
## Notes
#### 1.3 Trusted Proxy IPs
- New `proxyIPs` array in SmartProxy options
- Auto-enable PROXY protocol acceptance for connections from these IPs
- Reject PROXY protocol from untrusted sources (security)
### 2. Configuration Schema
```typescript
interface ISmartProxyOptions {
// ... existing options
// List of trusted proxy IPs that can send PROXY protocol
proxyIPs?: string[];
// Global option to accept PROXY protocol (defaults based on proxyIPs)
acceptProxyProtocol?: boolean;
// Global option to send PROXY protocol to all targets
sendProxyProtocol?: boolean;
}
interface IRouteAction {
// ... existing options
// Send PROXY protocol to this specific target
sendProxyProtocol?: boolean;
}
```
### 3. Implementation Steps
#### IMPORTANT: Phase 1 Must Be Completed First
The `ProxyProtocolSocket` (WrappedSocket) is the foundation for all PROXY protocol functionality. This wrapper class must be implemented and integrated BEFORE any PROXY protocol parsing can begin.
#### Phase 1: ProxyProtocolSocket (WrappedSocket) Foundation - ✅ COMPLETED (v19.5.19)
This phase creates the socket wrapper infrastructure that all subsequent phases depend on.
1. **Create WrappedSocket class** in `ts/core/models/wrapped-socket.ts`
- Used JavaScript Proxy pattern instead of EventEmitter (avoids infinite loops)
- Properties for real client IP and port
- Transparent getters that return real or socket IP/port
- All socket methods/properties delegated via Proxy
2. **Implement core wrapper functionality**
- Constructor accepts regular socket + optional metadata
- `remoteAddress` getter returns real IP or falls back to socket IP
- `remotePort` getter returns real port or falls back to socket port
- `isFromTrustedProxy` property to check if it has real client info
- `setProxyInfo()` method to update real client details
3. **Update ConnectionManager to handle wrapped sockets**
- Accept either `net.Socket` or `WrappedSocket`
- Created `getUnderlyingSocket()` helper for socket utilities
- All socket utility functions extract underlying socket
4. **Integration completed**
- All incoming sockets wrapped in RouteConnectionHandler
- Socket forwarding verified working with wrapped sockets
- Type safety maintained with index signature
**Deliverables**: ✅ Working WrappedSocket that can wrap any socket and provide transparent access to client info.
#### Phase 2: PROXY Protocol Parser - DEPENDS ON PHASE 1
Only after WrappedSocket is working can we add protocol parsing.
1. Create `ProxyProtocolParser` class in `ts/core/utils/proxy-protocol.ts`
2. Implement v1 text format parsing
3. Add validation and error handling
4. Integrate parser to work WITH WrappedSocket (not into it)
#### Phase 3: Connection Handler Integration - DEPENDS ON PHASES 1 & 2
1. ✅ Modify `RouteConnectionHandler` to create WrappedSocket for all connections
2. Check if connection is from trusted proxy IP
3. If trusted, attempt to parse PROXY protocol header
4. Update wrapped socket with real client info
5. Continue normal connection handling with wrapped socket
#### Phase 4: Outbound PROXY Protocol - DEPENDS ON PHASES 1-3
1. Add PROXY header generation in `setupDirectConnection`
2. Make it configurable per route
3. Send header immediately after TCP connection
4. Use ProxyProtocolSocket for outbound connections too
#### Phase 5: Security & Validation - FINAL PHASE
1. Validate PROXY headers strictly
2. Reject malformed headers
3. Only accept from trusted IPs
4. Add rate limiting for PROXY protocol parsing
### 4. Design Decision: Socket Wrapper Architecture
#### Option A: Minimal Single Socket Wrapper
- **Scope**: Wraps individual sockets with metadata
- **Use Case**: PROXY protocol support with minimal refactoring
- **Pros**: Simple, low risk, easy migration
- **Cons**: Still need separate connection management
#### Option B: Comprehensive Connection Wrapper
- **Scope**: Manages socket pairs (incoming + outgoing) with all utilities
- **Use Case**: Complete connection lifecycle management
- **Pros**:
- Encapsulates all socket utilities (forwarding, cleanup, backpressure)
- Single object represents entire connection
- Cleaner API for connection handling
- **Cons**:
- Major architectural change
- Higher implementation risk
- More complex migration
#### Recommendation
Start with **Option A** (ProxyProtocolSocket) for immediate PROXY protocol support, then evaluate Option B based on:
- Performance impact of additional abstraction
- Code simplification benefits
- Team comfort with architectural change
### 5. Code Implementation Details
#### 5.1 ProxyProtocolSocket (WrappedSocket) - PHASE 1 IMPLEMENTATION
This is the foundational wrapper class that MUST be implemented first. It wraps a regular socket and provides transparent access to the real client IP/port.
```typescript
// ts/core/models/proxy-protocol-socket.ts
import { EventEmitter } from 'events';
import * as plugins from '../../../plugins.js';
/**
* ProxyProtocolSocket wraps a regular net.Socket to provide transparent access
* to the real client IP and port when behind a proxy using PROXY protocol.
*
* This is the FOUNDATION for all PROXY protocol support and must be implemented
* before any protocol parsing can occur.
*/
export class ProxyProtocolSocket extends EventEmitter {
private realClientIP?: string;
private realClientPort?: number;
constructor(
public readonly socket: plugins.net.Socket,
realClientIP?: string,
realClientPort?: number
) {
super();
this.realClientIP = realClientIP;
this.realClientPort = realClientPort;
// Forward all socket events
this.forwardSocketEvents();
}
/**
* Returns the real client IP if available, otherwise the socket's remote address
*/
get remoteAddress(): string | undefined {
return this.realClientIP || this.socket.remoteAddress;
}
/**
* Returns the real client port if available, otherwise the socket's remote port
*/
get remotePort(): number | undefined {
return this.realClientPort || this.socket.remotePort;
}
/**
* Indicates if this connection came through a trusted proxy
*/
get isFromTrustedProxy(): boolean {
return !!this.realClientIP;
}
/**
* Updates the real client information (called after parsing PROXY protocol)
*/
setProxyInfo(ip: string, port: number): void {
this.realClientIP = ip;
this.realClientPort = port;
}
// Pass-through all socket methods
write(data: any, encoding?: any, callback?: any): boolean {
return this.socket.write(data, encoding, callback);
}
end(data?: any, encoding?: any, callback?: any): this {
this.socket.end(data, encoding, callback);
return this;
}
destroy(error?: Error): this {
this.socket.destroy(error);
return this;
}
// ... implement all other socket methods as pass-through
/**
* Forward all events from the underlying socket
*/
private forwardSocketEvents(): void {
const events = ['data', 'end', 'close', 'error', 'drain', 'timeout'];
events.forEach(event => {
this.socket.on(event, (...args) => {
this.emit(event, ...args);
});
});
}
}
```
**KEY POINT**: This wrapper must be fully functional and tested BEFORE moving to Phase 2.
#### 4.2 ProxyProtocolParser (new file)
```typescript
// ts/core/utils/proxy-protocol.ts
export class ProxyProtocolParser {
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
static parse(chunk: Buffer): IProxyInfo | null {
// Implementation
}
static generate(info: IProxyInfo): Buffer {
// Implementation
}
}
```
#### 4.3 Connection Handler Updates
```typescript
// In handleConnection method
let wrappedSocket: ProxyProtocolSocket | plugins.net.Socket = socket;
// Wrap socket if from trusted proxy
if (this.settings.proxyIPs?.includes(socket.remoteAddress)) {
wrappedSocket = new ProxyProtocolSocket(socket);
}
// Create connection record with wrapped socket
const record = this.connectionManager.createConnection(wrappedSocket);
// In handleInitialData method
if (wrappedSocket instanceof ProxyProtocolSocket) {
const proxyInfo = await this.checkForProxyProtocol(chunk);
if (proxyInfo) {
wrappedSocket.setProxyInfo(proxyInfo.sourceIP, proxyInfo.sourcePort);
// Continue with remaining data after PROXY header
}
}
```
#### 4.4 Security Manager Updates
- Accept socket or ProxyProtocolSocket
- Use `socket.remoteAddress` getter for real client IP
- Transparent handling of both socket types
### 5. Configuration Examples
#### Basic Setup
```typescript
// Outer proxy - sends PROXY protocol
const outerProxy = new SmartProxy({
ports: [443],
routes: [{
name: 'to-inner-proxy',
match: { ports: 443 },
action: {
type: 'forward',
target: { host: '195.201.98.232', port: 443 },
sendProxyProtocol: true // Enable for this route
}
}]
});
// Inner proxy - accepts PROXY protocol from outer proxy
const innerProxy = new SmartProxy({
ports: [443],
proxyIPs: ['212.95.99.130'], // Outer proxy IP
// acceptProxyProtocol: true is automatic for proxyIPs
routes: [{
name: 'to-backend',
match: { ports: 443 },
action: {
type: 'forward',
target: { host: '192.168.5.247', port: 443 }
}
}]
});
```
### 6. Testing Plan
#### Unit Tests
- PROXY protocol v1 parsing (valid/invalid formats)
- Header generation
- Trusted IP validation
- Connection record updates
#### Integration Tests
- Single proxy with PROXY protocol
- Proxy chain with PROXY protocol
- Security: reject from untrusted IPs
- Performance: minimal overhead
- Compatibility: works with TLS passthrough
#### Test Scenarios
1. **Connection limit test**: Verify inner proxy sees real client IPs
2. **Security test**: Ensure PROXY protocol rejected from untrusted sources
3. **Compatibility test**: Verify no impact on non-PROXY connections
4. **Performance test**: Measure overhead of PROXY protocol parsing
### 7. Security Considerations
1. **IP Spoofing Prevention**
- Only accept PROXY protocol from explicitly trusted IPs
- Validate all header fields
- Reject malformed headers immediately
2. **Resource Protection**
- Limit PROXY header size (107 bytes for v1)
- Timeout for incomplete headers
- Rate limit connection attempts
3. **Logging**
- Log all PROXY protocol acceptance/rejection
- Include real client IP in all connection logs
### 8. Rollout Strategy
1. **Phase 1**: Deploy parser and acceptance (backward compatible)
2. **Phase 2**: Enable between controlled proxy pairs
3. **Phase 3**: Monitor for issues and performance impact
4. **Phase 4**: Expand to all proxy chains
### 9. Success Metrics
- Inner proxy connection distribution matches outer proxy
- No more connection limit rejections in proxy chains
- Accurate client IP logging throughout the chain
- No performance degradation (<1ms added latency)
### 10. Future Enhancements
- PROXY protocol v2 (binary format) support
- TLV extensions for additional metadata
- AWS VPC endpoint ID support
- Custom metadata fields
## WrappedSocket Class Design
### Overview
A WrappedSocket class has been evaluated and recommended to provide cleaner PROXY protocol integration and better socket management architecture.
### Rationale for WrappedSocket
#### Current Challenges
- Sockets handled directly as `net.Socket` instances throughout codebase
- Metadata tracked separately in `IConnectionRecord` objects
- Socket augmentation via TypeScript module augmentation for TLS properties
- PROXY protocol would require modifying socket handling in multiple places
#### Benefits
1. **Clean PROXY Protocol Integration** - Parse and store real client IP/port without modifying existing socket handling
2. **Better Encapsulation** - Bundle socket + metadata + behavior together
3. **Type Safety** - No more module augmentation needed
4. **Future Extensibility** - Easy to add compression, metrics, etc.
5. **Simplified Testing** - Easier to mock and test socket behavior
### Implementation Strategy
#### Phase 1: Minimal ProxyProtocolSocket (Immediate)
Create a minimal wrapper for PROXY protocol support:
```typescript
class ProxyProtocolSocket {
constructor(
public socket: net.Socket,
public realClientIP?: string,
public realClientPort?: number
) {}
get remoteAddress(): string {
return this.realClientIP || this.socket.remoteAddress || '';
}
get remotePort(): number {
return this.realClientPort || this.socket.remotePort || 0;
}
get isFromTrustedProxy(): boolean {
return !!this.realClientIP;
}
}
```
Integration points:
- Use in `RouteConnectionHandler` when receiving from trusted proxy IPs
- Update `ConnectionManager` to accept wrapped sockets
- Modify security checks to use `socket.remoteAddress` getter
#### Phase 2: Connection-Aware WrappedSocket (Alternative Design)
A more comprehensive design that manages both sides of a connection:
```typescript
// Option A: Single Socket Wrapper (simpler)
class WrappedSocket extends EventEmitter {
private socket: net.Socket;
private connectionId: string;
private metadata: ISocketMetadata;
constructor(socket: net.Socket, metadata?: Partial<ISocketMetadata>) {
super();
this.socket = socket;
this.connectionId = this.generateId();
this.metadata = { ...defaultMetadata, ...metadata };
this.setupHandlers();
}
// ... single socket management
}
// Option B: Connection Pair Wrapper (comprehensive)
class WrappedConnection extends EventEmitter {
private connectionId: string;
private incoming: WrappedSocket;
private outgoing?: WrappedSocket;
private forwardingActive: boolean = false;
constructor(incomingSocket: net.Socket) {
super();
this.connectionId = this.generateId();
this.incoming = new WrappedSocket(incomingSocket);
}
// Connect to backend and set up forwarding
async connectToBackend(target: ITarget): Promise<void> {
const outgoingSocket = await this.createOutgoingConnection(target);
this.outgoing = new WrappedSocket(outgoingSocket);
await this.setupBidirectionalForwarding();
}
// Built-in forwarding logic from socket-utils
private async setupBidirectionalForwarding(): Promise<void> {
if (!this.outgoing) throw new Error('No outgoing socket');
// Handle data forwarding with backpressure
this.incoming.on('data', (chunk) => {
this.outgoing!.write(chunk, () => {
// Handle backpressure
});
});
this.outgoing.on('data', (chunk) => {
this.incoming.write(chunk, () => {
// Handle backpressure
});
});
// Handle connection lifecycle
const cleanup = (reason: string) => {
this.forwardingActive = false;
this.incoming.destroy();
this.outgoing?.destroy();
this.emit('closed', reason);
};
this.incoming.once('close', () => cleanup('incoming_closed'));
this.outgoing.once('close', () => cleanup('outgoing_closed'));
this.forwardingActive = true;
}
// PROXY protocol support
async handleProxyProtocol(trustedProxies: string[]): Promise<boolean> {
if (trustedProxies.includes(this.incoming.socket.remoteAddress)) {
const parsed = await this.incoming.parseProxyProtocol();
if (parsed && this.outgoing) {
// Forward PROXY protocol to backend if configured
await this.outgoing.sendProxyProtocol(this.incoming.realClientIP);
}
return parsed;
}
return false;
}
// Consolidated metrics
getMetrics(): IConnectionMetrics {
return {
connectionId: this.connectionId,
duration: Date.now() - this.startTime,
incoming: this.incoming.getMetrics(),
outgoing: this.outgoing?.getMetrics(),
totalBytes: this.getTotalBytes(),
state: this.getConnectionState()
};
}
}
```
#### Phase 3: Full Migration (Long-term)
- Replace all `net.Socket` usage with `WrappedSocket`
- Remove socket augmentation from `socket-augmentation.ts`
- Update all socket utilities to work with wrapped sockets
- Standardize socket handling across all components
### Integration with PROXY Protocol
The WrappedSocket class integrates seamlessly with PROXY protocol:
1. **Connection Acceptance**:
```typescript
const wrappedSocket = new ProxyProtocolSocket(socket);
if (this.isFromTrustedProxy(socket.remoteAddress)) {
await wrappedSocket.parseProxyProtocol(this.settings.proxyIPs);
}
```
2. **Security Checks**:
```typescript
// Automatically uses real client IP if available
const clientIP = wrappedSocket.remoteAddress;
if (!this.securityManager.isIPAllowed(clientIP)) {
wrappedSocket.destroy();
}
```
3. **Connection Records**:
```typescript
const record = this.connectionManager.createConnection(wrappedSocket);
// ConnectionManager uses wrappedSocket.remoteAddress transparently
```
### Option B Example: How It Would Replace Current Architecture
Instead of current approach with separate components:
```typescript
// Current: Multiple separate components
const record = connectionManager.createConnection(socket);
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
clientSocket, serverSocket, onBothClosed
);
setupBidirectionalForwarding(clientSocket, serverSocket, handlers);
```
Option B would consolidate everything:
```typescript
// Option B: Single connection object
const connection = new WrappedConnection(incomingSocket);
await connection.handleProxyProtocol(trustedProxies);
await connection.connectToBackend({ host: 'server', port: 443 });
// Everything is handled internally - forwarding, cleanup, metrics
connection.on('closed', (reason) => {
logger.log('Connection closed', connection.getMetrics());
});
```
This would replace:
- `IConnectionRecord` - absorbed into WrappedConnection
- `socket-utils.ts` functions - methods on WrappedConnection
- Separate incoming/outgoing tracking - unified in one object
- Manual cleanup coordination - automatic lifecycle management
Additional benefits with Option B:
- **Connection Pooling Integration**: WrappedConnection could integrate with EnhancedConnectionPool for backend connections
- **Unified Metrics**: Single point for all connection statistics
- **Protocol Negotiation**: Handle PROXY, TLS, HTTP/2 upgrade in one place
- **Resource Management**: Automatic cleanup with LifecycleComponent pattern
### Migration Path
1. **Week 1-2**: Implement minimal ProxyProtocolSocket (Option A)
2. **Week 3-4**: Test with PROXY protocol implementation
3. **Month 2**: Prototype WrappedConnection (Option B) if beneficial
4. **Month 3-6**: Gradual migration if Option B proves valuable
5. **Future**: Complete adoption in next major version
### Success Criteria
- PROXY protocol works transparently with wrapped sockets
- No performance regression (<0.1% overhead)
- Simplified code in connection handlers
- Better TypeScript type safety
- Easier to add new socket-level features
- All connection limiting is now consistent across SmartProxy and HttpProxy
- Route-level limits provide additional granular control
- Memory usage is optimized for high-traffic scenarios
- Comprehensive test coverage ensures reliability

View File

@ -1,341 +0,0 @@
# SmartProxy Routing Architecture Unification Plan
## Overview
This document analyzes the current state of routing in SmartProxy, identifies redundancies and inconsistencies, and proposes a unified architecture.
## Current State Analysis
### 1. Multiple Route Manager Implementations
#### 1.1 Core SharedRouteManager (`ts/core/utils/route-manager.ts`)
- **Purpose**: Designed as a shared component for SmartProxy and NetworkProxy
- **Features**:
- Port mapping and expansion (e.g., `[80, 443]` → individual routes)
- Comprehensive route matching (domain, path, IP, headers, TLS)
- Route validation and conflict detection
- Event emitter for route changes
- Detailed logging support
- **Status**: Well-designed but underutilized
#### 1.2 SmartProxy RouteManager (`ts/proxies/smart-proxy/route-manager.ts`)
- **Purpose**: SmartProxy-specific route management
- **Issues**:
- 95% duplicate code from SharedRouteManager
- Only difference is using `ISmartProxyOptions` instead of generic interface
- Contains deprecated security methods
- Unnecessary code duplication
- **Status**: Should be removed in favor of SharedRouteManager
#### 1.3 HttpProxy Route Management (`ts/proxies/http-proxy/`)
- **Purpose**: HTTP-specific routing
- **Implementation**: Minimal, inline route matching
- **Status**: Could benefit from SharedRouteManager
### 2. Multiple Router Implementations
#### 2.1 ProxyRouter (`ts/routing/router/proxy-router.ts`)
- **Purpose**: Legacy compatibility with `IReverseProxyConfig`
- **Features**: Domain-based routing with path patterns
- **Used by**: HttpProxy for backward compatibility
#### 2.2 RouteRouter (`ts/routing/router/route-router.ts`)
- **Purpose**: Modern routing with `IRouteConfig`
- **Features**: Nearly identical to ProxyRouter
- **Issues**: Code duplication with ProxyRouter
### 3. Scattered Route Utilities
#### 3.1 Core route-utils (`ts/core/utils/route-utils.ts`)
- **Purpose**: Shared matching functions
- **Features**: Domain, path, IP, CIDR matching
- **Status**: Well-implemented, should be the single source
#### 3.2 SmartProxy route-utils (`ts/proxies/smart-proxy/utils/route-utils.ts`)
- **Purpose**: Route configuration utilities
- **Features**: Different scope - config merging, not pattern matching
- **Status**: Keep separate as it serves different purpose
### 4. Other Route-Related Files
- `route-patterns.ts`: Constants for route patterns
- `route-validators.ts`: Route configuration validation
- `route-helpers.ts`: Additional utilities
- `route-connection-handler.ts`: Connection routing logic
## Problems Identified
### 1. Code Duplication
- **SharedRouteManager vs SmartProxy RouteManager**: ~1000 lines of duplicate code
- **ProxyRouter vs RouteRouter**: ~500 lines of duplicate code
- **Matching logic**: Implemented in 4+ different places
### 2. Inconsistent Implementations
```typescript
// Example: Domain matching appears in multiple places
// 1. In route-utils.ts
export function matchDomain(pattern: string, hostname: string): boolean
// 2. In SmartProxy RouteManager
private matchDomain(domain: string, hostname: string): boolean
// 3. In ProxyRouter
private matchesHostname(configName: string, hostname: string): boolean
// 4. In RouteRouter
private matchDomain(pattern: string, hostname: string): boolean
```
### 3. Unclear Separation of Concerns
- Route Managers handle both storage AND matching
- Routers also handle storage AND matching
- No clear boundaries between layers
### 4. Maintenance Burden
- Bug fixes need to be applied in multiple places
- New features must be implemented multiple times
- Testing effort multiplied
## Proposed Unified Architecture
### Layer 1: Core Routing Components
```
ts/core/routing/
├── types.ts # All route-related types
├── utils.ts # All matching logic (consolidated)
├── route-store.ts # Route storage and indexing
└── route-matcher.ts # Route matching engine
```
### Layer 2: Route Management
```
ts/core/routing/
└── route-manager.ts # Single RouteManager for all proxies
- Uses RouteStore for storage
- Uses RouteMatcher for matching
- Provides high-level API
```
### Layer 3: HTTP Routing
```
ts/routing/
└── http-router.ts # Single HTTP router implementation
- Uses RouteManager for route lookup
- Handles HTTP-specific concerns
- Legacy adapter built-in
```
### Layer 4: Proxy Integration
```
ts/proxies/
├── smart-proxy/
│ └── (uses core RouteManager directly)
├── http-proxy/
│ └── (uses core RouteManager + HttpRouter)
└── network-proxy/
└── (uses core RouteManager directly)
```
## Implementation Plan
### Phase 1: Consolidate Matching Logic (Week 1)
1. **Audit all matching implementations**
- Document differences in behavior
- Identify the most comprehensive implementation
- Create test suite covering all edge cases
2. **Create unified matching module**
```typescript
// ts/core/routing/matchers.ts
export class DomainMatcher {
static match(pattern: string, hostname: string): boolean
}
export class PathMatcher {
static match(pattern: string, path: string): MatchResult
}
export class IpMatcher {
static match(pattern: string, ip: string): boolean
static matchCidr(cidr: string, ip: string): boolean
}
```
3. **Update all components to use unified matchers**
- Replace local implementations
- Ensure backward compatibility
- Run comprehensive tests
### Phase 2: Unify Route Managers (Week 2)
1. **Enhance SharedRouteManager**
- Add any missing features from SmartProxy RouteManager
- Make it truly generic (no proxy-specific dependencies)
- Add adapter pattern for different options types
2. **Migrate SmartProxy to use SharedRouteManager**
```typescript
// Before
this.routeManager = new RouteManager(this.settings);
// After
this.routeManager = new SharedRouteManager({
logger: this.settings.logger,
enableDetailedLogging: this.settings.enableDetailedLogging
});
```
3. **Remove duplicate RouteManager**
- Delete `ts/proxies/smart-proxy/route-manager.ts`
- Update all imports
- Verify all tests pass
### Phase 3: Consolidate Routers (Week 3)
1. **Create unified HttpRouter**
```typescript
export class HttpRouter {
constructor(private routeManager: SharedRouteManager) {}
// Modern interface
route(req: IncomingMessage): RouteResult
// Legacy adapter
routeLegacy(config: IReverseProxyConfig): RouteResult
}
```
2. **Migrate HttpProxy**
- Replace both ProxyRouter and RouteRouter
- Use single HttpRouter with appropriate adapter
- Maintain backward compatibility
3. **Clean up legacy code**
- Mark old interfaces as deprecated
- Add migration guides
- Plan removal in next major version
### Phase 4: Architecture Cleanup (Week 4)
1. **Reorganize file structure**
```
ts/core/
├── routing/
│ ├── index.ts
│ ├── types.ts
│ ├── matchers/
│ │ ├── domain.ts
│ │ ├── path.ts
│ │ ├── ip.ts
│ │ └── index.ts
│ ├── route-store.ts
│ ├── route-matcher.ts
│ └── route-manager.ts
└── utils/
└── (remove route-specific utils)
```
2. **Update documentation**
- Architecture diagrams
- Migration guides
- API documentation
3. **Performance optimization**
- Add caching where beneficial
- Optimize hot paths
- Benchmark before/after
## Migration Strategy
### For SmartProxy RouteManager Users
```typescript
// Old way
import { RouteManager } from './route-manager.js';
const manager = new RouteManager(options);
// New way
import { SharedRouteManager as RouteManager } from '../core/utils/route-manager.js';
const manager = new RouteManager({
logger: options.logger,
enableDetailedLogging: options.enableDetailedLogging
});
```
### For Router Users
```typescript
// Old way
const proxyRouter = new ProxyRouter();
const routeRouter = new RouteRouter();
// New way
const router = new HttpRouter(routeManager);
// Automatically handles both modern and legacy configs
```
## Success Metrics
1. **Code Reduction**
- Target: Remove ~1,500 lines of duplicate code
- Measure: Lines of code before/after
2. **Performance**
- Target: No regression in routing performance
- Measure: Benchmark route matching operations
3. **Maintainability**
- Target: Single implementation for each concept
- Measure: Time to implement new features
4. **Test Coverage**
- Target: 100% coverage of routing logic
- Measure: Coverage reports
## Risks and Mitigations
### Risk 1: Breaking Changes
- **Mitigation**: Extensive adapter patterns and backward compatibility layers
- **Testing**: Run all existing tests plus new integration tests
### Risk 2: Performance Regression
- **Mitigation**: Benchmark critical paths before changes
- **Testing**: Load testing with production-like scenarios
### Risk 3: Hidden Dependencies
- **Mitigation**: Careful code analysis and dependency mapping
- **Testing**: Integration tests across all proxy types
## Long-term Vision
### Future Enhancements
1. **Route Caching**: LRU cache for frequently accessed routes
2. **Route Indexing**: Trie-based indexing for faster domain matching
3. **Route Priorities**: Explicit priority system instead of specificity
4. **Dynamic Routes**: Support for runtime route modifications
5. **Route Templates**: Reusable route configurations
### API Evolution
```typescript
// Future unified routing API
const routingEngine = new RoutingEngine({
stores: [fileStore, dbStore, dynamicStore],
matchers: [domainMatcher, pathMatcher, customMatcher],
cache: new LRUCache({ max: 1000 }),
indexes: {
domain: new TrieIndex(),
path: new RadixTree()
}
});
// Simple, powerful API
const route = await routingEngine.findRoute({
domain: 'example.com',
path: '/api/v1/users',
ip: '192.168.1.1',
headers: { 'x-custom': 'value' }
});
```
## Conclusion
The current routing architecture has significant duplication and inconsistencies. By following this unification plan, we can:
1. Reduce code by ~30%
2. Improve maintainability
3. Ensure consistent behavior
4. Enable future enhancements
The phased approach minimizes risk while delivering incremental value. Each phase is independently valuable and can be deployed separately.

View File

@ -0,0 +1,146 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
tap.test('cleanup queue bug - verify queue processing handles more than batch size', async () => {
console.log('\n=== Cleanup Queue Bug Test ===');
console.log('Purpose: Verify that the cleanup queue correctly processes all connections');
console.log('even when there are more than the batch size (100)');
// Create proxy
const proxy = new SmartProxy({
routes: [{
name: 'test-route',
match: { ports: 8588 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9996 }
}
}],
enableDetailedLogging: false,
});
await proxy.start();
console.log('✓ Proxy started on port 8588');
// Access connection manager
const cm = (proxy as any).connectionManager;
// Create mock connection records
console.log('\n--- Creating 150 mock connections ---');
const mockConnections: any[] = [];
for (let i = 0; i < 150; i++) {
// Create mock socket objects with necessary methods
const mockIncoming = {
destroyed: true,
writable: false,
remoteAddress: '127.0.0.1',
removeAllListeners: () => {},
destroy: () => {},
end: () => {},
on: () => {},
once: () => {},
emit: () => {},
pause: () => {},
resume: () => {}
};
const mockOutgoing = {
destroyed: true,
writable: false,
removeAllListeners: () => {},
destroy: () => {},
end: () => {},
on: () => {},
once: () => {},
emit: () => {}
};
const mockRecord = {
id: `mock-${i}`,
incoming: mockIncoming,
outgoing: mockOutgoing,
connectionClosed: false,
incomingStartTime: Date.now(),
lastActivity: Date.now(),
remoteIP: '127.0.0.1',
remotePort: 10000 + i,
localPort: 8588,
bytesReceived: 100,
bytesSent: 100,
incomingTerminationReason: null,
cleanupTimer: null
};
// Add to connection records
cm.connectionRecords.set(mockRecord.id, mockRecord);
mockConnections.push(mockRecord);
}
console.log(`Created ${cm.getConnectionCount()} mock connections`);
expect(cm.getConnectionCount()).toEqual(150);
// Queue all connections for cleanup
console.log('\n--- Queueing all connections for cleanup ---');
// The cleanup queue processes immediately when it reaches batch size (100)
// So after queueing 150, the first 100 will be processed immediately
for (const conn of mockConnections) {
cm.initiateCleanupOnce(conn, 'test_cleanup');
}
// After queueing 150, the first 100 should have been processed immediately
// leaving 50 in the queue
console.log(`Cleanup queue size after queueing: ${cm.cleanupQueue.size}`);
console.log(`Active connections after initial batch: ${cm.getConnectionCount()}`);
// The first 100 should have been cleaned up immediately
expect(cm.cleanupQueue.size).toEqual(50);
expect(cm.getConnectionCount()).toEqual(50);
// Wait for remaining cleanup to complete
console.log('\n--- Waiting for remaining cleanup batches to process ---');
// The remaining 50 connections should be cleaned up in the next batch
let waitTime = 0;
let lastCount = cm.getConnectionCount();
while (cm.getConnectionCount() > 0 || cm.cleanupQueue.size > 0) {
await new Promise(resolve => setTimeout(resolve, 100));
waitTime += 100;
const currentCount = cm.getConnectionCount();
if (currentCount !== lastCount) {
console.log(`Active connections: ${currentCount}, Queue size: ${cm.cleanupQueue.size}`);
lastCount = currentCount;
}
if (waitTime > 5000) {
console.log('Timeout waiting for cleanup to complete');
break;
}
}
console.log(`All cleanup completed in ${waitTime}ms`);
// Check final state
const finalCount = cm.getConnectionCount();
console.log(`\nFinal connection count: ${finalCount}`);
console.log(`Final cleanup queue size: ${cm.cleanupQueue.size}`);
// All connections should be cleaned up
expect(finalCount).toEqual(0);
expect(cm.cleanupQueue.size).toEqual(0);
// Verify termination stats - all 150 should have been terminated
const stats = cm.getTerminationStats();
console.log('Termination stats:', stats);
expect(stats.incoming.test_cleanup).toEqual(150);
// Cleanup
console.log('\n--- Stopping proxy ---');
await proxy.stop();
console.log('\n✓ Test complete: Cleanup queue now correctly processes all connections');
});
tap.start();

View File

@ -0,0 +1,299 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { HttpProxy } from '../ts/proxies/http-proxy/index.js';
let testServer: net.Server;
let smartProxy: SmartProxy;
let httpProxy: HttpProxy;
const TEST_SERVER_PORT = 5100;
const PROXY_PORT = 5101;
const HTTP_PROXY_PORT = 5102;
// Track all created servers and connections for cleanup
const allServers: net.Server[] = [];
const allProxies: (SmartProxy | HttpProxy)[] = [];
const activeConnections: net.Socket[] = [];
// Helper: Creates a test TCP server
function createTestServer(port: number): Promise<net.Server> {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
socket.on('data', (data) => {
socket.write(`Echo: ${data.toString()}`);
});
socket.on('error', () => {});
});
server.listen(port, 'localhost', () => {
console.log(`[Test Server] Listening on localhost:${port}`);
allServers.push(server);
resolve(server);
});
});
}
// Helper: Creates multiple concurrent connections
async function createConcurrentConnections(
port: number,
count: number,
fromIP?: string
): Promise<net.Socket[]> {
const connections: net.Socket[] = [];
const promises: Promise<net.Socket>[] = [];
for (let i = 0; i < count; i++) {
promises.push(
new Promise((resolve, reject) => {
const client = new net.Socket();
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`Connection ${i} timeout`));
}, 5000);
client.connect(port, 'localhost', () => {
clearTimeout(timeout);
activeConnections.push(client);
connections.push(client);
resolve(client);
});
client.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
})
);
}
await Promise.all(promises);
return connections;
}
// Helper: Clean up connections
function cleanupConnections(connections: net.Socket[]): void {
connections.forEach(conn => {
if (!conn.destroyed) {
conn.destroy();
}
});
}
tap.test('Setup test environment', async () => {
testServer = await createTestServer(TEST_SERVER_PORT);
// Create SmartProxy with low connection limits for testing
smartProxy = new SmartProxy({
routes: [{
name: 'test-route',
match: {
ports: PROXY_PORT
},
action: {
type: 'forward',
target: {
host: 'localhost',
port: TEST_SERVER_PORT
}
},
security: {
maxConnections: 5 // Low limit for testing
}
}],
maxConnectionsPerIP: 3, // Low per-IP limit
connectionRateLimitPerMinute: 10, // Low rate limit
defaults: {
security: {
maxConnections: 10 // Low global limit
}
}
});
await smartProxy.start();
allProxies.push(smartProxy);
});
tap.test('Per-IP connection limits', async () => {
// Test that we can create up to the per-IP limit
const connections1 = await createConcurrentConnections(PROXY_PORT, 3);
expect(connections1.length).toEqual(3);
// Try to create one more connection - should fail
try {
await createConcurrentConnections(PROXY_PORT, 1);
expect.fail('Should not allow more than 3 connections per IP');
} catch (err) {
expect(err.message).toInclude('ECONNRESET');
}
// Clean up first set of connections
cleanupConnections(connections1);
await new Promise(resolve => setTimeout(resolve, 100));
// Should be able to create new connections after cleanup
const connections2 = await createConcurrentConnections(PROXY_PORT, 2);
expect(connections2.length).toEqual(2);
cleanupConnections(connections2);
});
tap.test('Route-level connection limits', async () => {
// Create multiple connections up to route limit
const connections = await createConcurrentConnections(PROXY_PORT, 5);
expect(connections.length).toEqual(5);
// Try to exceed route limit
try {
await createConcurrentConnections(PROXY_PORT, 1);
expect.fail('Should not allow more than 5 connections for this route');
} catch (err) {
expect(err.message).toInclude('ECONNRESET');
}
cleanupConnections(connections);
});
tap.test('Connection rate limiting', async () => {
// Create connections rapidly
const connections: net.Socket[] = [];
// Create 10 connections rapidly (at rate limit)
for (let i = 0; i < 10; i++) {
try {
const conn = await createConcurrentConnections(PROXY_PORT, 1);
connections.push(...conn);
// Small delay to avoid per-IP limit
if (connections.length >= 3) {
cleanupConnections(connections.splice(0, 3));
await new Promise(resolve => setTimeout(resolve, 50));
}
} catch (err) {
// Expected to fail at some point due to rate limit
expect(i).toBeGreaterThan(0);
break;
}
}
cleanupConnections(connections);
});
tap.test('HttpProxy per-IP validation', async () => {
// Create HttpProxy
httpProxy = new HttpProxy({
port: HTTP_PROXY_PORT,
maxConnectionsPerIP: 2,
connectionRateLimitPerMinute: 10,
routes: []
});
await httpProxy.start();
allProxies.push(httpProxy);
// Update SmartProxy to use HttpProxy for TLS termination
await smartProxy.stop();
smartProxy = new SmartProxy({
routes: [{
name: 'https-route',
match: {
ports: PROXY_PORT + 10
},
action: {
type: 'forward',
target: {
host: 'localhost',
port: TEST_SERVER_PORT
},
tls: {
mode: 'terminate'
}
}
}],
useHttpProxy: [PROXY_PORT + 10],
httpProxyPort: HTTP_PROXY_PORT,
maxConnectionsPerIP: 3
});
await smartProxy.start();
// Test that HttpProxy enforces its own per-IP limits
const connections = await createConcurrentConnections(PROXY_PORT + 10, 2);
expect(connections.length).toEqual(2);
// Should reject additional connections
try {
await createConcurrentConnections(PROXY_PORT + 10, 1);
expect.fail('HttpProxy should enforce per-IP limits');
} catch (err) {
expect(err.message).toInclude('ECONNRESET');
}
cleanupConnections(connections);
});
tap.test('IP tracking cleanup', async (tools) => {
// Create and close many connections from different IPs
const connections: net.Socket[] = [];
for (let i = 0; i < 5; i++) {
const conn = await createConcurrentConnections(PROXY_PORT, 1);
connections.push(...conn);
}
// Close all connections
cleanupConnections(connections);
// Wait for cleanup interval (set to 60s in production, but we'll check immediately)
await tools.delayFor(100);
// Verify that IP tracking has been cleaned up
const securityManager = (smartProxy as any).securityManager;
const ipCount = (securityManager.connectionsByIP as Map<string, any>).size;
// Should have no IPs tracked after cleanup
expect(ipCount).toEqual(0);
});
tap.test('Cleanup queue race condition handling', async () => {
// Create many connections concurrently to trigger batched cleanup
const promises: Promise<net.Socket[]>[] = [];
for (let i = 0; i < 20; i++) {
promises.push(createConcurrentConnections(PROXY_PORT, 1).catch(() => []));
}
const results = await Promise.all(promises);
const allConnections = results.flat();
// Close all connections rapidly
allConnections.forEach(conn => conn.destroy());
// Give cleanup queue time to process
await new Promise(resolve => setTimeout(resolve, 500));
// Verify all connections were cleaned up
const connectionManager = (smartProxy as any).connectionManager;
const remainingConnections = connectionManager.getConnectionCount();
expect(remainingConnections).toEqual(0);
});
tap.test('Cleanup and shutdown', async () => {
// Clean up any remaining connections
cleanupConnections(activeConnections);
activeConnections.length = 0;
// Stop all proxies
for (const proxy of allProxies) {
await proxy.stop();
}
allProxies.length = 0;
// Close all test servers
for (const server of allServers) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
allServers.length = 0;
});
tap.start();

View File

@ -73,16 +73,17 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
validateIP: () => ({ allowed: true })
};
// Create a mock SmartProxy instance with necessary properties
const mockSmartProxy = {
settings: mockSettings,
connectionManager: mockConnectionManager,
securityManager: mockSecurityManager,
httpProxyBridge: mockHttpProxyBridge,
routeManager: mockRouteManager
} as any;
// Create route connection handler instance
const handler = new RouteConnectionHandler(
mockSettings,
mockConnectionManager as any,
mockSecurityManager as any, // security manager
{} as any, // tls manager
mockHttpProxyBridge as any,
{} as any, // timeout manager
mockRouteManager as any
);
const handler = new RouteConnectionHandler(mockSmartProxy);
// Override setupDirectConnection to track if it's called
handler['setupDirectConnection'] = (...args: any[]) => {
@ -200,15 +201,17 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
validateIP: () => ({ allowed: true })
};
const handler = new RouteConnectionHandler(
mockSettings,
mockConnectionManager as any,
mockSecurityManager as any,
mockTlsManager as any,
mockHttpProxyBridge as any,
{} as any,
mockRouteManager as any
);
// Create a mock SmartProxy instance with necessary properties
const mockSmartProxy = {
settings: mockSettings,
connectionManager: mockConnectionManager,
securityManager: mockSecurityManager,
tlsManager: mockTlsManager,
httpProxyBridge: mockHttpProxyBridge,
routeManager: mockRouteManager
} as any;
const handler = new RouteConnectionHandler(mockSmartProxy);
const mockSocket = {
localPort: 443,

View File

@ -0,0 +1,120 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SecurityManager } from '../ts/proxies/http-proxy/security-manager.js';
import { createLogger } from '../ts/proxies/http-proxy/models/types.js';
let securityManager: SecurityManager;
const logger = createLogger('error'); // Quiet logger for tests
tap.test('Setup HttpProxy SecurityManager', async () => {
securityManager = new SecurityManager(logger, [], 3, 10); // Low limits for testing
});
tap.test('HttpProxy IP connection tracking', async () => {
const testIP = '10.0.0.1';
// Track connections
securityManager.trackConnectionByIP(testIP, 'http-conn1');
securityManager.trackConnectionByIP(testIP, 'http-conn2');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(2);
// Validate IP should pass
let result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Add one more to reach limit
securityManager.trackConnectionByIP(testIP, 'http-conn3');
// Should now reject new connections
result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Maximum connections per IP (3) exceeded');
// Remove a connection
securityManager.removeConnectionByIP(testIP, 'http-conn1');
// Should allow connections again
result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Clean up
securityManager.removeConnectionByIP(testIP, 'http-conn2');
securityManager.removeConnectionByIP(testIP, 'http-conn3');
});
tap.test('HttpProxy connection rate limiting', async () => {
const testIP = '10.0.0.2';
// Make 10 connections rapidly (at rate limit)
for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Track the connection to simulate real usage
securityManager.trackConnectionByIP(testIP, `rate-conn${i}`);
}
// 11th connection should be rate limited
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit (10/min) exceeded');
// Clean up
for (let i = 0; i < 10; i++) {
securityManager.removeConnectionByIP(testIP, `rate-conn${i}`);
}
});
tap.test('HttpProxy CLIENT_IP header handling', async () => {
// This tests the scenario where SmartProxy forwards the real client IP
const realClientIP = '203.0.113.1';
const proxyIP = '127.0.0.1';
// Simulate SmartProxy tracking the real client IP
securityManager.trackConnectionByIP(realClientIP, 'forwarded-conn1');
securityManager.trackConnectionByIP(realClientIP, 'forwarded-conn2');
securityManager.trackConnectionByIP(realClientIP, 'forwarded-conn3');
// Real client IP should be at limit
let result = securityManager.validateIP(realClientIP);
expect(result.allowed).toBeFalse();
// But proxy IP should still be allowed
result = securityManager.validateIP(proxyIP);
expect(result.allowed).toBeTrue();
// Clean up
securityManager.removeConnectionByIP(realClientIP, 'forwarded-conn1');
securityManager.removeConnectionByIP(realClientIP, 'forwarded-conn2');
securityManager.removeConnectionByIP(realClientIP, 'forwarded-conn3');
});
tap.test('HttpProxy automatic cleanup', async (tools) => {
const testIP = '10.0.0.3';
// Create and immediately remove connections
for (let i = 0; i < 5; i++) {
securityManager.trackConnectionByIP(testIP, `cleanup-conn${i}`);
securityManager.removeConnectionByIP(testIP, `cleanup-conn${i}`);
}
// Add rate limit entries
for (let i = 0; i < 5; i++) {
securityManager.validateIP(testIP);
}
// Wait a bit (cleanup runs every 60 seconds in production)
// For testing, we'll just verify the cleanup logic works
await tools.delayFor(100);
// Manually trigger cleanup (in production this happens automatically)
(securityManager as any).performIpCleanup();
// IP should be cleaned up
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(0);
});
tap.test('Cleanup HttpProxy SecurityManager', async () => {
securityManager.clearIPTracking();
});
tap.start();

View File

@ -0,0 +1,250 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { SmartProxy } from '../ts/index.js';
import * as plugins from '../ts/plugins.js';
tap.test('keepalive support - verify keepalive connections are properly handled', async (tools) => {
console.log('\n=== KeepAlive Support Test ===');
console.log('Purpose: Verify that keepalive connections are not prematurely cleaned up');
// Create a simple echo backend
const echoBackend = net.createServer((socket) => {
socket.on('data', (data) => {
// Echo back received data
try {
socket.write(data);
} catch (err) {
// Ignore write errors during shutdown
}
});
socket.on('error', (err) => {
// Ignore errors from backend sockets
console.log(`Backend socket error (expected during cleanup): ${err.code}`);
});
});
await new Promise<void>((resolve) => {
echoBackend.listen(9998, () => {
console.log('✓ Echo backend started on port 9998');
resolve();
});
});
// Test 1: Standard keepalive treatment
console.log('\n--- Test 1: Standard KeepAlive Treatment ---');
const proxy1 = new SmartProxy({
routes: [{
name: 'keepalive-route',
match: { ports: 8590 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9998 }
}
}],
keepAlive: true,
keepAliveTreatment: 'standard',
inactivityTimeout: 5000, // 5 seconds for faster testing
enableDetailedLogging: false,
});
await proxy1.start();
console.log('✓ Proxy with standard keepalive started on port 8590');
// Create a keepalive connection
const client1 = net.connect(8590, 'localhost');
// Add error handler to prevent unhandled errors
client1.on('error', (err) => {
console.log(`Client1 error (expected during cleanup): ${err.code}`);
});
await new Promise<void>((resolve) => {
client1.on('connect', () => {
console.log('Client connected');
client1.setKeepAlive(true, 1000);
resolve();
});
});
// Send initial data
client1.write('Hello keepalive\n');
// Wait for echo
await new Promise<void>((resolve) => {
client1.once('data', (data) => {
console.log(`Received echo: ${data.toString().trim()}`);
resolve();
});
});
// Check connection is marked as keepalive
const cm1 = (proxy1 as any).connectionManager;
const connections1 = cm1.getConnections();
let keepAliveCount = 0;
for (const [id, record] of connections1) {
if (record.hasKeepAlive) {
keepAliveCount++;
console.log(`KeepAlive connection ${id}: hasKeepAlive=${record.hasKeepAlive}`);
}
}
expect(keepAliveCount).toEqual(1);
// Wait to ensure it's not cleaned up prematurely
await plugins.smartdelay.delayFor(6000);
const afterWaitCount1 = cm1.getConnectionCount();
console.log(`Connections after 6s wait: ${afterWaitCount1}`);
expect(afterWaitCount1).toEqual(1); // Should still be connected
// Send more data to keep it alive
client1.write('Still alive\n');
// Clean up test 1
client1.destroy();
await proxy1.stop();
await plugins.smartdelay.delayFor(500); // Wait for port to be released
// Test 2: Extended keepalive treatment
console.log('\n--- Test 2: Extended KeepAlive Treatment ---');
const proxy2 = new SmartProxy({
routes: [{
name: 'keepalive-extended',
match: { ports: 8591 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9998 }
}
}],
keepAlive: true,
keepAliveTreatment: 'extended',
keepAliveInactivityMultiplier: 6,
inactivityTimeout: 2000, // 2 seconds base, 12 seconds with multiplier
enableDetailedLogging: false,
});
await proxy2.start();
console.log('✓ Proxy with extended keepalive started on port 8591');
const client2 = net.connect(8591, 'localhost');
// Add error handler to prevent unhandled errors
client2.on('error', (err) => {
console.log(`Client2 error (expected during cleanup): ${err.code}`);
});
await new Promise<void>((resolve) => {
client2.on('connect', () => {
console.log('Client connected with extended timeout');
client2.setKeepAlive(true, 1000);
resolve();
});
});
// Send initial data
client2.write('Extended keepalive\n');
// Check connection
const cm2 = (proxy2 as any).connectionManager;
await plugins.smartdelay.delayFor(1000);
const connections2 = cm2.getConnections();
for (const [id, record] of connections2) {
console.log(`Extended connection ${id}: hasKeepAlive=${record.hasKeepAlive}, treatment=extended`);
}
// Wait 3 seconds (would timeout with standard treatment)
await plugins.smartdelay.delayFor(3000);
const midWaitCount = cm2.getConnectionCount();
console.log(`Connections after 3s (base timeout exceeded): ${midWaitCount}`);
expect(midWaitCount).toEqual(1); // Should still be connected due to extended treatment
// Clean up test 2
client2.destroy();
await proxy2.stop();
await plugins.smartdelay.delayFor(500); // Wait for port to be released
// Test 3: Immortal keepalive treatment
console.log('\n--- Test 3: Immortal KeepAlive Treatment ---');
const proxy3 = new SmartProxy({
routes: [{
name: 'keepalive-immortal',
match: { ports: 8592 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9998 }
}
}],
keepAlive: true,
keepAliveTreatment: 'immortal',
inactivityTimeout: 1000, // 1 second - should be ignored for immortal
enableDetailedLogging: false,
});
await proxy3.start();
console.log('✓ Proxy with immortal keepalive started on port 8592');
const client3 = net.connect(8592, 'localhost');
// Add error handler to prevent unhandled errors
client3.on('error', (err) => {
console.log(`Client3 error (expected during cleanup): ${err.code}`);
});
await new Promise<void>((resolve) => {
client3.on('connect', () => {
console.log('Client connected with immortal treatment');
client3.setKeepAlive(true, 1000);
resolve();
});
});
// Send initial data
client3.write('Immortal connection\n');
// Wait well beyond normal timeout
await plugins.smartdelay.delayFor(5000);
const cm3 = (proxy3 as any).connectionManager;
const immortalCount = cm3.getConnectionCount();
console.log(`Immortal connections after 5s inactivity: ${immortalCount}`);
expect(immortalCount).toEqual(1); // Should never timeout
// Verify zombie detection doesn't affect immortal connections
console.log('\n--- Verifying zombie detection respects keepalive ---');
// Manually trigger inactivity check
cm3.performOptimizedInactivityCheck();
await plugins.smartdelay.delayFor(1000);
const afterCheckCount = cm3.getConnectionCount();
console.log(`Connections after manual inactivity check: ${afterCheckCount}`);
expect(afterCheckCount).toEqual(1); // Should still be alive
// Clean up
client3.destroy();
await proxy3.stop();
// Close backend and wait for it to fully close
await new Promise<void>((resolve) => {
echoBackend.close(() => {
console.log('Echo backend closed');
resolve();
});
});
console.log('\n✓ All keepalive tests passed:');
console.log(' - Standard treatment works correctly');
console.log(' - Extended treatment applies multiplier');
console.log(' - Immortal treatment never times out');
console.log(' - Zombie detection respects keepalive settings');
});
tap.start();

View File

@ -0,0 +1,112 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { LogDeduplicator } from '../ts/core/utils/log-deduplicator.js';
let deduplicator: LogDeduplicator;
tap.test('Setup log deduplicator', async () => {
deduplicator = new LogDeduplicator(1000); // 1 second flush interval for testing
});
tap.test('Connection rejection deduplication', async (tools) => {
// Simulate multiple connection rejections
for (let i = 0; i < 10; i++) {
deduplicator.log(
'connection-rejected',
'warn',
'Connection rejected',
{ reason: 'global-limit', component: 'test' },
'global-limit'
);
}
for (let i = 0; i < 5; i++) {
deduplicator.log(
'connection-rejected',
'warn',
'Connection rejected',
{ reason: 'route-limit', component: 'test' },
'route-limit'
);
}
// Force flush
deduplicator.flush('connection-rejected');
// The logs should have been aggregated
// (Can't easily test the actual log output, but we can verify the mechanism works)
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('IP rejection deduplication', async (tools) => {
// Simulate rejections from multiple IPs
const ips = ['192.168.1.100', '192.168.1.101', '192.168.1.100', '10.0.0.1'];
const reasons = ['per-ip-limit', 'rate-limit', 'per-ip-limit', 'global-limit'];
for (let i = 0; i < ips.length; i++) {
deduplicator.log(
'ip-rejected',
'warn',
`Connection rejected from ${ips[i]}`,
{ remoteIP: ips[i], reason: reasons[i] },
ips[i]
);
}
// Add more rejections from the same IP
for (let i = 0; i < 20; i++) {
deduplicator.log(
'ip-rejected',
'warn',
'Connection rejected from 192.168.1.100',
{ remoteIP: '192.168.1.100', reason: 'rate-limit' },
'192.168.1.100'
);
}
// Force flush
deduplicator.flush('ip-rejected');
// Verify the deduplicator exists and works
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Connection cleanup deduplication', async (tools) => {
// Simulate various cleanup events
const reasons = ['normal', 'timeout', 'error', 'normal', 'zombie'];
for (const reason of reasons) {
for (let i = 0; i < 5; i++) {
deduplicator.log(
'connection-cleanup',
'info',
`Connection cleanup: ${reason}`,
{ connectionId: `conn-${i}`, reason },
reason
);
}
}
// Wait for automatic flush
await tools.delayFor(1500);
// Verify deduplicator is working
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Automatic periodic flush', async (tools) => {
// Add some events
deduplicator.log('test-event', 'info', 'Test message', {}, 'test');
// Wait for automatic flush (should happen within 2x flush interval = 2 seconds)
await tools.delayFor(2500);
// Events should have been flushed automatically
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Cleanup deduplicator', async () => {
deduplicator.cleanup();
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.start();

View File

@ -0,0 +1,152 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy, createHttpRoute } from '../ts/index.js';
import * as http from 'http';
tap.test('should not have memory leaks in long-running operations', async (tools) => {
// Get initial memory usage
const getMemoryUsage = () => {
if (global.gc) {
global.gc();
}
const usage = process.memoryUsage();
return {
heapUsed: Math.round(usage.heapUsed / 1024 / 1024), // MB
external: Math.round(usage.external / 1024 / 1024), // MB
rss: Math.round(usage.rss / 1024 / 1024) // MB
};
};
// Create a target server
const targetServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
});
await new Promise<void>((resolve) => targetServer.listen(3100, resolve));
// Create the proxy - use non-privileged port
const routes = [
createHttpRoute(['test1.local', 'test2.local', 'test3.local'], { host: 'localhost', port: 3100 }),
];
// Update route to use port 8080
routes[0].match.ports = 8080;
const proxy = new SmartProxy({
ports: [8080], // Use non-privileged port
routes: routes
});
await proxy.start();
console.log('Starting memory leak test...');
const initialMemory = getMemoryUsage();
console.log('Initial memory:', initialMemory);
// Function to make requests
const makeRequest = (domain: string): Promise<void> => {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: 'localhost',
port: 8080,
path: '/',
method: 'GET',
headers: {
'Host': domain
}
}, (res) => {
res.on('data', () => {});
res.on('end', resolve);
});
req.on('error', reject);
req.end();
});
};
// Test 1: Many requests to the same routes
console.log('Test 1: Making 1000 requests to same routes...');
for (let i = 0; i < 1000; i++) {
await makeRequest(`test${(i % 3) + 1}.local`);
if (i % 100 === 0) {
console.log(` Progress: ${i}/1000`);
}
}
const afterSameRoutesMemory = getMemoryUsage();
console.log('Memory after same routes:', afterSameRoutesMemory);
// Test 2: Many requests to different routes (tests routeContextCache)
console.log('Test 2: Making 1000 requests to different routes...');
for (let i = 0; i < 1000; i++) {
// Create unique domain to test cache growth
await makeRequest(`test${i}.local`);
if (i % 100 === 0) {
console.log(` Progress: ${i}/1000`);
}
}
const afterDifferentRoutesMemory = getMemoryUsage();
console.log('Memory after different routes:', afterDifferentRoutesMemory);
// Test 3: Check metrics collector memory
console.log('Test 3: Checking metrics collector...');
const metrics = proxy.getMetrics();
console.log(`Active connections: ${metrics.connections.active()}`);
console.log(`Total connections: ${metrics.connections.total()}`);
console.log(`RPS: ${metrics.requests.perSecond()}`);
// Test 4: Many rapid connections (tests requestTimestamps array)
console.log('Test 4: Making 500 rapid requests...');
const rapidRequests = [];
for (let i = 0; i < 500; i++) {
rapidRequests.push(makeRequest('test1.local'));
if (i % 50 === 0) {
// Wait a bit to let some complete
await Promise.all(rapidRequests);
rapidRequests.length = 0;
// Add delay to allow connections to close
await new Promise(resolve => setTimeout(resolve, 100));
console.log(` Progress: ${i}/500`);
}
}
await Promise.all(rapidRequests);
const afterRapidMemory = getMemoryUsage();
console.log('Memory after rapid requests:', afterRapidMemory);
// Force garbage collection and check final memory
await new Promise(resolve => setTimeout(resolve, 1000));
const finalMemory = getMemoryUsage();
console.log('Final memory:', finalMemory);
// Memory leak checks
const memoryGrowth = finalMemory.heapUsed - initialMemory.heapUsed;
console.log(`Total memory growth: ${memoryGrowth} MB`);
// Check for excessive memory growth
// Allow some growth but not excessive (e.g., more than 50MB for this test)
expect(memoryGrowth).toBeLessThan(50);
// Check specific potential leaks
// 1. Route context cache should not grow unbounded
const routeHandler = proxy.routeConnectionHandler as any;
if (routeHandler.routeContextCache) {
console.log(`Route context cache size: ${routeHandler.routeContextCache.size}`);
// Should not have 1000 entries from different routes test
expect(routeHandler.routeContextCache.size).toBeLessThan(100);
}
// 2. Metrics collector should clean up old timestamps
const metricsCollector = (proxy as any).metricsCollector;
if (metricsCollector && metricsCollector.requestTimestamps) {
console.log(`Request timestamps array length: ${metricsCollector.requestTimestamps.length}`);
// Should clean up old timestamps periodically
expect(metricsCollector.requestTimestamps.length).toBeLessThanOrEqual(10000);
}
// Cleanup
await proxy.stop();
await new Promise<void>((resolve) => targetServer.close(resolve));
console.log('Memory leak test completed successfully');
});
// Run with: node --expose-gc test.memory-leak-check.node.ts
tap.start();

View File

@ -0,0 +1,60 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy, createHttpRoute } from '../ts/index.js';
import * as http from 'http';
tap.test('memory leak fixes verification', async () => {
// Test 1: MetricsCollector requestTimestamps cleanup
console.log('\n=== Test 1: MetricsCollector requestTimestamps cleanup ===');
const proxy = new SmartProxy({
ports: [8081],
routes: [
createHttpRoute('test.local', { host: 'localhost', port: 3200 }, {
match: {
ports: 8081,
domains: 'test.local'
}
}),
]
});
await proxy.start();
const metricsCollector = (proxy as any).metricsCollector;
// Check initial state
console.log('Initial timestamps:', metricsCollector.requestTimestamps.length);
// Simulate many requests to test cleanup
for (let i = 0; i < 6000; i++) {
metricsCollector.recordRequest();
}
// Should be cleaned up to MAX_TIMESTAMPS (5000)
console.log('After 6000 requests:', metricsCollector.requestTimestamps.length);
expect(metricsCollector.requestTimestamps.length).toBeLessThanOrEqual(5000);
await proxy.stop();
// Test 2: Verify intervals are cleaned up
console.log('\n=== Test 2: Verify cleanup methods exist ===');
// Check RequestHandler has destroy method
const { RequestHandler } = await import('../ts/proxies/http-proxy/request-handler.js');
const requestHandler = new RequestHandler({}, null as any);
expect(typeof requestHandler.destroy).toEqual('function');
console.log('✓ RequestHandler has destroy method');
// Check FunctionCache has destroy method
const { FunctionCache } = await import('../ts/proxies/http-proxy/function-cache.js');
const functionCache = new FunctionCache({ debug: () => {}, info: () => {} } as any);
expect(typeof functionCache.destroy).toEqual('function');
console.log('✓ FunctionCache has destroy method');
// Cleanup
requestHandler.destroy();
functionCache.destroy();
console.log('\n✅ All memory leak fixes verified!');
});
tap.start();

View File

@ -0,0 +1,131 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
tap.test('memory leak fixes - unit tests', async () => {
console.log('\n=== Testing MetricsCollector memory management ===');
// Import and test MetricsCollector directly
const { MetricsCollector } = await import('../ts/proxies/smart-proxy/metrics-collector.js');
// Create a mock SmartProxy with minimal required properties
const mockProxy = {
connectionManager: {
getConnectionCount: () => 0,
getConnections: () => new Map(),
getTerminationStats: () => ({ incoming: {} })
},
routeConnectionHandler: {
newConnectionSubject: {
subscribe: () => ({ unsubscribe: () => {} })
}
},
settings: {}
};
const collector = new MetricsCollector(mockProxy as any);
collector.start();
// Test timestamp cleanup
console.log('Testing requestTimestamps cleanup...');
// Add 6000 timestamps
for (let i = 0; i < 6000; i++) {
collector.recordRequest();
}
// Access private property for testing
let timestamps = (collector as any).requestTimestamps;
console.log(`Timestamps after 6000 requests: ${timestamps.length}`);
// Force one more request to trigger cleanup
collector.recordRequest();
timestamps = (collector as any).requestTimestamps;
console.log(`Timestamps after cleanup trigger: ${timestamps.length}`);
// Now check the RPS window - all timestamps are within 1 minute so they won't be cleaned
const now = Date.now();
const oldestTimestamp = Math.min(...timestamps);
const windowAge = now - oldestTimestamp;
console.log(`Window age: ${windowAge}ms (should be < 60000ms for all to be kept)`);
// Since all timestamps are recent (within RPS window), they won't be cleaned by window
// But the array size should still be limited
console.log(`MAX_TIMESTAMPS: ${(collector as any).MAX_TIMESTAMPS}`);
// The issue is our rapid-fire test - all timestamps are within the window
// Let's test with older timestamps
console.log('\nTesting with mixed old/new timestamps...');
(collector as any).requestTimestamps = [];
// Add some old timestamps (older than window)
const oldTime = now - 70000; // 70 seconds ago
for (let i = 0; i < 3000; i++) {
(collector as any).requestTimestamps.push(oldTime);
}
// Add new timestamps to exceed limit
for (let i = 0; i < 3000; i++) {
collector.recordRequest();
}
timestamps = (collector as any).requestTimestamps;
console.log(`After mixed timestamps: ${timestamps.length} (old ones should be cleaned)`);
// Old timestamps should be cleaned when we exceed MAX_TIMESTAMPS
expect(timestamps.length).toBeLessThanOrEqual(5000);
// Stop the collector
collector.stop();
console.log('\n=== Testing FunctionCache cleanup ===');
const { FunctionCache } = await import('../ts/proxies/http-proxy/function-cache.js');
const mockLogger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {}
};
const cache = new FunctionCache(mockLogger as any);
// Check that cleanup interval was set
expect((cache as any).cleanupInterval).toBeTruthy();
// Test destroy method
cache.destroy();
// Cleanup interval should be cleared
expect((cache as any).cleanupInterval).toBeNull();
console.log('✓ FunctionCache properly cleans up interval');
console.log('\n=== Testing RequestHandler cleanup ===');
const { RequestHandler } = await import('../ts/proxies/http-proxy/request-handler.js');
const mockConnectionPool = {
getConnection: () => null,
releaseConnection: () => {}
};
const handler = new RequestHandler(
{ logLevel: 'error' },
mockConnectionPool as any
);
// Check that cleanup interval was set
expect((handler as any).rateLimitCleanupInterval).toBeTruthy();
// Test destroy method
handler.destroy();
// Cleanup interval should be cleared
expect((handler as any).rateLimitCleanupInterval).toBeNull();
console.log('✓ RequestHandler properly cleans up interval');
console.log('\n✅ All memory leak fixes verified!');
});
tap.start();

View File

@ -0,0 +1,280 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import * as net from 'net';
import * as plugins from '../ts/plugins.js';
tap.test('MetricsCollector provides accurate metrics', async (tools) => {
console.log('\n=== MetricsCollector Test ===');
// Create a simple echo server for testing
const echoServer = net.createServer((socket) => {
socket.on('data', (data) => {
socket.write(data);
});
socket.on('error', () => {}); // Ignore errors
});
await new Promise<void>((resolve) => {
echoServer.listen(9995, () => {
console.log('✓ Echo server started on port 9995');
resolve();
});
});
// Create SmartProxy with test routes
const proxy = new SmartProxy({
routes: [
{
name: 'test-route-1',
match: { ports: 8700 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9995 }
}
},
{
name: 'test-route-2',
match: { ports: 8701 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9995 }
}
}
],
enableDetailedLogging: true,
});
await proxy.start();
console.log('✓ Proxy started on ports 8700 and 8701');
// Get metrics interface
const metrics = proxy.getMetrics();
// Test 1: Initial state
console.log('\n--- Test 1: Initial State ---');
expect(metrics.connections.active()).toEqual(0);
expect(metrics.connections.total()).toEqual(0);
expect(metrics.requests.perSecond()).toEqual(0);
expect(metrics.connections.byRoute().size).toEqual(0);
expect(metrics.connections.byIP().size).toEqual(0);
const throughput = metrics.throughput.instant();
expect(throughput.in).toEqual(0);
expect(throughput.out).toEqual(0);
console.log('✓ Initial metrics are all zero');
// Test 2: Create connections and verify metrics
console.log('\n--- Test 2: Active Connections ---');
const clients: net.Socket[] = [];
// Create 3 connections to route 1
for (let i = 0; i < 3; i++) {
const client = net.connect(8700, 'localhost');
clients.push(client);
await new Promise<void>((resolve) => {
client.on('connect', resolve);
client.on('error', () => resolve());
});
}
// Create 2 connections to route 2
for (let i = 0; i < 2; i++) {
const client = net.connect(8701, 'localhost');
clients.push(client);
await new Promise<void>((resolve) => {
client.on('connect', resolve);
client.on('error', () => resolve());
});
}
// Wait for connections to be fully established and routed
await plugins.smartdelay.delayFor(300);
// Verify connection counts
expect(metrics.connections.active()).toEqual(5);
expect(metrics.connections.total()).toEqual(5);
console.log(`✓ Active connections: ${metrics.connections.active()}`);
console.log(`✓ Total connections: ${metrics.connections.total()}`);
// Test 3: Connections by route
console.log('\n--- Test 3: Connections by Route ---');
const routeConnections = metrics.connections.byRoute();
console.log('Route connections:', Array.from(routeConnections.entries()));
// Check if we have the expected counts
let route1Count = 0;
let route2Count = 0;
for (const [routeName, count] of routeConnections) {
if (routeName === 'test-route-1') route1Count = count;
if (routeName === 'test-route-2') route2Count = count;
}
expect(route1Count).toEqual(3);
expect(route2Count).toEqual(2);
console.log('✓ Route test-route-1 has 3 connections');
console.log('✓ Route test-route-2 has 2 connections');
// Test 4: Connections by IP
console.log('\n--- Test 4: Connections by IP ---');
const ipConnections = metrics.connections.byIP();
// All connections are from localhost (127.0.0.1 or ::1)
let totalIPConnections = 0;
for (const [ip, count] of ipConnections) {
console.log(` IP ${ip}: ${count} connections`);
totalIPConnections += count;
}
expect(totalIPConnections).toEqual(5);
console.log('✓ Total connections by IP matches active connections');
// Test 5: RPS calculation
console.log('\n--- Test 5: Requests Per Second ---');
const rps = metrics.requests.perSecond();
console.log(` Current RPS: ${rps.toFixed(2)}`);
// We created 5 connections, so RPS should be > 0
expect(rps).toBeGreaterThan(0);
console.log('✓ RPS is greater than 0');
// Test 6: Throughput
console.log('\n--- Test 6: Throughput ---');
// Send some data through connections
for (const client of clients) {
if (!client.destroyed) {
client.write('Hello metrics!\n');
}
}
// Wait for data to be transmitted and for sampling to occur
await plugins.smartdelay.delayFor(1100); // Wait for at least one sampling interval
const throughputAfter = metrics.throughput.instant();
console.log(` Bytes in: ${throughputAfter.in}`);
console.log(` Bytes out: ${throughputAfter.out}`);
// Throughput might still be 0 if no samples were taken, so just check it's defined
expect(throughputAfter.in).toBeDefined();
expect(throughputAfter.out).toBeDefined();
console.log('✓ Throughput shows bytes transferred');
// Test 7: Close some connections
console.log('\n--- Test 7: Connection Cleanup ---');
// Close first 2 clients
clients[0].destroy();
clients[1].destroy();
await plugins.smartdelay.delayFor(100);
expect(metrics.connections.active()).toEqual(3);
// Note: total() includes active connections + terminated connections from stats
// The terminated connections might not be counted immediately
const totalConns = metrics.connections.total();
expect(totalConns).toBeGreaterThanOrEqual(3); // At least the active connections
console.log(`✓ Active connections reduced to ${metrics.connections.active()}`);
console.log(`✓ Total connections: ${totalConns}`);
// Test 8: Helper methods
console.log('\n--- Test 8: Helper Methods ---');
// Test getTopIPs
const topIPs = metrics.connections.topIPs(5);
expect(topIPs.length).toBeGreaterThan(0);
console.log('✓ getTopIPs returns IP list');
// Test throughput rate
const throughputRate = metrics.throughput.recent();
console.log(` Throughput rate: ${throughputRate.in} bytes/sec in, ${throughputRate.out} bytes/sec out`);
console.log('✓ Throughput rates calculated');
// Cleanup
console.log('\n--- Cleanup ---');
for (const client of clients) {
if (!client.destroyed) {
client.destroy();
}
}
await proxy.stop();
echoServer.close();
console.log('\n✓ All MetricsCollector tests passed');
});
// Test with mock data for unit testing
tap.test('MetricsCollector unit test with mock data', async () => {
console.log('\n=== MetricsCollector Unit Test ===');
// Create a mock SmartProxy with mock ConnectionManager
const mockConnections = new Map([
['conn1', {
remoteIP: '192.168.1.1',
routeName: 'api',
bytesReceived: 1000,
bytesSent: 500,
incomingStartTime: Date.now() - 5000
}],
['conn2', {
remoteIP: '192.168.1.1',
routeName: 'web',
bytesReceived: 2000,
bytesSent: 1500,
incomingStartTime: Date.now() - 10000
}],
['conn3', {
remoteIP: '192.168.1.2',
routeName: 'api',
bytesReceived: 500,
bytesSent: 250,
incomingStartTime: Date.now() - 3000
}]
]);
const mockSmartProxy = {
connectionManager: {
getConnectionCount: () => mockConnections.size,
getConnections: () => mockConnections,
getTerminationStats: () => ({
incoming: { normal: 10, timeout: 2, error: 1 }
})
}
};
// Import MetricsCollector directly
const { MetricsCollector } = await import('../ts/proxies/smart-proxy/metrics-collector.js');
const metrics = new MetricsCollector(mockSmartProxy as any);
// Test metrics calculation
console.log('\n--- Testing with Mock Data ---');
expect(metrics.connections.active()).toEqual(3);
console.log(`✓ Active connections: ${metrics.connections.active()}`);
expect(metrics.connections.total()).toEqual(16); // 3 active + 13 terminated
console.log(`✓ Total connections: ${metrics.connections.total()}`);
const routeConns = metrics.connections.byRoute();
expect(routeConns.get('api')).toEqual(2);
expect(routeConns.get('web')).toEqual(1);
console.log('✓ Connections by route calculated correctly');
const ipConns = metrics.connections.byIP();
expect(ipConns.get('192.168.1.1')).toEqual(2);
expect(ipConns.get('192.168.1.2')).toEqual(1);
console.log('✓ Connections by IP calculated correctly');
// Throughput tracker returns rates, not totals - just verify it returns something
const throughput = metrics.throughput.instant();
expect(throughput.in).toBeDefined();
expect(throughput.out).toBeDefined();
console.log(`✓ Throughput rates calculated: ${throughput.in} bytes/sec in, ${throughput.out} bytes/sec out`);
// Test RPS tracking
metrics.recordRequest('test-1', 'test-route', '192.168.1.1');
metrics.recordRequest('test-2', 'test-route', '192.168.1.1');
metrics.recordRequest('test-3', 'test-route', '192.168.1.2');
const rps = metrics.requests.perSecond();
expect(rps).toBeGreaterThan(0);
console.log(`✓ RPS tracking works: ${rps.toFixed(2)} req/sec`);
console.log('\n✓ All unit tests passed');
});
export default tap.start();

261
test/test.metrics-new.ts Normal file
View File

@ -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<void>((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<void>((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<void>((resolve) => {
client.write(testData, () => {
console.log('Data sent');
resolve();
});
});
// Wait for echo response
await new Promise<void>((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<void>((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<void>((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<void>((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<void>((resolve) => {
echoServer.close(() => {
console.log('Echo server closed');
resolve();
});
});
});
tap.start();

View File

@ -0,0 +1,182 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { SmartProxy } from '../ts/index.js';
let outerProxy: SmartProxy;
let innerProxy: SmartProxy;
tap.test('setup two smartproxies in a chain configuration', async () => {
// Setup inner proxy (backend proxy)
innerProxy = new SmartProxy({
routes: [
{
match: {
ports: 8002
},
action: {
type: 'forward',
target: {
host: 'httpbin.org',
port: 443
}
}
}
],
defaults: {
target: {
host: 'httpbin.org',
port: 443
}
},
acceptProxyProtocol: true,
sendProxyProtocol: false,
enableDetailedLogging: true,
connectionCleanupInterval: 5000, // More frequent cleanup for testing
inactivityTimeout: 10000 // Shorter timeout for testing
});
await innerProxy.start();
// Setup outer proxy (frontend proxy)
outerProxy = new SmartProxy({
routes: [
{
match: {
ports: 8001
},
action: {
type: 'forward',
target: {
host: 'localhost',
port: 8002
},
sendProxyProtocol: true
}
}
],
defaults: {
target: {
host: 'localhost',
port: 8002
}
},
sendProxyProtocol: true,
enableDetailedLogging: true,
connectionCleanupInterval: 5000, // More frequent cleanup for testing
inactivityTimeout: 10000 // Shorter timeout for testing
});
await outerProxy.start();
});
tap.test('should properly cleanup connections in proxy chain', async (tools) => {
const testDuration = 30000; // 30 seconds
const connectionInterval = 500; // Create new connection every 500ms
const connectionDuration = 2000; // Each connection lasts 2 seconds
let connectionsCreated = 0;
let connectionsCompleted = 0;
// Function to create a test connection
const createTestConnection = async () => {
connectionsCreated++;
const connectionId = connectionsCreated;
try {
const socket = plugins.net.connect({
port: 8001,
host: 'localhost'
});
await new Promise<void>((resolve, reject) => {
socket.on('connect', () => {
console.log(`Connection ${connectionId} established`);
// Send TLS Client Hello for httpbin.org
const clientHello = Buffer.from([
0x16, 0x03, 0x01, 0x00, 0xc8, // TLS handshake header
0x01, 0x00, 0x00, 0xc4, // Client Hello
0x03, 0x03, // TLS 1.2
...Array(32).fill(0), // Random bytes
0x00, // Session ID length
0x00, 0x02, 0x13, 0x01, // Cipher suites
0x01, 0x00, // Compression methods
0x00, 0x97, // Extensions length
0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, // SNI extension
0x00, 0x00, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x62, 0x69, 0x6e, 0x2e, 0x6f, 0x72, 0x67 // "httpbin.org"
]);
socket.write(clientHello);
// Keep connection alive for specified duration
setTimeout(() => {
socket.destroy();
connectionsCompleted++;
console.log(`Connection ${connectionId} closed (completed: ${connectionsCompleted}/${connectionsCreated})`);
resolve();
}, connectionDuration);
});
socket.on('error', (err) => {
console.log(`Connection ${connectionId} error: ${err.message}`);
connectionsCompleted++;
reject(err);
});
});
} catch (err) {
console.log(`Failed to create connection ${connectionId}: ${err.message}`);
connectionsCompleted++;
}
};
// Start creating connections
const startTime = Date.now();
const connectionTimer = setInterval(() => {
if (Date.now() - startTime < testDuration) {
createTestConnection().catch(() => {});
} else {
clearInterval(connectionTimer);
}
}, connectionInterval);
// Monitor connection counts
const monitorInterval = setInterval(() => {
const outerConnections = (outerProxy as any).connectionManager.getConnectionCount();
const innerConnections = (innerProxy as any).connectionManager.getConnectionCount();
console.log(`Active connections - Outer: ${outerConnections}, Inner: ${innerConnections}, Created: ${connectionsCreated}, Completed: ${connectionsCompleted}`);
}, 2000);
// Wait for test duration + cleanup time
await tools.delayFor(testDuration + 10000);
clearInterval(connectionTimer);
clearInterval(monitorInterval);
// Wait for all connections to complete
while (connectionsCompleted < connectionsCreated) {
await tools.delayFor(100);
}
// Give some time for cleanup
await tools.delayFor(5000);
// Check final connection counts
const finalOuterConnections = (outerProxy as any).connectionManager.getConnectionCount();
const finalInnerConnections = (innerProxy as any).connectionManager.getConnectionCount();
console.log(`\nFinal connection counts:`);
console.log(`Outer proxy: ${finalOuterConnections}`);
console.log(`Inner proxy: ${finalInnerConnections}`);
console.log(`Total created: ${connectionsCreated}`);
console.log(`Total completed: ${connectionsCompleted}`);
// Both proxies should have cleaned up all connections
expect(finalOuterConnections).toEqual(0);
expect(finalInnerConnections).toEqual(0);
});
tap.test('cleanup proxies', async () => {
await outerProxy.stop();
await innerProxy.stop();
});
export default tap.start();

133
test/test.proxy-protocol.ts Normal file
View File

@ -0,0 +1,133 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartproxy from '../ts/index.js';
import { ProxyProtocolParser } from '../ts/core/utils/proxy-protocol.js';
tap.test('PROXY protocol v1 parser - valid headers', async () => {
// Test TCP4 format
const tcp4Header = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii');
const tcp4Result = ProxyProtocolParser.parse(tcp4Header);
expect(tcp4Result.proxyInfo).property('protocol').toEqual('TCP4');
expect(tcp4Result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
expect(tcp4Result.proxyInfo).property('sourcePort').toEqual(56324);
expect(tcp4Result.proxyInfo).property('destinationIP').toEqual('10.0.0.1');
expect(tcp4Result.proxyInfo).property('destinationPort').toEqual(443);
expect(tcp4Result.remainingData.length).toEqual(0);
// Test TCP6 format
const tcp6Header = Buffer.from('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n', 'ascii');
const tcp6Result = ProxyProtocolParser.parse(tcp6Header);
expect(tcp6Result.proxyInfo).property('protocol').toEqual('TCP6');
expect(tcp6Result.proxyInfo).property('sourceIP').toEqual('2001:db8::1');
expect(tcp6Result.proxyInfo).property('sourcePort').toEqual(56324);
expect(tcp6Result.proxyInfo).property('destinationIP').toEqual('2001:db8::2');
expect(tcp6Result.proxyInfo).property('destinationPort').toEqual(443);
// Test UNKNOWN protocol
const unknownHeader = Buffer.from('PROXY UNKNOWN\r\n', 'ascii');
const unknownResult = ProxyProtocolParser.parse(unknownHeader);
expect(unknownResult.proxyInfo).property('protocol').toEqual('UNKNOWN');
expect(unknownResult.proxyInfo).property('sourceIP').toEqual('');
expect(unknownResult.proxyInfo).property('sourcePort').toEqual(0);
});
tap.test('PROXY protocol v1 parser - with remaining data', async () => {
const headerWithData = Buffer.concat([
Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii'),
Buffer.from('GET / HTTP/1.1\r\n', 'ascii')
]);
const result = ProxyProtocolParser.parse(headerWithData);
expect(result.proxyInfo).property('protocol').toEqual('TCP4');
expect(result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
expect(result.remainingData.toString()).toEqual('GET / HTTP/1.1\r\n');
});
tap.test('PROXY protocol v1 parser - invalid headers', async () => {
// Not a PROXY protocol header
const notProxy = Buffer.from('GET / HTTP/1.1\r\n', 'ascii');
const notProxyResult = ProxyProtocolParser.parse(notProxy);
expect(notProxyResult.proxyInfo).toBeNull();
expect(notProxyResult.remainingData).toEqual(notProxy);
// Invalid protocol
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY INVALID 1.1.1.1 2.2.2.2 80 443\r\n', 'ascii'));
}).toThrow();
// Wrong number of fields
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324\r\n', 'ascii'));
}).toThrow();
// Invalid port
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n', 'ascii'));
}).toThrow();
// Invalid IP for protocol
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 2001:db8::1 10.0.0.1 56324 443\r\n', 'ascii'));
}).toThrow();
});
tap.test('PROXY protocol v1 parser - incomplete headers', async () => {
// Header without terminator
const incomplete = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443', 'ascii');
const result = ProxyProtocolParser.parse(incomplete);
expect(result.proxyInfo).toBeNull();
expect(result.remainingData).toEqual(incomplete);
// Header exceeding max length - create a buffer that actually starts with PROXY
const longHeader = Buffer.from('PROXY TCP4 ' + '1'.repeat(100), 'ascii');
expect(() => {
ProxyProtocolParser.parse(longHeader);
}).toThrow();
});
tap.test('PROXY protocol v1 generator', async () => {
// Generate TCP4 header
const tcp4Info = {
protocol: 'TCP4' as const,
sourceIP: '192.168.1.1',
sourcePort: 56324,
destinationIP: '10.0.0.1',
destinationPort: 443
};
const tcp4Header = ProxyProtocolParser.generate(tcp4Info);
expect(tcp4Header.toString('ascii')).toEqual('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n');
// Generate TCP6 header
const tcp6Info = {
protocol: 'TCP6' as const,
sourceIP: '2001:db8::1',
sourcePort: 56324,
destinationIP: '2001:db8::2',
destinationPort: 443
};
const tcp6Header = ProxyProtocolParser.generate(tcp6Info);
expect(tcp6Header.toString('ascii')).toEqual('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n');
// Generate UNKNOWN header
const unknownInfo = {
protocol: 'UNKNOWN' as const,
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
};
const unknownHeader = ProxyProtocolParser.generate(unknownInfo);
expect(unknownHeader.toString('ascii')).toEqual('PROXY UNKNOWN\r\n');
});
// Skipping integration tests for now - focus on unit tests
// Integration tests would require more complex setup and teardown
tap.start();

View File

@ -159,11 +159,11 @@ tap.test('should extract path parameters from URL', async () => {
// Test multiple configs for same hostname with different paths
tap.test('should support multiple configs for same hostname with different paths', async () => {
const apiConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001);
apiConfig.match.path = '/api';
apiConfig.match.path = '/api/*';
apiConfig.name = 'api-route';
const webConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002);
webConfig.match.path = '/web';
webConfig.match.path = '/web/*';
webConfig.name = 'web-route';
// Add both configs
@ -252,7 +252,7 @@ tap.test('should fall back to default configuration', async () => {
const defaultConfig = createRouteConfig('*');
const specificConfig = createRouteConfig(TEST_DOMAIN);
router.setRoutes([defaultConfig, specificConfig]);
router.setRoutes([specificConfig, defaultConfig]);
// Test specific domain routes to specific config
const specificReq = createMockRequest(TEST_DOMAIN);
@ -272,7 +272,7 @@ tap.test('should prioritize exact hostname over wildcard', async () => {
const wildcardConfig = createRouteConfig(TEST_WILDCARD);
const exactConfig = createRouteConfig(TEST_SUBDOMAIN);
router.setRoutes([wildcardConfig, exactConfig]);
router.setRoutes([exactConfig, wildcardConfig]);
// Test that exact match takes priority
const req = createMockRequest(TEST_SUBDOMAIN);

View File

@ -0,0 +1,159 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SharedSecurityManager } from '../ts/core/utils/shared-security-manager.js';
import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
let securityManager: SharedSecurityManager;
tap.test('Setup SharedSecurityManager', async () => {
securityManager = new SharedSecurityManager({
maxConnectionsPerIP: 5,
connectionRateLimitPerMinute: 10,
cleanupIntervalMs: 1000 // 1 second for faster testing
});
});
tap.test('IP connection tracking', async () => {
const testIP = '192.168.1.100';
// Track multiple connections
securityManager.trackConnectionByIP(testIP, 'conn1');
securityManager.trackConnectionByIP(testIP, 'conn2');
securityManager.trackConnectionByIP(testIP, 'conn3');
// Verify connection count
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(3);
// Remove a connection
securityManager.removeConnectionByIP(testIP, 'conn2');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(2);
// Remove remaining connections
securityManager.removeConnectionByIP(testIP, 'conn1');
securityManager.removeConnectionByIP(testIP, 'conn3');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(0);
});
tap.test('Per-IP connection limits validation', async () => {
const testIP = '192.168.1.101';
// Track connections up to limit
for (let i = 1; i <= 5; i++) {
securityManager.trackConnectionByIP(testIP, `conn${i}`);
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
}
// Verify we're at the limit
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5);
// Next connection should be rejected
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Maximum connections per IP');
// Clean up
for (let i = 1; i <= 5; i++) {
securityManager.removeConnectionByIP(testIP, `conn${i}`);
}
});
tap.test('Connection rate limiting', async () => {
const testIP = '192.168.1.102';
// Make connections at the rate limit
for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
securityManager.trackConnectionByIP(testIP, `conn${i}`);
}
// Next connection should exceed rate limit
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit');
// Clean up connections
for (let i = 0; i < 10; i++) {
securityManager.removeConnectionByIP(testIP, `conn${i}`);
}
});
tap.test('Route-level connection limits', async () => {
const route: IRouteConfig = {
name: 'test-route',
match: { ports: 443 },
action: { type: 'forward', target: { host: 'localhost', port: 8080 } },
security: {
maxConnections: 3
}
};
const context: IRouteContext = {
port: 443,
clientIp: '192.168.1.103',
serverIp: '0.0.0.0',
timestamp: Date.now(),
connectionId: 'test-conn'
};
// Test with connection counts below limit
expect(securityManager.isAllowed(route, context, 0)).toBeTrue();
expect(securityManager.isAllowed(route, context, 2)).toBeTrue();
// Test at limit
expect(securityManager.isAllowed(route, context, 3)).toBeFalse();
// Test above limit
expect(securityManager.isAllowed(route, context, 5)).toBeFalse();
});
tap.test('IPv4/IPv6 normalization', async () => {
const ipv4 = '127.0.0.1';
const ipv4Mapped = '::ffff:127.0.0.1';
// Track connection with IPv4
securityManager.trackConnectionByIP(ipv4, 'conn1');
// Both representations should show the same connection
expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(1);
expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(1);
// Track another connection with IPv6 representation
securityManager.trackConnectionByIP(ipv4Mapped, 'conn2');
// Both should show 2 connections
expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(2);
expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(2);
// Clean up
securityManager.removeConnectionByIP(ipv4, 'conn1');
securityManager.removeConnectionByIP(ipv4Mapped, 'conn2');
});
tap.test('Automatic cleanup of expired data', async (tools) => {
const testIP = '192.168.1.104';
// Track a connection and then remove it
securityManager.trackConnectionByIP(testIP, 'temp-conn');
securityManager.removeConnectionByIP(testIP, 'temp-conn');
// Add some rate limit entries (they expire after 1 minute)
for (let i = 0; i < 5; i++) {
securityManager.validateIP(testIP);
}
// Wait for cleanup interval (set to 1 second in our test)
await tools.delayFor(1500);
// The IP should be cleaned up since it has no connections
// Note: We can't directly check the internal map, but we can verify
// that a new connection is allowed (fresh rate limit)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
});
tap.test('Cleanup SharedSecurityManager', async () => {
securityManager.clearIPTracking();
});
tap.start();

View File

@ -0,0 +1,144 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { SmartProxy } from '../ts/index.js';
import * as plugins from '../ts/plugins.js';
tap.test('stuck connection cleanup - verify connections to hanging backends are cleaned up', async (tools) => {
console.log('\n=== Stuck Connection Cleanup Test ===');
console.log('Purpose: Verify that connections to backends that accept but never respond are cleaned up');
// Create a hanging backend that accepts connections but never responds
let backendConnections = 0;
const hangingBackend = net.createServer((socket) => {
backendConnections++;
console.log(`Hanging backend: Connection ${backendConnections} received`);
// Accept the connection but never send any data back
// This simulates a hung backend service
});
await new Promise<void>((resolve) => {
hangingBackend.listen(9997, () => {
console.log('✓ Hanging backend started on port 9997');
resolve();
});
});
// Create proxy that forwards to hanging backend
const proxy = new SmartProxy({
routes: [{
name: 'to-hanging-backend',
match: { ports: 8589 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9997 }
}
}],
keepAlive: true,
enableDetailedLogging: false,
inactivityTimeout: 5000, // 5 second inactivity check interval for faster testing
});
await proxy.start();
console.log('✓ Proxy started on port 8589');
// Create connections that will get stuck
console.log('\n--- Creating connections to hanging backend ---');
const clients: net.Socket[] = [];
for (let i = 0; i < 5; i++) {
const client = net.connect(8589, 'localhost');
clients.push(client);
await new Promise<void>((resolve) => {
client.on('connect', () => {
console.log(`Client ${i} connected`);
// Send data that will never get a response
client.write(`GET / HTTP/1.1\r\nHost: localhost\r\n\r\n`);
resolve();
});
client.on('error', (err) => {
console.log(`Client ${i} error: ${err.message}`);
resolve();
});
});
}
// Wait a moment for connections to establish
await plugins.smartdelay.delayFor(1000);
// Check initial connection count
const initialCount = (proxy as any).connectionManager.getConnectionCount();
console.log(`\nInitial connection count: ${initialCount}`);
expect(initialCount).toEqual(5);
// Get connection details
const connections = (proxy as any).connectionManager.getConnections();
let stuckCount = 0;
for (const [id, record] of connections) {
if (record.bytesReceived > 0 && record.bytesSent === 0) {
stuckCount++;
console.log(`Stuck connection ${id}: received=${record.bytesReceived}, sent=${record.bytesSent}`);
}
}
console.log(`Stuck connections found: ${stuckCount}`);
expect(stuckCount).toEqual(5);
// Wait for inactivity check to run (it checks every 30s by default, but we set it to 5s)
console.log('\n--- Waiting for stuck connection detection (65 seconds) ---');
console.log('Note: Stuck connections are cleaned up after 60 seconds with no response');
// Speed up time by manually triggering inactivity check after simulating time passage
// First, age the connections by updating their timestamps
const now = Date.now();
for (const [id, record] of connections) {
// Simulate that these connections are 61 seconds old
record.incomingStartTime = now - 61000;
record.lastActivity = now - 61000;
}
// Manually trigger inactivity check
console.log('Manually triggering inactivity check...');
(proxy as any).connectionManager.performOptimizedInactivityCheck();
// Wait for cleanup to complete
await plugins.smartdelay.delayFor(1000);
// Check connection count after cleanup
const afterCleanupCount = (proxy as any).connectionManager.getConnectionCount();
console.log(`\nConnection count after cleanup: ${afterCleanupCount}`);
// Verify termination stats
const stats = (proxy as any).connectionManager.getTerminationStats();
console.log('\nTermination stats:', stats);
// All connections should be cleaned up as "stuck_no_response"
expect(afterCleanupCount).toEqual(0);
// The termination reason might be under incoming or general stats
const stuckCleanups = (stats.incoming.stuck_no_response || 0) +
(stats.outgoing?.stuck_no_response || 0);
console.log(`Stuck cleanups detected: ${stuckCleanups}`);
expect(stuckCleanups).toBeGreaterThan(0);
// Verify clients were disconnected
let closedClients = 0;
for (const client of clients) {
if (client.destroyed) {
closedClients++;
}
}
console.log(`Closed clients: ${closedClients}/5`);
expect(closedClients).toEqual(5);
// Cleanup
console.log('\n--- Cleanup ---');
await proxy.stop();
hangingBackend.close();
console.log('✓ Test complete: Stuck connections are properly detected and cleaned up');
});
tap.start();

View File

@ -0,0 +1,158 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import * as net from 'net';
tap.test('websocket keep-alive settings for SNI passthrough', async (tools) => {
// Test 1: Verify grace periods for TLS connections
console.log('\n=== Test 1: Grace periods for encrypted connections ===');
const proxy = new SmartProxy({
ports: [8443],
keepAliveTreatment: 'extended',
keepAliveInactivityMultiplier: 10,
inactivityTimeout: 60000, // 1 minute for testing
routes: [
{
name: 'test-passthrough',
match: { ports: 8443, domains: 'test.local' },
action: {
type: 'forward',
target: { host: 'localhost', port: 9443 },
tls: { mode: 'passthrough' }
}
}
]
});
// Override route port
proxy.settings.routes[0].match.ports = 8443;
await proxy.start();
// Access connection manager
const connectionManager = proxy.connectionManager;
// Test 2: Verify longer grace periods are applied
console.log('\n=== Test 2: Checking grace period configuration ===');
// Create a mock connection record
const mockRecord = {
id: 'test-conn-1',
remoteIP: '127.0.0.1',
incomingStartTime: Date.now() - 120000, // 2 minutes old
isTLS: true,
incoming: { destroyed: false } as any,
outgoing: { destroyed: true } as any, // Half-zombie state
connectionClosed: false,
hasKeepAlive: true,
lastActivity: Date.now() - 60000
};
// The grace period should be 5 minutes for TLS connections
const gracePeriod = mockRecord.isTLS ? 300000 : 30000;
console.log(`Grace period for TLS connection: ${gracePeriod}ms (${gracePeriod / 1000} seconds)`);
expect(gracePeriod).toEqual(300000); // 5 minutes
// Test 3: Verify keep-alive treatment
console.log('\n=== Test 3: Keep-alive treatment configuration ===');
const settings = proxy.settings;
console.log(`Keep-alive treatment: ${settings.keepAliveTreatment}`);
console.log(`Keep-alive multiplier: ${settings.keepAliveInactivityMultiplier}`);
console.log(`Base inactivity timeout: ${settings.inactivityTimeout}ms`);
// Calculate effective timeout
const effectiveTimeout = settings.inactivityTimeout! * (settings.keepAliveInactivityMultiplier || 6);
console.log(`Effective timeout for keep-alive connections: ${effectiveTimeout}ms (${effectiveTimeout / 1000} seconds)`);
expect(settings.keepAliveTreatment).toEqual('extended');
expect(effectiveTimeout).toEqual(600000); // 10 minutes with our test config
// Test 4: Verify SNI passthrough doesn't get WebSocket heartbeat
console.log('\n=== Test 4: SNI passthrough handling ===');
// Check route configuration
const route = proxy.settings.routes[0];
expect(route.action.tls?.mode).toEqual('passthrough');
// In passthrough mode, WebSocket-specific handling should be skipped
// The connection should be treated as a raw TCP connection
console.log('✓ SNI passthrough routes bypass WebSocket heartbeat checks');
await proxy.stop();
console.log('\n✅ WebSocket keep-alive configuration test completed!');
});
// Test actual long-lived connection behavior
tap.test('long-lived connection survival test', async (tools) => {
console.log('\n=== Testing long-lived connection survival ===');
// Create a simple echo server
const echoServer = net.createServer((socket) => {
console.log('Echo server: client connected');
socket.on('data', (data) => {
socket.write(data); // Echo back
});
});
await new Promise<void>((resolve) => echoServer.listen(9444, resolve));
// Create proxy with immortal keep-alive
const proxy = new SmartProxy({
ports: [8444],
keepAliveTreatment: 'immortal', // Never timeout
routes: [
{
name: 'echo-passthrough',
match: { ports: 8444 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9444 }
}
}
]
});
// Override route port
proxy.settings.routes[0].match.ports = 8444;
await proxy.start();
// Create a client connection
const client = new net.Socket();
await new Promise<void>((resolve, reject) => {
client.connect(8444, 'localhost', () => {
console.log('Client connected to proxy');
resolve();
});
client.on('error', reject);
});
// Keep connection alive with periodic data
let pingCount = 0;
const pingInterval = setInterval(() => {
if (client.writable) {
client.write(`ping ${++pingCount}\n`);
console.log(`Sent ping ${pingCount}`);
}
}, 20000); // Every 20 seconds
// Wait 65 seconds to ensure it survives past old 30s and 60s timeouts
await new Promise(resolve => setTimeout(resolve, 65000));
// Check if connection is still alive
const isAlive = client.writable && !client.destroyed;
console.log(`Connection alive after 65 seconds: ${isAlive}`);
expect(isAlive).toBeTrue();
// Clean up
clearInterval(pingInterval);
client.destroy();
await proxy.stop();
await new Promise<void>((resolve) => echoServer.close(resolve));
console.log('✅ Long-lived connection survived past 30-second timeout!');
});
tap.start();

View File

@ -315,8 +315,6 @@ tap.test('WrappedSocket - should handle encoding and address methods', async ()
tap.test('WrappedSocket - should work with ConnectionManager', async () => {
// This test verifies that WrappedSocket can be used seamlessly with ConnectionManager
const { ConnectionManager } = await import('../ts/proxies/smart-proxy/connection-manager.js');
const { SecurityManager } = await import('../ts/proxies/smart-proxy/security-manager.js');
const { TimeoutManager } = await import('../ts/proxies/smart-proxy/timeout-manager.js');
// Create minimal settings
const settings = {
@ -328,9 +326,17 @@ tap.test('WrappedSocket - should work with ConnectionManager', async () => {
}
};
const securityManager = new SecurityManager(settings);
const timeoutManager = new TimeoutManager(settings);
const connectionManager = new ConnectionManager(settings, securityManager, timeoutManager);
// Create a mock SmartProxy instance
const mockSmartProxy = {
settings,
securityManager: {
trackConnectionByIP: () => {},
untrackConnectionByIP: () => {},
removeConnectionByIP: () => {}
}
} as any;
const connectionManager = new ConnectionManager(mockSmartProxy);
// Create a simple test server
const server = net.createServer();

View File

@ -0,0 +1,306 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import * as plugins from '../ts/plugins.js';
// Import SmartProxy
import { SmartProxy } from '../ts/index.js';
// Import types through type-only imports
import type { ConnectionManager } from '../ts/proxies/smart-proxy/connection-manager.js';
import type { IConnectionRecord } from '../ts/proxies/smart-proxy/models/interfaces.js';
tap.test('zombie connection cleanup - verify inactivity check detects and cleans destroyed sockets', async () => {
console.log('\n=== Zombie Connection Cleanup Test ===');
console.log('Purpose: Verify that connections with destroyed sockets are detected and cleaned up');
console.log('Setup: Client → OuterProxy (8590) → InnerProxy (8591) → Backend (9998)');
// Create backend server that can be controlled
let acceptConnections = true;
let destroyImmediately = false;
const backendConnections: net.Socket[] = [];
const backend = net.createServer((socket) => {
console.log('Backend: Connection received');
backendConnections.push(socket);
if (destroyImmediately) {
console.log('Backend: Destroying connection immediately');
socket.destroy();
} else {
socket.on('data', (data) => {
console.log('Backend: Received data, echoing back');
socket.write(data);
});
}
});
await new Promise<void>((resolve) => {
backend.listen(9998, () => {
console.log('✓ Backend server started on port 9998');
resolve();
});
});
// Create InnerProxy with faster inactivity check for testing
const innerProxy = new SmartProxy({
ports: [8591],
enableDetailedLogging: true,
inactivityTimeout: 5000, // 5 seconds for faster testing
inactivityCheckInterval: 1000, // Check every second
routes: [{
name: 'to-backend',
match: { ports: 8591 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 9998
}
}
}]
});
// Create OuterProxy with faster inactivity check
const outerProxy = new SmartProxy({
ports: [8590],
enableDetailedLogging: true,
inactivityTimeout: 5000, // 5 seconds for faster testing
inactivityCheckInterval: 1000, // Check every second
routes: [{
name: 'to-inner',
match: { ports: 8590 },
action: {
type: 'forward',
target: {
host: 'localhost',
port: 8591
}
}
}]
});
await innerProxy.start();
console.log('✓ InnerProxy started on port 8591');
await outerProxy.start();
console.log('✓ OuterProxy started on port 8590');
// Helper to get connection details
const getConnectionDetails = () => {
const outerConnMgr = (outerProxy as any).connectionManager as ConnectionManager;
const innerConnMgr = (innerProxy as any).connectionManager as ConnectionManager;
const outerRecords = Array.from((outerConnMgr as any).connectionRecords.values()) as IConnectionRecord[];
const innerRecords = Array.from((innerConnMgr as any).connectionRecords.values()) as IConnectionRecord[];
return {
outer: {
count: outerConnMgr.getConnectionCount(),
records: outerRecords,
zombies: outerRecords.filter(r =>
!r.connectionClosed &&
r.incoming?.destroyed &&
(r.outgoing?.destroyed ?? true)
),
halfZombies: outerRecords.filter(r =>
!r.connectionClosed &&
(r.incoming?.destroyed || r.outgoing?.destroyed) &&
!(r.incoming?.destroyed && (r.outgoing?.destroyed ?? true))
)
},
inner: {
count: innerConnMgr.getConnectionCount(),
records: innerRecords,
zombies: innerRecords.filter(r =>
!r.connectionClosed &&
r.incoming?.destroyed &&
(r.outgoing?.destroyed ?? true)
),
halfZombies: innerRecords.filter(r =>
!r.connectionClosed &&
(r.incoming?.destroyed || r.outgoing?.destroyed) &&
!(r.incoming?.destroyed && (r.outgoing?.destroyed ?? true))
)
}
};
};
console.log('\n--- Test 1: Create zombie by destroying sockets without events ---');
// Create a connection and forcefully destroy sockets to create zombies
const client1 = new net.Socket();
await new Promise<void>((resolve) => {
client1.connect(8590, 'localhost', () => {
console.log('Client1 connected to OuterProxy');
client1.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
// Wait for connection to be established through the chain
setTimeout(() => {
console.log('Forcefully destroying backend connections to create zombies');
// Get connection details before destruction
const beforeDetails = getConnectionDetails();
console.log(`Before destruction: Outer=${beforeDetails.outer.count}, Inner=${beforeDetails.inner.count}`);
// Destroy all backend connections without proper close events
backendConnections.forEach(conn => {
if (!conn.destroyed) {
// Remove all listeners to prevent proper cleanup
conn.removeAllListeners();
conn.destroy();
}
});
// Also destroy the client socket abruptly
client1.removeAllListeners();
client1.destroy();
resolve();
}, 500);
});
});
// Check immediately after destruction
await new Promise(resolve => setTimeout(resolve, 100));
let details = getConnectionDetails();
console.log(`\nAfter destruction:`);
console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`);
console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`);
// Wait for inactivity check to run (should detect zombies)
console.log('\nWaiting for inactivity check to detect zombies...');
await new Promise(resolve => setTimeout(resolve, 2000));
details = getConnectionDetails();
console.log(`\nAfter first inactivity check:`);
console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`);
console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`);
console.log('\n--- Test 2: Create half-zombie by destroying only one socket ---');
// Clear backend connections array
backendConnections.length = 0;
const client2 = new net.Socket();
await new Promise<void>((resolve) => {
client2.connect(8590, 'localhost', () => {
console.log('Client2 connected to OuterProxy');
client2.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
setTimeout(() => {
console.log('Creating half-zombie by destroying only outgoing socket on outer proxy');
// Access the connection records directly
const outerConnMgr = (outerProxy as any).connectionManager as ConnectionManager;
const outerRecords = Array.from((outerConnMgr as any).connectionRecords.values()) as IConnectionRecord[];
// Find the active connection and destroy only its outgoing socket
const activeRecord = outerRecords.find(r => !r.connectionClosed && r.outgoing && !r.outgoing.destroyed);
if (activeRecord && activeRecord.outgoing) {
console.log('Found active connection, destroying outgoing socket');
activeRecord.outgoing.removeAllListeners();
activeRecord.outgoing.destroy();
}
resolve();
}, 500);
});
});
// Check half-zombie state
await new Promise(resolve => setTimeout(resolve, 100));
details = getConnectionDetails();
console.log(`\nAfter creating half-zombie:`);
console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`);
console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`);
// Wait for 30-second grace period (simulated by multiple checks)
console.log('\nWaiting for half-zombie grace period (30 seconds simulated)...');
// Manually age the connection to trigger half-zombie cleanup
const outerConnMgr = (outerProxy as any).connectionManager as ConnectionManager;
const records = Array.from((outerConnMgr as any).connectionRecords.values()) as IConnectionRecord[];
records.forEach(record => {
if (!record.connectionClosed) {
// Age the connection by 35 seconds
record.incomingStartTime -= 35000;
}
});
// Trigger inactivity check
await new Promise(resolve => setTimeout(resolve, 2000));
details = getConnectionDetails();
console.log(`\nAfter half-zombie cleanup:`);
console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`);
console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`);
// Clean up client2 properly
if (!client2.destroyed) {
client2.destroy();
}
console.log('\n--- Test 3: Rapid zombie creation under load ---');
// Create multiple connections rapidly and destroy them
const rapidClients: net.Socket[] = [];
for (let i = 0; i < 5; i++) {
const client = new net.Socket();
rapidClients.push(client);
client.connect(8590, 'localhost', () => {
console.log(`Rapid client ${i} connected`);
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
// Destroy after random delay
setTimeout(() => {
client.removeAllListeners();
client.destroy();
}, Math.random() * 500);
});
// Small delay between connections
await new Promise(resolve => setTimeout(resolve, 50));
}
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 1000));
details = getConnectionDetails();
console.log(`\nAfter rapid connections:`);
console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`);
console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`);
// Wait for cleanup
console.log('\nWaiting for final cleanup...');
await new Promise(resolve => setTimeout(resolve, 3000));
details = getConnectionDetails();
console.log(`\nFinal state:`);
console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`);
console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`);
// Cleanup
await outerProxy.stop();
await innerProxy.stop();
backend.close();
// Verify all connections are cleaned up
console.log('\n--- Verification ---');
if (details.outer.count === 0 && details.inner.count === 0) {
console.log('✅ PASS: All zombie connections were cleaned up');
} else {
console.log('❌ FAIL: Some connections remain');
}
expect(details.outer.count).toEqual(0);
expect(details.inner.count).toEqual(0);
expect(details.outer.zombies.length).toEqual(0);
expect(details.inner.zombies.length).toEqual(0);
expect(details.outer.halfZombies.length).toEqual(0);
expect(details.inner.halfZombies.length).toEqual(0);
});
tap.start();

View File

@ -52,6 +52,9 @@ export class WrappedSocket {
if (prop === 'setProxyInfo') {
return target.setProxyInfo.bind(target);
}
if (prop === 'remoteFamily') {
return target.remoteFamily;
}
// For all other properties/methods, delegate to the underlying socket
const value = target.socket[prop as keyof plugins.net.Socket];
@ -89,6 +92,21 @@ export class WrappedSocket {
return !!this.realClientIP;
}
/**
* Returns the address family of the remote IP
*/
get remoteFamily(): string | undefined {
const ip = this.realClientIP || this.socket.remoteAddress;
if (!ip) return undefined;
// Check if it's IPv6
if (ip.includes(':')) {
return 'IPv6';
}
// Otherwise assume IPv4
return 'IPv4';
}
/**
* Updates the real client information (called after parsing PROXY protocol)
*/

View File

@ -95,7 +95,8 @@ export class PathMatcher implements IMatcher<IPathMatchResult> {
if (normalizedPattern.includes('*') && match.length > paramNames.length + 1) {
const wildcardCapture = match[match.length - 1];
if (wildcardCapture) {
pathRemainder = wildcardCapture;
// Ensure pathRemainder includes leading slash if it had one
pathRemainder = wildcardCapture.startsWith('/') ? wildcardCapture : '/' + wildcardCapture;
pathMatch = normalizedPath.substring(0, normalizedPath.length - wildcardCapture.length);
}
}

View File

@ -15,3 +15,4 @@ export * from './lifecycle-component.js';
export * from './binary-heap.js';
export * from './enhanced-connection-pool.js';
export * from './socket-utils.js';
export * from './proxy-protocol.js';

View File

@ -0,0 +1,280 @@
import { logger } from './logger.js';
interface ILogEvent {
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: any;
count: number;
firstSeen: number;
lastSeen: number;
}
interface IAggregatedEvent {
key: string;
events: Map<string, ILogEvent>;
flushTimer?: NodeJS.Timeout;
}
/**
* Log deduplication utility to reduce log spam for repetitive events
*/
export class LogDeduplicator {
private globalFlushTimer?: NodeJS.Timeout;
private aggregatedEvents: Map<string, IAggregatedEvent> = new Map();
private flushInterval: number = 5000; // 5 seconds
private maxBatchSize: number = 100;
constructor(flushInterval?: number) {
if (flushInterval) {
this.flushInterval = flushInterval;
}
// Set up global periodic flush to ensure logs are emitted regularly
this.globalFlushTimer = setInterval(() => {
this.flushAll();
}, this.flushInterval * 2); // Flush everything every 2x the normal interval
if (this.globalFlushTimer.unref) {
this.globalFlushTimer.unref();
}
}
/**
* Log a deduplicated event
* @param key - Aggregation key (e.g., 'connection-rejected', 'cleanup-batch')
* @param level - Log level
* @param message - Log message template
* @param data - Additional data
* @param dedupeKey - Deduplication key within the aggregation (e.g., IP address, reason)
*/
public log(
key: string,
level: 'info' | 'warn' | 'error' | 'debug',
message: string,
data?: any,
dedupeKey?: string
): void {
const eventKey = dedupeKey || message;
const now = Date.now();
if (!this.aggregatedEvents.has(key)) {
this.aggregatedEvents.set(key, {
key,
events: new Map(),
flushTimer: undefined
});
}
const aggregated = this.aggregatedEvents.get(key)!;
if (aggregated.events.has(eventKey)) {
const event = aggregated.events.get(eventKey)!;
event.count++;
event.lastSeen = now;
if (data) {
event.data = { ...event.data, ...data };
}
} else {
aggregated.events.set(eventKey, {
level,
message,
data,
count: 1,
firstSeen: now,
lastSeen: now
});
}
// Check if we should flush due to size
if (aggregated.events.size >= this.maxBatchSize) {
this.flush(key);
} else if (!aggregated.flushTimer) {
// Schedule flush
aggregated.flushTimer = setTimeout(() => {
this.flush(key);
}, this.flushInterval);
if (aggregated.flushTimer.unref) {
aggregated.flushTimer.unref();
}
}
}
/**
* Flush aggregated events for a specific key
*/
public flush(key: string): void {
const aggregated = this.aggregatedEvents.get(key);
if (!aggregated || aggregated.events.size === 0) {
return;
}
if (aggregated.flushTimer) {
clearTimeout(aggregated.flushTimer);
aggregated.flushTimer = undefined;
}
// Emit aggregated log based on the key
switch (key) {
case 'connection-rejected':
this.flushConnectionRejections(aggregated);
break;
case 'connection-cleanup':
this.flushConnectionCleanups(aggregated);
break;
case 'ip-rejected':
this.flushIPRejections(aggregated);
break;
default:
this.flushGeneric(aggregated);
}
// Clear events
aggregated.events.clear();
}
/**
* Flush all pending events
*/
public flushAll(): void {
for (const key of this.aggregatedEvents.keys()) {
this.flush(key);
}
}
private flushConnectionRejections(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'unknown';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
logger.log('warn', `Rejected ${totalCount} connections`, {
reasons: reasonSummary,
uniqueIPs: aggregated.events.size,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'connection-dedup'
});
}
private flushConnectionCleanups(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'normal';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5) // Top 5 reasons
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
logger.log('info', `Cleaned up ${totalCount} connections`, {
reasons: reasonSummary,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'connection-dedup'
});
}
private flushIPRejections(aggregated: IAggregatedEvent): void {
const byIP = new Map<string, { count: number; reasons: Set<string> }>();
for (const [ip, event] of aggregated.events) {
if (!byIP.has(ip)) {
byIP.set(ip, { count: 0, reasons: new Set() });
}
const ipData = byIP.get(ip)!;
ipData.count += event.count;
if (event.data?.reason) {
ipData.reasons.add(event.data.reason);
}
}
// Log top offenders
const topOffenders = Array.from(byIP.entries())
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 10)
.map(([ip, data]) => `${ip} (${data.count}x, ${Array.from(data.reasons).join('/')})`)
.join(', ');
const totalRejections = Array.from(byIP.values()).reduce((sum, data) => sum + data.count, 0);
logger.log('warn', `Rejected ${totalRejections} connections from ${byIP.size} IPs`, {
topOffenders,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'ip-dedup'
});
}
private flushGeneric(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const level = aggregated.events.values().next().value?.level || 'info';
// Special handling for IP cleanup events
if (aggregated.key === 'ip-cleanup') {
const totalCleaned = Array.from(aggregated.events.values()).reduce((sum, e) => {
return sum + (e.data?.cleanedIPs || 0) + (e.data?.cleanedRateLimits || 0);
}, 0);
if (totalCleaned > 0) {
logger.log(level as any, `IP tracking cleanup: removed ${totalCleaned} entries across ${totalCount} cleanup cycles`, {
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'log-dedup'
});
}
} else {
logger.log(level as any, `${aggregated.key}: ${totalCount} events`, {
uniqueEvents: aggregated.events.size,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'log-dedup'
});
}
}
/**
* Cleanup and stop deduplication
*/
public cleanup(): void {
this.flushAll();
if (this.globalFlushTimer) {
clearInterval(this.globalFlushTimer);
this.globalFlushTimer = undefined;
}
for (const aggregated of this.aggregatedEvents.values()) {
if (aggregated.flushTimer) {
clearTimeout(aggregated.flushTimer);
}
}
this.aggregatedEvents.clear();
}
}
// Global instance for connection-related log deduplication
export const connectionLogDeduplicator = new LogDeduplicator(5000); // 5 second batches
// Ensure logs are flushed on process exit
process.on('beforeExit', () => {
connectionLogDeduplicator.flushAll();
});
process.on('SIGINT', () => {
connectionLogDeduplicator.cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
connectionLogDeduplicator.cleanup();
process.exit(0);
});

View File

@ -0,0 +1,246 @@
import * as plugins from '../../plugins.js';
import { logger } from './logger.js';
/**
* Interface representing parsed PROXY protocol information
*/
export interface IProxyInfo {
protocol: 'TCP4' | 'TCP6' | 'UNKNOWN';
sourceIP: string;
sourcePort: number;
destinationIP: string;
destinationPort: number;
}
/**
* Interface for parse result including remaining data
*/
export interface IProxyParseResult {
proxyInfo: IProxyInfo | null;
remainingData: Buffer;
}
/**
* Parser for PROXY protocol v1 (text format)
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
*/
export class ProxyProtocolParser {
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
static readonly HEADER_TERMINATOR = '\r\n';
/**
* Parse PROXY protocol v1 header from buffer
* Returns proxy info and remaining data after header
*/
static parse(data: Buffer): IProxyParseResult {
// Check if buffer starts with PROXY signature
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
return {
proxyInfo: null,
remainingData: data
};
}
// Find header terminator
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
if (headerEndIndex === -1) {
// Header incomplete, need more data
if (data.length > this.MAX_HEADER_LENGTH) {
// Header too long, invalid
throw new Error('PROXY protocol header exceeds maximum length');
}
return {
proxyInfo: null,
remainingData: data
};
}
// Extract header line
const headerLine = data.toString('ascii', 0, headerEndIndex);
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
// Parse header
const parts = headerLine.split(' ');
if (parts.length < 2) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [signature, protocol] = parts;
// Validate protocol
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
throw new Error(`Invalid PROXY protocol: ${protocol}`);
}
// For UNKNOWN protocol, ignore addresses
if (protocol === 'UNKNOWN') {
return {
proxyInfo: {
protocol: 'UNKNOWN',
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
},
remainingData
};
}
// For TCP4/TCP6, we need all 6 parts
if (parts.length !== 6) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
// Validate and parse ports
const sourcePort = parseInt(srcPort, 10);
const destinationPort = parseInt(dstPort, 10);
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
throw new Error(`Invalid source port: ${srcPort}`);
}
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
throw new Error(`Invalid destination port: ${dstPort}`);
}
// Validate IP addresses
const protocolType = protocol as 'TCP4' | 'TCP6' | 'UNKNOWN';
if (!this.isValidIP(srcIP, protocolType)) {
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
}
if (!this.isValidIP(dstIP, protocolType)) {
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
}
return {
proxyInfo: {
protocol: protocol as 'TCP4' | 'TCP6',
sourceIP: srcIP,
sourcePort,
destinationIP: dstIP,
destinationPort
},
remainingData
};
}
/**
* Generate PROXY protocol v1 header
*/
static generate(info: IProxyInfo): Buffer {
if (info.protocol === 'UNKNOWN') {
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
}
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
if (header.length > this.MAX_HEADER_LENGTH) {
throw new Error('Generated PROXY protocol header exceeds maximum length');
}
return Buffer.from(header, 'ascii');
}
/**
* Validate IP address format
*/
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
if (protocol === 'TCP4') {
return plugins.net.isIPv4(ip);
} else if (protocol === 'TCP6') {
return plugins.net.isIPv6(ip);
}
return false;
}
/**
* Attempt to read a complete PROXY protocol header from a socket
* Returns null if no PROXY protocol detected or incomplete
*/
static async readFromSocket(socket: plugins.net.Socket, timeout: number = 5000): Promise<IProxyParseResult | null> {
return new Promise((resolve) => {
let buffer = Buffer.alloc(0);
let resolved = false;
const cleanup = () => {
socket.removeListener('data', onData);
socket.removeListener('error', onError);
clearTimeout(timer);
};
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
}, timeout);
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
// Check if we have enough data
if (!buffer.toString('ascii', 0, Math.min(6, buffer.length)).startsWith(this.PROXY_V1_SIGNATURE)) {
// Not PROXY protocol
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
return;
}
// Try to parse
try {
const result = this.parse(buffer);
if (result.proxyInfo) {
// Successfully parsed
resolved = true;
cleanup();
resolve(result);
} else if (buffer.length > this.MAX_HEADER_LENGTH) {
// Header too long
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
// Otherwise continue reading
} catch (error) {
// Parse error
logger.log('error', `PROXY protocol parse error: ${error.message}`);
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
};
const onError = (error: Error) => {
logger.log('error', `Socket error while reading PROXY protocol: ${error.message}`);
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
};
socket.on('data', onData);
socket.on('error', onError);
});
}
}

View File

@ -152,9 +152,10 @@ export class SharedSecurityManager {
*
* @param route - The route to check
* @param context - The request context
* @param routeConnectionCount - Current connection count for this route (optional)
* @returns Whether access is allowed
*/
public isAllowed(route: IRouteConfig, context: IRouteContext): boolean {
public isAllowed(route: IRouteConfig, context: IRouteContext, routeConnectionCount?: number): boolean {
if (!route.security) {
return true; // No security restrictions
}
@ -165,6 +166,14 @@ export class SharedSecurityManager {
return false;
}
// --- Route-level connection limit ---
if (route.security.maxConnections !== undefined && routeConnectionCount !== undefined) {
if (routeConnectionCount >= route.security.maxConnections) {
this.logger?.debug?.(`Route connection limit (${route.security.maxConnections}) exceeded for route ${route.name || 'unnamed'}`);
return false;
}
}
// --- Rate limiting ---
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
@ -304,6 +313,20 @@ export class SharedSecurityManager {
// Clean up rate limits
cleanupExpiredRateLimits(this.rateLimits, this.logger);
// Clean up IP connection tracking
let cleanedIPs = 0;
for (const [ip, info] of this.connectionsByIP.entries()) {
// Remove IPs with no active connections and no recent timestamps
if (info.connections.size === 0 && info.timestamps.length === 0) {
this.connectionsByIP.delete(ip);
cleanedIPs++;
}
}
if (cleanedIPs > 0 && this.logger?.debug) {
this.logger.debug(`Cleaned up ${cleanedIPs} IPs with no active connections`);
}
// IP filter cache doesn't need cleanup (tied to routes)
}

View File

@ -258,23 +258,62 @@ export function createSocketWithErrorHandler(options: SafeSocketOptions): plugin
// Create socket with immediate error handler attachment
const socket = new plugins.net.Socket();
// Track if connected
let connected = false;
let connectionTimeout: NodeJS.Timeout | null = null;
// Attach error handler BEFORE connecting to catch immediate errors
socket.on('error', (error) => {
console.error(`Socket connection error to ${host}:${port}: ${error.message}`);
// Clear the connection timeout if it exists
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
if (onError) {
onError(error);
}
});
// Attach connect handler if provided
if (onConnect) {
socket.on('connect', onConnect);
// Attach connect handler
const handleConnect = () => {
connected = true;
// Clear the connection timeout
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
// Set timeout if provided
// Set inactivity timeout if provided (after connection is established)
if (timeout) {
socket.setTimeout(timeout);
}
if (onConnect) {
onConnect();
}
};
socket.on('connect', handleConnect);
// Implement connection establishment timeout
if (timeout) {
connectionTimeout = setTimeout(() => {
if (!connected && !socket.destroyed) {
// Connection timed out - destroy the socket
const error = new Error(`Connection timeout after ${timeout}ms to ${host}:${port}`);
(error as any).code = 'ETIMEDOUT';
console.error(`Socket connection timeout to ${host}:${port} after ${timeout}ms`);
// Destroy the socket
socket.destroy();
// Call error handler
if (onError) {
onError(error);
}
}
}, timeout);
}
// Now attempt to connect - any immediate errors will be caught
socket.connect(port, host);

View File

@ -30,6 +30,7 @@ import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index
import * as smartlog from '@push.rocks/smartlog';
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
import * as taskbuffer from '@push.rocks/taskbuffer';
import * as smartrx from '@push.rocks/smartrx';
export {
lik,
@ -45,6 +46,7 @@ export {
smartlog,
smartlogDestinationLocal,
taskbuffer,
smartrx,
};
// third party scope

View File

@ -30,6 +30,9 @@ export class FunctionCache {
// Logger
private logger: ILogger;
// Cleanup interval timer
private cleanupInterval: NodeJS.Timeout | null = null;
/**
* Creates a new function cache
*
@ -48,7 +51,12 @@ export class FunctionCache {
this.defaultTtl = options.defaultTtl || 5000; // 5 seconds default
// Start the cache cleanup timer
setInterval(() => this.cleanupCache(), 30000); // Cleanup every 30 seconds
this.cleanupInterval = setInterval(() => this.cleanupCache(), 30000); // Cleanup every 30 seconds
// Make sure the interval doesn't keep the process alive
if (this.cleanupInterval.unref) {
this.cleanupInterval.unref();
}
}
/**
@ -256,4 +264,16 @@ export class FunctionCache {
this.portCache.clear();
this.logger.info('Function cache cleared');
}
/**
* Destroy the cache and cleanup resources
*/
public destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.clearCache();
this.logger.debug('Function cache destroyed');
}
}

View File

@ -17,6 +17,8 @@ import { WebSocketHandler } from './websocket-handler.js';
import { HttpRouter } from '../../routing/router/index.js';
import { cleanupSocket } from '../../core/utils/socket-utils.js';
import { FunctionCache } from './function-cache.js';
import { SecurityManager } from './security-manager.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
/**
* HttpProxy provides a reverse proxy with TLS termination, WebSocket support,
@ -43,6 +45,7 @@ export class HttpProxy implements IMetricsTracker {
private router = new HttpRouter(); // Unified HTTP router
private routeManager: RouteManager;
private functionCache: FunctionCache;
private securityManager: SecurityManager;
// State tracking
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
@ -114,6 +117,14 @@ export class HttpProxy implements IMetricsTracker {
defaultTtl: this.options.functionCacheTtl || 5000
});
// Initialize security manager
this.securityManager = new SecurityManager(
this.logger,
[],
this.options.maxConnectionsPerIP || 100,
this.options.connectionRateLimitPerMinute || 300
);
// Initialize other components
this.certificateManager = new CertificateManager(this.options);
this.connectionPool = new ConnectionPool(this.options);
@ -269,14 +280,113 @@ export class HttpProxy implements IMetricsTracker {
*/
private setupConnectionTracking(): void {
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
// Check if max connections reached
if (this.socketMap.getArray().length >= this.options.maxConnections) {
this.logger.warn(`Max connections (${this.options.maxConnections}) reached, rejecting new connection`);
let remoteIP = connection.remoteAddress || '';
const connectionId = Math.random().toString(36).substring(2, 15);
const isFromSmartProxy = this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1');
// For SmartProxy connections, wait for CLIENT_IP header
if (isFromSmartProxy) {
let headerBuffer = Buffer.alloc(0);
let headerParsed = false;
const parseHeader = (data: Buffer) => {
if (headerParsed) return data;
headerBuffer = Buffer.concat([headerBuffer, data]);
const headerStr = headerBuffer.toString();
const headerEnd = headerStr.indexOf('\r\n');
if (headerEnd !== -1) {
const header = headerStr.substring(0, headerEnd);
if (header.startsWith('CLIENT_IP:')) {
remoteIP = header.substring(10); // Extract IP after "CLIENT_IP:"
this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`);
}
headerParsed = true;
// Store the real IP on the connection
(connection as any)._realRemoteIP = remoteIP;
// Validate the real IP
const ipValidation = this.securityManager.validateIP(remoteIP);
if (!ipValidation.allowed) {
connectionLogDeduplicator.log(
'ip-rejected',
'warn',
`HttpProxy connection rejected (via SmartProxy)`,
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
remoteIP
);
connection.destroy();
return null;
}
// Track connection by real IP
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
// Return remaining data after header
return headerBuffer.slice(headerEnd + 2);
}
return null;
};
// Override the first data handler to parse header
const originalEmit = connection.emit;
connection.emit = function(event: string, ...args: any[]) {
if (event === 'data' && !headerParsed) {
const remaining = parseHeader(args[0]);
if (remaining && remaining.length > 0) {
// Call original emit with remaining data
return originalEmit.apply(connection, ['data', remaining]);
} else if (headerParsed) {
// Header parsed but no remaining data
return true;
}
// Header not complete yet, suppress this data event
return true;
}
return originalEmit.apply(connection, [event, ...args]);
} as any;
} else {
// Direct connection - validate immediately
const ipValidation = this.securityManager.validateIP(remoteIP);
if (!ipValidation.allowed) {
connectionLogDeduplicator.log(
'ip-rejected',
'warn',
`HttpProxy connection rejected`,
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
remoteIP
);
connection.destroy();
return;
}
// Add connection to tracking
// Track connection by IP
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
}
// Then check global max connections
if (this.socketMap.getArray().length >= this.options.maxConnections) {
connectionLogDeduplicator.log(
'connection-rejected',
'warn',
'HttpProxy max connections reached',
{
reason: 'global-limit',
currentConnections: this.socketMap.getArray().length,
maxConnections: this.options.maxConnections,
component: 'http-proxy'
},
'http-proxy-global-limit'
);
connection.destroy();
return;
}
// Add connection to tracking with metadata
(connection as any)._connectionId = connectionId;
(connection as any)._remoteIP = remoteIP;
this.socketMap.add(connection);
this.connectedClients = this.socketMap.getArray().length;
@ -284,12 +394,12 @@ export class HttpProxy implements IMetricsTracker {
const localPort = connection.localPort || 0;
const remotePort = connection.remotePort || 0;
// If this connection is from a SmartProxy (usually indicated by it coming from localhost)
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
// If this connection is from a SmartProxy
if (isFromSmartProxy) {
this.portProxyConnections++;
this.logger.debug(`New connection from SmartProxy (local: ${localPort}, remote: ${remotePort})`);
this.logger.debug(`New connection from SmartProxy for client ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
} else {
this.logger.debug(`New direct connection (local: ${localPort}, remote: ${remotePort})`);
this.logger.debug(`New direct connection from ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
}
// Setup connection cleanup handlers
@ -298,12 +408,19 @@ export class HttpProxy implements IMetricsTracker {
this.socketMap.remove(connection);
this.connectedClients = this.socketMap.getArray().length;
// Remove IP tracking
const connId = (connection as any)._connectionId;
const connIP = (connection as any)._realRemoteIP || (connection as any)._remoteIP;
if (connId && connIP) {
this.securityManager.removeConnectionByIP(connIP, connId);
}
// If this was a SmartProxy connection, decrement the counter
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections--;
}
this.logger.debug(`Connection closed. ${this.connectedClients} connections remaining`);
this.logger.debug(`Connection closed from ${connIP || 'unknown'}. ${this.connectedClients} connections remaining`);
}
};
@ -464,6 +581,11 @@ export class HttpProxy implements IMetricsTracker {
// Stop WebSocket handler
this.webSocketHandler.shutdown();
// Destroy request handler (cleans up intervals and caches)
if (this.requestHandler && typeof this.requestHandler.destroy === 'function') {
this.requestHandler.destroy();
}
// Close all tracked sockets
const socketCleanupPromises = this.socketMap.getArray().map(socket =>
cleanupSocket(socket, 'http-proxy-stop', { immediate: true })
@ -475,6 +597,9 @@ export class HttpProxy implements IMetricsTracker {
// Certificate management cleanup is handled by SmartCertManager
// Flush any pending deduplicated logs
connectionLogDeduplicator.flushAll();
// Close the HTTPS server
return new Promise((resolve) => {
this.httpsServer.close(() => {

View File

@ -45,6 +45,10 @@ export interface IHttpProxyOptions {
// Direct route configurations
routes?: IRouteConfig[];
// Rate limiting and security
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
}
/**

View File

@ -43,6 +43,9 @@ export class RequestHandler {
// Security manager for IP filtering, rate limiting, etc.
public securityManager: SecurityManager;
// Rate limit cleanup interval
private rateLimitCleanupInterval: NodeJS.Timeout | null = null;
constructor(
private options: IHttpProxyOptions,
private connectionPool: ConnectionPool,
@ -54,9 +57,14 @@ export class RequestHandler {
this.securityManager = new SecurityManager(this.logger);
// Schedule rate limit cleanup every minute
setInterval(() => {
this.rateLimitCleanupInterval = setInterval(() => {
this.securityManager.cleanupExpiredRateLimits();
}, 60000);
// Make sure the interval doesn't keep the process alive
if (this.rateLimitCleanupInterval.unref) {
this.rateLimitCleanupInterval.unref();
}
}
/**
@ -741,4 +749,27 @@ export class RequestHandler {
stream.end('Not Found: No route configuration for this request');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
}
/**
* Cleanup resources and stop intervals
*/
public destroy(): void {
if (this.rateLimitCleanupInterval) {
clearInterval(this.rateLimitCleanupInterval);
this.rateLimitCleanupInterval = null;
}
// Close all HTTP/2 sessions
for (const [key, session] of this.h2Sessions) {
session.close();
}
this.h2Sessions.clear();
// Clear function cache if it has a destroy method
if (this.functionCache && typeof this.functionCache.destroy === 'function') {
this.functionCache.destroy();
}
this.logger.debug('RequestHandler destroyed');
}
}

View File

@ -14,7 +14,14 @@ export class SecurityManager {
// Store rate limits per route and key
private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = new Map();
constructor(private logger: ILogger, private routes: IRouteConfig[] = []) {}
// Connection tracking by IP
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
constructor(private logger: ILogger, private routes: IRouteConfig[] = [], private maxConnectionsPerIP: number = 100, private connectionRateLimitPerMinute: number = 300) {
// Start periodic cleanup for connection tracking
this.startPeriodicIpCleanup();
}
/**
* Update the routes configuration
@ -295,4 +302,132 @@ export class SecurityManager {
return false;
}
}
/**
* Get connections count by IP
*/
public getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
* @returns true if within rate limit, false if exceeding limit
*/
public checkConnectionRate(ip: string): boolean {
const now = Date.now();
const minute = 60 * 1000;
if (!this.connectionRateByIP.has(ip)) {
this.connectionRateByIP.set(ip, [now]);
return true;
}
// Get timestamps and filter out entries older than 1 minute
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
timestamps.push(now);
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.connectionRateLimitPerMinute;
}
/**
* Track connection by IP
*/
public trackConnectionByIP(ip: string, connectionId: string): void {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip)!.add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
public removeConnectionByIP(ip: string, connectionId: string): void {
if (this.connectionsByIP.has(ip)) {
const connections = this.connectionsByIP.get(ip)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
}
}
}
/**
* Check if IP should be allowed considering connection rate and max connections
* @returns Object with result and reason
*/
public validateIP(ip: string): { allowed: boolean; reason?: string } {
// Check connection count limit
if (this.getConnectionCountByIP(ip) >= this.maxConnectionsPerIP) {
return {
allowed: false,
reason: `Maximum connections per IP (${this.maxConnectionsPerIP}) exceeded`
};
}
// Check connection rate limit
if (!this.checkConnectionRate(ip)) {
return {
allowed: false,
reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded`
};
}
return { allowed: true };
}
/**
* Clears all IP tracking data (for shutdown)
*/
public clearIPTracking(): void {
this.connectionsByIP.clear();
this.connectionRateByIP.clear();
}
/**
* Start periodic cleanup of IP tracking data
*/
private startPeriodicIpCleanup(): void {
// Clean up IP tracking data every minute
setInterval(() => {
this.performIpCleanup();
}, 60000).unref();
}
/**
* Perform cleanup of expired IP data
*/
private performIpCleanup(): void {
const now = Date.now();
const minute = 60 * 1000;
let cleanedRateLimits = 0;
let cleanedIPs = 0;
// Clean up expired rate limit timestamps
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
const validTimestamps = timestamps.filter(time => now - time < minute);
if (validTimestamps.length === 0) {
this.connectionRateByIP.delete(ip);
cleanedRateLimits++;
} else if (validTimestamps.length < timestamps.length) {
this.connectionRateByIP.set(ip, validTimestamps);
}
}
// Clean up IPs with no active connections
for (const [ip, connections] of this.connectionsByIP.entries()) {
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
cleanedIPs++;
}
}
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`);
}
}
}

View File

@ -1,11 +1,11 @@
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 { 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 type { SmartProxy } from './smart-proxy.js';
/**
* Manages connection lifecycle, tracking, and cleanup with performance optimizations
@ -27,19 +27,21 @@ export class ConnectionManager extends LifecycleComponent {
// Cleanup queue for batched processing
private cleanupQueue: Set<string> = new Set();
private cleanupTimer: NodeJS.Timeout | null = null;
private isProcessingCleanup: boolean = false;
// Route-level connection tracking
private connectionsByRoute: Map<string, Set<string>> = new Map();
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();
}
}
@ -59,17 +61,26 @@ export class ConnectionManager extends LifecycleComponent {
public createConnection(socket: plugins.net.Socket | WrappedSocket): IConnectionRecord | null {
// Enforce connection limit
if (this.connectionRecords.size >= this.maxConnections) {
logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, {
// Use deduplicated logging for connection limit
connectionLogDeduplicator.log(
'connection-rejected',
'warn',
'Global connection limit reached',
{
reason: 'global-limit',
currentConnections: this.connectionRecords.size,
maxConnections: this.maxConnections,
component: 'connection-manager'
});
},
'global-limit'
);
socket.destroy();
return null;
}
const connectionId = this.generateConnectionId();
const remoteIP = socket.remoteAddress || '';
const remotePort = socket.remotePort || 0;
const localPort = socket.localPort || 0;
const now = Date.now();
@ -85,6 +96,7 @@ export class ConnectionManager extends LifecycleComponent {
bytesReceived: 0,
bytesSent: 0,
remoteIP,
remotePort,
localPort,
isTLS: false,
tlsHandshakeComplete: false,
@ -106,10 +118,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);
}
}
@ -118,14 +130,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;
}
}
@ -138,10 +150,10 @@ export class ConnectionManager extends LifecycleComponent {
* Start the inactivity check timer
*/
private startInactivityCheckTimer(): void {
// Check every 30 seconds for connections that need inactivity check
// Check more frequently (every 10 seconds) to catch zombies and stuck connections faster
this.setInterval(() => {
this.performOptimizedInactivityCheck();
}, 30000);
}, 10000);
// Note: LifecycleComponent's setInterval already calls unref()
}
@ -166,18 +178,53 @@ export class ConnectionManager extends LifecycleComponent {
return this.connectionRecords.size;
}
/**
* Track connection by route
*/
public trackConnectionByRoute(routeId: string, connectionId: string): void {
if (!this.connectionsByRoute.has(routeId)) {
this.connectionsByRoute.set(routeId, new Set());
}
this.connectionsByRoute.get(routeId)!.add(connectionId);
}
/**
* Remove connection tracking for a route
*/
public removeConnectionByRoute(routeId: string, connectionId: string): void {
if (this.connectionsByRoute.has(routeId)) {
const connections = this.connectionsByRoute.get(routeId)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByRoute.delete(routeId);
}
}
}
/**
* Get connection count by route
*/
public getConnectionCountByRoute(routeId: string): number {
return this.connectionsByRoute.get(routeId)?.size || 0;
}
/**
* Initiates cleanup once for a connection
*/
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
if (this.settings.enableDetailedLogging) {
logger.log('info', `Connection cleanup initiated`, {
// Use deduplicated logging for cleanup events
connectionLogDeduplicator.log(
'connection-cleanup',
'info',
`Connection cleanup: ${reason}`,
{
connectionId: record.id,
remoteIP: record.remoteIP,
reason,
component: 'connection-manager'
});
}
},
reason
);
if (record.incomingTerminationReason == null) {
record.incomingTerminationReason = reason;
@ -192,12 +239,19 @@ export class ConnectionManager extends LifecycleComponent {
* Queue a connection for cleanup
*/
private queueCleanup(connectionId: string): void {
// Check if connection is already being processed
const record = this.connectionRecords.get(connectionId);
if (!record || record.connectionClosed) {
// Already cleaned up or doesn't exist, skip
return;
}
this.cleanupQueue.add(connectionId);
// Process immediately if queue is getting large
if (this.cleanupQueue.size >= this.cleanupBatchSize) {
// Process immediately if queue is getting large and not already processing
if (this.cleanupQueue.size >= this.cleanupBatchSize && !this.isProcessingCleanup) {
this.processCleanupQueue();
} else if (!this.cleanupTimer) {
} else if (!this.cleanupTimer && !this.isProcessingCleanup) {
// Otherwise, schedule batch processing
this.cleanupTimer = this.setTimeout(() => {
this.processCleanupQueue();
@ -209,28 +263,42 @@ export class ConnectionManager extends LifecycleComponent {
* Process the cleanup queue in batches
*/
private processCleanupQueue(): void {
// Prevent concurrent processing
if (this.isProcessingCleanup) {
return;
}
this.isProcessingCleanup = true;
if (this.cleanupTimer) {
this.clearTimeout(this.cleanupTimer);
this.cleanupTimer = null;
}
try {
// Take a snapshot of items to process
const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize);
this.cleanupQueue.clear();
// Remove only the items we're processing from the queue
for (const connectionId of toCleanup) {
this.cleanupQueue.delete(connectionId);
const record = this.connectionRecords.get(connectionId);
if (record) {
this.cleanupConnection(record, record.incomingTerminationReason || 'normal');
}
}
} finally {
// Always reset the processing flag
this.isProcessingCleanup = false;
// If there are more in queue, schedule next batch
// Check if more items were added while we were processing
if (this.cleanupQueue.size > 0) {
this.cleanupTimer = this.setTimeout(() => {
this.processCleanupQueue();
}, 10);
}
}
}
/**
* Clean up a connection record
@ -243,7 +311,17 @@ 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 route tracking
if (record.routeId) {
this.removeConnectionByRoute(record.routeId, record.id);
}
// Remove from metrics tracking
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.removeConnection(record.id);
}
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
@ -324,7 +402,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`,
@ -404,7 +482,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,
@ -454,6 +532,84 @@ export class ConnectionManager extends LifecycleComponent {
}
}
// Also check ALL connections for zombie state (destroyed sockets but not cleaned up)
// This is critical for proxy chains where sockets can be destroyed without events
for (const [connectionId, record] of this.connectionRecords) {
if (!record.connectionClosed) {
const incomingDestroyed = record.incoming?.destroyed || false;
const outgoingDestroyed = record.outgoing?.destroyed || false;
// Check for zombie connections: both sockets destroyed but connection not cleaned up
if (incomingDestroyed && outgoingDestroyed) {
logger.log('warn', `Zombie connection detected: ${connectionId} - both sockets destroyed but not cleaned up`, {
connectionId,
remoteIP: record.remoteIP,
age: plugins.prettyMs(now - record.incomingStartTime),
component: 'connection-manager'
});
// Clean up immediately
this.cleanupConnection(record, 'zombie_cleanup');
continue;
}
// Check for half-zombie: one socket destroyed
if (incomingDestroyed || outgoingDestroyed) {
const age = now - record.incomingStartTime;
// Use longer grace period for encrypted connections (5 minutes vs 30 seconds)
const gracePeriod = record.isTLS ? 300000 : 30000;
// Also ensure connection is old enough to avoid premature cleanup
if (age > gracePeriod && age > 10000) {
logger.log('warn', `Half-zombie connection detected: ${connectionId} - ${incomingDestroyed ? 'incoming' : 'outgoing'} destroyed`, {
connectionId,
remoteIP: record.remoteIP,
age: plugins.prettyMs(age),
incomingDestroyed,
outgoingDestroyed,
isTLS: record.isTLS,
gracePeriod: plugins.prettyMs(gracePeriod),
component: 'connection-manager'
});
// Clean up
this.cleanupConnection(record, 'half_zombie_cleanup');
}
}
// Check for stuck connections: no data sent back to client
if (!record.connectionClosed && record.outgoing && record.bytesReceived > 0 && record.bytesSent === 0) {
const age = now - record.incomingStartTime;
// Use longer grace period for encrypted connections (5 minutes vs 60 seconds)
const stuckThreshold = record.isTLS ? 300000 : 60000;
// If connection is older than threshold and no data sent back, likely stuck
if (age > stuckThreshold) {
logger.log('warn', `Stuck connection detected: ${connectionId} - received ${record.bytesReceived} bytes but sent 0 bytes`, {
connectionId,
remoteIP: record.remoteIP,
age: plugins.prettyMs(age),
bytesReceived: record.bytesReceived,
targetHost: record.targetHost,
targetPort: record.targetPort,
isTLS: record.isTLS,
threshold: plugins.prettyMs(stuckThreshold),
component: 'connection-manager'
});
// Set termination reason and increment stats
if (record.incomingTerminationReason == null) {
record.incomingTerminationReason = 'stuck_no_response';
this.incrementTerminationStat('incoming', 'stuck_no_response');
}
// Clean up
this.cleanupConnection(record, 'stuck_no_response');
}
}
}
}
// Process only connections that need checking
for (const connectionId of connectionsToCheck) {
const record = this.connectionRecords.get(connectionId);
@ -465,9 +621,9 @@ export class ConnectionManager extends LifecycleComponent {
const inactivityTime = now - record.lastActivity;
// Use extended timeout for extended-treatment keep-alive connections
let effectiveTimeout = this.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
let effectiveTimeout = this.smartProxy.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}

View File

@ -1,14 +1,15 @@
import * as plugins from '../../plugins.js';
import { HttpProxy } from '../http-proxy/index.js';
import { setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import type { IConnectionRecord } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
import type { SmartProxy } from './smart-proxy.js';
export class HttpProxyBridge {
private httpProxy: HttpProxy | null = null;
constructor(private settings: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {}
/**
* Get the HttpProxy instance
@ -21,18 +22,18 @@ export class HttpProxyBridge {
* Initialize HttpProxy instance
*/
public async initialize(): Promise<void> {
if (!this.httpProxy && this.settings.useHttpProxy && this.settings.useHttpProxy.length > 0) {
if (!this.httpProxy && this.smartProxy.settings.useHttpProxy && this.smartProxy.settings.useHttpProxy.length > 0) {
const httpProxyOptions: any = {
port: this.settings.httpProxyPort!,
port: this.smartProxy.settings.httpProxyPort!,
portProxyIntegration: true,
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info'
logLevel: this.smartProxy.settings.enableDetailedLogging ? 'debug' : 'info'
};
this.httpProxy = new HttpProxy(httpProxyOptions);
console.log(`Initialized HttpProxy on port ${this.settings.httpProxyPort}`);
console.log(`Initialized HttpProxy on port ${this.smartProxy.settings.httpProxyPort}`);
// Apply route configurations to HttpProxy
await this.syncRoutesToHttpProxy(this.settings.routes || []);
await this.syncRoutesToHttpProxy(this.smartProxy.settings.routes || []);
}
}
@ -51,7 +52,7 @@ export class HttpProxyBridge {
: [route.match.ports];
return routePorts.some(port =>
this.settings.useHttpProxy?.includes(port)
this.smartProxy.settings.useHttpProxy?.includes(port)
);
})
.map(route => this.routeToHttpProxyConfig(route));
@ -120,8 +121,18 @@ export class HttpProxyBridge {
proxySocket.on('error', reject);
});
// Send client IP information header first (custom protocol)
// Format: "CLIENT_IP:<ip>\r\n"
const clientIPHeader = Buffer.from(`CLIENT_IP:${record.remoteIP}\r\n`);
proxySocket.write(clientIPHeader);
// Send initial chunk if present
if (initialChunk) {
// Count the initial chunk bytes
record.bytesReceived += initialChunk.length;
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.recordBytes(record.id, initialChunk.length, 0);
}
proxySocket.write(initialChunk);
}
@ -131,15 +142,21 @@ export class HttpProxyBridge {
setupBidirectionalForwarding(underlyingSocket, proxySocket, {
onClientData: (chunk) => {
// Update stats if needed
// Update stats - this is the ONLY place bytes are counted for HttpProxy connections
if (record) {
record.bytesReceived += chunk.length;
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.recordBytes(record.id, chunk.length, 0);
}
}
},
onServerData: (chunk) => {
// Update stats if needed
// Update stats - this is the ONLY place bytes are counted for HttpProxy connections
if (record) {
record.bytesSent += chunk.length;
if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.recordBytes(record.id, 0, chunk.length);
}
}
},
onCleanup: (reason) => {

View File

@ -0,0 +1,401 @@
import * as plugins from '../../plugins.js';
import type { SmartProxy } from './smart-proxy.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 provides metrics for SmartProxy with clean API
*/
export class MetricsCollector implements IMetrics {
// Throughput tracking
private throughputTracker: ThroughputTracker;
private routeThroughputTrackers = new Map<string, ThroughputTracker>();
private ipThroughputTrackers = new Map<string, ThroughputTracker>();
// Request tracking
private requestTimestamps: number[] = [];
private totalRequests: number = 0;
// Connection byte tracking for per-route/IP metrics
private connectionByteTrackers = new Map<string, IByteTracker>();
// Subscriptions
private samplingInterval?: NodeJS.Timeout;
private connectionSubscription?: plugins.smartrx.rxjs.Subscription;
// Configuration
private readonly sampleIntervalMs: number;
private readonly retentionSeconds: number;
constructor(
private smartProxy: SmartProxy,
config?: {
sampleIntervalMs?: number;
retentionSeconds?: number;
}
) {
this.sampleIntervalMs = config?.sampleIntervalMs || 1000;
this.retentionSeconds = config?.retentionSeconds || 3600;
this.throughputTracker = new ThroughputTracker(this.retentionSeconds);
}
// Connection metrics implementation
public connections = {
active: (): number => {
return this.smartProxy.connectionManager.getConnectionCount();
},
total: (): number => {
const stats = this.smartProxy.connectionManager.getTerminationStats();
let total = this.smartProxy.connectionManager.getConnectionCount();
for (const reason in stats.incoming) {
total += stats.incoming[reason];
}
return total;
},
byRoute: (): Map<string, number> => {
const routeCounts = new Map<string, number>();
const connections = this.smartProxy.connectionManager.getConnections();
for (const [_, record] of connections) {
const routeName = (record as any).routeName ||
record.routeConfig?.name ||
'unknown';
const current = routeCounts.get(routeName) || 0;
routeCounts.set(routeName, current + 1);
}
return routeCounts;
},
byIP: (): Map<string, number> => {
const ipCounts = new Map<string, number>();
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
const ip = record.remoteIP;
const current = ipCounts.get(ip) || 0;
ipCounts.set(ip, current + 1);
}
return ipCounts;
},
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
const ipCounts = this.connections.byIP();
return Array.from(ipCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, count]) => ({ ip, count }));
}
};
// Throughput metrics implementation
public throughput = {
instant: (): IThroughputData => {
return this.throughputTracker.getRate(1);
},
recent: (): IThroughputData => {
return this.throughputTracker.getRate(10);
},
average: (): IThroughputData => {
return this.throughputTracker.getRate(60);
},
custom: (seconds: number): IThroughputData => {
return this.throughputTracker.getRate(seconds);
},
history: (seconds: number): Array<IThroughputHistoryPoint> => {
return this.throughputTracker.getHistory(seconds);
},
byRoute: (windowSeconds: number = 1): Map<string, IThroughputData> => {
const routeThroughput = new Map<string, IThroughputData>();
// Get throughput from each route's dedicated tracker
for (const [route, tracker] of this.routeThroughputTrackers) {
const rate = tracker.getRate(windowSeconds);
if (rate.in > 0 || rate.out > 0) {
routeThroughput.set(route, rate);
}
}
return routeThroughput;
},
byIP: (windowSeconds: number = 1): Map<string, IThroughputData> => {
const ipThroughput = new Map<string, IThroughputData>();
// Get throughput from each IP's dedicated tracker
for (const [ip, tracker] of this.ipThroughputTrackers) {
const rate = tracker.getRate(windowSeconds);
if (rate.in > 0 || rate.out > 0) {
ipThroughput.set(ip, rate);
}
}
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 }
};
}
};
/**
* Record a new request
*/
public recordRequest(connectionId: string, routeName: string, remoteIP: string): void {
const now = Date.now();
this.requestTimestamps.push(now);
this.totalRequests++;
// Initialize byte tracker for this connection
this.connectionByteTrackers.set(connectionId, {
connectionId,
routeName,
remoteIP,
bytesIn: 0,
bytesOut: 0,
startTime: now,
lastUpdate: now
});
// Cleanup old request timestamps
if (this.requestTimestamps.length > 5000) {
// First try to clean up old timestamps (older than 1 minute)
const cutoff = now - 60000;
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > cutoff);
// If still too many, enforce hard cap of 5000 most recent
if (this.requestTimestamps.length > 5000) {
this.requestTimestamps = this.requestTimestamps.slice(-5000);
}
}
}
/**
* Record bytes transferred for a connection
*/
public recordBytes(connectionId: string, bytesIn: number, bytesOut: number): void {
// Update global throughput tracker
this.throughputTracker.recordBytes(bytesIn, bytesOut);
// Update connection-specific tracker
const tracker = this.connectionByteTrackers.get(connectionId);
if (tracker) {
tracker.bytesIn += bytesIn;
tracker.bytesOut += bytesOut;
tracker.lastUpdate = Date.now();
// Update per-route throughput tracker
let routeTracker = this.routeThroughputTrackers.get(tracker.routeName);
if (!routeTracker) {
routeTracker = new ThroughputTracker(this.retentionSeconds);
this.routeThroughputTrackers.set(tracker.routeName, routeTracker);
}
routeTracker.recordBytes(bytesIn, bytesOut);
// Update per-IP throughput tracker
let ipTracker = this.ipThroughputTrackers.get(tracker.remoteIP);
if (!ipTracker) {
ipTracker = new ThroughputTracker(this.retentionSeconds);
this.ipThroughputTrackers.set(tracker.remoteIP, ipTracker);
}
ipTracker.recordBytes(bytesIn, bytesOut);
}
}
/**
* Clean up tracking for a closed connection
*/
public removeConnection(connectionId: string): void {
this.connectionByteTrackers.delete(connectionId);
}
/**
* Start the metrics collector
*/
public start(): void {
if (!this.smartProxy.routeConnectionHandler) {
throw new Error('MetricsCollector: RouteConnectionHandler not available');
}
// Start periodic sampling
this.samplingInterval = setInterval(() => {
// Sample global throughput
this.throughputTracker.takeSample();
// Sample per-route throughput
for (const [_, tracker] of this.routeThroughputTrackers) {
tracker.takeSample();
}
// Sample per-IP throughput
for (const [_, tracker] of this.ipThroughputTrackers) {
tracker.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);
}
}
// Clean up unused route trackers
const activeRoutes = new Set(Array.from(this.connectionByteTrackers.values()).map(t => t.routeName));
for (const [route, _] of this.routeThroughputTrackers) {
if (!activeRoutes.has(route)) {
this.routeThroughputTrackers.delete(route);
}
}
// Clean up unused IP trackers
const activeIPs = new Set(Array.from(this.connectionByteTrackers.values()).map(t => t.remoteIP));
for (const [ip, _] of this.ipThroughputTrackers) {
if (!activeIPs.has(ip)) {
this.ipThroughputTrackers.delete(ip);
}
}
}, this.sampleIntervalMs);
// Subscribe to new connections
this.connectionSubscription = this.smartProxy.routeConnectionHandler.newConnectionSubject.subscribe({
next: (record) => {
const routeName = record.routeConfig?.name || 'unknown';
this.recordRequest(record.id, routeName, record.remoteIP);
if (this.smartProxy.settings?.enableDetailedLogging) {
logger.log('debug', `MetricsCollector: New connection recorded`, {
connectionId: record.id,
remoteIP: record.remoteIP,
routeName,
component: 'metrics'
});
}
},
error: (err) => {
logger.log('error', `MetricsCollector: Error in connection subscription`, {
error: err.message,
component: 'metrics'
});
}
});
logger.log('debug', 'MetricsCollector started', { component: 'metrics' });
}
/**
* 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;
}
logger.log('debug', 'MetricsCollector stopped', { component: 'metrics' });
}
/**
* Alias for stop() for compatibility
*/
public destroy(): void {
this.stop();
}
}

View File

@ -4,3 +4,4 @@
// Export everything except IAcmeOptions from interfaces
export type { ISmartProxyOptions, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js';
export * from './route-types.js';
export * from './metrics-types.js';

View File

@ -69,6 +69,7 @@ export interface ISmartProxyOptions {
maxVersion?: string;
// Timeout settings
connectionTimeout?: number; // Timeout for establishing connection to backend (ms), default: 30000 (30s)
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
@ -104,6 +105,13 @@ export interface ISmartProxyOptions {
useHttpProxy?: number[]; // Array of ports to forward to HttpProxy
httpProxyPort?: number; // Port where HttpProxy is listening (default: 8443)
// Metrics configuration
metrics?: {
enabled?: boolean;
sampleIntervalMs?: number;
retentionSeconds?: number;
};
/**
* Global ACME configuration options for SmartProxy
*
@ -141,7 +149,7 @@ export interface IConnectionRecord {
outgoingClosedTime?: number;
lockedDomain?: string; // Used to lock this connection to the initial SNI
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
cleanupTimer?: NodeJS.Timeout | null; // Timer for max lifetime/inactivity
alertFallbackTimeout?: NodeJS.Timeout; // Timer for fallback after alert
lastActivity: number; // Last activity timestamp for inactivity detection
pendingData: Buffer[]; // Buffer to hold data during connection setup
@ -151,11 +159,13 @@ export interface IConnectionRecord {
bytesReceived: number; // Total bytes received
bytesSent: number; // Total bytes sent
remoteIP: string; // Remote IP (cached for logging after socket close)
remotePort: number; // Remote port (cached for logging after socket close)
localPort: number; // Local port (cached for logging)
isTLS: boolean; // Whether this connection is a TLS connection
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
hasReceivedInitialData: boolean; // Whether initial data has been received
routeConfig?: IRouteConfig; // Associated route config for this connection
routeId?: string; // ID of the route this connection is associated with
// Target information (for dynamic port/host mapping)
targetHost?: string; // Resolved target host

View File

@ -0,0 +1,112 @@
/**
* Interface for throughput sample data
*/
export interface IThroughputSample {
timestamp: number;
bytesIn: number;
bytesOut: number;
tags?: {
route?: string;
ip?: string;
[key: string]: string | undefined;
};
}
/**
* Interface for throughput data
*/
export interface IThroughputData {
in: number;
out: number;
}
/**
* Interface for time-series throughput data
*/
export interface IThroughputHistoryPoint {
timestamp: number;
in: number;
out: number;
}
/**
* Main metrics interface with clean, grouped API
*/
export interface IMetrics {
// Connection metrics
connections: {
active(): number;
total(): number;
byRoute(): Map<string, number>;
byIP(): Map<string, number>;
topIPs(limit?: number): Array<{ ip: string; count: number }>;
};
// Throughput metrics (bytes per second)
throughput: {
instant(): IThroughputData; // Last 1 second
recent(): IThroughputData; // Last 10 seconds
average(): IThroughputData; // Last 60 seconds
custom(seconds: number): IThroughputData;
history(seconds: number): Array<IThroughputHistoryPoint>;
byRoute(windowSeconds?: number): Map<string, IThroughputData>; // Default: 1 second
byIP(windowSeconds?: number): Map<string, IThroughputData>; // Default: 1 second
};
// 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;
startTime: number;
lastUpdate: number;
}

View File

@ -250,6 +250,9 @@ export interface IRouteAction {
// Socket handler function (when type is 'socket-handler')
socketHandler?: TSocketHandler;
// PROXY protocol support
sendProxyProtocol?: boolean;
}
/**

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
import * as plugins from '../../plugins.js';
import type { ISmartProxyOptions } from './models/interfaces.js';
import type { SmartProxy } from './smart-proxy.js';
import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
/**
* Handles security aspects like IP tracking, rate limiting, and authorization
@ -7,8 +9,12 @@ import type { ISmartProxyOptions } from './models/interfaces.js';
export class SecurityManager {
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(private settings: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {
// Start periodic cleanup every 60 seconds
this.startPeriodicCleanup();
}
/**
* Get connections count by IP
@ -36,7 +42,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 +143,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`
};
}
@ -164,7 +170,76 @@ export class SecurityManager {
* Clears all IP tracking data (for shutdown)
*/
public clearIPTracking(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.connectionsByIP.clear();
this.connectionRateByIP.clear();
}
/**
* Start periodic cleanup of expired data
*/
private startPeriodicCleanup(): void {
this.cleanupInterval = setInterval(() => {
this.performCleanup();
}, 60000); // Run every minute
// Unref the timer so it doesn't keep the process alive
if (this.cleanupInterval.unref) {
this.cleanupInterval.unref();
}
}
/**
* Perform cleanup of expired rate limits and empty IP entries
*/
private performCleanup(): void {
const now = Date.now();
const minute = 60 * 1000;
let cleanedRateLimits = 0;
let cleanedIPs = 0;
// Clean up expired rate limit timestamps
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
const validTimestamps = timestamps.filter(time => now - time < minute);
if (validTimestamps.length === 0) {
// No valid timestamps, remove the IP entry
this.connectionRateByIP.delete(ip);
cleanedRateLimits++;
} else if (validTimestamps.length < timestamps.length) {
// Some timestamps expired, update with valid ones
this.connectionRateByIP.set(ip, validTimestamps);
}
}
// Clean up IPs with no active connections
for (const [ip, connections] of this.connectionsByIP.entries()) {
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
cleanedIPs++;
}
}
// Log cleanup stats if anything was cleaned
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
if (this.smartProxy.settings.enableDetailedLogging) {
connectionLogDeduplicator.log(
'ip-cleanup',
'debug',
'IP tracking cleanup completed',
{
cleanedRateLimits,
cleanedIPs,
remainingIPs: this.connectionsByIP.size,
remainingRateLimits: this.connectionRateByIP.size,
component: 'security-manager'
},
'periodic-cleanup'
);
}
}
}
}

View File

@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
// Importing required components
import { ConnectionManager } from './connection-manager.js';
@ -27,6 +28,10 @@ import { Mutex } from './utils/mutex.js';
// Import ACME state manager
import { AcmeStateManager } from './acme-state-manager.js';
// Import metrics collector
import { MetricsCollector } from './metrics-collector.js';
import type { IMetrics } from './models/metrics-types.js';
/**
* SmartProxy - Pure route-based API
*
@ -47,22 +52,25 @@ export class SmartProxy extends plugins.EventEmitter {
private isShuttingDown: boolean = false;
// Component managers
private connectionManager: ConnectionManager;
private securityManager: SecurityManager;
private tlsManager: TlsManager;
private httpProxyBridge: HttpProxyBridge;
private timeoutManager: TimeoutManager;
public routeManager: RouteManager; // Made public for route management
private routeConnectionHandler: RouteConnectionHandler;
private nftablesManager: NFTablesManager;
public connectionManager: ConnectionManager;
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
public metricsCollector: MetricsCollector;
// Track port usage across route updates
private portUsageMap: Map<number, Set<string>> = new Map();
@ -154,13 +162,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
@ -179,31 +183,29 @@ 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();
// Initialize ACME state manager
this.acmeStateManager = new AcmeStateManager();
// Initialize metrics collector with reference to this SmartProxy instance
this.metricsCollector = new MetricsCollector(this, {
sampleIntervalMs: this.settings.metrics?.sampleIntervalMs,
retentionSeconds: this.settings.metrics?.retentionSeconds
});
}
/**
@ -384,6 +386,9 @@ export class SmartProxy extends plugins.EventEmitter {
await this.certManager.provisionAllCertificates();
}
// Start the metrics collector now that all components are initialized
this.metricsCollector.start();
// Set up periodic connection logging and inactivity checks
this.connectionLogger = setInterval(() => {
// Immediately return if shutting down
@ -509,6 +514,12 @@ export class SmartProxy extends plugins.EventEmitter {
// Clear ACME state manager
this.acmeStateManager.clear();
// Stop metrics collector
this.metricsCollector.stop();
// Flush any pending deduplicated logs
connectionLogDeduplicator.flushAll();
logger.log('info', 'SmartProxy shutdown complete.');
}
@ -905,6 +916,15 @@ export class SmartProxy extends plugins.EventEmitter {
return this.certManager.getCertificateStatus(routeName);
}
/**
* Get proxy metrics with clean API
*
* @returns IMetrics interface with grouped metrics methods
*/
public getMetrics(): IMetrics {
return this.metricsCollector;
}
/**
* Validates if a domain name is valid for certificate issuance
*/

View File

@ -0,0 +1,138 @@
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 };
}
// Calculate total bytes in window
const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0);
const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0);
// Use actual number of seconds covered by samples for accurate rate
const oldestSampleTime = relevantSamples[0].timestamp;
const newestSampleTime = relevantSamples[relevantSamples.length - 1].timestamp;
const actualSeconds = Math.max(1, (newestSampleTime - oldestSampleTime) / 1000 + 1);
return {
in: Math.round(totalBytesIn / actualSeconds),
out: Math.round(totalBytesOut / actualSeconds)
};
}
/**
* Get throughput history for specified duration
*/
public getHistory(durationSeconds: number): IThroughputHistoryPoint[] {
const now = Date.now();
const startTime = now - (durationSeconds * 1000);
// Filter samples within duration
const relevantSamples = this.samples.filter(s => s.timestamp > startTime);
// Convert to history points with per-second rates
const history: IThroughputHistoryPoint[] = [];
for (let i = 0; i < relevantSamples.length; i++) {
const sample = relevantSamples[i];
// For the first sample or samples after gaps, we can't calculate rate
if (i === 0 || sample.timestamp - relevantSamples[i - 1].timestamp > 2000) {
history.push({
timestamp: sample.timestamp,
in: sample.bytesIn,
out: sample.bytesOut
});
} else {
// Calculate rate based on time since previous sample
const prevSample = relevantSamples[i - 1];
const timeDelta = (sample.timestamp - prevSample.timestamp) / 1000;
history.push({
timestamp: sample.timestamp,
in: Math.round(sample.bytesIn / timeDelta),
out: Math.round(sample.bytesOut / timeDelta)
});
}
}
return history;
}
/**
* Clear all samples
*/
public clear(): void {
this.samples = [];
this.accumulatedBytesIn = 0;
this.accumulatedBytesOut = 0;
this.lastSampleTime = 0;
}
/**
* Get sample count for debugging
*/
public getSampleCount(): number {
return this.samples.length;
}
}

View File

@ -1,10 +1,11 @@
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import type { IConnectionRecord } from './models/interfaces.js';
import type { SmartProxy } from './smart-proxy.js';
/**
* Manages timeouts and inactivity tracking for connections
*/
export class TimeoutManager {
constructor(private settings: ISmartProxyOptions) {}
constructor(private smartProxy: SmartProxy) {}
/**
* Ensure timeout values don't exceed Node.js max safe integer
@ -41,16 +42,16 @@ export class TimeoutManager {
* Calculate effective inactivity timeout based on connection type
*/
public getEffectiveInactivityTimeout(record: IConnectionRecord): number {
let effectiveTimeout = this.settings.inactivityTimeout || 14400000; // 4 hours default
let effectiveTimeout = this.smartProxy.settings.inactivityTimeout || 14400000; // 4 hours default
// For immortal keep-alive connections, use an extremely long timeout
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, apply multiplier
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
const multiplier = this.smartProxy.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
@ -63,23 +64,23 @@ export class TimeoutManager {
public getEffectiveMaxLifetime(record: IConnectionRecord): number {
// Use route-specific timeout if available from the routeConfig
const baseTimeout = record.routeConfig?.action.advanced?.timeout ||
this.settings.maxConnectionLifetime ||
this.smartProxy.settings.maxConnectionLifetime ||
86400000; // 24 hours default
// For immortal keep-alive connections, use an extremely long lifetime
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, use the extended lifetime setting
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'extended') {
return this.ensureSafeTimeout(
this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
this.smartProxy.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
);
}
// Apply randomization if enabled
if (this.settings.enableRandomizedTimeouts) {
if (this.smartProxy.settings.enableRandomizedTimeouts) {
return this.randomizeTimeout(baseTimeout);
}
@ -93,12 +94,17 @@ export class TimeoutManager {
public setupConnectionTimeout(
record: IConnectionRecord,
onTimeout: (record: IConnectionRecord, reason: string) => void
): NodeJS.Timeout {
): NodeJS.Timeout | null {
// Clear any existing timer
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
}
// Skip timeout for immortal keep-alive connections
if (record.hasKeepAlive && this.smartProxy.settings.keepAliveTreatment === 'immortal') {
return null;
}
// Calculate effective timeout
const effectiveLifetime = this.getEffectiveMaxLifetime(record);
@ -127,7 +133,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 +143,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 +177,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 +187,7 @@ export class TimeoutManager {
}
// Apply normal timeouts
const timeout = this.ensureSafeTimeout(this.settings.socketTimeout || 3600000); // 1 hour default
const timeout = this.ensureSafeTimeout(this.smartProxy.settings.socketTimeout || 3600000); // 1 hour default
record.incoming.setTimeout(timeout);
if (record.outgoing) {
record.outgoing.setTimeout(timeout);

View File

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

View File

@ -168,7 +168,7 @@ export class HttpRouter {
if (pathResult.matches) {
return {
route,
pathMatch: path,
pathMatch: pathResult.pathMatch || path,
pathParams: pathResult.params,
pathRemainder: pathResult.pathRemainder
};