Compare commits

..

58 Commits

Author SHA1 Message Date
Juergen Kunz
36068a6d92 feat(protocols): refactor protocol utilities into centralized protocols module
Some checks failed
Default (tags) / security (push) Successful in 55s
Default (tags) / test (push) Failing after 30m45s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 22:37:45 +00:00
Juergen Kunz
d47b048517 feat(detection): add centralized protocol detection module
- Created ts/detection module for unified protocol detection
- Implemented TLS and HTTP detectors with fragmentation support
- Moved TLS detection logic from existing code to centralized module
- Updated RouteConnectionHandler to use ProtocolDetector for both TLS and HTTP
- Refactored ACME HTTP parsing to use detection module
- Added comprehensive tests for detection functionality
- Eliminated duplicate protocol detection code across codebase

This centralizes all non-destructive protocol detection into a single module,
improving code organization and reducing duplication between ACME and routing.
2025-07-21 19:40:01 +00:00
Juergen Kunz
c84947068c BREAKING_CHANGE(core): remove legacy forwarding module in favor of route-based system
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 30m40s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
- Removed the forwarding namespace export from main index
- Removed TForwardingType and all forwarding handlers
- Consolidated route helper functions into route-helpers.ts
- All functionality is now available through the route-based system
- Users must migrate from forwarding.* imports to direct route helper imports
2025-07-21 18:44:59 +00:00
Juergen Kunz
26f7431111 fix(docs): update documentation to improve clarity
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 31m10s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 12:23:22 +00:00
Juergen Kunz
aa6ddbc4a6 BREAKING_CHANGE(routing): refactor route configuration to support multiple targets
Some checks failed
Default (tags) / security (push) Successful in 53s
Default (tags) / test (push) Failing after 31m2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 08:52:07 +00:00
Juergen Kunz
6aa5f415c1 update 2025-07-17 20:51:50 +00:00
Juergen Kunz
b26abbfd87 update 2025-07-17 15:34:58 +00:00
Juergen Kunz
82df9a6f52 update 2025-07-17 15:13:09 +00:00
Juergen Kunz
a625675922 19.6.17
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 30m42s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-13 00:41:50 +00:00
Juergen Kunz
eac6075a12 fix(cert): fix tsclass ICert usage 2025-07-13 00:41:44 +00:00
Juergen Kunz
2d2e9e9475 feat(certificates): add custom provisioning option 2025-07-13 00:27:49 +00:00
Juergen Kunz
257a5dc319 update 2025-07-13 00:05:32 +00:00
Juergen Kunz
5d206b9800 add plan for better cert provisioning 2025-07-12 21:58:46 +00:00
Juergen Kunz
f82d44164c 19.6.16
Some checks failed
Default (tags) / security (push) Successful in 1m20s
Default (tags) / test (push) Failing after 29m31s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-03 03:17:35 +00:00
Juergen Kunz
2a4ed38f6b update logs 2025-07-03 02:54:56 +00:00
Juergen Kunz
bb2c82b44a 19.6.15
Some checks failed
Default (tags) / security (push) Successful in 1m22s
Default (tags) / test (push) Failing after 29m38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-03 02:45:30 +00:00
Juergen Kunz
dddcf8dec4 improve logging 2025-07-03 02:45:08 +00:00
Juergen Kunz
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
Juergen Kunz
5d011ba84c better logging 2025-07-03 02:32:17 +00:00
Juergen Kunz
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
Juergen Kunz
3857d2670f fix(metrics): fix metrics 2025-06-23 15:42:04 +00:00
Juergen Kunz
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
Juergen Kunz
82ca0381e9 fix(metrics): fix metrics 2025-06-23 13:19:39 +00:00
Juergen Kunz
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
Juergen Kunz
caa15e539e fix(metrics): fix metrics 2025-06-23 13:07:30 +00:00
Juergen Kunz
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
Juergen Kunz
8df0333dc3 fix(metrics): fix metrics 2025-06-23 09:35:37 +00:00
Juergen Kunz
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
Juergen Kunz
86b016cac3 fix(metrics): update hints 2025-06-23 09:03:09 +00:00
Juergen Kunz
e81d0386d6 fix(metrics): fix metrics 2025-06-23 09:02:42 +00:00
Juergen Kunz
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
Juergen Kunz
753b03d3e9 fix(metrics): fix metrics 2025-06-23 08:50:19 +00:00
Juergen Kunz
be58700a2f fix(tests): fix tests 2025-06-23 08:38:14 +00:00
Juergen Kunz
1aead55296 fix(tests): fix tests 2025-06-22 23:15:30 +00:00
Juergen Kunz
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
Juergen Kunz
e5ec48abd3 fix(tests): fix tests 2025-06-22 23:10:56 +00:00
Juergen Kunz
131a454b28 fix(metrics): improve metrics 2025-06-22 22:28:37 +00:00
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
424407d879 fix(readme): update 2025-06-13 17:22:31 +00:00
Juergen Kunz
7e1b7b190c fix(readme): update 2025-06-12 16:59:25 +00:00
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
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
Juergen Kunz
dc3eda5e29 fix accumulation 2025-06-08 12:25:31 +00:00
Juergen Kunz
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
Juergen Kunz
890e907664 fix(connection): filter zombie connections part 2 2025-06-07 20:37:49 +00:00
Juergen Kunz
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
Juergen Kunz
47735adbf2 Implement zombie connection detection and cleanup in ConnectionManager; enhance tests for edge cases 2025-06-07 10:55:59 +00:00
Juergen Kunz
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
Juergen Kunz
9aebcd488d Implement connection timeout handling and improve connection cleanup in SmartProxy 2025-06-06 23:34:50 +00:00
Juergen Kunz
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
Juergen Kunz
578d1ba2f7 update 2025-06-06 15:00:46 +00:00
153 changed files with 10195 additions and 7338 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-19T22:36:33.093Z",
"issueDate": "2025-07-21T22:36:33.093Z",
"savedAt": "2025-07-21T22:36:33.094Z"
}

View File

@@ -1,5 +1,39 @@
# Changelog
## 2025-07-21 - 21.1.0 - feat(protocols)
Refactor protocol utilities into centralized protocols module
- Moved TLS utilities from `ts/tls/` to `ts/protocols/tls/`
- Created centralized protocol modules for HTTP, WebSocket, Proxy, and TLS
- Core utilities now delegate to protocol modules for parsing and utilities
- Maintains backward compatibility through re-exports in original locations
- Improves code organization and separation of concerns
## 2025-07-22 - 21.0.0 - BREAKING_CHANGE(forwarding)
Remove legacy forwarding module
- Removed the `forwarding` namespace export from main index
- Removed TForwardingType and all forwarding handlers
- Consolidated route helper functions into route-helpers.ts
- All functionality is now available through the route-based system
- MIGRATION: Replace `import { forwarding } from '@push.rocks/smartproxy'` with direct imports of route helpers
## 2025-07-21 - 20.0.2 - fix(docs)
Update documentation to improve clarity
- Enhanced readme with clearer breaking change warning for v20.0.0
- Fixed example email address from ssl@bleu.de to ssl@example.com
- Added load balancing and failover features to feature list
- Improved documentation structure and examples
## 2025-07-20 - 20.0.1 - BREAKING_CHANGE(routing)
Refactor route configuration to support multiple targets
- Changed route action configuration from single `target` to `targets` array
- Enables load balancing and failover capabilities with multiple upstream targets
- Updated all test files to use new `targets` array syntax
- Automatic certificate metadata refresh
## 2025-06-01 - 19.5.19 - fix(smartproxy)
Fix connection handling and improve route matching edge cases

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "19.5.21",
"version": "21.1.0",
"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",
@@ -50,7 +51,8 @@
"assets/**/*",
"cli.js",
"npmextra.json",
"readme.md"
"readme.md",
"changelog.md"
],
"browserslist": [
"last 1 chrome versions"

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

1939
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,625 +1,154 @@
# PROXY Protocol Implementation Plan
# SmartProxy Enhanced Routing Plan
## ⚠️ CRITICAL: Implementation Order
## Goal
Implement enhanced routing structure with multiple targets per route, sub-matching capabilities, and target-specific overrides to enable more elegant and DRY configurations.
**Phase 1 (ProxyProtocolSocket/WrappedSocket) MUST be completed first!**
## Key Changes
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. Update Route Target Interface
- Add `match` property to `IRouteTarget` for sub-matching within routes
- Add target-specific override properties (tls, websocket, loadBalancing, etc.)
- Add priority field for controlling match order
1. **FIRST**: Implement ProxyProtocolSocket (the WrappedSocket)
2. **THEN**: Add PROXY protocol parser
3. **THEN**: Integrate with connection handlers
4. **FINALLY**: Add security and validation
## 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.
## 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
## Solution Design
### 1. Core Features
#### 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)
#### 1.2 PROXY Protocol Generation
- Add ability to send PROXY protocol headers when forwarding connections
- Configurable per route or target
#### 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;
}
```
### 2. Update Route Action Interface
- Remove singular `target` property
- Use only `targets` array (single target = array with one element)
- Maintain backwards compatibility during migration
### 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: Type Updates
- [x] Update `IRouteTarget` interface in `route-types.ts`
- Add `match?: ITargetMatch` property
- Add override properties (tls, websocket, etc.)
- Add `priority?: number` field
- [x] Create `ITargetMatch` interface for sub-matching criteria
- [x] Update `IRouteAction` to use only `targets: IRouteTarget[]`
#### Phase 1: ProxyProtocolSocket (WrappedSocket) Foundation - ✅ COMPLETED (v19.5.19)
This phase creates the socket wrapper infrastructure that all subsequent phases depend on.
#### Phase 2: Route Resolution Logic
- [x] Update route matching logic to handle multiple targets
- [x] Implement target sub-matching algorithm:
1. Sort targets by priority (highest first)
2. For each target with a match property, check if request matches
3. Use first matching target, or fallback to target without match
- [x] Ensure target-specific settings override route-level settings
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
#### Phase 3: Code Migration
- [x] Find all occurrences of `action.target` and update to use `action.targets`
- [x] Update route helpers and utilities
- [x] Update certificate manager to handle multiple targets
- [x] Update connection handlers
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
#### Phase 4: Testing
- [x] Update existing tests to use new format
- [ ] Add tests for multi-target scenarios
- [ ] Add tests for sub-matching logic
- [ ] Add tests for setting overrides
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
#### Phase 5: Documentation
- [ ] Update type documentation
- [ ] Add examples of new routing patterns
- [ ] Document migration path for existing configs
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 - ✅ COMPLETED (v19.5.21)
Only after WrappedSocket is working can we add protocol parsing.
1. ✅ Created `ProxyProtocolParser` class in `ts/core/utils/proxy-protocol.ts`
2. ✅ Implemented v1 text format parsing with full validation
3. ✅ Added comprehensive error handling and IP validation
4. ✅ Integrated parser to work WITH WrappedSocket in RouteConnectionHandler
**Deliverables**: ✅ Working PROXY protocol v1 parser that validates headers, extracts client info, and handles both TCP4 and TCP6 protocols.
#### Phase 3: Connection Handler Integration - ✅ COMPLETED (v19.5.21)
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
**Deliverables**: ✅ RouteConnectionHandler now parses PROXY protocol from trusted proxies and updates connection records with real client info.
#### Phase 4: Outbound PROXY Protocol - ✅ COMPLETED (v19.5.21)
1. ✅ Add PROXY header generation in `setupDirectConnection`
2. ✅ Make it configurable per route via `sendProxyProtocol` option
3. ✅ Send header immediately after TCP connection
4. ✅ Added remotePort tracking to connection records
**Deliverables**: ✅ SmartProxy can now send PROXY protocol headers to backend servers when configured, preserving client IP through proxy chains.
#### 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.
## Example Configurations
### Before (Current)
```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 (IMPLEMENTED ✅)
```typescript
// Outer proxy - sends PROXY protocol
const outerProxy = new SmartProxy({
routes: [{
name: 'to-inner-proxy',
match: { ports: 443 },
// Need separate routes for different ports/paths
[
{
match: { domains: ['api.example.com'], ports: [80] },
action: {
type: 'forward',
target: { host: '195.201.98.232', port: 443 },
sendProxyProtocol: true // Enable for this route
target: { host: 'backend', port: 8080 },
tls: { mode: 'terminate' }
}
}]
});
// Inner proxy - accepts PROXY protocol from outer proxy
const innerProxy = new SmartProxy({
proxyIPs: ['212.95.99.130'], // Outer proxy IP
acceptProxyProtocol: true, // Optional - defaults to true when proxyIPs is set
routes: [{
name: 'to-backend',
match: { ports: 443 },
},
{
match: { domains: ['api.example.com'], ports: [443] },
action: {
type: 'forward',
target: { host: '192.168.5.247', port: 443 }
target: { host: 'backend', port: 8081 },
tls: { mode: 'passthrough' }
}
}]
});
}
]
```
### 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:
### After (Enhanced)
```typescript
class ProxyProtocolSocket {
constructor(
public socket: net.Socket,
public realClientIP?: string,
public realClientPort?: number
) {}
get remoteAddress(): string {
return this.realClientIP || this.socket.remoteAddress || '';
// Single route with multiple targets
{
match: { domains: ['api.example.com'], ports: [80, 443] },
action: {
type: 'forward',
targets: [
{
match: { ports: [80] },
host: 'backend',
port: 8080,
tls: { mode: 'terminate' }
},
{
match: { ports: [443] },
host: 'backend',
port: 8081,
tls: { mode: 'passthrough' }
}
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:
### Advanced Example
```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();
{
match: { domains: ['app.example.com'], ports: [443] },
action: {
type: 'forward',
tls: { mode: 'terminate', certificate: 'auto' }, // Route-level default
websocket: { enabled: true }, // Route-level default
targets: [
{
match: { path: '/api/v2/*' },
host: 'api-v2',
port: 8082,
priority: 10
},
{
match: { path: '/api/*', headers: { 'X-Version': 'v1' } },
host: 'api-v1',
port: 8081,
priority: 5
},
{
match: { path: '/ws/*' },
host: 'websocket-server',
port: 8090,
websocket: {
enabled: true,
rewritePath: '/' // Strip /ws prefix
}
// ... 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);
},
{
// Default target (no match property)
host: 'web-backend',
port: 8080
}
// 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
## Benefits
1. **DRY Configuration**: No need to duplicate common settings across routes
2. **Flexibility**: Different backends for different ports/paths within same domain
3. **Clarity**: All routing for a domain in one place
4. **Performance**: Single route lookup instead of multiple
5. **Backwards Compatible**: Can migrate gradually
### 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
## Migration Strategy
1. Keep support for `target` temporarily with deprecation warning
2. Auto-convert `target` to `targets: [target]` internally
3. Update documentation with migration examples
4. Remove `target` support in next major version

View File

@@ -1,112 +0,0 @@
# SmartProxy: Proxy Protocol and Proxy Chaining Summary
## Quick Summary
SmartProxy supports proxy chaining through the **WrappedSocket** infrastructure, which is designed to handle PROXY protocol for preserving real client IP addresses across multiple proxy layers. While the infrastructure is in place (v19.5.19+), the actual PROXY protocol parsing is not yet implemented.
## Current State
### ✅ What's Implemented
- **WrappedSocket class** - Foundation for proxy protocol support
- **Proxy IP configuration** - `proxyIPs` setting to define trusted proxies
- **Socket wrapping** - All incoming connections wrapped automatically
- **Connection tracking** - Real client IP tracking in connection records
- **Test infrastructure** - Tests for proxy chaining scenarios
### ❌ What's Missing
- **PROXY protocol v1 parsing** - Header parsing not implemented
- **PROXY protocol v2 support** - Binary format not supported
- **Automatic header generation** - Must be manually implemented
- **Production testing** - No HAProxy/AWS ELB compatibility tests
## Key Files
### Core Implementation
- `ts/core/models/wrapped-socket.ts` - WrappedSocket class
- `ts/core/models/socket-types.ts` - Helper functions
- `ts/proxies/smart-proxy/route-connection-handler.ts` - Connection handling
- `ts/proxies/smart-proxy/models/interfaces.ts` - Configuration interfaces
### Tests
- `test/test.wrapped-socket.ts` - WrappedSocket unit tests
- `test/test.proxy-chain-simple.node.ts` - Basic proxy chain test
- `test/test.proxy-chaining-accumulation.node.ts` - Connection leak tests
### Documentation
- `readme.proxy-protocol.md` - Detailed implementation guide
- `readme.proxy-protocol-example.md` - Code examples and future implementation
- `readme.hints.md` - Project overview with WrappedSocket notes
## Quick Configuration Example
```typescript
// Outer proxy (internet-facing)
const outerProxy = new SmartProxy({
sendProxyProtocol: true, // Will send PROXY protocol (when implemented)
routes: [{
name: 'forward-to-inner',
match: { ports: 443 },
action: {
type: 'forward',
target: { host: 'inner-proxy.local', port: 443 },
tls: { mode: 'passthrough' }
}
}]
});
// Inner proxy (backend-facing)
const innerProxy = new SmartProxy({
proxyIPs: ['outer-proxy.local'], // Trust the outer proxy
acceptProxyProtocol: true, // Will parse PROXY protocol (when implemented)
routes: [{
name: 'forward-to-backend',
match: { ports: 443, domains: 'api.example.com' },
action: {
type: 'forward',
target: { host: 'backend.local', port: 8080 },
tls: { mode: 'terminate' }
}
}]
});
```
## How It Works (Conceptually)
1. **Client** connects to **Outer Proxy**
2. **Outer Proxy** wraps socket in WrappedSocket
3. **Outer Proxy** forwards to **Inner Proxy**
- Would prepend: `PROXY TCP4 <client-ip> <proxy-ip> <client-port> <proxy-port>\r\n`
4. **Inner Proxy** receives connection from trusted proxy
5. **Inner Proxy** would parse PROXY protocol header
6. **Inner Proxy** updates WrappedSocket with real client IP
7. **Backend** receives connection with preserved client information
## Important Notes
### Connection Cleanup
The fix for proxy chain connection accumulation (v19.5.14+) changed the default socket behavior:
- **Before**: Half-open connections supported by default (caused accumulation)
- **After**: Both sockets close when one closes (prevents accumulation)
- **Override**: Set `enableHalfOpen: true` if half-open needed
### Security
- Only parse PROXY protocol from IPs listed in `proxyIPs`
- Never use `0.0.0.0/0` as a trusted proxy range
- Each proxy in chain must explicitly trust the previous proxy
### Testing
Use the test files as reference implementations:
- Simple chains: `test.proxy-chain-simple.node.ts`
- Connection leaks: `test.proxy-chaining-accumulation.node.ts`
- Rapid reconnects: `test.rapid-retry-cleanup.node.ts`
## Next Steps
To fully implement PROXY protocol support:
1. Implement the parser in `ProxyProtocolParser` class
2. Integrate parser into `handleConnection` method
3. Add header generation to `setupDirectConnection`
4. Test with real proxies (HAProxy, nginx, AWS ELB)
5. Add PROXY protocol v2 support for better performance
See `readme.proxy-protocol-example.md` for detailed implementation examples.

View File

@@ -1,462 +0,0 @@
# SmartProxy PROXY Protocol Implementation Example
This document shows how PROXY protocol parsing could be implemented in SmartProxy. Note that this is a conceptual implementation guide - the actual parsing is not yet implemented in the current version.
## Conceptual PROXY Protocol v1 Parser Implementation
### Parser Class
```typescript
// This would go in ts/core/utils/proxy-protocol-parser.ts
import { logger } from './logger.js';
export interface IProxyProtocolInfo {
version: 1 | 2;
command: 'PROXY' | 'LOCAL';
family: 'TCP4' | 'TCP6' | 'UNKNOWN';
sourceIP: string;
destIP: string;
sourcePort: number;
destPort: number;
headerLength: number;
}
export class ProxyProtocolParser {
private static readonly PROXY_V1_SIGNATURE = 'PROXY ';
private static readonly MAX_V1_HEADER_LENGTH = 108; // Max possible v1 header
/**
* Parse PROXY protocol v1 header from buffer
* Returns null if not a valid PROXY protocol header
*/
static parseV1(buffer: Buffer): IProxyProtocolInfo | null {
// Need at least 8 bytes for "PROXY " + newline
if (buffer.length < 8) {
return null;
}
// Check for v1 signature
const possibleHeader = buffer.toString('ascii', 0, 6);
if (possibleHeader !== this.PROXY_V1_SIGNATURE) {
return null;
}
// Find the end of the header (CRLF)
let headerEnd = -1;
for (let i = 6; i < Math.min(buffer.length, this.MAX_V1_HEADER_LENGTH); i++) {
if (buffer[i] === 0x0D && buffer[i + 1] === 0x0A) { // \r\n
headerEnd = i + 2;
break;
}
}
if (headerEnd === -1) {
// No complete header found
return null;
}
// Parse the header line
const headerLine = buffer.toString('ascii', 0, headerEnd - 2);
const parts = headerLine.split(' ');
if (parts.length !== 6) {
logger.log('warn', 'Invalid PROXY v1 header format', {
headerLine,
partCount: parts.length
});
return null;
}
const [proxy, family, srcIP, dstIP, srcPort, dstPort] = parts;
// Validate family
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(family)) {
logger.log('warn', 'Invalid PROXY protocol family', { family });
return null;
}
// Validate ports
const sourcePort = parseInt(srcPort);
const destPort = parseInt(dstPort);
if (isNaN(sourcePort) || sourcePort < 1 || sourcePort > 65535 ||
isNaN(destPort) || destPort < 1 || destPort > 65535) {
logger.log('warn', 'Invalid PROXY protocol ports', { srcPort, dstPort });
return null;
}
return {
version: 1,
command: 'PROXY',
family: family as 'TCP4' | 'TCP6' | 'UNKNOWN',
sourceIP: srcIP,
destIP: dstIP,
sourcePort,
destPort,
headerLength: headerEnd
};
}
/**
* Check if buffer potentially contains PROXY protocol
*/
static mightBeProxyProtocol(buffer: Buffer): boolean {
if (buffer.length < 6) return false;
// Check for v1 signature
const start = buffer.toString('ascii', 0, 6);
if (start === this.PROXY_V1_SIGNATURE) return true;
// Check for v2 signature (12 bytes: \x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A)
if (buffer.length >= 12) {
const v2Sig = Buffer.from([0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A]);
if (buffer.compare(v2Sig, 0, 12, 0, 12) === 0) return true;
}
return false;
}
}
```
### Integration with RouteConnectionHandler
```typescript
// This shows how it would be integrated into route-connection-handler.ts
private async handleProxyProtocol(
socket: plugins.net.Socket,
wrappedSocket: WrappedSocket,
record: IConnectionRecord
): Promise<Buffer | null> {
const remoteIP = socket.remoteAddress || '';
// Only parse PROXY protocol from trusted IPs
if (!this.settings.proxyIPs?.includes(remoteIP)) {
return null;
}
return new Promise((resolve) => {
let buffer = Buffer.alloc(0);
let headerParsed = false;
const parseHandler = (chunk: Buffer) => {
// Accumulate data
buffer = Buffer.concat([buffer, chunk]);
// Try to parse PROXY protocol
const proxyInfo = ProxyProtocolParser.parseV1(buffer);
if (proxyInfo) {
// Update wrapped socket with real client info
wrappedSocket.setProxyInfo(proxyInfo.sourceIP, proxyInfo.sourcePort);
// Update connection record
record.remoteIP = proxyInfo.sourceIP;
logger.log('info', 'PROXY protocol parsed', {
connectionId: record.id,
realIP: proxyInfo.sourceIP,
realPort: proxyInfo.sourcePort,
proxyIP: remoteIP
});
// Remove this handler
socket.removeListener('data', parseHandler);
headerParsed = true;
// Return remaining data after header
const remaining = buffer.slice(proxyInfo.headerLength);
resolve(remaining.length > 0 ? remaining : null);
} else if (buffer.length > 108) {
// Max v1 header length exceeded, not PROXY protocol
socket.removeListener('data', parseHandler);
headerParsed = true;
resolve(buffer);
}
};
// Set timeout for PROXY protocol parsing
const timeout = setTimeout(() => {
if (!headerParsed) {
socket.removeListener('data', parseHandler);
logger.log('warn', 'PROXY protocol parsing timeout', {
connectionId: record.id,
bufferLength: buffer.length
});
resolve(buffer.length > 0 ? buffer : null);
}
}, 1000); // 1 second timeout
socket.on('data', parseHandler);
// Clean up on early close
socket.once('close', () => {
clearTimeout(timeout);
if (!headerParsed) {
socket.removeListener('data', parseHandler);
resolve(null);
}
});
});
}
// Modified handleConnection to include PROXY protocol parsing
public async handleConnection(socket: plugins.net.Socket): void {
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort || 0;
// Always wrap the socket
const wrappedSocket = new WrappedSocket(socket);
// Create connection record
const record = this.connectionManager.createConnection(wrappedSocket);
if (!record) return;
// If from trusted proxy, parse PROXY protocol
if (this.settings.proxyIPs?.includes(remoteIP)) {
const remainingData = await this.handleProxyProtocol(socket, wrappedSocket, record);
if (remainingData) {
// Process remaining data as normal
this.handleInitialData(wrappedSocket, record, remainingData);
} else {
// Wait for more data
this.handleInitialData(wrappedSocket, record);
}
} else {
// Not from trusted proxy, handle normally
this.handleInitialData(wrappedSocket, record);
}
}
```
### Sending PROXY Protocol When Forwarding
```typescript
// This would be added to setupDirectConnection method
private setupDirectConnection(
socket: plugins.net.Socket | WrappedSocket,
record: IConnectionRecord,
serverName?: string,
initialChunk?: Buffer,
overridePort?: number,
targetHost?: string,
targetPort?: number
): void {
// ... existing code ...
// Create target socket
const targetSocket = createSocketWithErrorHandler({
port: finalTargetPort,
host: finalTargetHost,
onConnect: () => {
// If sendProxyProtocol is enabled, send PROXY header first
if (this.settings.sendProxyProtocol) {
const proxyHeader = this.buildProxyProtocolHeader(wrappedSocket, targetSocket);
targetSocket.write(proxyHeader);
}
// Then send any pending data
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
targetSocket.write(combinedData);
}
// ... rest of connection setup ...
}
});
}
private buildProxyProtocolHeader(
clientSocket: WrappedSocket,
serverSocket: net.Socket
): Buffer {
const family = clientSocket.remoteFamily === 'IPv6' ? 'TCP6' : 'TCP4';
const srcIP = clientSocket.remoteAddress || '0.0.0.0';
const srcPort = clientSocket.remotePort || 0;
const dstIP = serverSocket.localAddress || '0.0.0.0';
const dstPort = serverSocket.localPort || 0;
const header = `PROXY ${family} ${srcIP} ${dstIP} ${srcPort} ${dstPort}\r\n`;
return Buffer.from(header, 'ascii');
}
```
## Complete Example: HAProxy Compatible Setup
```typescript
// Example showing a complete HAProxy-compatible SmartProxy setup
import { SmartProxy } from '@push.rocks/smartproxy';
// Configuration matching HAProxy's proxy protocol behavior
const proxy = new SmartProxy({
// Accept PROXY protocol from these sources (like HAProxy's 'accept-proxy')
proxyIPs: [
'10.0.0.0/8', // Private network load balancers
'172.16.0.0/12', // Docker networks
'192.168.0.0/16' // Local networks
],
// Send PROXY protocol to backends (like HAProxy's 'send-proxy')
sendProxyProtocol: true,
routes: [
{
name: 'web-app',
match: {
ports: 443,
domains: ['app.example.com', 'www.example.com']
},
action: {
type: 'forward',
target: {
host: 'backend-pool.internal',
port: 8080
},
tls: {
mode: 'terminate',
certificate: 'auto',
acme: {
email: 'ssl@example.com'
}
}
}
}
]
});
// Start the proxy
await proxy.start();
// The proxy will now:
// 1. Accept connections on port 443
// 2. Parse PROXY protocol from trusted IPs
// 3. Terminate TLS
// 4. Forward to backend with PROXY protocol header
// 5. Backend sees real client IP
```
## Testing PROXY Protocol
```typescript
// Test client that sends PROXY protocol
import * as net from 'net';
function createProxyProtocolClient(
realClientIP: string,
realClientPort: number,
proxyHost: string,
proxyPort: number
): net.Socket {
const client = net.connect(proxyPort, proxyHost);
client.on('connect', () => {
// Send PROXY protocol header
const header = `PROXY TCP4 ${realClientIP} ${proxyHost} ${realClientPort} ${proxyPort}\r\n`;
client.write(header);
// Then send actual request
client.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
});
return client;
}
// Usage
const client = createProxyProtocolClient(
'203.0.113.45', // Real client IP
54321, // Real client port
'localhost', // Proxy host
8080 // Proxy port
);
```
## AWS Network Load Balancer Example
```typescript
// Configuration for AWS NLB with PROXY protocol v2
const proxy = new SmartProxy({
// AWS NLB IP ranges (get current list from AWS)
proxyIPs: [
'10.0.0.0/8', // VPC CIDR
// Add specific NLB IPs or use AWS IP ranges
],
// AWS NLB uses PROXY protocol v2 by default
acceptProxyProtocolV2: true, // Future feature
routes: [{
name: 'aws-app',
match: { ports: 443 },
action: {
type: 'forward',
target: {
host: 'app-cluster.internal',
port: 8443
},
tls: { mode: 'passthrough' }
}
}]
});
// The proxy will:
// 1. Accept PROXY protocol v2 from AWS NLB
// 2. Preserve VPC endpoint IDs and other metadata
// 3. Forward to backend with real client information
```
## Debugging PROXY Protocol
```typescript
// Enable detailed logging to debug PROXY protocol parsing
const proxy = new SmartProxy({
enableDetailedLogging: true,
proxyIPs: ['10.0.0.1'],
// Add custom logging for debugging
routes: [{
name: 'debug-route',
match: { ports: 8080 },
action: {
type: 'socket-handler',
socketHandler: async (socket, context) => {
console.log('Socket handler called with context:', {
clientIp: context.clientIp, // Real IP from PROXY protocol
port: context.port,
connectionId: context.connectionId,
timestamp: context.timestamp
});
// Handle the socket...
}
}
}]
});
```
## Security Considerations
1. **Always validate trusted proxy IPs** - Never accept PROXY protocol from untrusted sources
2. **Use specific IP ranges** - Avoid wildcards like `0.0.0.0/0`
3. **Implement rate limiting** - PROXY protocol parsing has a computational cost
4. **Validate header format** - Reject malformed headers immediately
5. **Set parsing timeouts** - Prevent slow loris attacks via PROXY headers
6. **Log parsing failures** - Monitor for potential attacks or misconfigurations
## Performance Considerations
1. **Header parsing overhead** - Minimal, one-time cost per connection
2. **Memory usage** - Small buffer for header accumulation (max 108 bytes for v1)
3. **Connection establishment** - Slight delay for PROXY protocol parsing
4. **Throughput impact** - None after initial header parsing
5. **CPU usage** - Negligible for well-formed headers
## Future Enhancements
1. **PROXY Protocol v2** - Binary format for better performance
2. **TLS information preservation** - Pass TLS version, cipher, SNI via PP2
3. **Custom type-length-value (TLV) fields** - Extended metadata support
4. **Connection pooling** - Reuse backend connections with different client IPs
5. **Health checks** - Skip PROXY protocol for health check connections

View File

@@ -1,415 +0,0 @@
# SmartProxy PROXY Protocol and Proxy Chaining Documentation
## Overview
SmartProxy implements support for the PROXY protocol v1 to enable proxy chaining and preserve real client IP addresses across multiple proxy layers. This documentation covers the implementation details, configuration, and usage patterns for proxy chaining scenarios.
## Architecture
### WrappedSocket Implementation
The foundation of PROXY protocol support is the `WrappedSocket` class, which wraps regular `net.Socket` instances to provide transparent access to real client information when behind a proxy.
```typescript
// ts/core/models/wrapped-socket.ts
export class WrappedSocket {
public readonly socket: plugins.net.Socket;
private realClientIP?: string;
private realClientPort?: number;
constructor(
socket: plugins.net.Socket,
realClientIP?: string,
realClientPort?: number
) {
this.socket = socket;
this.realClientIP = realClientIP;
this.realClientPort = realClientPort;
// Uses JavaScript Proxy to delegate all methods to underlying socket
return new Proxy(this, {
get(target, prop, receiver) {
// Override specific properties
if (prop === 'remoteAddress') {
return target.remoteAddress;
}
if (prop === 'remotePort') {
return target.remotePort;
}
// ... delegate other properties to underlying socket
}
});
}
get remoteAddress(): string | undefined {
return this.realClientIP || this.socket.remoteAddress;
}
get remotePort(): number | undefined {
return this.realClientPort || this.socket.remotePort;
}
get isFromTrustedProxy(): boolean {
return !!this.realClientIP;
}
}
```
### Key Design Decisions
1. **All sockets are wrapped** - Every incoming connection is wrapped in a WrappedSocket, not just those from trusted proxies
2. **Proxy pattern for delegation** - Uses JavaScript Proxy to transparently delegate all Socket methods while allowing property overrides
3. **Not a Duplex stream** - Simple wrapper approach avoids complexity and infinite loops
4. **Trust-based parsing** - PROXY protocol parsing only occurs for connections from trusted proxy IPs
## Configuration
### Basic PROXY Protocol Configuration
```typescript
const proxy = new SmartProxy({
// List of trusted proxy IPs that can send PROXY protocol
proxyIPs: ['10.0.0.1', '10.0.0.2', '192.168.1.0/24'],
// Global option to accept PROXY protocol (defaults based on proxyIPs)
acceptProxyProtocol: true,
// Global option to send PROXY protocol to all targets
sendProxyProtocol: false,
routes: [
{
name: 'backend-app',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
target: { host: 'backend.internal', port: 8443 },
tls: { mode: 'passthrough' }
}
}
]
});
```
### Proxy Chain Configuration
Setting up two SmartProxies in a chain:
```typescript
// Outer Proxy (Internet-facing)
const outerProxy = new SmartProxy({
proxyIPs: [], // No trusted proxies for outer proxy
sendProxyProtocol: true, // Send PROXY protocol to inner proxy
routes: [{
name: 'to-inner-proxy',
match: { ports: 443 },
action: {
type: 'forward',
target: {
host: 'inner-proxy.internal',
port: 443
},
tls: { mode: 'passthrough' }
}
}]
});
// Inner Proxy (Backend-facing)
const innerProxy = new SmartProxy({
proxyIPs: ['outer-proxy.internal'], // Trust the outer proxy
acceptProxyProtocol: true,
routes: [{
name: 'to-backend',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
target: {
host: 'backend.internal',
port: 8080
},
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}]
});
```
## How Two SmartProxies Communicate
### Connection Flow
1. **Client connects to Outer Proxy**
```
Client (203.0.113.45:54321) → Outer Proxy (1.2.3.4:443)
```
2. **Outer Proxy wraps the socket**
```typescript
// In RouteConnectionHandler.handleConnection()
const wrappedSocket = new WrappedSocket(socket);
// At this point:
// wrappedSocket.remoteAddress = '203.0.113.45'
// wrappedSocket.remotePort = 54321
```
3. **Outer Proxy forwards to Inner Proxy**
- Creates new connection to inner proxy
- If `sendProxyProtocol` is enabled, prepends PROXY protocol header:
```
PROXY TCP4 203.0.113.45 1.2.3.4 54321 443\r\n
[Original TLS/HTTP data follows]
```
4. **Inner Proxy receives connection**
- Sees connection from outer proxy IP
- Checks if IP is in `proxyIPs` list
- If trusted, parses PROXY protocol header
- Updates WrappedSocket with real client info:
```typescript
wrappedSocket.setProxyInfo('203.0.113.45', 54321);
```
5. **Inner Proxy routes based on real client IP**
- Security checks use real client IP
- Connection records track real client IP
- Backend sees requests from the original client IP
### Connection Record Tracking
```typescript
// In ConnectionManager
interface IConnectionRecord {
id: string;
incoming: WrappedSocket; // Wrapped socket with real client info
outgoing: net.Socket | null;
remoteIP: string; // Real client IP from PROXY protocol or direct connection
localPort: number;
// ... other fields
}
```
## Implementation Details
### Socket Wrapping in Route Handler
```typescript
// ts/proxies/smart-proxy/route-connection-handler.ts
public handleConnection(socket: plugins.net.Socket): void {
const remoteIP = socket.remoteAddress || '';
// Always wrap the socket to prepare for potential PROXY protocol
const wrappedSocket = new WrappedSocket(socket);
// If this is from a trusted proxy, log it
if (this.settings.proxyIPs?.includes(remoteIP)) {
logger.log('debug', `Connection from trusted proxy ${remoteIP}, PROXY protocol parsing will be enabled`);
}
// Create connection record with wrapped socket
const record = this.connectionManager.createConnection(wrappedSocket);
// Continue with normal connection handling...
}
```
### Socket Utility Integration
When passing wrapped sockets to socket utility functions, the underlying socket must be extracted:
```typescript
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
// In setupDirectConnection()
const incomingSocket = getUnderlyingSocket(socket); // Extract raw socket
setupBidirectionalForwarding(incomingSocket, targetSocket, {
onClientData: (chunk) => {
record.bytesReceived += chunk.length;
},
onServerData: (chunk) => {
record.bytesSent += chunk.length;
},
onCleanup: (reason) => {
this.connectionManager.cleanupConnection(record, reason);
},
enableHalfOpen: false // Required for proxy chains
});
```
## Current Status and Limitations
### Implemented (v19.5.19+)
- ✅ WrappedSocket foundation class
- ✅ Socket wrapping in connection handler
- ✅ Connection manager support for wrapped sockets
- ✅ Socket utility integration helpers
- ✅ Proxy IP configuration options
### Not Yet Implemented
- ❌ PROXY protocol v1 header parsing
- ❌ PROXY protocol v2 binary format support
- ❌ Automatic PROXY protocol header generation when forwarding
- ❌ HAProxy compatibility testing
- ❌ AWS ELB/NLB compatibility testing
### Known Issues
1. **No actual PROXY protocol parsing** - The infrastructure is in place but the protocol parsing is not yet implemented
2. **Manual configuration required** - No automatic detection of PROXY protocol support
3. **Limited to TCP connections** - WebSocket connections through proxy chains may not preserve client IPs
## Testing Proxy Chains
### Basic Proxy Chain Test
```typescript
// test/test.proxy-chain-simple.node.ts
tap.test('simple proxy chain test', async () => {
// Create backend server
const backend = net.createServer((socket) => {
console.log('Backend: Connection received');
socket.write('HTTP/1.1 200 OK\r\n\r\nHello from backend');
socket.end();
});
// Create inner proxy (downstream)
const innerProxy = new SmartProxy({
proxyIPs: ['127.0.0.1'], // Trust localhost for testing
routes: [{
name: 'to-backend',
match: { ports: 8591 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9999 }
}
}]
});
// Create outer proxy (upstream)
const outerProxy = new SmartProxy({
sendProxyProtocol: true, // Send PROXY to inner
routes: [{
name: 'to-inner',
match: { ports: 8590 },
action: {
type: 'forward',
target: { host: 'localhost', port: 8591 }
}
}]
});
// Test connection through chain
const client = net.connect(8590, 'localhost');
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
// Verify no connection accumulation
const counts = getConnectionCounts();
expect(counts.proxy1).toEqual(0);
expect(counts.proxy2).toEqual(0);
});
```
## Best Practices
### 1. Always Configure Trusted Proxies
```typescript
// Be specific about which IPs can send PROXY protocol
proxyIPs: ['10.0.0.1', '10.0.0.2'], // Good
proxyIPs: ['0.0.0.0/0'], // Bad - trusts everyone
```
### 2. Use CIDR Notation for Subnets
```typescript
proxyIPs: [
'10.0.0.0/24', // Trust entire subnet
'192.168.1.5', // Trust specific IP
'172.16.0.0/16' // Trust private network
]
```
### 3. Enable Half-Open Only When Needed
```typescript
// For proxy chains, always disable half-open
setupBidirectionalForwarding(client, server, {
enableHalfOpen: false // Ensures proper cascade cleanup
});
```
### 4. Monitor Connection Counts
```typescript
// Regular monitoring prevents connection leaks
setInterval(() => {
const stats = proxy.getStatistics();
console.log(`Active connections: ${stats.activeConnections}`);
if (stats.activeConnections > 1000) {
console.warn('High connection count detected');
}
}, 60000);
```
## Future Enhancements
### Phase 2: PROXY Protocol v1 Parser
```typescript
// Planned implementation
class ProxyProtocolParser {
static parse(buffer: Buffer): ProxyInfo | null {
// Parse "PROXY TCP4 <src-ip> <dst-ip> <src-port> <dst-port>\r\n"
const header = buffer.toString('ascii', 0, 108);
const match = header.match(/^PROXY (TCP4|TCP6) (\S+) (\S+) (\d+) (\d+)\r\n/);
if (match) {
return {
protocol: match[1],
sourceIP: match[2],
destIP: match[3],
sourcePort: parseInt(match[4]),
destPort: parseInt(match[5]),
headerLength: match[0].length
};
}
return null;
}
}
```
### Phase 3: Automatic PROXY Protocol Detection
- Peek at first bytes to detect PROXY protocol signature
- Automatic fallback to direct connection if not present
- Configurable timeout for protocol detection
### Phase 4: PROXY Protocol v2 Support
- Binary protocol format for better performance
- Additional metadata support (TLS info, ALPN, etc.)
- AWS VPC endpoint ID preservation
## Troubleshooting
### Connection Accumulation in Proxy Chains
If connections accumulate when chaining proxies:
1. Verify `enableHalfOpen: false` in socket forwarding
2. Check that both proxies have proper cleanup handlers
3. Monitor with connection count logging
4. Use `test.proxy-chain-simple.node.ts` as reference
### Real Client IP Not Preserved
If the backend sees proxy IP instead of client IP:
1. Verify outer proxy has `sendProxyProtocol: true`
2. Verify inner proxy has outer proxy IP in `proxyIPs` list
3. Check logs for "Connection from trusted proxy" message
4. Ensure PROXY protocol parsing is implemented (currently pending)
### Performance Impact
PROXY protocol adds minimal overhead:
- One-time parsing cost per connection
- Small memory overhead for real client info storage
- No impact on data transfer performance
- Negligible CPU impact for header generation
## Related Documentation
- [Socket Utilities](./ts/core/utils/socket-utils.ts) - Low-level socket handling
- [Connection Manager](./ts/proxies/smart-proxy/connection-manager.ts) - Connection lifecycle
- [Route Handler](./ts/proxies/smart-proxy/route-connection-handler.ts) - Request routing
- [Test Suite](./test/test.wrapped-socket.ts) - WrappedSocket unit tests

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

@@ -32,14 +32,14 @@ tap.test('PathMatcher - wildcard matching', async () => {
const result = PathMatcher.match('/api/*', '/api/users/123/profile');
expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash
expect(result.pathRemainder).toEqual('users/123/profile');
expect(result.pathRemainder).toEqual('/users/123/profile');
});
tap.test('PathMatcher - mixed parameters and wildcards', async () => {
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
expect(result.matches).toEqual(true);
expect(result.params).toEqual({ version: 'v1' });
expect(result.pathRemainder).toEqual('users/123');
expect(result.pathRemainder).toEqual('/users/123');
});
tap.test('PathMatcher - trailing slash normalization', async () => {

View File

@@ -58,7 +58,7 @@ tap.test('Shared Security Manager', async () => {
},
action: {
type: 'forward',
target: { host: 'target.com', port: 443 }
targets: [{ host: 'target.com', port: 443 }]
},
security: {
ipAllowList: ['10.0.0.*', '192.168.1.*'],
@@ -113,7 +113,7 @@ tap.test('Shared Security Manager', async () => {
},
action: {
type: 'forward',
target: { host: 'target.com', port: 443 }
targets: [{ host: 'target.com', port: 443 }]
},
security: {
rateLimit: {

View File

@@ -59,7 +59,7 @@ tap.test('should create ACME challenge route', async (tools) => {
},
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 8080 }
targets: [{ host: 'localhost', port: 8080 }]
}
},
challengeRoute

View File

@@ -18,7 +18,7 @@ tap.test('should defer certificate provisioning until ports are ready', async (t
},
action: {
type: 'forward',
target: { host: 'localhost', port: 8181 },
targets: [{ host: 'localhost', port: 8181 }],
tls: {
mode: 'terminate',
certificate: 'auto',

View File

@@ -30,7 +30,7 @@ tap.test('should defer certificate provisioning until after ports are listening'
},
action: {
type: 'forward',
target: { host: 'localhost', port: 8181 },
targets: [{ host: 'localhost', port: 8181 }],
tls: {
mode: 'terminate',
certificate: 'auto',
@@ -126,7 +126,7 @@ tap.test('should have ACME challenge route ready before certificate provisioning
},
action: {
type: 'forward',
target: { host: 'localhost', port: 8181 },
targets: [{ host: 'localhost', port: 8181 }],
tls: {
mode: 'terminate',
certificate: 'auto'

View File

@@ -16,10 +16,10 @@ tap.test('SmartCertManager should call getCertificateForDomain with wildcard opt
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 8080
},
}],
tls: {
mode: 'terminate',
certificate: 'auto',

View File

@@ -0,0 +1,360 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import type { TSmartProxyCertProvisionObject } from '../ts/index.js';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let testProxy: SmartProxy;
// Load test certificates from helpers
const testCert = fs.readFileSync(path.join(__dirname, 'helpers/test-cert.pem'), 'utf8');
const testKey = fs.readFileSync(path.join(__dirname, 'helpers/test-key.pem'), 'utf8');
tap.test('SmartProxy should support custom certificate provision function', async () => {
// Create test certificate object matching ICert interface
const testCertObject = {
id: 'test-cert-1',
domainName: 'test.example.com',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days
privateKey: testKey,
publicKey: testCert,
csr: ''
};
// Custom certificate store for testing
const customCerts = new Map<string, typeof testCertObject>();
customCerts.set('test.example.com', testCertObject);
// Create proxy with custom certificate provision
testProxy = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
console.log(`Custom cert provision called for domain: ${domain}`);
// Return custom cert for known domains
if (customCerts.has(domain)) {
console.log(`Returning custom certificate for ${domain}`);
return customCerts.get(domain)!;
}
// Fallback to Let's Encrypt for other domains
console.log(`Falling back to Let's Encrypt for ${domain}`);
return 'http01';
},
certProvisionFallbackToAcme: true,
acme: {
email: 'test@example.com',
useProduction: false
},
routes: [
{
name: 'test-route',
match: {
ports: [443],
domains: ['test.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
expect(testProxy).toBeInstanceOf(SmartProxy);
});
tap.test('Custom certificate provision function should be called', async () => {
let provisionCalled = false;
const provisionedDomains: string[] = [];
const testProxy2 = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
provisionCalled = true;
provisionedDomains.push(domain);
// Return a test certificate matching ICert interface
return {
id: `test-cert-${domain}`,
domainName: domain,
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: testKey,
publicKey: testCert,
csr: ''
};
},
acme: {
email: 'test@example.com',
useProduction: false,
port: 9080
},
routes: [
{
name: 'custom-cert-route',
match: {
ports: [9443],
domains: ['custom.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock the certificate manager to test our custom provision function
let certManagerCalled = false;
const origCreateCertManager = (testProxy2 as any).createCertificateManager;
(testProxy2 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy2, args);
// Override provisionAllCertificates to track calls
const origProvisionAll = certManager.provisionAllCertificates;
certManager.provisionAllCertificates = async function() {
certManagerCalled = true;
await origProvisionAll.call(certManager);
};
return certManager;
};
// Start the proxy (this will trigger certificate provisioning)
await testProxy2.start();
expect(certManagerCalled).toBeTrue();
expect(provisionCalled).toBeTrue();
expect(provisionedDomains).toContain('custom.example.com');
await testProxy2.stop();
});
tap.test('Should fallback to ACME when custom provision fails', async () => {
const failedDomains: string[] = [];
let acmeAttempted = false;
const testProxy3 = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
failedDomains.push(domain);
throw new Error('Custom provision failed for testing');
},
certProvisionFallbackToAcme: true,
acme: {
email: 'test@example.com',
useProduction: false,
port: 9080
},
routes: [
{
name: 'fallback-route',
match: {
ports: [9444],
domains: ['fallback.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock to track ACME attempts
const origCreateCertManager = (testProxy3 as any).createCertificateManager;
(testProxy3 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy3, args);
// Mock SmartAcme to avoid real ACME calls
(certManager as any).smartAcme = {
getCertificateForDomain: async () => {
acmeAttempted = true;
throw new Error('Mocked ACME failure');
}
};
return certManager;
};
// Start the proxy
await testProxy3.start();
// Custom provision should have failed
expect(failedDomains).toContain('fallback.example.com');
// ACME should have been attempted as fallback
expect(acmeAttempted).toBeTrue();
await testProxy3.stop();
});
tap.test('Should not fallback when certProvisionFallbackToAcme is false', async () => {
let errorThrown = false;
let errorMessage = '';
const testProxy4 = new SmartProxy({
certProvisionFunction: async (_domain: string): Promise<TSmartProxyCertProvisionObject> => {
throw new Error('Custom provision failed for testing');
},
certProvisionFallbackToAcme: false,
routes: [
{
name: 'no-fallback-route',
match: {
ports: [9445],
domains: ['no-fallback.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock certificate manager to capture errors
const origCreateCertManager = (testProxy4 as any).createCertificateManager;
(testProxy4 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy4, args);
// Override provisionAllCertificates to capture errors
const origProvisionAll = certManager.provisionAllCertificates;
certManager.provisionAllCertificates = async function() {
try {
await origProvisionAll.call(certManager);
} catch (e) {
errorThrown = true;
errorMessage = e.message;
throw e;
}
};
return certManager;
};
try {
await testProxy4.start();
} catch (e) {
// Expected to fail
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toInclude('Custom provision failed for testing');
await testProxy4.stop();
});
tap.test('Should return http01 for unknown domains', async () => {
let returnedHttp01 = false;
let acmeAttempted = false;
const testProxy5 = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
if (domain === 'known.example.com') {
return {
id: `test-cert-${domain}`,
domainName: domain,
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: testKey,
publicKey: testCert,
csr: ''
};
}
returnedHttp01 = true;
return 'http01';
},
acme: {
email: 'test@example.com',
useProduction: false,
port: 9081
},
routes: [
{
name: 'unknown-domain-route',
match: {
ports: [9446],
domains: ['unknown.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock to track ACME attempts
const origCreateCertManager = (testProxy5 as any).createCertificateManager;
(testProxy5 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy5, args);
// Mock SmartAcme to track attempts
(certManager as any).smartAcme = {
getCertificateForDomain: async () => {
acmeAttempted = true;
throw new Error('Mocked ACME failure');
}
};
return certManager;
};
await testProxy5.start();
// Should have returned http01 for unknown domain
expect(returnedHttp01).toBeTrue();
// ACME should have been attempted
expect(acmeAttempted).toBeTrue();
await testProxy5.stop();
});
tap.test('cleanup', async () => {
// Clean up any test proxies
if (testProxy) {
await testProxy.stop();
}
});
export default tap.start();

View File

@@ -7,7 +7,7 @@ const testProxy = new SmartProxy({
match: { ports: 9443, domains: 'test.local' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
targets: [{ host: 'localhost', port: 8080 }],
tls: {
mode: 'terminate',
certificate: 'auto',
@@ -67,7 +67,7 @@ tap.test('should handle static certificates', async () => {
match: { ports: 9444, domains: 'static.example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
targets: [{ host: 'localhost', port: 8080 }],
tls: {
mode: 'terminate',
certificate: {
@@ -96,7 +96,7 @@ tap.test('should handle ACME challenge routes', async () => {
match: { ports: 9445, domains: 'acme.local' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
targets: [{ host: 'localhost', port: 8080 }],
tls: {
mode: 'terminate',
certificate: 'auto',
@@ -112,7 +112,7 @@ tap.test('should handle ACME challenge routes', async () => {
match: { ports: 9081, domains: 'acme.local' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 }
targets: [{ host: 'localhost', port: 8080 }]
}
}],
acme: {
@@ -167,7 +167,7 @@ tap.test('should renew certificates', async () => {
match: { ports: 9446, domains: 'renew.local' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
targets: [{ host: 'localhost', port: 8080 }],
tls: {
mode: 'terminate',
certificate: 'auto',

View File

@@ -8,7 +8,7 @@ tap.test('should create SmartProxy with certificate routes', async () => {
match: { ports: 8443, domains: 'test.example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
targets: [{ host: 'localhost', port: 8080 }],
tls: {
mode: 'terminate',
certificate: 'auto',

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',
targets: [{ 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

@@ -18,10 +18,10 @@ tap.test('should handle clients that connect and immediately disconnect without
match: { ports: 8560 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9999 // Non-existent port
}
}]
}
}]
});
@@ -173,10 +173,10 @@ tap.test('should handle clients that error during connection', async () => {
match: { ports: 8561 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9999
}
}]
}
}]
});

View File

@@ -20,10 +20,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
match: { ports: 8570 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9999 // Non-existent port
}
}]
}
},
{
@@ -31,10 +31,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
match: { ports: 8571 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9999 // Non-existent port
},
}],
tls: {
mode: 'passthrough'
}
@@ -215,10 +215,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
action: {
type: 'forward',
forwardingEngine: 'nftables',
target: {
targets: [{
host: 'localhost',
port: 9999
}
}]
}
}]
});

View File

@@ -65,10 +65,10 @@ tap.test('should forward TCP connections correctly', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 7001,
},
}],
},
},
],
@@ -118,10 +118,10 @@ tap.test('should handle TLS passthrough correctly', async () => {
tls: {
mode: 'passthrough',
},
target: {
targets: [{
host: '127.0.0.1',
port: 7002,
},
}],
},
},
],
@@ -179,10 +179,10 @@ tap.test('should handle SNI-based forwarding', async () => {
tls: {
mode: 'passthrough',
},
target: {
targets: [{
host: '127.0.0.1',
port: 7002,
},
}],
},
},
{
@@ -197,10 +197,10 @@ tap.test('should handle SNI-based forwarding', async () => {
tls: {
mode: 'passthrough',
},
target: {
targets: [{
host: '127.0.0.1',
port: 7002,
},
}],
},
},
],

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',
targets: [{
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',
targets: [{
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();

131
test/test.detection.ts Normal file
View File

@@ -0,0 +1,131 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartproxy from '../ts/index.js';
tap.test('Protocol Detection - TLS Detection', async () => {
// Test TLS handshake detection
const tlsHandshake = Buffer.from([
0x16, // Handshake record type
0x03, 0x01, // TLS 1.0
0x00, 0x05, // Length: 5 bytes
0x01, // ClientHello
0x00, 0x00, 0x01, 0x00 // Handshake length and data
]);
const detector = new smartproxy.detection.TlsDetector();
expect(detector.canHandle(tlsHandshake)).toEqual(true);
const result = detector.detect(tlsHandshake);
expect(result).toBeDefined();
expect(result?.protocol).toEqual('tls');
expect(result?.connectionInfo.tlsVersion).toEqual('TLSv1.0');
});
tap.test('Protocol Detection - HTTP Detection', async () => {
// Test HTTP request detection
const httpRequest = Buffer.from(
'GET /test HTTP/1.1\r\n' +
'Host: example.com\r\n' +
'User-Agent: TestClient/1.0\r\n' +
'\r\n'
);
const detector = new smartproxy.detection.HttpDetector();
expect(detector.canHandle(httpRequest)).toEqual(true);
const result = detector.detect(httpRequest);
expect(result).toBeDefined();
expect(result?.protocol).toEqual('http');
expect(result?.connectionInfo.method).toEqual('GET');
expect(result?.connectionInfo.path).toEqual('/test');
expect(result?.connectionInfo.domain).toEqual('example.com');
});
tap.test('Protocol Detection - Main Detector TLS', async () => {
const tlsHandshake = Buffer.from([
0x16, // Handshake record type
0x03, 0x03, // TLS 1.2
0x00, 0x05, // Length: 5 bytes
0x01, // ClientHello
0x00, 0x00, 0x01, 0x00 // Handshake length and data
]);
const result = await smartproxy.detection.ProtocolDetector.detect(tlsHandshake);
expect(result.protocol).toEqual('tls');
expect(result.connectionInfo.tlsVersion).toEqual('TLSv1.2');
});
tap.test('Protocol Detection - Main Detector HTTP', async () => {
const httpRequest = Buffer.from(
'POST /api/test HTTP/1.1\r\n' +
'Host: api.example.com\r\n' +
'Content-Type: application/json\r\n' +
'Content-Length: 2\r\n' +
'\r\n' +
'{}'
);
const result = await smartproxy.detection.ProtocolDetector.detect(httpRequest);
expect(result.protocol).toEqual('http');
expect(result.connectionInfo.method).toEqual('POST');
expect(result.connectionInfo.path).toEqual('/api/test');
expect(result.connectionInfo.domain).toEqual('api.example.com');
});
tap.test('Protocol Detection - Unknown Protocol', async () => {
const unknownData = Buffer.from('UNKNOWN PROTOCOL DATA\r\n');
const result = await smartproxy.detection.ProtocolDetector.detect(unknownData);
expect(result.protocol).toEqual('unknown');
expect(result.isComplete).toEqual(true);
});
tap.test('Protocol Detection - Fragmented HTTP', async () => {
const connectionId = 'test-connection-1';
// First fragment
const fragment1 = Buffer.from('GET /test HT');
let result = await smartproxy.detection.ProtocolDetector.detectWithConnectionTracking(
fragment1,
connectionId
);
expect(result.protocol).toEqual('http');
expect(result.isComplete).toEqual(false);
// Second fragment
const fragment2 = Buffer.from('TP/1.1\r\nHost: example.com\r\n\r\n');
result = await smartproxy.detection.ProtocolDetector.detectWithConnectionTracking(
fragment2,
connectionId
);
expect(result.protocol).toEqual('http');
expect(result.isComplete).toEqual(true);
expect(result.connectionInfo.method).toEqual('GET');
expect(result.connectionInfo.path).toEqual('/test');
expect(result.connectionInfo.domain).toEqual('example.com');
});
tap.test('Protocol Detection - HTTP Methods', async () => {
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
for (const method of methods) {
const request = Buffer.from(
`${method} /test HTTP/1.1\r\n` +
'Host: example.com\r\n' +
'\r\n'
);
const detector = new smartproxy.detection.HttpDetector();
const result = detector.detect(request);
expect(result?.connectionInfo.method).toEqual(method);
}
});
tap.test('Protocol Detection - Invalid Data', async () => {
// Binary data that's not a valid protocol
const binaryData = Buffer.from([0xFF, 0xFE, 0xFD, 0xFC, 0xFB]);
const result = await smartproxy.detection.ProtocolDetector.detect(binaryData);
expect(result.protocol).toEqual('unknown');
});
tap.start();

View File

@@ -9,7 +9,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
match: { ports: [18443], domains: ['test.local'] },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 },
targets: [{ host: 'localhost', port: 3000 }],
tls: {
mode: 'terminate',
certificate: 'auto',
@@ -63,7 +63,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
match: { ports: [18444], domains: ['test2.local'] },
action: {
type: 'forward',
target: { host: 'localhost', port: 3001 },
targets: [{ host: 'localhost', port: 3001 }],
tls: {
mode: 'terminate',
certificate: 'auto',

View File

@@ -37,7 +37,7 @@ tap.test('regular forward route should work correctly', async () => {
match: { ports: 7890 },
action: {
type: 'forward',
target: { host: 'localhost', port: 6789 }
targets: [{ host: 'localhost', port: 6789 }]
}
}]
});
@@ -106,7 +106,7 @@ tap.skip.test('NFTables forward route should not terminate connections (requires
action: {
type: 'forward',
forwardingEngine: 'nftables',
target: { host: 'localhost', port: 6789 }
targets: [{ host: 'localhost', port: 6789 }]
}
}]
});

View File

@@ -39,10 +39,10 @@ tap.test('forward connections should not be immediately closed', async (t) => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 9090,
},
}],
},
},
],

View File

@@ -1,9 +1,6 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import type { IForwardConfig, TForwardingType } from '../ts/forwarding/config/forwarding-types.js';
// First, import the components directly to avoid issues with compiled modules
import { ForwardingHandlerFactory } from '../ts/forwarding/factory/forwarding-factory.js';
// Import route-based helpers
import {
createHttpRoute,
@@ -39,7 +36,7 @@ tap.test('Route Helpers - Create HTTP routes', async () => {
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('example.com');
expect(route.action.target).toEqual({ host: 'localhost', port: 3000 });
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
});
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {

View File

@@ -1,53 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
// First, import the components directly to avoid issues with compiled modules
import { ForwardingHandlerFactory } from '../ts/forwarding/factory/forwarding-factory.js';
// Import route-based helpers from the correct location
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../ts/proxies/smart-proxy/utils/route-patterns.js';
// Create helper functions for building forwarding configs
const helpers = {
httpOnly: () => ({ type: 'http-only' as const }),
tlsTerminateToHttp: () => ({ type: 'https-terminate-to-http' as const }),
tlsTerminateToHttps: () => ({ type: 'https-terminate-to-https' as const }),
httpsPassthrough: () => ({ type: 'https-passthrough' as const })
};
tap.test('ForwardingHandlerFactory - apply defaults based on type', async () => {
// HTTP-only defaults
const httpConfig = {
type: 'http-only' as const,
target: { host: 'localhost', port: 3000 }
};
const httpWithDefaults = ForwardingHandlerFactory['applyDefaults'](httpConfig);
expect(httpWithDefaults.port).toEqual(80);
expect(httpWithDefaults.socket).toEqual('/tmp/forwarding-http-only-80.sock');
// HTTPS passthrough defaults
const httpsPassthroughConfig = {
type: 'https-passthrough' as const,
target: { host: 'localhost', port: 443 }
};
const httpsPassthroughWithDefaults = ForwardingHandlerFactory['applyDefaults'](httpsPassthroughConfig);
expect(httpsPassthroughWithDefaults.port).toEqual(443);
expect(httpsPassthroughWithDefaults.socket).toEqual('/tmp/forwarding-https-passthrough-443.sock');
});
tap.test('ForwardingHandlerFactory - factory function for handlers', async () => {
// @todo Implement unit tests for ForwardingHandlerFactory
// These tests would need proper mocking of the handlers
});
export default tap.start();

View File

@@ -20,7 +20,7 @@ tap.test('should forward non-TLS connections on HttpProxy ports', async (tapTest
match: { ports: testPort },
action: {
type: 'forward',
target: { host: 'localhost', port: 8181 }
targets: [{ host: 'localhost', port: 8181 }]
}
}]
};
@@ -81,7 +81,7 @@ tap.test('should use direct connection for non-HttpProxy ports', async (tapTest)
match: { ports: 8080 }, // Not in useHttpProxy
action: {
type: 'forward',
target: { host: 'localhost', port: 8181 }
targets: [{ host: 'localhost', port: 8181 }]
}
}]
};
@@ -142,7 +142,7 @@ tap.test('should handle ACME HTTP-01 challenges on port 80 with HttpProxy', asyn
},
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 }
targets: [{ host: 'localhost', port: 8080 }]
}
}]
};

View File

@@ -14,7 +14,7 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
match: { ports: 8080 },
action: {
type: 'forward',
target: { host: 'localhost', port: 8181 }
targets: [{ host: 'localhost', port: 8181 }]
}
}]
};
@@ -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[]) => {
@@ -139,7 +140,7 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
match: { ports: 443 },
action: {
type: 'forward',
target: { host: 'localhost', port: 8443 },
targets: [{ host: 'localhost', port: 8443 }],
tls: { mode: 'terminate' }
}
}]
@@ -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

@@ -17,7 +17,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
match: { ports: 8081 },
action: {
type: 'forward',
target: { host: 'localhost', port: 8181 }
targets: [{ host: 'localhost', port: 8181 }]
}
}]
});
@@ -120,7 +120,7 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
},
action: {
type: 'forward',
target: { host: 'localhost', port: targetPort }
targets: [{ host: 'localhost', port: targetPort }]
}
}]
});

View File

@@ -42,7 +42,7 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
},
action: {
type: 'forward',
target: { host: 'localhost', port: targetPort }
targets: [{ host: 'localhost', port: targetPort }]
}
}]
});
@@ -131,7 +131,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
},
action: {
type: 'forward',
target: { host: 'localhost', port: targetPort }
targets: [{ host: 'localhost', port: targetPort }]
}
}]
});

View File

@@ -67,7 +67,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
},
action: {
type: 'forward',
target: { host: 'localhost', port: targetPort },
targets: [{ host: 'localhost', port: targetPort }],
tls: {
mode: 'terminate',
certificate: 'auto' // Use ACME for certificate
@@ -83,7 +83,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
},
action: {
type: 'forward',
target: { host: 'localhost', port: targetPort }
targets: [{ host: 'localhost', port: targetPort }]
}
}
],
@@ -191,7 +191,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
},
action: {
type: 'forward' as const,
target: { host: 'localhost', port: targetPort }
targets: [{ host: 'localhost', port: targetPort }]
}
}
];

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

@@ -95,10 +95,10 @@ tap.test('should support static host/port routes', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: serverPort
}
}]
}
}
];
@@ -135,13 +135,13 @@ tap.test('should support function-based host', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: (context: IRouteContext) => {
// Return localhost always in this test
return 'localhost';
},
port: serverPort
}
}]
}
}
];
@@ -178,13 +178,13 @@ tap.test('should support function-based port', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: (context: IRouteContext) => {
// Return test server port
return serverPort;
}
}
}]
}
}
];
@@ -221,14 +221,14 @@ tap.test('should support function-based host AND port', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: (context: IRouteContext) => {
return 'localhost';
},
port: (context: IRouteContext) => {
return serverPort;
}
}
}]
}
}
];
@@ -265,7 +265,7 @@ tap.test('should support context-based routing with path', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: (context: IRouteContext) => {
// Use path to determine host
if (context.path?.startsWith('/api')) {
@@ -275,7 +275,7 @@ tap.test('should support context-based routing with path', async () => {
}
},
port: serverPort
}
}]
}
}
];

View File

@@ -232,10 +232,10 @@ tap.test('should start the proxy server', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3100
},
}],
tls: {
mode: 'terminate'
},

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',
targets: [{ 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',
targets: [{ 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',
targets: [{ 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

@@ -39,10 +39,10 @@ tap.test('setup test environment', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9876
}
}]
// No TLS configuration - just plain TCP forwarding
}
}],

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',
targets: [{ host: 'localhost', port: 9995 }]
}
},
{
name: 'test-route-2',
match: { ports: 8701 },
action: {
type: 'forward',
targets: [{ 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',
targets: [{
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

@@ -34,10 +34,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
action: {
type: 'forward',
forwardingEngine: 'nftables',
target: {
targets: [{
host: '127.0.0.1',
port: 8001,
},
}],
},
},
// Also add regular forwarding route for comparison
@@ -49,10 +49,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 8001,
},
}],
},
},
],

View File

@@ -42,10 +42,10 @@ const sampleRoute: IRouteConfig = {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 8000
},
}],
forwardingEngine: 'nftables',
nftables: {
protocol: 'tcp',
@@ -115,10 +115,10 @@ tap.skip.test('NFTablesManager route updating test', async () => {
...sampleRoute,
action: {
...sampleRoute.action,
target: {
targets: [{
host: 'localhost',
port: 9000 // Different port
},
}],
nftables: {
...sampleRoute.action.nftables,
protocol: 'all' // Different protocol
@@ -147,10 +147,10 @@ tap.skip.test('NFTablesManager route deprovisioning test', async () => {
...sampleRoute,
action: {
...sampleRoute.action,
target: {
targets: [{
host: 'localhost',
port: 9000 // Different port from original test
},
}],
nftables: {
...sampleRoute.action.nftables,
protocol: 'all' // Different protocol from original test

View File

@@ -91,7 +91,7 @@ testFn('SmartProxy getNfTablesStatus functionality', async () => {
match: { ports: 3004 },
action: {
type: 'forward',
target: { host: 'localhost', port: 3005 }
targets: [{ host: 'localhost', port: 3005 }]
}
}
]

View File

@@ -29,7 +29,7 @@ tap.test('port forwarding should not immediately close connections', async (tool
match: { ports: 9999 },
action: {
type: 'forward',
target: { host: 'localhost', port: 8888 }
targets: [{ host: 'localhost', port: 8888 }]
}
}]
});
@@ -63,7 +63,7 @@ tap.test('TLS passthrough should work correctly', async () => {
action: {
type: 'forward',
tls: { mode: 'passthrough' },
target: { host: 'localhost', port: 443 }
targets: [{ host: 'localhost', port: 443 }]
}
}]
});

View File

@@ -214,12 +214,12 @@ tap.test('should handle errors in port mapping functions', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: () => {
throw new Error('Test error in port mapping function');
}
}
}]
},
name: 'Error Route'
};

View File

@@ -21,7 +21,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
},
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 3000 }
targets: [{ host: 'localhost', port: 3000 }]
}
},
{
@@ -31,7 +31,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
},
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 3001 },
targets: [{ host: 'localhost', port: 3001 }],
tls: {
mode: 'terminate' as const,
certificate: 'auto' as const
@@ -153,7 +153,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
},
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 3000 }
targets: [{ host: 'localhost', port: 3000 }]
}
},
{
@@ -163,7 +163,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
},
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 3001 },
targets: [{ host: 'localhost', port: 3001 }],
tls: {
mode: 'terminate' as const,
certificate: 'auto' as const

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',
targets: [{
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',
targets: [{
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();

View File

@@ -32,10 +32,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
match: { ports: 8591 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9998 // Backend that closes immediately
}
}]
}
}]
});
@@ -50,10 +50,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
match: { ports: 8590 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 8591 // Forward to proxy2
}
}]
}
}]
});

View File

@@ -19,10 +19,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
match: { ports: 8581 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9999 // Non-existent backend
}
}]
}
}]
});
@@ -37,10 +37,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
match: { ports: 8580 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 8581 // Forward to proxy2
}
}]
}
}]
});
@@ -270,10 +270,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
match: { ports: 8583 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9999 // Non-existent backend
}
}]
}
}]
});
@@ -289,10 +289,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
match: { ports: 8582 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 8583 // Forward to proxy2
}
}]
}
}]
});

View File

@@ -19,10 +19,10 @@ tap.test('should handle rapid connection retries without leaking connections', a
match: { ports: 8550 },
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 9999 // Non-existent port to force connection failures
}
}]
}
}]
});

View File

@@ -17,7 +17,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
},
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 },
targets: [{ host: 'localhost', port: 3000 }],
tls: {
mode: 'terminate',
certificate: 'auto',
@@ -95,7 +95,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
},
action: {
type: 'forward',
target: { host: 'localhost', port: 3001 },
targets: [{ host: 'localhost', port: 3001 }],
tls: {
mode: 'terminate',
certificate: 'auto',

View File

@@ -56,8 +56,8 @@ tap.test('Routes: Should create basic HTTP route', async () => {
expect(httpRoute.match.ports).toEqual(80);
expect(httpRoute.match.domains).toEqual('example.com');
expect(httpRoute.action.type).toEqual('forward');
expect(httpRoute.action.target?.host).toEqual('localhost');
expect(httpRoute.action.target?.port).toEqual(3000);
expect(httpRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(httpRoute.action.targets?.[0]?.port).toEqual(3000);
expect(httpRoute.name).toEqual('Basic HTTP Route');
});
@@ -74,8 +74,8 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
expect(httpsRoute.action.type).toEqual('forward');
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
expect(httpsRoute.action.tls?.certificate).toEqual('auto');
expect(httpsRoute.action.target?.host).toEqual('localhost');
expect(httpsRoute.action.target?.port).toEqual(8080);
expect(httpsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(httpsRoute.action.targets?.[0]?.port).toEqual(8080);
expect(httpsRoute.name).toEqual('HTTPS Route');
});
@@ -131,10 +131,10 @@ tap.test('Routes: Should create load balancer route', async () => {
// Validate the route configuration
expect(lbRoute.match.domains).toEqual('app.example.com');
expect(lbRoute.action.type).toEqual('forward');
expect(Array.isArray(lbRoute.action.target?.host)).toBeTrue();
expect((lbRoute.action.target?.host as string[]).length).toEqual(3);
expect((lbRoute.action.target?.host as string[])[0]).toEqual('10.0.0.1');
expect(lbRoute.action.target?.port).toEqual(8080);
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
expect((lbRoute.action.targets?.[0]?.host as string[]).length).toEqual(3);
expect((lbRoute.action.targets?.[0]?.host as string[])[0]).toEqual('10.0.0.1');
expect(lbRoute.action.targets?.[0]?.port).toEqual(8080);
expect(lbRoute.action.tls?.mode).toEqual('terminate');
});
@@ -152,8 +152,8 @@ tap.test('Routes: Should create API route with CORS', async () => {
expect(apiRoute.match.path).toEqual('/v1/*');
expect(apiRoute.action.type).toEqual('forward');
expect(apiRoute.action.tls?.mode).toEqual('terminate');
expect(apiRoute.action.target?.host).toEqual('localhost');
expect(apiRoute.action.target?.port).toEqual(3000);
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
// Check CORS headers
expect(apiRoute.headers).toBeDefined();
@@ -177,8 +177,8 @@ tap.test('Routes: Should create WebSocket route', async () => {
expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.tls?.mode).toEqual('terminate');
expect(wsRoute.action.target?.host).toEqual('localhost');
expect(wsRoute.action.target?.port).toEqual(5000);
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
// Check WebSocket configuration
expect(wsRoute.action.websocket).toBeDefined();
@@ -209,10 +209,10 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
})
],
defaults: {
target: {
targets: [{
host: 'localhost',
port: 8080
},
}],
security: {
ipAllowList: ['127.0.0.1', '192.168.0.*'],
maxConnections: 100
@@ -294,13 +294,13 @@ tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
expect(bestMatch).not.toBeUndefined();
if (bestMatch) {
expect(bestMatch.action.target.port).toEqual(3001); // Should match the exact domain route
expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route
}
// Test with a different subdomain - should only match the wildcard route
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
expect(otherMatches.length).toEqual(1);
expect(otherMatches[0].action.target.port).toEqual(3000); // Should match the wildcard domain route
expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route
});
tap.test('Edge Case - Disabled Routes', async () => {
@@ -316,7 +316,7 @@ tap.test('Edge Case - Disabled Routes', async () => {
// Should only find the enabled route
expect(matches.length).toEqual(1);
expect(matches[0].action.target.port).toEqual(3000);
expect(matches[0].action.targets[0].port).toEqual(3000);
});
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
@@ -333,10 +333,10 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'internal-api',
port: 8080
},
}],
tls: {
mode: 'terminate',
certificate: 'auto'
@@ -376,10 +376,10 @@ tap.test('Edge Case - Port Range Matching', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'backend',
port: 3000
}
}]
},
name: 'Port Range Route'
};
@@ -404,10 +404,10 @@ tap.test('Edge Case - Port Range Matching', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'backend',
port: 3000
}
}]
},
name: 'Multi Range Route'
};
@@ -452,7 +452,7 @@ tap.test('Wildcard Domain Handling', async () => {
expect(bestSpecificMatch).not.toBeUndefined();
if (bestSpecificMatch) {
// Find which route was matched
const matchedPort = bestSpecificMatch.action.target.port;
const matchedPort = bestSpecificMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the specific subdomain route (with highest priority)
@@ -465,7 +465,7 @@ tap.test('Wildcard Domain Handling', async () => {
expect(bestWildcardMatch).not.toBeUndefined();
if (bestWildcardMatch) {
// Find which route was matched
const matchedPort = bestWildcardMatch.action.target.port;
const matchedPort = bestWildcardMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the wildcard subdomain route (with medium priority)
@@ -513,7 +513,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(webServerMatch).not.toBeUndefined();
if (webServerMatch) {
expect(webServerMatch.action.type).toEqual('forward');
expect(webServerMatch.action.target.host).toEqual('web-server');
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
}
// Web server (HTTP redirect via socket handler)
@@ -532,7 +532,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(apiMatch).not.toBeUndefined();
if (apiMatch) {
expect(apiMatch.action.type).toEqual('forward');
expect(apiMatch.action.target.host).toEqual('api-server');
expect(apiMatch.action.targets[0].host).toEqual('api-server');
}
// WebSocket server
@@ -544,7 +544,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(wsMatch).not.toBeUndefined();
if (wsMatch) {
expect(wsMatch.action.type).toEqual('forward');
expect(wsMatch.action.target.host).toEqual('websocket-server');
expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
expect(wsMatch.action.websocket?.enabled).toBeTrue();
}

View File

@@ -28,10 +28,10 @@ tap.test('route security should block connections from unauthorized IPs', async
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 9990
}
}]
},
security: {
// Only allow a non-existent IP
@@ -142,10 +142,10 @@ tap.test('route security with block list should work', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 9992
}
}]
},
security: { // Security at route level, not action level
ipBlockList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
@@ -234,10 +234,10 @@ tap.test('route without security should allow all connections', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 9994
}
}]
}
// No security defined
}];

View File

@@ -10,10 +10,10 @@ tap.test('route security should be correctly configured', async () => {
},
action: {
type: 'forward' as const,
target: {
targets: [{
host: '127.0.0.1',
port: 8991
},
}],
security: {
ipAllowList: ['192.168.1.1'],
ipBlockList: ['10.0.0.1']

View File

@@ -26,10 +26,10 @@ tap.test('route-specific security should be enforced', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 8877
}
}]
},
security: {
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
@@ -108,10 +108,10 @@ tap.test('route-specific IP block list should be enforced', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 8879
}
}]
},
security: {
ipAllowList: ['0.0.0.0/0', '::/0'], // Allow all IPs
@@ -215,10 +215,10 @@ tap.test('routes without security should allow all connections', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: 8881
}
}]
// No security section - should allow all
}
}];

View File

@@ -13,10 +13,10 @@ const createRoute = (id: number, domain: string, port: number = 8443) => ({
},
action: {
type: 'forward' as const,
target: {
targets: [{
host: 'localhost',
port: 3000 + id
},
}],
tls: {
mode: 'terminate' as const,
certificate: 'auto' as const,
@@ -209,10 +209,10 @@ tap.test('should handle route updates when cert manager is not initialized', asy
},
action: {
type: 'forward' as const,
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
}]
});

View File

@@ -47,7 +47,7 @@ import {
addRateLimiting,
addBasicAuth,
addJwtAuth
} from '../ts/proxies/smart-proxy/utils/route-patterns.js';
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type {
IRouteConfig,
@@ -134,10 +134,10 @@ tap.test('Route Validation - validateRouteAction', async () => {
// Valid forward action
const validForwardAction: IRouteAction = {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
};
const validForwardResult = validateRouteAction(validForwardAction);
expect(validForwardResult.valid).toBeTrue();
@@ -154,14 +154,14 @@ tap.test('Route Validation - validateRouteAction', async () => {
expect(validSocketResult.valid).toBeTrue();
expect(validSocketResult.errors.length).toEqual(0);
// Invalid action (missing target)
// Invalid action (missing targets)
const invalidAction: IRouteAction = {
type: 'forward'
};
const invalidResult = validateRouteAction(invalidAction);
expect(invalidResult.valid).toBeFalse();
expect(invalidResult.errors.length).toBeGreaterThan(0);
expect(invalidResult.errors[0]).toInclude('Target is required');
expect(invalidResult.errors[0]).toInclude('Targets array is required');
// Invalid action (missing socket handler)
const invalidSocketAction: IRouteAction = {
@@ -180,7 +180,7 @@ tap.test('Route Validation - validateRouteConfig', async () => {
expect(validResult.valid).toBeTrue();
expect(validResult.errors.length).toEqual(0);
// Invalid route config (missing target)
// Invalid route config (missing targets)
const invalidRoute: IRouteConfig = {
match: {
domains: 'example.com',
@@ -309,16 +309,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
const actionOverride: Partial<IRouteConfig> = {
action: {
type: 'forward',
target: {
targets: [{
host: 'new-host.local',
port: 5000
}
}]
}
};
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
expect(actionMergedRoute.action.target.host).toEqual('new-host.local');
expect(actionMergedRoute.action.target.port).toEqual(5000);
expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
// Test replacing action with socket handler
const typeChangeOverride: Partial<IRouteConfig> = {
@@ -336,7 +336,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
expect(typeChangedRoute.action.type).toEqual('socket-handler');
expect(typeChangedRoute.action.socketHandler).toBeDefined();
expect(typeChangedRoute.action.target).toBeUndefined();
expect(typeChangedRoute.action.targets).toBeUndefined();
});
tap.test('Route Matching - routeMatchesDomain', async () => {
@@ -379,10 +379,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -393,10 +393,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -427,10 +427,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -443,10 +443,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -458,10 +458,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -494,10 +494,10 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
}
}]
}
};
@@ -641,7 +641,7 @@ tap.test('Route Utilities - cloneRoute', async () => {
expect(clonedRoute.name).toEqual(originalRoute.name);
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
expect(clonedRoute.action.target.port).toEqual(originalRoute.action.target.port);
expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
// Modify the clone and check that the original is unchanged
clonedRoute.name = 'Modified Clone';
@@ -656,8 +656,8 @@ tap.test('Route Helpers - createHttpRoute', async () => {
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(80);
expect(route.action.type).toEqual('forward');
expect(route.action.target.host).toEqual('localhost');
expect(route.action.target.port).toEqual(3000);
expect(route.action.targets?.[0]?.host).toEqual('localhost');
expect(route.action.targets?.[0]?.port).toEqual(3000);
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
@@ -790,11 +790,11 @@ tap.test('Route Helpers - createLoadBalancerRoute', async () => {
expect(route.match.domains).toEqual('loadbalancer.example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(Array.isArray(route.action.target.host)).toBeTrue();
if (Array.isArray(route.action.target.host)) {
expect(route.action.target.host.length).toEqual(3);
expect(route.action.targets).toBeDefined();
if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
expect((route.action.targets[0].host as string[]).length).toEqual(3);
}
expect(route.action.target.port).toEqual(8080);
expect(route.action.targets?.[0]?.port).toEqual(8080);
expect(route.action.tls.mode).toEqual('terminate');
const validationResult = validateRouteConfig(route);
@@ -819,7 +819,7 @@ tap.test('Route Patterns - createApiGatewayRoute', async () => {
expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
expect(apiGatewayRoute.match.path).toInclude('/v1');
expect(apiGatewayRoute.action.type).toEqual('forward');
expect(apiGatewayRoute.action.target.port).toEqual(3000);
expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (apiGatewayRoute.action.tls) {
@@ -854,7 +854,7 @@ tap.test('Route Patterns - createWebSocketPattern', async () => {
expect(wsRoute.match.domains).toEqual('ws.example.com');
expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.target.port).toEqual(3000);
expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (wsRoute.action.tls) {
@@ -891,8 +891,8 @@ tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
expect(lbRoute.action.type).toEqual('forward');
// Check target hosts
if (Array.isArray(lbRoute.action.target.host)) {
expect(lbRoute.action.target.host.length).toEqual(3);
if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
}
// Check TLS configuration

View File

@@ -37,10 +37,10 @@ function createRouteConfig(
},
action: {
type: 'forward',
target: {
targets: [{
host: destinationIp,
port: destinationPort
}
}]
}
};
}
@@ -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,157 @@
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++) {
// Validate BEFORE tracking the connection (checking if we can add a new connection)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Now track the connection
securityManager.trackConnectionByIP(testIP, `conn${i}`);
}
// Verify we're at the limit
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5);
// Next connection should be rejected (we're already at 5)
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
// Note: validateIP() already tracks timestamps internally for rate limiting
for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
}
// Next connection should exceed rate limit
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit');
});
tap.test('Route-level connection limits', async () => {
const route: IRouteConfig = {
name: 'test-route',
match: { ports: 443 },
action: { type: 'forward', targets: [{ 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',
isTls: true
};
// 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

@@ -15,10 +15,10 @@ tap.test('should create a SmartCertManager instance', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: 3000
},
}],
tls: {
mode: 'terminate',
certificate: 'auto',

View File

@@ -73,10 +73,10 @@ tap.test('setup port proxy test environment', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -112,10 +112,10 @@ tap.test('should forward TCP connections to custom host', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -157,10 +157,10 @@ tap.test('should forward connections to custom IP', async () => {
},
action: {
type: 'forward',
target: {
targets: [{
host: '127.0.0.1',
port: targetServerPort
}
}]
}
}
],
@@ -252,10 +252,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: PROXY_PORT + 5
}
}]
}
}
],
@@ -273,10 +273,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -311,10 +311,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: PROXY_PORT + 7
}
}]
}
}
],
@@ -334,10 +334,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
},
action: {
type: 'forward',
target: {
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}
}]
}
}
],
@@ -377,10 +377,10 @@ tap.test('should use round robin for multiple target hosts in domain config', as
},
action: {
type: 'forward' as const,
target: {
targets: [{
host: ['hostA', 'hostB'], // Array of hosts for round-robin
port: 80
}
}]
}
};
@@ -400,9 +400,9 @@ tap.test('should use round robin for multiple target hosts in domain config', as
// For route-based approach, the actual round-robin logic happens in connection handling
// Just make sure our config has the expected hosts
expect(Array.isArray(routeConfig.action.target.host)).toBeTrue();
expect(routeConfig.action.target.host).toContain('hostA');
expect(routeConfig.action.target.host).toContain('hostB');
expect(Array.isArray(routeConfig.action.targets![0].host)).toBeTrue();
expect(routeConfig.action.targets![0].host).toContain('hostA');
expect(routeConfig.action.targets![0].host).toContain('hostB');
});
// CLEANUP: Tear down all servers and proxies

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',
targets: [{ 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',
targets: [{ 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',
targets: [{ 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',
targets: [{
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',
targets: [{
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

@@ -0,0 +1,370 @@
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;
private rapidEventThreshold: number = 50; // Flush early if this many events in 1 second
private lastRapidCheck: number = Date.now();
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 for rapid events (many events in short time)
const totalEvents = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
// If we're getting flooded with events, flush more frequently
if (now - this.lastRapidCheck < 1000 && totalEvents >= this.rapidEventThreshold) {
this.flush(key);
this.lastRapidCheck = now;
} else if (aggregated.events.size >= this.maxBatchSize) {
// Check if we should flush due to size
this.flush(key);
} else if (!aggregated.flushTimer) {
// Schedule flush
aggregated.flushTimer = setTimeout(() => {
this.flush(key);
}, this.flushInterval);
if (aggregated.flushTimer.unref) {
aggregated.flushTimer.unref();
}
}
// Update rapid check time
if (now - this.lastRapidCheck >= 1000) {
this.lastRapidCheck = now;
}
}
/**
* 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 'connection-terminated':
this.flushConnectionTerminations(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(', ');
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
logger.log('warn', `[SUMMARY] Rejected ${totalCount} connections in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
uniqueIPs: aggregated.events.size,
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 flushConnectionTerminations(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
const byIP = new Map<string, number>();
let lastActiveCount = 0;
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'unknown';
const ip = event.data?.remoteIP || 'unknown';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
// Track by IP
if (ip !== 'unknown') {
byIP.set(ip, (byIP.get(ip) || 0) + event.count);
}
// Track the last active connection count
if (event.data?.activeConnections !== undefined) {
lastActiveCount = event.data.activeConnections;
}
}
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(', ');
// Show top IPs if there are many different ones
let ipInfo = '';
if (byIP.size > 3) {
const topIPs = Array.from(byIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([ip, count]) => `${ip} (${count})`)
.join(', ');
ipInfo = `, from ${byIP.size} IPs (top: ${topIPs})`;
} else if (byIP.size > 0) {
ipInfo = `, IPs: ${Array.from(byIP.keys()).join(', ')}`;
}
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
// Special handling for localhost connections (HttpProxy)
const localhostCount = byIP.get('::ffff:127.0.0.1') || 0;
if (localhostCount > 0 && byIP.size === 1) {
// All connections are from localhost (HttpProxy)
logger.log('info', `[SUMMARY] ${totalCount} HttpProxy connections terminated in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
activeConnections: lastActiveCount,
component: 'connection-dedup'
});
} else {
logger.log('info', `[SUMMARY] ${totalCount} connections terminated in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
activeConnections: lastActiveCount,
uniqueReasons: byReason.size,
...(ipInfo ? { ips: ipInfo } : {}),
component: 'connection-dedup'
});
}
}
private flushIPRejections(aggregated: IAggregatedEvent): void {
const byIP = new Map<string, { count: number; reasons: Set<string> }>();
const allReasons = new Map<string, number>();
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);
// Track overall reason counts
allReasons.set(event.data.reason, (allReasons.get(event.data.reason) || 0) + event.count);
}
}
// Create reason summary
const reasonSummary = Array.from(allReasons.entries())
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
// 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);
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
logger.log('warn', `[SUMMARY] Rejected ${totalRejections} connections from ${byIP.size} IPs in ${Math.round(duration/1000)}s (${reasonSummary})`, {
topOffenders,
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

@@ -1,161 +1,44 @@
import * as plugins from '../../plugins.js';
import { logger } from './logger.js';
import { ProxyProtocolParser as ProtocolParser, type IProxyInfo, type IProxyParseResult } from '../../protocols/proxy/index.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;
}
// Re-export types from protocols for backward compatibility
export type { IProxyInfo, IProxyParseResult } from '../../protocols/proxy/index.js';
/**
* Parser for PROXY protocol v1 (text format)
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
*
* This class now delegates to the protocol parser but adds
* smartproxy-specific features like socket reading and logging
*/
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';
static readonly PROXY_V1_SIGNATURE = ProtocolParser.PROXY_V1_SIGNATURE;
static readonly MAX_HEADER_LENGTH = ProtocolParser.MAX_HEADER_LENGTH;
static readonly HEADER_TERMINATOR = ProtocolParser.HEADER_TERMINATOR;
/**
* 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
};
// Delegate to protocol parser
return ProtocolParser.parse(data);
}
/**
* 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');
// Delegate to protocol parser
return ProtocolParser.generate(info);
}
/**
* 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;
return ProtocolParser.isValidIP(ip, protocol);
}
/**

View File

@@ -13,7 +13,8 @@ import {
trackConnection,
removeConnection,
cleanupExpiredRateLimits,
parseBasicAuthHeader
parseBasicAuthHeader,
normalizeIP
} from './security-utils.js';
/**
@@ -78,7 +79,15 @@ export class SharedSecurityManager {
* @returns Number of connections from this IP
*/
public getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.connections.size || 0;
// Check all normalized variants of the IP
const variants = normalizeIP(ip);
for (const variant of variants) {
const info = this.connectionsByIP.get(variant);
if (info) {
return info.connections.size;
}
}
return 0;
}
/**
@@ -88,7 +97,19 @@ export class SharedSecurityManager {
* @param connectionId - The connection ID to associate
*/
public trackConnectionByIP(ip: string, connectionId: string): void {
trackConnection(ip, connectionId, this.connectionsByIP);
// Check if any variant already exists
const variants = normalizeIP(ip);
let existingKey: string | null = null;
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
existingKey = variant;
break;
}
}
// Use existing key or the original IP
trackConnection(existingKey || ip, connectionId, this.connectionsByIP);
}
/**
@@ -98,7 +119,15 @@ export class SharedSecurityManager {
* @param connectionId - The connection ID to remove
*/
public removeConnectionByIP(ip: string, connectionId: string): void {
removeConnection(ip, connectionId, this.connectionsByIP);
// Check all variants to find where the connection is tracked
const variants = normalizeIP(ip);
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
removeConnection(variant, connectionId, this.connectionsByIP);
break;
}
}
}
/**
@@ -152,9 +181,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 +195,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 +342,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

@@ -1,12 +1,13 @@
/**
* WebSocket utility functions
*
* This module provides smartproxy-specific WebSocket utilities
* and re-exports protocol utilities from the protocols module
*/
/**
* Type for WebSocket RawData that can be different types in different environments
* This matches the ws library's type definition
*/
export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
// Import and re-export from protocols
import { getMessageSize as protocolGetMessageSize, toBuffer as protocolToBuffer } from '../../protocols/websocket/index.js';
export type { RawData } from '../../protocols/websocket/index.js';
/**
* Get the length of a WebSocket message regardless of its type
@@ -15,35 +16,9 @@ export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
* @param data - The data message from WebSocket (could be any RawData type)
* @returns The length of the data in bytes
*/
export function getMessageSize(data: RawData): number {
if (typeof data === 'string') {
// For string data, get the byte length
return Buffer.from(data, 'utf8').length;
} else if (data instanceof Buffer) {
// For Node.js Buffer
return data.length;
} else if (data instanceof ArrayBuffer) {
// For ArrayBuffer
return data.byteLength;
} else if (Array.isArray(data)) {
// For array of buffers, sum their lengths
return data.reduce((sum, chunk) => {
if (chunk instanceof Buffer) {
return sum + chunk.length;
} else if (chunk instanceof ArrayBuffer) {
return sum + chunk.byteLength;
}
return sum;
}, 0);
} else {
// For other types, try to determine the size or return 0
try {
return Buffer.from(data).length;
} catch (e) {
console.warn('Could not determine message size', e);
return 0;
}
}
export function getMessageSize(data: import('../../protocols/websocket/index.js').RawData): number {
// Delegate to protocol implementation
return protocolGetMessageSize(data);
}
/**
@@ -52,30 +27,7 @@ export function getMessageSize(data: RawData): number {
* @param data - The data message from WebSocket (could be any RawData type)
* @returns A Buffer containing the data
*/
export function toBuffer(data: RawData): Buffer {
if (typeof data === 'string') {
return Buffer.from(data, 'utf8');
} else if (data instanceof Buffer) {
return data;
} else if (data instanceof ArrayBuffer) {
return Buffer.from(data);
} else if (Array.isArray(data)) {
// For array of buffers, concatenate them
return Buffer.concat(data.map(chunk => {
if (chunk instanceof Buffer) {
return chunk;
} else if (chunk instanceof ArrayBuffer) {
return Buffer.from(chunk);
}
return Buffer.from(chunk);
}));
} else {
// For other types, try to convert to Buffer or return empty Buffer
try {
return Buffer.from(data);
} catch (e) {
console.warn('Could not convert message to Buffer', e);
return Buffer.alloc(0);
}
}
export function toBuffer(data: import('../../protocols/websocket/index.js').RawData): Buffer {
// Delegate to protocol implementation
return protocolToBuffer(data);
}

View File

@@ -0,0 +1,281 @@
/**
* HTTP protocol detector
*/
import type { IProtocolDetector } from '../models/interfaces.js';
import type { IDetectionResult, IDetectionOptions, IConnectionInfo, THttpMethod } from '../models/detection-types.js';
import { extractLine, isPrintableAscii, BufferAccumulator } from '../utils/buffer-utils.js';
import { parseHttpRequestLine, parseHttpHeaders, extractDomainFromHost, isHttpMethod } from '../utils/parser-utils.js';
/**
* HTTP detector implementation
*/
export class HttpDetector implements IProtocolDetector {
/**
* Minimum bytes needed to identify HTTP method
*/
private static readonly MIN_HTTP_METHOD_SIZE = 3; // GET
/**
* Maximum reasonable HTTP header size
*/
private static readonly MAX_HEADER_SIZE = 8192;
/**
* Fragment tracking for incomplete headers
*/
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
/**
* Detect HTTP protocol from buffer
*/
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
// Check if buffer is too small
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
return null;
}
// Quick check: first bytes should be printable ASCII
if (!isPrintableAscii(buffer, Math.min(20, buffer.length))) {
return null;
}
// Try to extract the first line
const firstLineResult = extractLine(buffer, 0);
if (!firstLineResult) {
// No complete line yet
return {
protocol: 'http',
connectionInfo: { protocol: 'http' },
isComplete: false,
bytesNeeded: buffer.length + 100 // Estimate
};
}
// Parse the request line
const requestLine = parseHttpRequestLine(firstLineResult.line);
if (!requestLine) {
// Not a valid HTTP request line
return null;
}
// Initialize connection info
const connectionInfo: IConnectionInfo = {
protocol: 'http',
method: requestLine.method,
path: requestLine.path,
httpVersion: requestLine.version
};
// Check if we want to extract headers
if (options?.extractFullHeaders !== false) {
// Look for the end of headers (double CRLF)
const headerEndSequence = Buffer.from('\r\n\r\n');
const headerEndIndex = buffer.indexOf(headerEndSequence);
if (headerEndIndex === -1) {
// Headers not complete yet
const maxSize = options?.maxBufferSize || HttpDetector.MAX_HEADER_SIZE;
if (buffer.length >= maxSize) {
// Headers too large, reject
return null;
}
return {
protocol: 'http',
connectionInfo,
isComplete: false,
bytesNeeded: buffer.length + 200 // Estimate
};
}
// Extract all header lines
const headerLines: string[] = [];
let currentOffset = firstLineResult.nextOffset;
while (currentOffset < headerEndIndex) {
const lineResult = extractLine(buffer, currentOffset);
if (!lineResult) {
break;
}
if (lineResult.line.length === 0) {
// Empty line marks end of headers
break;
}
headerLines.push(lineResult.line);
currentOffset = lineResult.nextOffset;
}
// Parse headers
const headers = parseHttpHeaders(headerLines);
connectionInfo.headers = headers;
// Extract domain from Host header
const hostHeader = headers['host'];
if (hostHeader) {
connectionInfo.domain = extractDomainFromHost(hostHeader);
}
// Calculate remaining buffer
const bodyStartIndex = headerEndIndex + 4; // After \r\n\r\n
const remainingBuffer = buffer.length > bodyStartIndex
? buffer.slice(bodyStartIndex)
: undefined;
return {
protocol: 'http',
connectionInfo,
remainingBuffer,
isComplete: true
};
} else {
// Just extract Host header for domain
let currentOffset = firstLineResult.nextOffset;
const maxLines = 50; // Reasonable limit
for (let i = 0; i < maxLines && currentOffset < buffer.length; i++) {
const lineResult = extractLine(buffer, currentOffset);
if (!lineResult) {
// Need more data
return {
protocol: 'http',
connectionInfo,
isComplete: false,
bytesNeeded: buffer.length + 50
};
}
if (lineResult.line.length === 0) {
// End of headers
break;
}
// Quick check for Host header
if (lineResult.line.toLowerCase().startsWith('host:')) {
const colonIndex = lineResult.line.indexOf(':');
const hostValue = lineResult.line.slice(colonIndex + 1).trim();
connectionInfo.domain = extractDomainFromHost(hostValue);
// If we only needed the domain, we can return early
return {
protocol: 'http',
connectionInfo,
isComplete: true
};
}
currentOffset = lineResult.nextOffset;
}
// If we reach here, no Host header found yet
return {
protocol: 'http',
connectionInfo,
isComplete: false,
bytesNeeded: buffer.length + 100
};
}
}
/**
* Check if buffer can be handled by this detector
*/
canHandle(buffer: Buffer): boolean {
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
return false;
}
// Check if first bytes could be an HTTP method
const firstWord = buffer.slice(0, Math.min(10, buffer.length)).toString('ascii').split(' ')[0];
return isHttpMethod(firstWord);
}
/**
* Get minimum bytes needed for detection
*/
getMinimumBytes(): number {
return HttpDetector.MIN_HTTP_METHOD_SIZE;
}
/**
* Quick check if buffer starts with HTTP method
*/
static quickCheck(buffer: Buffer): boolean {
if (buffer.length < 3) {
return false;
}
// Check common HTTP methods
const start = buffer.slice(0, 7).toString('ascii');
return start.startsWith('GET ') ||
start.startsWith('POST ') ||
start.startsWith('PUT ') ||
start.startsWith('DELETE ') ||
start.startsWith('HEAD ') ||
start.startsWith('OPTIONS') ||
start.startsWith('PATCH ') ||
start.startsWith('CONNECT') ||
start.startsWith('TRACE ');
}
/**
* Handle fragmented HTTP detection with connection tracking
*/
static detectWithFragments(
buffer: Buffer,
connectionId: string,
options?: IDetectionOptions
): IDetectionResult | null {
const detector = new HttpDetector();
// Try direct detection first
const directResult = detector.detect(buffer, options);
if (directResult && directResult.isComplete) {
// Clean up any tracked fragments for this connection
this.fragmentedBuffers.delete(connectionId);
return directResult;
}
// Handle fragmentation
let accumulator = this.fragmentedBuffers.get(connectionId);
if (!accumulator) {
accumulator = new BufferAccumulator();
this.fragmentedBuffers.set(connectionId, accumulator);
}
accumulator.append(buffer);
const fullBuffer = accumulator.getBuffer();
// Check size limit
const maxSize = options?.maxBufferSize || this.MAX_HEADER_SIZE;
if (fullBuffer.length > maxSize) {
// Too large, clean up and reject
this.fragmentedBuffers.delete(connectionId);
return null;
}
// Try detection on accumulated buffer
const result = detector.detect(fullBuffer, options);
if (result && result.isComplete) {
// Success - clean up
this.fragmentedBuffers.delete(connectionId);
return result;
}
return result;
}
/**
* Clean up old fragment buffers
*/
static cleanupFragments(maxAge: number = 5000): void {
// TODO: Add timestamp tracking to BufferAccumulator for cleanup
// For now, just clear if too many connections
if (this.fragmentedBuffers.size > 1000) {
this.fragmentedBuffers.clear();
}
}
}

View File

@@ -0,0 +1,259 @@
/**
* TLS protocol detector
*/
// TLS detector doesn't need plugins imports
import type { IProtocolDetector } from '../models/interfaces.js';
import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from '../models/detection-types.js';
import { readUInt16BE, readUInt24BE, BufferAccumulator } from '../utils/buffer-utils.js';
import { tlsVersionToString } from '../utils/parser-utils.js';
// Import from protocols
import { TlsRecordType, TlsHandshakeType, TlsExtensionType } from '../../protocols/tls/index.js';
// Import TLS utilities for SNI extraction from protocols
import { SniExtraction } from '../../protocols/tls/sni/sni-extraction.js';
import { ClientHelloParser } from '../../protocols/tls/sni/client-hello-parser.js';
/**
* TLS detector implementation
*/
export class TlsDetector implements IProtocolDetector {
/**
* Minimum bytes needed to identify TLS (record header)
*/
private static readonly MIN_TLS_HEADER_SIZE = 5;
/**
* Fragment tracking for incomplete handshakes
*/
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
/**
* Detect TLS protocol from buffer
*/
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
// Check if buffer is too small
if (buffer.length < TlsDetector.MIN_TLS_HEADER_SIZE) {
return null;
}
// Check if this is a TLS record
if (!this.isTlsRecord(buffer)) {
return null;
}
// Extract basic TLS info
const recordType = buffer[0];
const tlsMajor = buffer[1];
const tlsMinor = buffer[2];
const recordLength = readUInt16BE(buffer, 3);
// Initialize connection info
const connectionInfo: IConnectionInfo = {
protocol: 'tls',
tlsVersion: tlsVersionToString(tlsMajor, tlsMinor) || undefined
};
// If it's a handshake, try to extract more info
if (recordType === TlsRecordType.HANDSHAKE && buffer.length >= 6) {
const handshakeType = buffer[5];
// For ClientHello, extract SNI and other info
if (handshakeType === TlsHandshakeType.CLIENT_HELLO) {
// Check if we have the complete handshake
const totalRecordLength = recordLength + 5; // Including TLS header
if (buffer.length >= totalRecordLength) {
// Extract SNI using existing logic
const sni = SniExtraction.extractSNI(buffer);
if (sni) {
connectionInfo.domain = sni;
connectionInfo.sni = sni;
}
// Parse ClientHello for additional info
const parseResult = ClientHelloParser.parseClientHello(buffer);
if (parseResult.isValid) {
// Extract ALPN if present
const alpnExtension = parseResult.extensions.find(
ext => ext.type === TlsExtensionType.APPLICATION_LAYER_PROTOCOL_NEGOTIATION
);
if (alpnExtension) {
connectionInfo.alpn = this.parseAlpnExtension(alpnExtension.data);
}
// Store cipher suites if needed
if (parseResult.cipherSuites && options?.extractFullHeaders) {
connectionInfo.cipherSuites = this.parseCipherSuites(parseResult.cipherSuites);
}
}
// Return complete result
return {
protocol: 'tls',
connectionInfo,
remainingBuffer: buffer.length > totalRecordLength
? buffer.subarray(totalRecordLength)
: undefined,
isComplete: true
};
} else {
// Incomplete handshake
return {
protocol: 'tls',
connectionInfo,
isComplete: false,
bytesNeeded: totalRecordLength
};
}
}
}
// For other TLS record types, just return basic info
return {
protocol: 'tls',
connectionInfo,
isComplete: true,
remainingBuffer: buffer.length > recordLength + 5
? buffer.subarray(recordLength + 5)
: undefined
};
}
/**
* Check if buffer can be handled by this detector
*/
canHandle(buffer: Buffer): boolean {
return buffer.length >= TlsDetector.MIN_TLS_HEADER_SIZE &&
this.isTlsRecord(buffer);
}
/**
* Get minimum bytes needed for detection
*/
getMinimumBytes(): number {
return TlsDetector.MIN_TLS_HEADER_SIZE;
}
/**
* Check if buffer contains a valid TLS record
*/
private isTlsRecord(buffer: Buffer): boolean {
const recordType = buffer[0];
// Check for valid record type
const validTypes = [
TlsRecordType.CHANGE_CIPHER_SPEC,
TlsRecordType.ALERT,
TlsRecordType.HANDSHAKE,
TlsRecordType.APPLICATION_DATA,
TlsRecordType.HEARTBEAT
];
if (!validTypes.includes(recordType)) {
return false;
}
// Check TLS version bytes (should be 0x03 0x0X)
if (buffer[1] !== 0x03) {
return false;
}
// Check record length is reasonable
const recordLength = readUInt16BE(buffer, 3);
if (recordLength > 16384) { // Max TLS record size
return false;
}
return true;
}
/**
* Parse ALPN extension data
*/
private parseAlpnExtension(data: Buffer): string[] {
const protocols: string[] = [];
if (data.length < 2) {
return protocols;
}
const listLength = readUInt16BE(data, 0);
let offset = 2;
while (offset < Math.min(2 + listLength, data.length)) {
const protoLength = data[offset];
offset++;
if (offset + protoLength <= data.length) {
const protocol = data.subarray(offset, offset + protoLength).toString('ascii');
protocols.push(protocol);
offset += protoLength;
} else {
break;
}
}
return protocols;
}
/**
* Parse cipher suites
*/
private parseCipherSuites(data: Buffer): number[] {
const suites: number[] = [];
for (let i = 0; i + 1 < data.length; i += 2) {
const suite = readUInt16BE(data, i);
suites.push(suite);
}
return suites;
}
/**
* Handle fragmented TLS detection with connection tracking
*/
static detectWithFragments(
buffer: Buffer,
connectionId: string,
options?: IDetectionOptions
): IDetectionResult | null {
const detector = new TlsDetector();
// Try direct detection first
const directResult = detector.detect(buffer, options);
if (directResult && directResult.isComplete) {
// Clean up any tracked fragments for this connection
this.fragmentedBuffers.delete(connectionId);
return directResult;
}
// Handle fragmentation
let accumulator = this.fragmentedBuffers.get(connectionId);
if (!accumulator) {
accumulator = new BufferAccumulator();
this.fragmentedBuffers.set(connectionId, accumulator);
}
accumulator.append(buffer);
const fullBuffer = accumulator.getBuffer();
// Try detection on accumulated buffer
const result = detector.detect(fullBuffer, options);
if (result && result.isComplete) {
// Success - clean up
this.fragmentedBuffers.delete(connectionId);
return result;
}
// Check timeout
if (options?.timeout) {
// TODO: Implement timeout handling
}
return result;
}
}

22
ts/detection/index.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Centralized Protocol Detection Module
*
* This module provides unified protocol detection capabilities for
* both TLS and HTTP protocols, extracting connection information
* without consuming the data stream.
*/
// Main detector
export * from './protocol-detector.js';
// Models
export * from './models/detection-types.js';
export * from './models/interfaces.js';
// Individual detectors
export * from './detectors/tls-detector.js';
export * from './detectors/http-detector.js';
// Utilities
export * from './utils/buffer-utils.js';
export * from './utils/parser-utils.js';

View File

@@ -0,0 +1,102 @@
/**
* Type definitions for protocol detection
*/
/**
* Supported protocol types that can be detected
*/
export type TProtocolType = 'tls' | 'http' | 'unknown';
/**
* HTTP method types
*/
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
/**
* TLS version identifiers
*/
export type TTlsVersion = 'SSLv3' | 'TLSv1.0' | 'TLSv1.1' | 'TLSv1.2' | 'TLSv1.3';
/**
* Connection information extracted from protocol detection
*/
export interface IConnectionInfo {
/**
* The detected protocol type
*/
protocol: TProtocolType;
/**
* Domain/hostname extracted from the connection
* - For TLS: from SNI extension
* - For HTTP: from Host header
*/
domain?: string;
/**
* HTTP-specific fields
*/
method?: THttpMethod;
path?: string;
httpVersion?: string;
headers?: Record<string, string>;
/**
* TLS-specific fields
*/
tlsVersion?: TTlsVersion;
sni?: string;
alpn?: string[];
cipherSuites?: number[];
}
/**
* Result of protocol detection
*/
export interface IDetectionResult {
/**
* The detected protocol type
*/
protocol: TProtocolType;
/**
* Extracted connection information
*/
connectionInfo: IConnectionInfo;
/**
* Any remaining buffer data after detection headers
* This can be used to continue processing the stream
*/
remainingBuffer?: Buffer;
/**
* Whether the detection is complete or needs more data
*/
isComplete: boolean;
/**
* Minimum bytes needed for complete detection (if incomplete)
*/
bytesNeeded?: number;
}
/**
* Options for protocol detection
*/
export interface IDetectionOptions {
/**
* Maximum bytes to buffer for detection (default: 8192)
*/
maxBufferSize?: number;
/**
* Timeout for detection in milliseconds (default: 5000)
*/
timeout?: number;
/**
* Whether to extract full headers or just essential info
*/
extractFullHeaders?: boolean;
}

View File

@@ -0,0 +1,115 @@
/**
* Interface definitions for protocol detection components
*/
import type { IDetectionResult, IDetectionOptions } from './detection-types.js';
/**
* Interface for protocol detectors
*/
export interface IProtocolDetector {
/**
* Detect protocol from buffer data
* @param buffer The buffer to analyze
* @param options Detection options
* @returns Detection result or null if protocol cannot be determined
*/
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null;
/**
* Check if buffer potentially contains this protocol
* @param buffer The buffer to check
* @returns True if buffer might contain this protocol
*/
canHandle(buffer: Buffer): boolean;
/**
* Get the minimum bytes needed for detection
*/
getMinimumBytes(): number;
}
/**
* Interface for connection tracking during fragmented detection
*/
export interface IConnectionTracker {
/**
* Connection identifier
*/
id: string;
/**
* Accumulated buffer data
*/
buffer: Buffer;
/**
* Timestamp of first data
*/
startTime: number;
/**
* Current detection state
*/
state: 'detecting' | 'complete' | 'failed';
/**
* Partial detection result (if any)
*/
partialResult?: Partial<IDetectionResult>;
}
/**
* Interface for buffer accumulator (handles fragmented data)
*/
export interface IBufferAccumulator {
/**
* Add data to accumulator
*/
append(data: Buffer): void;
/**
* Get accumulated buffer
*/
getBuffer(): Buffer;
/**
* Get buffer length
*/
length(): number;
/**
* Clear accumulated data
*/
clear(): void;
/**
* Check if accumulator has enough data
*/
hasMinimumBytes(minBytes: number): boolean;
}
/**
* Detection events
*/
export interface IDetectionEvents {
/**
* Emitted when protocol is successfully detected
*/
detected: (result: IDetectionResult) => void;
/**
* Emitted when detection fails
*/
failed: (error: Error) => void;
/**
* Emitted when detection times out
*/
timeout: () => void;
/**
* Emitted when more data is needed
*/
needMoreData: (bytesNeeded: number) => void;
}

View File

@@ -0,0 +1,222 @@
/**
* Main protocol detector that orchestrates detection across different protocols
*/
import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from './models/detection-types.js';
import { TlsDetector } from './detectors/tls-detector.js';
import { HttpDetector } from './detectors/http-detector.js';
/**
* Main protocol detector class
*/
export class ProtocolDetector {
/**
* Connection tracking for fragmented detection
*/
private static connectionTracking = new Map<string, {
startTime: number;
protocol?: 'tls' | 'http' | 'unknown';
}>();
/**
* Detect protocol from buffer data
*
* @param buffer The buffer to analyze
* @param options Detection options
* @returns Detection result with protocol information
*/
static async detect(
buffer: Buffer,
options?: IDetectionOptions
): Promise<IDetectionResult> {
// Quick sanity check
if (!buffer || buffer.length === 0) {
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
// Try TLS detection first (more specific)
const tlsDetector = new TlsDetector();
if (tlsDetector.canHandle(buffer)) {
const tlsResult = tlsDetector.detect(buffer, options);
if (tlsResult) {
return tlsResult;
}
}
// Try HTTP detection
const httpDetector = new HttpDetector();
if (httpDetector.canHandle(buffer)) {
const httpResult = httpDetector.detect(buffer, options);
if (httpResult) {
return httpResult;
}
}
// Neither TLS nor HTTP
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
/**
* Detect protocol with connection tracking for fragmented data
*
* @param buffer The buffer to analyze
* @param connectionId Unique connection identifier
* @param options Detection options
* @returns Detection result with protocol information
*/
static async detectWithConnectionTracking(
buffer: Buffer,
connectionId: string,
options?: IDetectionOptions
): Promise<IDetectionResult> {
// Initialize or get connection tracking
let tracking = this.connectionTracking.get(connectionId);
if (!tracking) {
tracking = { startTime: Date.now() };
this.connectionTracking.set(connectionId, tracking);
}
// Check timeout
if (options?.timeout) {
const elapsed = Date.now() - tracking.startTime;
if (elapsed > options.timeout) {
// Timeout - clean up and return unknown
this.connectionTracking.delete(connectionId);
TlsDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
HttpDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
}
// If we already know the protocol, use the appropriate detector
if (tracking.protocol === 'tls') {
const result = TlsDetector.detectWithFragments(buffer, connectionId, options);
if (result && result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result || {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
} else if (tracking.protocol === 'http') {
const result = HttpDetector.detectWithFragments(buffer, connectionId, options);
if (result && result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result || {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
// First time detection - try to determine protocol
// Quick checks first
if (buffer.length > 0) {
// TLS always starts with specific byte values
if (buffer[0] >= 0x14 && buffer[0] <= 0x18) {
tracking.protocol = 'tls';
const result = TlsDetector.detectWithFragments(buffer, connectionId, options);
if (result) {
if (result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result;
}
}
// HTTP starts with ASCII text
else if (HttpDetector.quickCheck(buffer)) {
tracking.protocol = 'http';
const result = HttpDetector.detectWithFragments(buffer, connectionId, options);
if (result) {
if (result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result;
}
}
}
// Can't determine protocol yet
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: false,
bytesNeeded: 10 // Need more data to determine protocol
};
}
/**
* Clean up old connection tracking entries
*
* @param maxAge Maximum age in milliseconds (default: 30 seconds)
*/
static cleanupConnections(maxAge: number = 30000): void {
const now = Date.now();
const toDelete: string[] = [];
for (const [connectionId, tracking] of this.connectionTracking.entries()) {
if (now - tracking.startTime > maxAge) {
toDelete.push(connectionId);
}
}
for (const connectionId of toDelete) {
this.connectionTracking.delete(connectionId);
// Also clean up detector-specific buffers
TlsDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
HttpDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
}
// Also trigger cleanup in detectors
HttpDetector.cleanupFragments(maxAge);
}
/**
* Extract domain from connection info
*
* @param connectionInfo Connection information from detection
* @returns The domain/hostname if found
*/
static extractDomain(connectionInfo: IConnectionInfo): string | undefined {
// For both TLS and HTTP, domain is stored in the domain field
return connectionInfo.domain;
}
/**
* Create a connection ID from connection parameters
*
* @param params Connection parameters
* @returns A unique connection identifier
*/
static createConnectionId(params: {
sourceIp?: string;
sourcePort?: number;
destIp?: string;
destPort?: number;
socketId?: string;
}): string {
// If socketId is provided, use it
if (params.socketId) {
return params.socketId;
}
// Otherwise create from connection tuple
const { sourceIp = 'unknown', sourcePort = 0, destIp = 'unknown', destPort = 0 } = params;
return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
}
}

View File

@@ -0,0 +1,141 @@
/**
* Buffer manipulation utilities for protocol detection
*/
// Import from protocols
import { HttpParser } from '../../protocols/http/index.js';
/**
* BufferAccumulator class for handling fragmented data
*/
export class BufferAccumulator {
private chunks: Buffer[] = [];
private totalLength = 0;
/**
* Append data to the accumulator
*/
append(data: Buffer): void {
this.chunks.push(data);
this.totalLength += data.length;
}
/**
* Get the accumulated buffer
*/
getBuffer(): Buffer {
if (this.chunks.length === 0) {
return Buffer.alloc(0);
}
if (this.chunks.length === 1) {
return this.chunks[0];
}
return Buffer.concat(this.chunks, this.totalLength);
}
/**
* Get current buffer length
*/
length(): number {
return this.totalLength;
}
/**
* Clear all accumulated data
*/
clear(): void {
this.chunks = [];
this.totalLength = 0;
}
/**
* Check if accumulator has minimum bytes
*/
hasMinimumBytes(minBytes: number): boolean {
return this.totalLength >= minBytes;
}
}
/**
* Read a big-endian 16-bit integer from buffer
*/
export function readUInt16BE(buffer: Buffer, offset: number): number {
if (offset + 2 > buffer.length) {
throw new Error('Buffer too short for UInt16BE read');
}
return (buffer[offset] << 8) | buffer[offset + 1];
}
/**
* Read a big-endian 24-bit integer from buffer
*/
export function readUInt24BE(buffer: Buffer, offset: number): number {
if (offset + 3 > buffer.length) {
throw new Error('Buffer too short for UInt24BE read');
}
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
}
/**
* Find a byte sequence in a buffer
*/
export function findSequence(buffer: Buffer, sequence: Buffer, startOffset = 0): number {
if (sequence.length === 0) {
return startOffset;
}
const searchLength = buffer.length - sequence.length + 1;
for (let i = startOffset; i < searchLength; i++) {
let found = true;
for (let j = 0; j < sequence.length; j++) {
if (buffer[i + j] !== sequence[j]) {
found = false;
break;
}
}
if (found) {
return i;
}
}
return -1;
}
/**
* Extract a line from buffer (up to CRLF or LF)
*/
export function extractLine(buffer: Buffer, startOffset = 0): { line: string; nextOffset: number } | null {
// Delegate to protocol parser
return HttpParser.extractLine(buffer, startOffset);
}
/**
* Check if buffer starts with a string (case-insensitive)
*/
export function startsWithString(buffer: Buffer, str: string, offset = 0): boolean {
if (offset + str.length > buffer.length) {
return false;
}
const bufferStr = buffer.slice(offset, offset + str.length).toString('utf8');
return bufferStr.toLowerCase() === str.toLowerCase();
}
/**
* Safe buffer slice that doesn't throw on out-of-bounds
*/
export function safeSlice(buffer: Buffer, start: number, end?: number): Buffer {
const safeStart = Math.max(0, Math.min(start, buffer.length));
const safeEnd = end === undefined
? buffer.length
: Math.max(safeStart, Math.min(end, buffer.length));
return buffer.slice(safeStart, safeEnd);
}
/**
* Check if buffer contains printable ASCII
*/
export function isPrintableAscii(buffer: Buffer, length?: number): boolean {
// Delegate to protocol parser
return HttpParser.isPrintableAscii(buffer, length);
}

View File

@@ -0,0 +1,77 @@
/**
* Parser utilities for protocol detection
* Now delegates to protocol modules for actual parsing
*/
import type { THttpMethod, TTlsVersion } from '../models/detection-types.js';
import { HttpParser, HTTP_METHODS, HTTP_VERSIONS } from '../../protocols/http/index.js';
import { tlsVersionToString as protocolTlsVersionToString } from '../../protocols/tls/index.js';
// Re-export constants for backward compatibility
export { HTTP_METHODS, HTTP_VERSIONS };
/**
* Parse HTTP request line
*/
export function parseHttpRequestLine(line: string): {
method: THttpMethod;
path: string;
version: string;
} | null {
// Delegate to protocol parser
const result = HttpParser.parseRequestLine(line);
return result ? {
method: result.method as THttpMethod,
path: result.path,
version: result.version
} : null;
}
/**
* Parse HTTP header line
*/
export function parseHttpHeader(line: string): { name: string; value: string } | null {
// Delegate to protocol parser
return HttpParser.parseHeaderLine(line);
}
/**
* Parse HTTP headers from lines
*/
export function parseHttpHeaders(lines: string[]): Record<string, string> {
// Delegate to protocol parser
return HttpParser.parseHeaders(lines);
}
/**
* Convert TLS version bytes to version string
*/
export function tlsVersionToString(major: number, minor: number): TTlsVersion | null {
// Delegate to protocol parser
return protocolTlsVersionToString(major, minor) as TTlsVersion;
}
/**
* Extract domain from Host header value
*/
export function extractDomainFromHost(hostHeader: string): string {
// Delegate to protocol parser
return HttpParser.extractDomainFromHost(hostHeader);
}
/**
* Validate domain name
*/
export function isValidDomain(domain: string): boolean {
// Delegate to protocol parser
return HttpParser.isValidDomain(domain);
}
/**
* Check if string is a valid HTTP method
*/
export function isHttpMethod(str: string): str is THttpMethod {
// Delegate to protocol parser
return HttpParser.isHttpMethod(str) && (str as THttpMethod) !== undefined;
}

View File

@@ -1,76 +0,0 @@
import type * as plugins from '../../plugins.js';
/**
* The primary forwarding types supported by SmartProxy
* Used for configuration compatibility
*/
export type TForwardingType =
| 'http-only' // HTTP forwarding only (no HTTPS)
| 'https-passthrough' // Pass-through TLS traffic (SNI forwarding)
| 'https-terminate-to-http' // Terminate TLS and forward to HTTP backend
| 'https-terminate-to-https'; // Terminate TLS and forward to HTTPS backend
/**
* Event types emitted by forwarding handlers
*/
export enum ForwardingHandlerEvents {
CONNECTED = 'connected',
DISCONNECTED = 'disconnected',
ERROR = 'error',
DATA_FORWARDED = 'data-forwarded',
HTTP_REQUEST = 'http-request',
HTTP_RESPONSE = 'http-response',
CERTIFICATE_NEEDED = 'certificate-needed',
CERTIFICATE_LOADED = 'certificate-loaded'
}
/**
* Base interface for forwarding handlers
*/
export interface IForwardingHandler extends plugins.EventEmitter {
initialize(): Promise<void>;
handleConnection(socket: plugins.net.Socket): void;
handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
}
// Route-based helpers are now available directly from route-patterns.ts
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../../proxies/smart-proxy/utils/route-patterns.js';
export {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
};
// Note: Legacy helper functions have been removed
// Please use the route-based helpers instead:
// - createHttpRoute
// - createHttpsTerminateRoute
// - createHttpsPassthroughRoute
// - createHttpToHttpsRedirect
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
// For backward compatibility, kept only the basic configuration interface
export interface IForwardConfig {
type: TForwardingType;
target: {
host: string | string[];
port: number | 'preserve' | ((ctx: any) => number);
};
http?: any;
https?: any;
acme?: any;
security?: any;
advanced?: any;
[key: string]: any;
}

View File

@@ -1,26 +0,0 @@
/**
* Forwarding configuration exports
*
* Note: The legacy domain-based configuration has been replaced by route-based configuration.
* See /ts/proxies/smart-proxy/models/route-types.ts for the new route-based configuration.
*/
export type {
TForwardingType,
IForwardConfig,
IForwardingHandler
} from './forwarding-types.js';
export {
ForwardingHandlerEvents
} from './forwarding-types.js';
// Import route helpers from route-patterns instead of deleted route-helpers
export {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../../proxies/smart-proxy/utils/route-patterns.js';

View File

@@ -1,189 +0,0 @@
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandler } from '../handlers/base-handler.js';
import { HttpForwardingHandler } from '../handlers/http-handler.js';
import { HttpsPassthroughHandler } from '../handlers/https-passthrough-handler.js';
import { HttpsTerminateToHttpHandler } from '../handlers/https-terminate-to-http-handler.js';
import { HttpsTerminateToHttpsHandler } from '../handlers/https-terminate-to-https-handler.js';
/**
* Factory for creating forwarding handlers based on the configuration type
*/
export class ForwardingHandlerFactory {
/**
* Create a forwarding handler based on the configuration
* @param config The forwarding configuration
* @returns The appropriate forwarding handler
*/
public static createHandler(config: IForwardConfig): ForwardingHandler {
// Create the appropriate handler based on the forwarding type
switch (config.type) {
case 'http-only':
return new HttpForwardingHandler(config);
case 'https-passthrough':
return new HttpsPassthroughHandler(config);
case 'https-terminate-to-http':
return new HttpsTerminateToHttpHandler(config);
case 'https-terminate-to-https':
return new HttpsTerminateToHttpsHandler(config);
default:
// Type system should prevent this, but just in case:
throw new Error(`Unknown forwarding type: ${(config as any).type}`);
}
}
/**
* Apply default values to a forwarding configuration based on its type
* @param config The original forwarding configuration
* @returns A configuration with defaults applied
*/
public static applyDefaults(config: IForwardConfig): IForwardConfig {
// Create a deep copy of the configuration
const result: IForwardConfig = JSON.parse(JSON.stringify(config));
// Apply defaults based on forwarding type
switch (config.type) {
case 'http-only':
// Set defaults for HTTP-only mode
result.http = {
enabled: true,
...config.http
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 80;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
case 'https-passthrough':
// Set defaults for HTTPS passthrough
result.https = {
forwardSni: true,
...config.https
};
// SNI forwarding doesn't do HTTP
result.http = {
enabled: false,
...config.http
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 443;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
case 'https-terminate-to-http':
// Set defaults for HTTPS termination to HTTP
result.https = {
...config.https
};
// Support HTTP access by default in this mode
result.http = {
enabled: true,
redirectToHttps: true,
...config.http
};
// Enable ACME by default
result.acme = {
enabled: true,
maintenance: true,
...config.acme
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 443;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
case 'https-terminate-to-https':
// Similar to terminate-to-http but with different target handling
result.https = {
...config.https
};
result.http = {
enabled: true,
redirectToHttps: true,
...config.http
};
result.acme = {
enabled: true,
maintenance: true,
...config.acme
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 443;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
}
return result;
}
/**
* Validate a forwarding configuration
* @param config The configuration to validate
* @throws Error if the configuration is invalid
*/
public static validateConfig(config: IForwardConfig): void {
// Validate common properties
if (!config.target) {
throw new Error('Forwarding configuration must include a target');
}
if (!config.target.host || (Array.isArray(config.target.host) && config.target.host.length === 0)) {
throw new Error('Target must include a host or array of hosts');
}
// Validate port if it's a number
if (typeof config.target.port === 'number') {
if (config.target.port <= 0 || config.target.port > 65535) {
throw new Error('Target must include a valid port (1-65535)');
}
} else if (config.target.port !== 'preserve' && typeof config.target.port !== 'function') {
throw new Error('Target port must be a number, "preserve", or a function');
}
// Type-specific validation
switch (config.type) {
case 'http-only':
// HTTP-only needs http.enabled to be true
if (config.http?.enabled === false) {
throw new Error('HTTP-only forwarding must have HTTP enabled');
}
break;
case 'https-passthrough':
// HTTPS passthrough doesn't support HTTP
if (config.http?.enabled === true) {
throw new Error('HTTPS passthrough does not support HTTP');
}
// HTTPS passthrough doesn't work with ACME
if (config.acme?.enabled === true) {
throw new Error('HTTPS passthrough does not support ACME');
}
break;
case 'https-terminate-to-http':
case 'https-terminate-to-https':
// These modes support all options, nothing specific to validate
break;
}
}
}

View File

@@ -1,5 +0,0 @@
/**
* Forwarding factory implementations
*/
export { ForwardingHandlerFactory } from './forwarding-factory.js';

View File

@@ -1,155 +0,0 @@
import * as plugins from '../../plugins.js';
import type {
IForwardConfig,
IForwardingHandler
} from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
/**
* Base class for all forwarding handlers
*/
export abstract class ForwardingHandler extends plugins.EventEmitter implements IForwardingHandler {
/**
* Create a new ForwardingHandler
* @param config The forwarding configuration
*/
constructor(protected config: IForwardConfig) {
super();
}
/**
* Initialize the handler
* Base implementation does nothing, subclasses should override as needed
*/
public async initialize(): Promise<void> {
// Base implementation - no initialization needed
}
/**
* Handle a new socket connection
* @param socket The incoming socket connection
*/
public abstract handleConnection(socket: plugins.net.Socket): void;
/**
* Handle an HTTP request
* @param req The HTTP request
* @param res The HTTP response
*/
public abstract handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
/**
* Get a target from the configuration, supporting round-robin selection
* @param incomingPort Optional incoming port for 'preserve' mode
* @returns A resolved target object with host and port
*/
protected getTargetFromConfig(incomingPort: number = 80): { host: string, port: number } {
const { target } = this.config;
// Handle round-robin host selection
if (Array.isArray(target.host)) {
if (target.host.length === 0) {
throw new Error('No target hosts specified');
}
// Simple round-robin selection
const randomIndex = Math.floor(Math.random() * target.host.length);
return {
host: target.host[randomIndex],
port: this.resolvePort(target.port, incomingPort)
};
}
// Single host
return {
host: target.host,
port: this.resolvePort(target.port, incomingPort)
};
}
/**
* Resolves a port value, handling 'preserve' and function ports
* @param port The port value to resolve
* @param incomingPort Optional incoming port to use for 'preserve' mode
*/
protected resolvePort(
port: number | 'preserve' | ((ctx: any) => number),
incomingPort: number = 80
): number {
if (typeof port === 'function') {
try {
// Create a minimal context for the function that includes the incoming port
const ctx = { port: incomingPort };
return port(ctx);
} catch (err) {
console.error('Error resolving port function:', err);
return incomingPort; // Fall back to incoming port
}
} else if (port === 'preserve') {
return incomingPort; // Use the actual incoming port for 'preserve'
} else {
return port;
}
}
/**
* Redirect an HTTP request to HTTPS
* @param req The HTTP request
* @param res The HTTP response
*/
protected redirectToHttps(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
const host = req.headers.host || '';
const path = req.url || '/';
const redirectUrl = `https://${host}${path}`;
res.writeHead(301, {
'Location': redirectUrl,
'Cache-Control': 'no-cache'
});
res.end(`Redirecting to ${redirectUrl}`);
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: 301,
headers: { 'Location': redirectUrl },
size: 0
});
}
/**
* Apply custom headers from configuration
* @param headers The original headers
* @param variables Variables to replace in the headers
* @returns The headers with custom values applied
*/
protected applyCustomHeaders(
headers: Record<string, string | string[] | undefined>,
variables: Record<string, string>
): Record<string, string | string[] | undefined> {
const customHeaders = this.config.advanced?.headers || {};
const result = { ...headers };
// Apply custom headers with variable substitution
for (const [key, value] of Object.entries(customHeaders)) {
if (typeof value !== 'string') continue;
let processedValue = value;
// Replace variables in the header value
for (const [varName, varValue] of Object.entries(variables)) {
processedValue = processedValue.replace(`{${varName}}`, varValue);
}
result[key] = processedValue;
}
return result;
}
/**
* Get the timeout for this connection from configuration
* @returns Timeout in milliseconds
*/
protected getTimeout(): number {
return this.config.advanced?.timeout || 60000; // Default: 60 seconds
}
}

View File

@@ -1,163 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { setupSocketHandlers } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTP-only forwarding
*/
export class HttpForwardingHandler extends ForwardingHandler {
/**
* Create a new HTTP forwarding handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTP-only configuration
if (config.type !== 'http-only') {
throw new Error(`Invalid configuration type for HttpForwardingHandler: ${config.type}`);
}
}
/**
* Initialize the handler
* HTTP handler doesn't need special initialization
*/
public async initialize(): Promise<void> {
// Basic initialization from parent class
await super.initialize();
}
/**
* Handle a raw socket connection
* HTTP handler doesn't do much with raw sockets as it mainly processes
* parsed HTTP requests
*/
public handleConnection(socket: plugins.net.Socket): void {
// For HTTP, we mainly handle parsed requests, but we can still set up
// some basic connection tracking
const remoteAddress = socket.remoteAddress || 'unknown';
const localPort = socket.localPort || 80;
// Set up socket handlers with proper cleanup
const handleClose = (reason: string) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
};
// Use custom timeout handler that doesn't close the socket
setupSocketHandlers(socket, handleClose, () => {
// For HTTP, we can be more aggressive with timeouts since connections are shorter
// But still don't close immediately - let the connection finish naturally
console.warn(`HTTP socket timeout from ${remoteAddress}`);
}, 'http');
socket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: error.message
});
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
localPort
});
}
/**
* Handle an HTTP request
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Get the local port from the request (for 'preserve' port handling)
const localPort = req.socket.localPort || 80;
// Get the target from configuration, passing the incoming port
const target = this.getTargetFromConfig(localPort);
// Create a custom headers object with variables for substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers
};
// Create the proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track bytes for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@@ -1,185 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS passthrough (SNI forwarding without termination)
*/
export class HttpsPassthroughHandler extends ForwardingHandler {
/**
* Create a new HTTPS passthrough handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS passthrough configuration
if (config.type !== 'https-passthrough') {
throw new Error(`Invalid configuration type for HttpsPassthroughHandler: ${config.type}`);
}
}
/**
* Initialize the handler
* HTTPS passthrough handler doesn't need special initialization
*/
public async initialize(): Promise<void> {
// Basic initialization from parent class
await super.initialize();
}
/**
* Handle a TLS/SSL socket connection by forwarding it without termination
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Get the target from configuration
const target = this.getTargetFromConfig();
// Log the connection
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
target: `${target.host}:${target.port}`
});
// Track data transfer for logging
let bytesSent = 0;
let bytesReceived = 0;
let serverSocket: plugins.net.Socket | null = null;
let cleanupClient: ((reason: string) => Promise<void>) | null = null;
let cleanupServer: ((reason: string) => Promise<void>) | null = null;
// Create a connection to the target server with immediate error handling
serverSocket = createSocketWithErrorHandler({
port: target.port,
host: target.host,
onError: async (error) => {
// Server connection failed - clean up client socket immediately
this.emit(ForwardingHandlerEvents.ERROR, {
error: error.message,
code: (error as any).code || 'UNKNOWN',
remoteAddress,
target: `${target.host}:${target.port}`
});
// Clean up the client socket since we can't forward
if (!clientSocket.destroyed) {
clientSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
bytesSent: 0,
bytesReceived: 0,
reason: `server_connection_failed: ${error.message}`
});
},
onConnect: () => {
// Connection successful - set up forwarding handlers
const handlers = createIndependentSocketHandlers(
clientSocket,
serverSocket!,
(reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
bytesSent,
bytesReceived,
reason
});
}
);
cleanupClient = handlers.cleanupClient;
cleanupServer = handlers.cleanupServer;
// Setup handlers with custom timeout handling that doesn't close connections
const timeout = this.getTimeout();
setupSocketHandlers(clientSocket, cleanupClient, (socket) => {
// Just reset timeout, don't close
socket.setTimeout(timeout);
}, 'client');
setupSocketHandlers(serverSocket!, cleanupServer, (socket) => {
// Just reset timeout, don't close
socket.setTimeout(timeout);
}, 'server');
// Forward data from client to server
clientSocket.on('data', (data) => {
bytesSent += data.length;
// Check if server socket is writable
if (serverSocket && serverSocket.writable) {
const flushed = serverSocket.write(data);
// Handle backpressure
if (!flushed) {
clientSocket.pause();
serverSocket.once('drain', () => {
clientSocket.resume();
});
}
}
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
bytes: data.length,
total: bytesSent
});
});
// Forward data from server to client
serverSocket!.on('data', (data) => {
bytesReceived += data.length;
// Check if client socket is writable
if (clientSocket.writable) {
const flushed = clientSocket.write(data);
// Handle backpressure
if (!flushed) {
serverSocket!.pause();
clientSocket.once('drain', () => {
serverSocket!.resume();
});
}
}
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'inbound',
bytes: data.length,
total: bytesReceived
});
});
// Set initial timeouts - they will be reset on each timeout event
clientSocket.setTimeout(timeout);
serverSocket!.setTimeout(timeout);
}
});
}
/**
* Handle an HTTP request - HTTPS passthrough doesn't support HTTP
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(_req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// HTTPS passthrough doesn't support HTTP requests
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('HTTP not supported for this domain');
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: 404,
headers: { 'Content-Type': 'text/plain' },
size: 'HTTP not supported for this domain'.length
});
}
}

View File

@@ -1,312 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS termination with HTTP backend
*/
export class HttpsTerminateToHttpHandler extends ForwardingHandler {
private tlsServer: plugins.tls.Server | null = null;
private secureContext: plugins.tls.SecureContext | null = null;
/**
* Create a new HTTPS termination with HTTP backend handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS terminate to HTTP configuration
if (config.type !== 'https-terminate-to-http') {
throw new Error(`Invalid configuration type for HttpsTerminateToHttpHandler: ${config.type}`);
}
}
/**
* Initialize the handler, setting up TLS context
*/
public async initialize(): Promise<void> {
// We need to load or create TLS certificates
if (this.config.https?.customCert) {
// Use custom certificate from configuration
this.secureContext = plugins.tls.createSecureContext({
key: this.config.https.customCert.key,
cert: this.config.https.customCert.cert
});
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
source: 'config',
domain: this.config.target.host
});
} else if (this.config.acme?.enabled) {
// Request certificate through ACME if needed
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
domain: Array.isArray(this.config.target.host)
? this.config.target.host[0]
: this.config.target.host,
useProduction: this.config.acme.production || false
});
// In a real implementation, we would wait for the certificate to be issued
// For now, we'll use a dummy context
this.secureContext = plugins.tls.createSecureContext({
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
});
} else {
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
}
}
/**
* Set the secure context for TLS termination
* Called when a certificate is available
* @param context The secure context
*/
public setSecureContext(context: plugins.tls.SecureContext): void {
this.secureContext = context;
}
/**
* Handle a TLS/SSL socket connection by terminating TLS and forwarding to HTTP backend
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Make sure we have a secure context
if (!this.secureContext) {
clientSocket.destroy(new Error('TLS secure context not initialized'));
return;
}
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
// Create a TLS socket using our secure context
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
secureContext: this.secureContext,
isServer: true,
server: this.tlsServer || undefined
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
tls: true
});
// Variables to track connections
let backendSocket: plugins.net.Socket | null = null;
let dataBuffer = Buffer.alloc(0);
let connectionEstablished = false;
let forwardingSetup = false;
// Set up initial error handling for TLS socket
const tlsCleanupHandler = (reason: string) => {
if (!forwardingSetup) {
// If forwarding not set up yet, emit disconnected and cleanup
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
if (backendSocket && !backendSocket.destroyed) {
backendSocket.destroy();
}
}
// If forwarding is setup, setupBidirectionalForwarding will handle cleanup
};
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
// Set timeout
const timeout = this.getTimeout();
tlsSocket.setTimeout(timeout);
tlsSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'TLS connection timeout'
});
tlsCleanupHandler('timeout');
});
// Handle TLS data
tlsSocket.on('data', (data) => {
// If backend connection already established, just forward the data
if (connectionEstablished && backendSocket && !backendSocket.destroyed) {
backendSocket.write(data);
return;
}
// Append to buffer
dataBuffer = Buffer.concat([dataBuffer, data]);
// Very basic HTTP parsing - in a real implementation, use http-parser
if (dataBuffer.includes(Buffer.from('\r\n\r\n')) && !connectionEstablished) {
const target = this.getTargetFromConfig();
// Create backend connection with immediate error handling
backendSocket = createSocketWithErrorHandler({
port: target.port,
host: target.host,
onError: (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
error: error.message,
code: (error as any).code || 'UNKNOWN',
remoteAddress,
target: `${target.host}:${target.port}`
});
// Clean up the TLS socket since we can't forward
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason: `backend_connection_failed: ${error.message}`
});
},
onConnect: () => {
connectionEstablished = true;
// Send buffered data
if (dataBuffer.length > 0) {
backendSocket!.write(dataBuffer);
dataBuffer = Buffer.alloc(0);
}
// Now set up bidirectional forwarding with proper cleanup
forwardingSetup = true;
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
forwardingSetup = false;
},
enableHalfOpen: false // Close both when one closes
});
}
});
// Additional error logging for backend socket
backendSocket.on('error', (error) => {
if (!connectionEstablished) {
// Connection failed during setup
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Target connection error: ${error.message}`
});
}
// If connected, setupBidirectionalForwarding handles cleanup
});
}
});
}
/**
* Handle an HTTP request by forwarding to the HTTP backend
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Check if we should redirect to HTTPS
if (this.config.http?.redirectToHttps) {
this.redirectToHttps(req, res);
return;
}
// Get the target from configuration
const target = this.getTargetFromConfig();
// Create custom headers with variable substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers
};
// Create the proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track response size for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@@ -1,297 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS termination with HTTPS backend
*/
export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
private secureContext: plugins.tls.SecureContext | null = null;
/**
* Create a new HTTPS termination with HTTPS backend handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS terminate to HTTPS configuration
if (config.type !== 'https-terminate-to-https') {
throw new Error(`Invalid configuration type for HttpsTerminateToHttpsHandler: ${config.type}`);
}
}
/**
* Initialize the handler, setting up TLS context
*/
public async initialize(): Promise<void> {
// We need to load or create TLS certificates for termination
if (this.config.https?.customCert) {
// Use custom certificate from configuration
this.secureContext = plugins.tls.createSecureContext({
key: this.config.https.customCert.key,
cert: this.config.https.customCert.cert
});
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
source: 'config',
domain: this.config.target.host
});
} else if (this.config.acme?.enabled) {
// Request certificate through ACME if needed
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
domain: Array.isArray(this.config.target.host)
? this.config.target.host[0]
: this.config.target.host,
useProduction: this.config.acme.production || false
});
// In a real implementation, we would wait for the certificate to be issued
// For now, we'll use a dummy context
this.secureContext = plugins.tls.createSecureContext({
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
});
} else {
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
}
}
/**
* Set the secure context for TLS termination
* Called when a certificate is available
* @param context The secure context
*/
public setSecureContext(context: plugins.tls.SecureContext): void {
this.secureContext = context;
}
/**
* Handle a TLS/SSL socket connection by terminating TLS and creating a new TLS connection to backend
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Make sure we have a secure context
if (!this.secureContext) {
clientSocket.destroy(new Error('TLS secure context not initialized'));
return;
}
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
// Create a TLS socket using our secure context
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
secureContext: this.secureContext,
isServer: true
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
tls: true
});
// Variable to track backend socket
let backendSocket: plugins.tls.TLSSocket | null = null;
let isConnectedToBackend = false;
// Set up initial error handling for TLS socket
const tlsCleanupHandler = (reason: string) => {
if (!isConnectedToBackend) {
// If backend not connected yet, just emit disconnected event
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
// Cleanup TLS socket if needed
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
}
// If connected to backend, setupBidirectionalForwarding will handle cleanup
};
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
// Set timeout
const timeout = this.getTimeout();
tlsSocket.setTimeout(timeout);
tlsSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'TLS connection timeout'
});
tlsCleanupHandler('timeout');
});
// Get the target from configuration
const target = this.getTargetFromConfig();
// Set up the connection to the HTTPS backend
const connectToBackend = () => {
backendSocket = plugins.tls.connect({
host: target.host,
port: target.port,
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
}, () => {
isConnectedToBackend = true;
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
target: `${target.host}:${target.port}`,
tls: true
});
// Set up bidirectional forwarding with proper cleanup
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
},
enableHalfOpen: false // Close both when one closes
});
// Set timeout for backend socket
backendSocket!.setTimeout(timeout);
backendSocket!.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Backend connection timeout'
});
// Let setupBidirectionalForwarding handle the cleanup
});
});
// Handle backend connection errors
backendSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Backend connection error: ${error.message}`
});
if (!isConnectedToBackend) {
// Connection failed, clean up TLS socket
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason: `backend_connection_failed: ${error.message}`
});
}
// If connected, let setupBidirectionalForwarding handle cleanup
});
};
// Wait for the TLS handshake to complete before connecting to backend
tlsSocket.on('secure', () => {
connectToBackend();
});
}
/**
* Handle an HTTP request by forwarding to the HTTPS backend
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Check if we should redirect to HTTPS
if (this.config.http?.redirectToHttps) {
this.redirectToHttps(req, res);
return;
}
// Get the target from configuration
const target = this.getTargetFromConfig();
// Create custom headers with variable substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers,
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
};
// Create the proxy request using HTTPS
const proxyReq = plugins.https.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track response size for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@@ -1,9 +0,0 @@
/**
* Forwarding handler implementations
*/
export { ForwardingHandler } from './base-handler.js';
export { HttpForwardingHandler } from './http-handler.js';
export { HttpsPassthroughHandler } from './https-passthrough-handler.js';
export { HttpsTerminateToHttpHandler } from './https-terminate-to-http-handler.js';
export { HttpsTerminateToHttpsHandler } from './https-terminate-to-https-handler.js';

View File

@@ -1,35 +0,0 @@
/**
* Forwarding system module
* Provides a flexible and type-safe way to configure and manage various forwarding strategies
*/
// Export handlers
export { ForwardingHandler } from './handlers/base-handler.js';
export * from './handlers/http-handler.js';
export * from './handlers/https-passthrough-handler.js';
export * from './handlers/https-terminate-to-http-handler.js';
export * from './handlers/https-terminate-to-https-handler.js';
// Export factory
export * from './factory/forwarding-factory.js';
// Export types - these include TForwardingType and IForwardConfig
export type {
TForwardingType,
IForwardConfig,
IForwardingHandler
} from './config/forwarding-types.js';
export {
ForwardingHandlerEvents
} from './config/forwarding-types.js';
// Export route helpers directly from route-patterns
export {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../proxies/smart-proxy/utils/route-patterns.js';

Some files were not shown because too many files have changed in this diff Show More