Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
527cacb1a8 | |||
5f175b4ca8 | |||
b9be6533ae | |||
18d79ac7e1 | |||
2a75e7c490 | |||
cf70b6ace5 | |||
54ffbadb86 | |||
01e1153fb8 | |||
fa9166be4b | |||
c5efee3bfe | |||
47508eb1eb | |||
fb147148ef | |||
07f5ceddc4 | |||
3ac3345be8 | |||
5b40e82c41 | |||
2a75a86d73 | |||
250eafd36c | |||
facb68a9d0 | |||
23898c1577 | |||
2d240671ab | |||
705a59413d | |||
e9723a8af9 | |||
300ab1a077 | |||
900942a263 | |||
d45485985a | |||
9fdc2d5069 | |||
37c87e8450 | |||
92b2f230ef | |||
e7ebf57ce1 | |||
ad80798210 | |||
265b80ee04 | |||
726d40b9a5 | |||
cacc88797a | |||
bed1a76537 | |||
eb2e67fecc | |||
c7c325a7d8 | |||
a2affcd93e | |||
e0f3e8a0ec | |||
96c4de0f8a | |||
829ae0d6a3 | |||
7b81186bb3 | |||
02603c3b07 |
@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiryDate": "2025-08-27T14:28:53.471Z",
|
||||
"issueDate": "2025-05-29T14:28:53.471Z",
|
||||
"savedAt": "2025-05-29T14:28:53.473Z"
|
||||
"expiryDate": "2025-09-03T17:57:28.583Z",
|
||||
"issueDate": "2025-06-05T17:57:28.583Z",
|
||||
"savedAt": "2025-06-05T17:57:28.583Z"
|
||||
}
|
@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-06-01 - 19.5.19 - fix(smartproxy)
|
||||
Fix connection handling and improve route matching edge cases
|
||||
|
||||
- Enhanced cleanup logic to prevent connection accumulation under rapid retry scenarios
|
||||
- Improved matching for wildcard domains and path parameters in the route configuration
|
||||
- Minor refactoring in async utilities and internal socket handling for better performance
|
||||
- Updated test suites and documentation for clearer configuration examples
|
||||
|
||||
## 2025-05-29 - 19.5.3 - fix(smartproxy)
|
||||
Fix route security configuration location and improve ACME timing tests and socket mock implementations
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "19.5.4",
|
||||
"version": "19.5.20",
|
||||
"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",
|
||||
@ -18,7 +18,7 @@
|
||||
"@git.zone/tsbuild": "^2.6.4",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^2.3.1",
|
||||
"@types/node": "^22.15.24",
|
||||
"@types/node": "^22.15.29",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
@ -70,8 +70,8 @@ importers:
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1(@aws-sdk/credential-providers@3.798.0)(socks@2.8.4)(typescript@5.8.3)
|
||||
'@types/node':
|
||||
specifier: ^22.15.24
|
||||
version: 22.15.24
|
||||
specifier: ^22.15.29
|
||||
version: 22.15.29
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
@ -1635,11 +1635,11 @@ packages:
|
||||
'@types/node-forge@1.3.11':
|
||||
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
|
||||
|
||||
'@types/node@18.19.105':
|
||||
resolution: {integrity: sha512-a+DrwD2VyzqQR2W0EVF8EaCh6Em4ilQAYLEPZnMNkQHXR7ziWW7RUhZMWZAgRpkDDAdUIcJOXSPJT/zBEwz3sA==}
|
||||
'@types/node@18.19.110':
|
||||
resolution: {integrity: sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q==}
|
||||
|
||||
'@types/node@22.15.24':
|
||||
resolution: {integrity: sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==}
|
||||
'@types/node@22.15.29':
|
||||
resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==}
|
||||
|
||||
'@types/ping@0.4.4':
|
||||
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
|
||||
@ -5708,6 +5708,7 @@ snapshots:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- aws-crt
|
||||
- encoding
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
@ -7097,27 +7098,27 @@ snapshots:
|
||||
|
||||
'@types/bn.js@5.1.6':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/body-parser@1.19.5':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/buffer-json@2.0.3': {}
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/cors@2.8.18':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
@ -7129,7 +7130,7 @@ snapshots:
|
||||
|
||||
'@types/dns-packet@5.6.5':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/elliptic@6.4.18':
|
||||
dependencies:
|
||||
@ -7137,7 +7138,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.0.6':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
'@types/qs': 6.9.18
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.4
|
||||
@ -7154,30 +7155,30 @@ snapshots:
|
||||
|
||||
'@types/from2@2.3.5':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/fs-extra@9.0.13':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/glob@7.2.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/glob@8.1.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/gunzip-maybe@1.4.2':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@ -7199,7 +7200,7 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
@ -7217,18 +7218,18 @@ snapshots:
|
||||
|
||||
'@types/node-fetch@2.6.12':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
form-data: 4.0.2
|
||||
|
||||
'@types/node-forge@1.3.11':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/node@18.19.105':
|
||||
'@types/node@18.19.110':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@22.15.24':
|
||||
'@types/node@22.15.29':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
@ -7244,30 +7245,30 @@ snapshots:
|
||||
|
||||
'@types/s3rver@3.7.4':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/semver@7.7.0': {}
|
||||
|
||||
'@types/send@0.17.4':
|
||||
dependencies:
|
||||
'@types/mime': 1.3.5
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/serve-static@1.15.7':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.4
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
'@types/send': 0.17.4
|
||||
|
||||
'@types/symbol-tree@3.2.5': {}
|
||||
|
||||
'@types/tar-stream@2.2.3':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
@ -7291,18 +7292,18 @@ snapshots:
|
||||
|
||||
'@types/whatwg-url@8.2.2':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@types/which@3.0.4': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@ -7582,7 +7583,7 @@ snapshots:
|
||||
|
||||
cloudflare@4.2.0:
|
||||
dependencies:
|
||||
'@types/node': 18.19.105
|
||||
'@types/node': 18.19.110
|
||||
'@types/node-fetch': 2.6.12
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
@ -7835,7 +7836,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.18
|
||||
'@types/node': 22.15.24
|
||||
'@types/node': 22.15.29
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
|
187
readme.delete.md
Normal file
187
readme.delete.md
Normal file
@ -0,0 +1,187 @@
|
||||
# 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
|
533
readme.hints.md
533
readme.hints.md
@ -318,4 +318,535 @@ const routes: IRouteConfig[] = [{
|
||||
- Authentication requires TLS termination (cannot be enforced on passthrough/direct connections)
|
||||
- Per-route connection limits are not yet implemented
|
||||
- Security is defined at the route level (route.security), not in the action
|
||||
- Route matching is based solely on match criteria; security is enforced after matching
|
||||
- Route matching is based solely on match criteria; security is enforced after matching
|
||||
|
||||
## Performance Issues Investigation (v19.5.3+)
|
||||
|
||||
### Critical Blocking Operations Found
|
||||
1. **Busy Wait Loop** in `ts/proxies/nftables-proxy/nftables-proxy.ts:235-238`
|
||||
- Blocks entire event loop with `while (Date.now() < waitUntil) {}`
|
||||
- Should use `await new Promise(resolve => setTimeout(resolve, delay))`
|
||||
|
||||
2. **Synchronous Filesystem Operations**
|
||||
- Certificate management uses `fs.existsSync()`, `fs.mkdirSync()`, `fs.readFileSync()`
|
||||
- NFTables proxy uses `execSync()` for system commands
|
||||
- Certificate store uses `ensureDirSync()`, `fileExistsSync()`, `removeManySync()`
|
||||
|
||||
3. **Memory Leak Risks**
|
||||
- Several `setInterval()` calls without storing references for cleanup
|
||||
- Event listeners added without proper cleanup in error paths
|
||||
- Missing `removeAllListeners()` calls in some connection cleanup scenarios
|
||||
|
||||
### Performance Recommendations
|
||||
- Replace all sync filesystem operations with async alternatives
|
||||
- Fix the busy wait loop immediately (critical event loop blocker)
|
||||
- Add proper cleanup for all timers and event listeners
|
||||
- Consider worker threads for CPU-intensive operations
|
||||
- See `readme.problems.md` for detailed analysis and recommendations
|
||||
|
||||
## Performance Optimizations Implemented (Phase 1 - v19.6.0)
|
||||
|
||||
### 1. Async Utilities Created (`ts/core/utils/async-utils.ts`)
|
||||
- **delay()**: Non-blocking alternative to busy wait loops
|
||||
- **retryWithBackoff()**: Retry operations with exponential backoff
|
||||
- **withTimeout()**: Execute operations with timeout protection
|
||||
- **parallelLimit()**: Run async operations with concurrency control
|
||||
- **debounceAsync()**: Debounce async functions
|
||||
- **AsyncMutex**: Ensure exclusive access to resources
|
||||
- **CircuitBreaker**: Protect against cascading failures
|
||||
|
||||
### 2. Filesystem Utilities Created (`ts/core/utils/fs-utils.ts`)
|
||||
- **AsyncFileSystem**: Complete async filesystem operations
|
||||
- exists(), ensureDir(), readFile(), writeFile()
|
||||
- readJSON(), writeJSON() with proper error handling
|
||||
- copyFile(), moveFile(), removeDir()
|
||||
- Stream creation and file listing utilities
|
||||
|
||||
### 3. Critical Fixes Applied
|
||||
|
||||
#### Busy Wait Loop Fixed
|
||||
- **Location**: `ts/proxies/nftables-proxy/nftables-proxy.ts:235-238`
|
||||
- **Fix**: Replaced `while (Date.now() < waitUntil) {}` with `await delay(ms)`
|
||||
- **Impact**: Unblocks event loop, massive performance improvement
|
||||
|
||||
#### Certificate Manager Migration
|
||||
- **File**: `ts/proxies/http-proxy/certificate-manager.ts`
|
||||
- Added async initialization method
|
||||
- Kept sync methods for backward compatibility with deprecation warnings
|
||||
- Added `loadDefaultCertificatesAsync()` method
|
||||
|
||||
#### Certificate Store Migration
|
||||
- **File**: `ts/proxies/smart-proxy/cert-store.ts`
|
||||
- Replaced all `fileExistsSync`, `ensureDirSync`, `removeManySync`
|
||||
- Used parallel operations with `Promise.all()` for better performance
|
||||
- Improved error handling and async JSON operations
|
||||
|
||||
#### NFTables Proxy Improvements
|
||||
- Added deprecation warnings to sync methods
|
||||
- Created `executeWithTempFile()` helper for common pattern
|
||||
- Started migration of sync filesystem operations to async
|
||||
- Added import for delay and AsyncFileSystem utilities
|
||||
|
||||
### 4. Backward Compatibility Maintained
|
||||
- All sync methods retained with deprecation warnings
|
||||
- Existing APIs unchanged, new async methods added alongside
|
||||
- Feature flags prepared for gradual rollout
|
||||
|
||||
### 5. Phase 1 Completion Status
|
||||
✅ **Phase 1 COMPLETE** - All critical performance fixes have been implemented:
|
||||
- ✅ Fixed busy wait loop in nftables-proxy.ts
|
||||
- ✅ Created async utilities (delay, retry, timeout, parallelLimit, mutex, circuit breaker)
|
||||
- ✅ Created filesystem utilities (AsyncFileSystem with full async operations)
|
||||
- ✅ Migrated all certificate management to async operations
|
||||
- ✅ Migrated nftables-proxy filesystem operations to async (except stopSync for exit handlers)
|
||||
- ✅ All tests passing for new utilities
|
||||
|
||||
### 6. Phase 2 Progress Status
|
||||
🔨 **Phase 2 IN PROGRESS** - Resource Lifecycle Management:
|
||||
- ✅ Created LifecycleComponent base class for automatic resource cleanup
|
||||
- ✅ Created BinaryHeap data structure for priority queue operations
|
||||
- ✅ Created EnhancedConnectionPool with backpressure and health checks
|
||||
- ✅ Cleaned up legacy code (removed ts/common/, event-utils.ts, event-system.ts)
|
||||
- 📋 TODO: Migrate existing components to extend LifecycleComponent
|
||||
- 📋 TODO: Add integration tests for resource management
|
||||
|
||||
### 7. Next Steps (Remaining Work)
|
||||
- **Phase 2 (cont)**: Migrate components to use LifecycleComponent
|
||||
- **Phase 3**: Add worker threads for CPU-intensive operations
|
||||
- **Phase 4**: Performance monitoring dashboard
|
||||
|
||||
## Socket Error Handling Fix (v19.5.11+)
|
||||
|
||||
### Issue
|
||||
Server crashed with unhandled 'error' event when backend connections failed (ECONNREFUSED). Also caused memory leak with rising active connection count as failed connections weren't cleaned up properly.
|
||||
|
||||
### Root Cause
|
||||
1. **Race Condition**: In forwarding handlers, sockets were created with `net.connect()` but error handlers were attached later, creating a window where errors could crash the server
|
||||
2. **Incomplete Cleanup**: When server connections failed, client sockets weren't properly cleaned up, leaving connection records in memory
|
||||
|
||||
### Solution
|
||||
Created `createSocketWithErrorHandler()` utility that attaches error handlers immediately:
|
||||
```typescript
|
||||
// Before (race condition):
|
||||
const socket = net.connect(port, host);
|
||||
// ... other code ...
|
||||
socket.on('error', handler); // Too late!
|
||||
|
||||
// After (safe):
|
||||
const socket = createSocketWithErrorHandler({
|
||||
port, host,
|
||||
onError: (error) => {
|
||||
// Handle error immediately
|
||||
clientSocket.destroy();
|
||||
},
|
||||
onConnect: () => {
|
||||
// Set up forwarding
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Changes Made
|
||||
1. **New Utility**: `ts/core/utils/socket-utils.ts` - Added `createSocketWithErrorHandler()`
|
||||
2. **Updated Handlers**:
|
||||
- `https-passthrough-handler.ts` - Uses safe socket creation
|
||||
- `https-terminate-to-http-handler.ts` - Uses safe socket creation
|
||||
3. **Connection Cleanup**: Client sockets destroyed immediately on server connection failure
|
||||
|
||||
### Test Coverage
|
||||
- `test/test.socket-error-handling.node.ts` - Verifies server doesn't crash on ECONNREFUSED
|
||||
- `test/test.forwarding-error-fix.node.ts` - Tests forwarding handlers handle errors gracefully
|
||||
|
||||
### Configuration
|
||||
No configuration changes needed. The fix is transparent to users.
|
||||
|
||||
### Important Note
|
||||
The fix was applied in two places:
|
||||
1. **ForwardingHandler classes** (`https-passthrough-handler.ts`, etc.) - These are standalone forwarding utilities
|
||||
2. **SmartProxy route-connection-handler** (`route-connection-handler.ts`) - This is where the actual SmartProxy connection handling happens
|
||||
|
||||
The critical fix for SmartProxy was in `setupDirectConnection()` method in route-connection-handler.ts, which now uses `createSocketWithErrorHandler()` to properly handle connection failures and clean up connection records.
|
||||
|
||||
## Connection Cleanup Improvements (v19.5.12+)
|
||||
|
||||
### Issue
|
||||
Connections were still counting up during rapid retry scenarios, especially when routing failed or backend connections were refused. This was due to:
|
||||
1. **Delayed Cleanup**: Using `initiateCleanupOnce` queued cleanup operations (batch of 100 every 100ms) instead of immediate cleanup
|
||||
2. **NFTables Memory Leak**: NFTables connections were never cleaned up, staying in memory forever
|
||||
3. **Connection Limit Bypass**: When max connections reached, connection record check happened after creation
|
||||
|
||||
### Root Cause Analysis
|
||||
1. **Queued vs Immediate Cleanup**:
|
||||
- `initiateCleanupOnce()`: Adds to cleanup queue, processes up to 100 connections every 100ms
|
||||
- `cleanupConnection()`: Immediate synchronous cleanup
|
||||
- Under rapid retries, connections were created faster than the queue could process them
|
||||
|
||||
2. **NFTables Connections**:
|
||||
- Marked with `usingNetworkProxy = true` but never cleaned up
|
||||
- Connection records stayed in memory indefinitely
|
||||
|
||||
3. **Error Path Cleanup**:
|
||||
- Many error paths used `socket.end()` (async) followed by cleanup
|
||||
- Created timing windows where connections weren't fully cleaned
|
||||
|
||||
### Solution
|
||||
1. **Immediate Cleanup**: Changed all error paths from `initiateCleanupOnce()` to `cleanupConnection()` for immediate cleanup
|
||||
2. **NFTables Cleanup**: Added socket close listener to clean up connection records when NFTables connections close
|
||||
3. **Connection Limit Fix**: Added null check after `createConnection()` to handle rejection properly
|
||||
|
||||
### Changes Made in route-connection-handler.ts
|
||||
```typescript
|
||||
// 1. NFTables cleanup (line 551-553)
|
||||
socket.once('close', () => {
|
||||
this.connectionManager.cleanupConnection(record, 'nftables_closed');
|
||||
});
|
||||
|
||||
// 2. Connection limit check (line 93-96)
|
||||
const record = this.connectionManager.createConnection(socket);
|
||||
if (!record) {
|
||||
// Connection was rejected due to limit - socket already destroyed
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Changed all error paths to use immediate cleanup
|
||||
// Before: this.connectionManager.initiateCleanupOnce(record, reason)
|
||||
// After: this.connectionManager.cleanupConnection(record, reason)
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
- `test/test.rapid-retry-cleanup.node.ts` - Verifies connection cleanup under rapid retry scenarios
|
||||
- Test shows connection count stays at 0 even with 20 rapid retries with 50ms intervals
|
||||
- Confirms both ECONNREFUSED and routing failure scenarios are handled correctly
|
||||
|
||||
### Performance Impact
|
||||
- **Positive**: No more connection accumulation under load
|
||||
- **Positive**: Immediate cleanup reduces memory usage
|
||||
- **Consideration**: More frequent cleanup operations, but prevents queue backlog
|
||||
|
||||
### Migration Notes
|
||||
No configuration changes needed. The improvements are automatic and backward compatible.
|
||||
|
||||
## Early Client Disconnect Handling (v19.5.13+)
|
||||
|
||||
### Issue
|
||||
Connections were accumulating when clients connected but disconnected before sending data or during routing. This occurred in two scenarios:
|
||||
1. **TLS Path**: Clients connecting and disconnecting before sending initial TLS handshake data
|
||||
2. **Non-TLS Immediate Routing**: Clients disconnecting while backend connection was being established
|
||||
|
||||
### Root Cause
|
||||
1. **Missing Cleanup Handlers**: During initial data wait and immediate routing, no close/end handlers were attached to catch early disconnections
|
||||
2. **Race Condition**: Backend connection attempts continued even after client disconnected, causing unhandled errors
|
||||
3. **Timing Window**: Between accepting connection and establishing full bidirectional flow, disconnections weren't properly handled
|
||||
|
||||
### Solution
|
||||
1. **TLS Path Fix**: Added close/end handlers during initial data wait (lines 224-253 in route-connection-handler.ts)
|
||||
2. **Immediate Routing Fix**: Used `setupSocketHandlers` for proper handler attachment (lines 180-205)
|
||||
3. **Backend Error Handling**: Check if connection already closed before handling backend errors (line 1144)
|
||||
|
||||
### Changes Made
|
||||
```typescript
|
||||
// 1. TLS path - handle disconnect before initial data
|
||||
socket.once('close', () => {
|
||||
if (!initialDataReceived) {
|
||||
this.connectionManager.cleanupConnection(record, 'closed_before_data');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Immediate routing path - proper handler setup
|
||||
setupSocketHandlers(socket, (reason) => {
|
||||
if (!record.outgoing || record.outgoing.readyState !== 'open') {
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
record.outgoing.destroy(); // Abort pending backend connection
|
||||
}
|
||||
this.connectionManager.cleanupConnection(record, reason);
|
||||
}
|
||||
}, undefined, 'immediate-route-client');
|
||||
|
||||
// 3. Backend connection error handling
|
||||
onError: (error) => {
|
||||
if (record.connectionClosed) {
|
||||
logger.log('debug', 'Backend connection failed but client already disconnected');
|
||||
return; // Client already gone, nothing to clean up
|
||||
}
|
||||
// ... normal error handling
|
||||
}
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
- `test/test.connect-disconnect-cleanup.node.ts` - Comprehensive test for early disconnect scenarios
|
||||
- Tests verify connection count stays at 0 even with rapid connect/disconnect patterns
|
||||
- Covers immediate disconnect, delayed disconnect, and mixed patterns
|
||||
|
||||
### Performance Impact
|
||||
- **Positive**: No more connection accumulation from early disconnects
|
||||
- **Positive**: Immediate cleanup reduces memory usage
|
||||
- **Positive**: Prevents resource exhaustion from rapid reconnection attempts
|
||||
|
||||
### Migration Notes
|
||||
No configuration changes needed. The fix is automatic and backward compatible.
|
||||
|
||||
## Proxy Chain Connection Accumulation Fix (v19.5.14+)
|
||||
|
||||
### Issue
|
||||
When chaining SmartProxies (Client → SmartProxy1 → SmartProxy2 → Backend), connections would accumulate and never be cleaned up. This was particularly severe when the backend was down or closing connections immediately.
|
||||
|
||||
### Root Cause
|
||||
The half-open connection support was preventing proper cascade cleanup in proxy chains:
|
||||
1. Backend closes → SmartProxy2's server socket closes
|
||||
2. SmartProxy2 keeps client socket open (half-open support)
|
||||
3. SmartProxy1 never gets notified that downstream is closed
|
||||
4. Connections accumulate at each proxy in the chain
|
||||
|
||||
The issue was in `createIndependentSocketHandlers()` which waited for BOTH sockets to close before cleanup.
|
||||
|
||||
### Solution
|
||||
1. **Changed default behavior**: When one socket closes, both close immediately
|
||||
2. **Made half-open support opt-in**: Only enabled when explicitly requested
|
||||
3. **Centralized socket handling**: Created `setupBidirectionalForwarding()` for consistent behavior
|
||||
4. **Applied everywhere**: Updated HttpProxyBridge and route-connection-handler to use centralized handling
|
||||
|
||||
### Changes Made
|
||||
```typescript
|
||||
// socket-utils.ts - Default behavior now closes both sockets
|
||||
export function createIndependentSocketHandlers(
|
||||
clientSocket, serverSocket, onBothClosed,
|
||||
options: { enableHalfOpen?: boolean } = {} // Half-open is opt-in
|
||||
) {
|
||||
// When server closes, immediately close client (unless half-open enabled)
|
||||
if (!clientClosed && !options.enableHalfOpen) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// New centralized function for consistent socket pairing
|
||||
export function setupBidirectionalForwarding(
|
||||
clientSocket, serverSocket,
|
||||
handlers: {
|
||||
onClientData?: (chunk) => void;
|
||||
onServerData?: (chunk) => void;
|
||||
onCleanup: (reason) => void;
|
||||
enableHalfOpen?: boolean; // Default: false
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
- `test/test.proxy-chain-simple.node.ts` - Verifies proxy chains don't accumulate connections
|
||||
- Tests confirm connections stay at 0 even with backend closing immediately
|
||||
- Works for any proxy chain configuration (not just localhost)
|
||||
|
||||
### Performance Impact
|
||||
- **Positive**: No more connection accumulation in proxy chains
|
||||
- **Positive**: Immediate cleanup reduces memory usage
|
||||
- **Neutral**: Half-open connections still available when needed (opt-in)
|
||||
|
||||
### Migration Notes
|
||||
No configuration changes needed. The fix applies to all proxy chains automatically.
|
||||
|
||||
## Socket Cleanup Handler Deprecation (v19.5.15+)
|
||||
|
||||
### Issue
|
||||
The deprecated `createSocketCleanupHandler()` function was still being used in forwarding handlers, despite being marked as deprecated.
|
||||
|
||||
### Solution
|
||||
Updated all forwarding handlers to use the new centralized socket utilities:
|
||||
1. **Replaced `createSocketCleanupHandler()`** with `setupBidirectionalForwarding()` in:
|
||||
- `https-terminate-to-https-handler.ts`
|
||||
- `https-terminate-to-http-handler.ts`
|
||||
2. **Removed deprecated function** from `socket-utils.ts`
|
||||
|
||||
### Benefits
|
||||
- Consistent socket handling across all handlers
|
||||
- Proper cleanup in proxy chains (no half-open connections by default)
|
||||
- Better backpressure handling with the centralized implementation
|
||||
- Reduced code duplication
|
||||
|
||||
### Migration Notes
|
||||
No user-facing changes. All forwarding handlers now use the same robust socket handling as the main SmartProxy connection handler.
|
||||
|
||||
## WrappedSocket Class Evaluation for PROXY Protocol (v19.5.19+)
|
||||
|
||||
### Current Socket Handling Architecture
|
||||
- Sockets are handled directly as `net.Socket` instances throughout the codebase
|
||||
- Socket augmentation via TypeScript module augmentation for TLS properties
|
||||
- Metadata tracked separately in `IConnectionRecord` objects
|
||||
- Socket utilities provide helper functions but don't encapsulate the socket
|
||||
- Connection records track extensive metadata (IDs, timestamps, byte counters, TLS state, etc.)
|
||||
|
||||
### Evaluation: Should We Introduce a WrappedSocket Class?
|
||||
|
||||
**Yes, a WrappedSocket class would make sense**, particularly for PROXY protocol implementation and future extensibility.
|
||||
|
||||
### Design Considerations for WrappedSocket
|
||||
|
||||
```typescript
|
||||
class WrappedSocket {
|
||||
private socket: net.Socket;
|
||||
private connectionId: string;
|
||||
private metadata: {
|
||||
realClientIP?: string; // From PROXY protocol
|
||||
realClientPort?: number; // From PROXY protocol
|
||||
proxyIP?: string; // Immediate connection IP
|
||||
proxyPort?: number; // Immediate connection port
|
||||
bytesReceived: number;
|
||||
bytesSent: number;
|
||||
lastActivity: number;
|
||||
isTLS: boolean;
|
||||
// ... other metadata
|
||||
};
|
||||
|
||||
// PROXY protocol handling
|
||||
private proxyProtocolParsed: boolean = false;
|
||||
private pendingData: Buffer[] = [];
|
||||
|
||||
constructor(socket: net.Socket) {
|
||||
this.socket = socket;
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
// Getters for clean access
|
||||
get remoteAddress(): string {
|
||||
return this.metadata.realClientIP || this.socket.remoteAddress || '';
|
||||
}
|
||||
|
||||
get remotePort(): number {
|
||||
return this.metadata.realClientPort || this.socket.remotePort || 0;
|
||||
}
|
||||
|
||||
get isFromTrustedProxy(): boolean {
|
||||
return !!this.metadata.realClientIP;
|
||||
}
|
||||
|
||||
// PROXY protocol parsing
|
||||
async parseProxyProtocol(trustedProxies: string[]): Promise<boolean> {
|
||||
// Implementation here
|
||||
}
|
||||
|
||||
// Delegate socket methods
|
||||
write(data: any): boolean {
|
||||
this.metadata.bytesSent += Buffer.byteLength(data);
|
||||
return this.socket.write(data);
|
||||
}
|
||||
|
||||
destroy(error?: Error): void {
|
||||
this.socket.destroy(error);
|
||||
}
|
||||
|
||||
// Event forwarding
|
||||
on(event: string, listener: Function): this {
|
||||
this.socket.on(event, listener);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Benefits
|
||||
|
||||
1. **Encapsulation**: Bundle socket + metadata + behavior in one place
|
||||
2. **PROXY Protocol Integration**: Cleaner handling without modifying existing socket code
|
||||
3. **State Management**: Centralized socket state tracking and validation
|
||||
4. **API Consistency**: Uniform interface for all socket operations
|
||||
5. **Future Extensibility**: Easy to add new socket-level features (compression, encryption, etc.)
|
||||
6. **Type Safety**: Better TypeScript support without module augmentation
|
||||
7. **Testing**: Easier to mock and test socket behavior
|
||||
|
||||
### Implementation Drawbacks
|
||||
|
||||
1. **Major Refactoring**: Would require changes throughout the codebase
|
||||
2. **Performance Overhead**: Additional abstraction layer (minimal but present)
|
||||
3. **Compatibility**: Need to maintain event emitter compatibility
|
||||
4. **Learning Curve**: Developers need to understand the wrapper
|
||||
|
||||
### Recommended Approach: Phased Implementation
|
||||
|
||||
**Phase 1: PROXY Protocol Only** (Immediate)
|
||||
- Create minimal `ProxyProtocolSocket` wrapper for new connections from trusted proxies
|
||||
- Use in connection handler when receiving from trusted proxy IPs
|
||||
- Minimal disruption to existing code
|
||||
|
||||
```typescript
|
||||
class ProxyProtocolSocket {
|
||||
constructor(
|
||||
public socket: net.Socket,
|
||||
public realClientIP?: string,
|
||||
public realClientPort?: number
|
||||
) {}
|
||||
|
||||
get remoteAddress(): string {
|
||||
return this.realClientIP || this.socket.remoteAddress || '';
|
||||
}
|
||||
|
||||
get remotePort(): number {
|
||||
return this.realClientPort || this.socket.remotePort || 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Phase 2: Gradual Migration** (Future)
|
||||
- Extend wrapper with more functionality
|
||||
- Migrate critical paths to use wrapper
|
||||
- Add performance monitoring
|
||||
|
||||
**Phase 3: Full Adoption** (Long-term)
|
||||
- Complete migration to WrappedSocket
|
||||
- Remove socket augmentation
|
||||
- Standardize all socket handling
|
||||
|
||||
### Decision Summary
|
||||
|
||||
✅ **Implement minimal ProxyProtocolSocket for immediate PROXY protocol support**
|
||||
- Low risk, high value
|
||||
- Solves the immediate proxy chain connection limit issue
|
||||
- Sets foundation for future improvements
|
||||
- Can be implemented alongside existing code
|
||||
|
||||
📋 **Consider full WrappedSocket for future major version**
|
||||
- Cleaner architecture
|
||||
- Better maintainability
|
||||
- But requires significant refactoring
|
||||
|
||||
## WrappedSocket Implementation (PROXY Protocol Phase 1) - v19.5.19+
|
||||
|
||||
The WrappedSocket class has been implemented as the foundation for PROXY protocol support:
|
||||
|
||||
### Implementation Details
|
||||
|
||||
1. **Design Approach**: Uses JavaScript Proxy to delegate all Socket methods/properties to the underlying socket while allowing override of specific properties (remoteAddress, remotePort).
|
||||
|
||||
2. **Key Design Decisions**:
|
||||
- NOT a Duplex stream - Initially tried this approach but it created infinite loops
|
||||
- Simple wrapper using Proxy pattern for transparent delegation
|
||||
- All sockets are wrapped, not just those from trusted proxies
|
||||
- Trusted proxy detection happens after wrapping
|
||||
|
||||
3. **Usage Pattern**:
|
||||
```typescript
|
||||
// In RouteConnectionHandler.handleConnection()
|
||||
const wrappedSocket = new WrappedSocket(socket);
|
||||
// Pass wrappedSocket throughout the flow
|
||||
|
||||
// When calling socket-utils functions, extract underlying socket:
|
||||
const underlyingSocket = getUnderlyingSocket(socket);
|
||||
setupBidirectionalForwarding(underlyingSocket, targetSocket, {...});
|
||||
```
|
||||
|
||||
4. **Important Implementation Notes**:
|
||||
- Socket utility functions (setupBidirectionalForwarding, cleanupSocket) expect raw net.Socket
|
||||
- Always extract underlying socket before passing to these utilities using `getUnderlyingSocket()`
|
||||
- WrappedSocket preserves all Socket functionality through Proxy delegation
|
||||
- TypeScript typing handled via index signature: `[key: string]: any`
|
||||
|
||||
5. **Files Modified**:
|
||||
- `ts/core/models/wrapped-socket.ts` - The WrappedSocket implementation
|
||||
- `ts/core/models/socket-types.ts` - Helper functions and type guards
|
||||
- `ts/proxies/smart-proxy/route-connection-handler.ts` - Updated to wrap all incoming sockets
|
||||
- `ts/proxies/smart-proxy/connection-manager.ts` - Updated to accept WrappedSocket
|
||||
- `ts/proxies/smart-proxy/http-proxy-bridge.ts` - Updated to handle WrappedSocket
|
||||
|
||||
6. **Test Coverage**:
|
||||
- `test/test.wrapped-socket-forwarding.ts` - Verifies data forwarding through wrapped sockets
|
||||
|
||||
### Next Steps for PROXY Protocol
|
||||
- Phase 2: Parse PROXY protocol header from trusted proxies
|
||||
- Phase 3: Update real client IP/port after parsing
|
||||
- Phase 4: Test with HAProxy and AWS ELB
|
||||
- Phase 5: Documentation and configuration
|
841
readme.plan.md
841
readme.plan.md
@ -1,316 +1,621 @@
|
||||
# SmartProxy Development Plan
|
||||
# PROXY Protocol Implementation Plan
|
||||
|
||||
## Implementation Plan: Socket Handler Function Support (Simplified) ✅ COMPLETED
|
||||
## ⚠️ CRITICAL: Implementation Order
|
||||
|
||||
### Overview
|
||||
Add support for custom socket handler functions with the simplest possible API - just pass a function that receives the socket.
|
||||
**Phase 1 (ProxyProtocolSocket/WrappedSocket) MUST be completed first!**
|
||||
|
||||
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. **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
|
||||
|
||||
### User Experience Goal
|
||||
```typescript
|
||||
const proxy = new SmartProxy({
|
||||
interface ISmartProxyOptions {
|
||||
// ... existing options
|
||||
|
||||
// List of trusted proxy IPs that can send PROXY protocol
|
||||
proxyIPs?: string[];
|
||||
|
||||
// Global option to accept PROXY protocol (defaults based on proxyIPs)
|
||||
acceptProxyProtocol?: boolean;
|
||||
|
||||
// Global option to send PROXY protocol to all targets
|
||||
sendProxyProtocol?: boolean;
|
||||
}
|
||||
|
||||
interface IRouteAction {
|
||||
// ... existing options
|
||||
|
||||
// Send PROXY protocol to this specific target
|
||||
sendProxyProtocol?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Implementation Steps
|
||||
|
||||
#### IMPORTANT: Phase 1 Must Be Completed First
|
||||
The `ProxyProtocolSocket` (WrappedSocket) is the foundation for all PROXY protocol functionality. This wrapper class must be implemented and integrated BEFORE any PROXY protocol parsing can begin.
|
||||
|
||||
#### Phase 1: ProxyProtocolSocket (WrappedSocket) Foundation - ✅ COMPLETED (v19.5.19)
|
||||
This phase creates the socket wrapper infrastructure that all subsequent phases depend on.
|
||||
|
||||
1. **Create WrappedSocket class** in `ts/core/models/wrapped-socket.ts` ✅
|
||||
- Used JavaScript Proxy pattern instead of EventEmitter (avoids infinite loops)
|
||||
- Properties for real client IP and port
|
||||
- Transparent getters that return real or socket IP/port
|
||||
- All socket methods/properties delegated via Proxy
|
||||
|
||||
2. **Implement core wrapper functionality** ✅
|
||||
- Constructor accepts regular socket + optional metadata
|
||||
- `remoteAddress` getter returns real IP or falls back to socket IP
|
||||
- `remotePort` getter returns real port or falls back to socket port
|
||||
- `isFromTrustedProxy` property to check if it has real client info
|
||||
- `setProxyInfo()` method to update real client details
|
||||
|
||||
3. **Update ConnectionManager to handle wrapped sockets** ✅
|
||||
- Accept either `net.Socket` or `WrappedSocket`
|
||||
- Created `getUnderlyingSocket()` helper for socket utilities
|
||||
- All socket utility functions extract underlying socket
|
||||
|
||||
4. **Integration completed** ✅
|
||||
- All incoming sockets wrapped in RouteConnectionHandler
|
||||
- Socket forwarding verified working with wrapped sockets
|
||||
- Type safety maintained with index signature
|
||||
|
||||
**Deliverables**: ✅ Working WrappedSocket that can wrap any socket and provide transparent access to client info.
|
||||
|
||||
#### Phase 2: PROXY Protocol Parser - DEPENDS ON PHASE 1
|
||||
Only after WrappedSocket is working can we add protocol parsing.
|
||||
|
||||
1. Create `ProxyProtocolParser` class in `ts/core/utils/proxy-protocol.ts`
|
||||
2. Implement v1 text format parsing
|
||||
3. Add validation and error handling
|
||||
4. Integrate parser to work WITH WrappedSocket (not into it)
|
||||
|
||||
#### Phase 3: Connection Handler Integration - DEPENDS ON PHASES 1 & 2
|
||||
1. ✅ Modify `RouteConnectionHandler` to create WrappedSocket for all connections
|
||||
2. Check if connection is from trusted proxy IP
|
||||
3. If trusted, attempt to parse PROXY protocol header
|
||||
4. Update wrapped socket with real client info
|
||||
5. Continue normal connection handling with wrapped socket
|
||||
|
||||
#### Phase 4: Outbound PROXY Protocol - DEPENDS ON PHASES 1-3
|
||||
1. Add PROXY header generation in `setupDirectConnection`
|
||||
2. Make it configurable per route
|
||||
3. Send header immediately after TCP connection
|
||||
4. Use ProxyProtocolSocket for outbound connections too
|
||||
|
||||
#### Phase 5: Security & Validation - FINAL PHASE
|
||||
1. Validate PROXY headers strictly
|
||||
2. Reject malformed headers
|
||||
3. Only accept from trusted IPs
|
||||
4. Add rate limiting for PROXY protocol parsing
|
||||
|
||||
### 4. Design Decision: Socket Wrapper Architecture
|
||||
|
||||
#### Option A: Minimal Single Socket Wrapper
|
||||
- **Scope**: Wraps individual sockets with metadata
|
||||
- **Use Case**: PROXY protocol support with minimal refactoring
|
||||
- **Pros**: Simple, low risk, easy migration
|
||||
- **Cons**: Still need separate connection management
|
||||
|
||||
#### Option B: Comprehensive Connection Wrapper
|
||||
- **Scope**: Manages socket pairs (incoming + outgoing) with all utilities
|
||||
- **Use Case**: Complete connection lifecycle management
|
||||
- **Pros**:
|
||||
- Encapsulates all socket utilities (forwarding, cleanup, backpressure)
|
||||
- Single object represents entire connection
|
||||
- Cleaner API for connection handling
|
||||
- **Cons**:
|
||||
- Major architectural change
|
||||
- Higher implementation risk
|
||||
- More complex migration
|
||||
|
||||
#### Recommendation
|
||||
Start with **Option A** (ProxyProtocolSocket) for immediate PROXY protocol support, then evaluate Option B based on:
|
||||
- Performance impact of additional abstraction
|
||||
- Code simplification benefits
|
||||
- Team comfort with architectural change
|
||||
|
||||
### 5. Code Implementation Details
|
||||
|
||||
#### 5.1 ProxyProtocolSocket (WrappedSocket) - PHASE 1 IMPLEMENTATION
|
||||
This is the foundational wrapper class that MUST be implemented first. It wraps a regular socket and provides transparent access to the real client IP/port.
|
||||
|
||||
```typescript
|
||||
// ts/core/models/proxy-protocol-socket.ts
|
||||
import { EventEmitter } from 'events';
|
||||
import * as plugins from '../../../plugins.js';
|
||||
|
||||
/**
|
||||
* ProxyProtocolSocket wraps a regular net.Socket to provide transparent access
|
||||
* to the real client IP and port when behind a proxy using PROXY protocol.
|
||||
*
|
||||
* This is the FOUNDATION for all PROXY protocol support and must be implemented
|
||||
* before any protocol parsing can occur.
|
||||
*/
|
||||
export class ProxyProtocolSocket extends EventEmitter {
|
||||
private realClientIP?: string;
|
||||
private realClientPort?: number;
|
||||
|
||||
constructor(
|
||||
public readonly socket: plugins.net.Socket,
|
||||
realClientIP?: string,
|
||||
realClientPort?: number
|
||||
) {
|
||||
super();
|
||||
this.realClientIP = realClientIP;
|
||||
this.realClientPort = realClientPort;
|
||||
|
||||
// Forward all socket events
|
||||
this.forwardSocketEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the real client IP if available, otherwise the socket's remote address
|
||||
*/
|
||||
get remoteAddress(): string | undefined {
|
||||
return this.realClientIP || this.socket.remoteAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the real client port if available, otherwise the socket's remote port
|
||||
*/
|
||||
get remotePort(): number | undefined {
|
||||
return this.realClientPort || this.socket.remotePort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this connection came through a trusted proxy
|
||||
*/
|
||||
get isFromTrustedProxy(): boolean {
|
||||
return !!this.realClientIP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the real client information (called after parsing PROXY protocol)
|
||||
*/
|
||||
setProxyInfo(ip: string, port: number): void {
|
||||
this.realClientIP = ip;
|
||||
this.realClientPort = port;
|
||||
}
|
||||
|
||||
// Pass-through all socket methods
|
||||
write(data: any, encoding?: any, callback?: any): boolean {
|
||||
return this.socket.write(data, encoding, callback);
|
||||
}
|
||||
|
||||
end(data?: any, encoding?: any, callback?: any): this {
|
||||
this.socket.end(data, encoding, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(error?: Error): this {
|
||||
this.socket.destroy(error);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ... implement all other socket methods as pass-through
|
||||
|
||||
/**
|
||||
* Forward all events from the underlying socket
|
||||
*/
|
||||
private forwardSocketEvents(): void {
|
||||
const events = ['data', 'end', 'close', 'error', 'drain', 'timeout'];
|
||||
events.forEach(event => {
|
||||
this.socket.on(event, (...args) => {
|
||||
this.emit(event, ...args);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**KEY POINT**: This wrapper must be fully functional and tested BEFORE moving to Phase 2.
|
||||
|
||||
#### 4.2 ProxyProtocolParser (new file)
|
||||
```typescript
|
||||
// ts/core/utils/proxy-protocol.ts
|
||||
export class ProxyProtocolParser {
|
||||
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
|
||||
|
||||
static parse(chunk: Buffer): IProxyInfo | null {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
static generate(info: IProxyInfo): Buffer {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3 Connection Handler Updates
|
||||
```typescript
|
||||
// In handleConnection method
|
||||
let wrappedSocket: ProxyProtocolSocket | plugins.net.Socket = socket;
|
||||
|
||||
// Wrap socket if from trusted proxy
|
||||
if (this.settings.proxyIPs?.includes(socket.remoteAddress)) {
|
||||
wrappedSocket = new ProxyProtocolSocket(socket);
|
||||
}
|
||||
|
||||
// Create connection record with wrapped socket
|
||||
const record = this.connectionManager.createConnection(wrappedSocket);
|
||||
|
||||
// In handleInitialData method
|
||||
if (wrappedSocket instanceof ProxyProtocolSocket) {
|
||||
const proxyInfo = await this.checkForProxyProtocol(chunk);
|
||||
if (proxyInfo) {
|
||||
wrappedSocket.setProxyInfo(proxyInfo.sourceIP, proxyInfo.sourcePort);
|
||||
// Continue with remaining data after PROXY header
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.4 Security Manager Updates
|
||||
- Accept socket or ProxyProtocolSocket
|
||||
- Use `socket.remoteAddress` getter for real client IP
|
||||
- Transparent handling of both socket types
|
||||
|
||||
### 5. Configuration Examples
|
||||
|
||||
#### Basic Setup
|
||||
```typescript
|
||||
// Outer proxy - sends PROXY protocol
|
||||
const outerProxy = new SmartProxy({
|
||||
ports: [443],
|
||||
routes: [{
|
||||
name: 'my-custom-protocol',
|
||||
match: { ports: 9000, domains: 'custom.example.com' },
|
||||
name: 'to-inner-proxy',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket) => {
|
||||
// User has full control of the socket
|
||||
socket.write('Welcome!\n');
|
||||
socket.on('data', (data) => {
|
||||
socket.write(`Echo: ${data}`);
|
||||
});
|
||||
}
|
||||
type: 'forward',
|
||||
target: { host: '195.201.98.232', port: 443 },
|
||||
sendProxyProtocol: true // Enable for this route
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Inner proxy - accepts PROXY protocol from outer proxy
|
||||
const innerProxy = new SmartProxy({
|
||||
ports: [443],
|
||||
proxyIPs: ['212.95.99.130'], // Outer proxy IP
|
||||
// acceptProxyProtocol: true is automatic for proxyIPs
|
||||
routes: [{
|
||||
name: 'to-backend',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: '192.168.5.247', port: 443 }
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
That's it. Simple and powerful.
|
||||
### 6. Testing Plan
|
||||
|
||||
---
|
||||
#### Unit Tests
|
||||
- PROXY protocol v1 parsing (valid/invalid formats)
|
||||
- Header generation
|
||||
- Trusted IP validation
|
||||
- Connection record updates
|
||||
|
||||
## Phase 1: Minimal Type Changes
|
||||
#### 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
|
||||
|
||||
### 1.1 Add Socket Handler Action Type
|
||||
**File:** `ts/proxies/smart-proxy/models/route-types.ts`
|
||||
#### Test Scenarios
|
||||
1. **Connection limit test**: Verify inner proxy sees real client IPs
|
||||
2. **Security test**: Ensure PROXY protocol rejected from untrusted sources
|
||||
3. **Compatibility test**: Verify no impact on non-PROXY connections
|
||||
4. **Performance test**: Measure overhead of PROXY protocol parsing
|
||||
|
||||
### 7. Security Considerations
|
||||
|
||||
1. **IP Spoofing Prevention**
|
||||
- Only accept PROXY protocol from explicitly trusted IPs
|
||||
- Validate all header fields
|
||||
- Reject malformed headers immediately
|
||||
|
||||
2. **Resource Protection**
|
||||
- Limit PROXY header size (107 bytes for v1)
|
||||
- Timeout for incomplete headers
|
||||
- Rate limit connection attempts
|
||||
|
||||
3. **Logging**
|
||||
- Log all PROXY protocol acceptance/rejection
|
||||
- Include real client IP in all connection logs
|
||||
|
||||
### 8. Rollout Strategy
|
||||
|
||||
1. **Phase 1**: Deploy parser and acceptance (backward compatible)
|
||||
2. **Phase 2**: Enable between controlled proxy pairs
|
||||
3. **Phase 3**: Monitor for issues and performance impact
|
||||
4. **Phase 4**: Expand to all proxy chains
|
||||
|
||||
### 9. Success Metrics
|
||||
|
||||
- Inner proxy connection distribution matches outer proxy
|
||||
- No more connection limit rejections in proxy chains
|
||||
- Accurate client IP logging throughout the chain
|
||||
- No performance degradation (<1ms added latency)
|
||||
|
||||
### 10. Future Enhancements
|
||||
|
||||
- PROXY protocol v2 (binary format) support
|
||||
- TLV extensions for additional metadata
|
||||
- AWS VPC endpoint ID support
|
||||
- Custom metadata fields
|
||||
|
||||
## WrappedSocket Class Design
|
||||
|
||||
### Overview
|
||||
A WrappedSocket class has been evaluated and recommended to provide cleaner PROXY protocol integration and better socket management architecture.
|
||||
|
||||
### Rationale for WrappedSocket
|
||||
|
||||
#### Current Challenges
|
||||
- Sockets handled directly as `net.Socket` instances throughout codebase
|
||||
- Metadata tracked separately in `IConnectionRecord` objects
|
||||
- Socket augmentation via TypeScript module augmentation for TLS properties
|
||||
- PROXY protocol would require modifying socket handling in multiple places
|
||||
|
||||
#### Benefits
|
||||
1. **Clean PROXY Protocol Integration** - Parse and store real client IP/port without modifying existing socket handling
|
||||
2. **Better Encapsulation** - Bundle socket + metadata + behavior together
|
||||
3. **Type Safety** - No more module augmentation needed
|
||||
4. **Future Extensibility** - Easy to add compression, metrics, etc.
|
||||
5. **Simplified Testing** - Easier to mock and test socket behavior
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
#### Phase 1: Minimal ProxyProtocolSocket (Immediate)
|
||||
Create a minimal wrapper for PROXY protocol support:
|
||||
|
||||
```typescript
|
||||
// Update action type
|
||||
export type TRouteActionType = 'forward' | 'redirect' | 'block' | 'static' | 'socket-handler';
|
||||
|
||||
// Add simple socket handler type
|
||||
export type TSocketHandler = (socket: net.Socket) => void | Promise<void>;
|
||||
|
||||
// Extend IRouteAction
|
||||
export interface IRouteAction {
|
||||
// ... existing properties
|
||||
class ProxyProtocolSocket {
|
||||
constructor(
|
||||
public socket: net.Socket,
|
||||
public realClientIP?: string,
|
||||
public realClientPort?: number
|
||||
) {}
|
||||
|
||||
// Socket handler function (when type is 'socket-handler')
|
||||
socketHandler?: TSocketHandler;
|
||||
get remoteAddress(): string {
|
||||
return this.realClientIP || this.socket.remoteAddress || '';
|
||||
}
|
||||
|
||||
get remotePort(): number {
|
||||
return this.realClientPort || this.socket.remotePort || 0;
|
||||
}
|
||||
|
||||
get isFromTrustedProxy(): boolean {
|
||||
return !!this.realClientIP;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
Integration points:
|
||||
- Use in `RouteConnectionHandler` when receiving from trusted proxy IPs
|
||||
- Update `ConnectionManager` to accept wrapped sockets
|
||||
- Modify security checks to use `socket.remoteAddress` getter
|
||||
|
||||
## Phase 2: Simple Implementation
|
||||
|
||||
### 2.1 Update Route Connection Handler
|
||||
**File:** `ts/proxies/smart-proxy/route-connection-handler.ts`
|
||||
|
||||
In the `handleConnection` method, add handling for socket-handler:
|
||||
#### Phase 2: Connection-Aware WrappedSocket (Alternative Design)
|
||||
A more comprehensive design that manages both sides of a connection:
|
||||
|
||||
```typescript
|
||||
// After route matching...
|
||||
if (matchedRoute) {
|
||||
const action = matchedRoute.action;
|
||||
// Option A: Single Socket Wrapper (simpler)
|
||||
class WrappedSocket extends EventEmitter {
|
||||
private socket: net.Socket;
|
||||
private connectionId: string;
|
||||
private metadata: ISocketMetadata;
|
||||
|
||||
if (action.type === 'socket-handler') {
|
||||
if (!action.socketHandler) {
|
||||
logger.error('socket-handler action missing socketHandler function');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
constructor(socket: net.Socket, metadata?: Partial<ISocketMetadata>) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
this.connectionId = this.generateId();
|
||||
this.metadata = { ...defaultMetadata, ...metadata };
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
// ... single socket management
|
||||
}
|
||||
|
||||
// Option B: Connection Pair Wrapper (comprehensive)
|
||||
class WrappedConnection extends EventEmitter {
|
||||
private connectionId: string;
|
||||
private incoming: WrappedSocket;
|
||||
private outgoing?: WrappedSocket;
|
||||
private forwardingActive: boolean = false;
|
||||
|
||||
constructor(incomingSocket: net.Socket) {
|
||||
super();
|
||||
this.connectionId = this.generateId();
|
||||
this.incoming = new WrappedSocket(incomingSocket);
|
||||
}
|
||||
|
||||
// Connect to backend and set up forwarding
|
||||
async connectToBackend(target: ITarget): Promise<void> {
|
||||
const outgoingSocket = await this.createOutgoingConnection(target);
|
||||
this.outgoing = new WrappedSocket(outgoingSocket);
|
||||
await this.setupBidirectionalForwarding();
|
||||
}
|
||||
|
||||
// Built-in forwarding logic from socket-utils
|
||||
private async setupBidirectionalForwarding(): Promise<void> {
|
||||
if (!this.outgoing) throw new Error('No outgoing socket');
|
||||
|
||||
try {
|
||||
// Simply call the handler with the socket
|
||||
const result = action.socketHandler(socket);
|
||||
|
||||
// If it returns a promise, handle errors
|
||||
if (result instanceof Promise) {
|
||||
result.catch(error => {
|
||||
logger.error('Socket handler error:', error);
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Socket handler error:', error);
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
return; // Done - user has control now
|
||||
}
|
||||
|
||||
// ... rest of existing action handling
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Optional Context (If Needed)
|
||||
|
||||
If users need more info, we can optionally pass a minimal context as a second parameter:
|
||||
|
||||
```typescript
|
||||
export type TSocketHandler = (
|
||||
socket: net.Socket,
|
||||
context?: {
|
||||
route: IRouteConfig;
|
||||
clientIp: string;
|
||||
localPort: number;
|
||||
}
|
||||
) => void | Promise<void>;
|
||||
```
|
||||
|
||||
Usage:
|
||||
```typescript
|
||||
socketHandler: (socket, context) => {
|
||||
console.log(`Connection from ${context.clientIp} to port ${context.localPort}`);
|
||||
// Handle socket...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Helper Utilities (Optional)
|
||||
|
||||
### 4.1 Common Patterns
|
||||
**File:** `ts/proxies/smart-proxy/utils/route-helpers.ts`
|
||||
|
||||
```typescript
|
||||
// Simple helper to create socket handler routes
|
||||
export function createSocketHandlerRoute(
|
||||
domains: string | string[],
|
||||
ports: TPortRange,
|
||||
handler: TSocketHandler,
|
||||
options?: { name?: string; priority?: number }
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options?.name || 'socket-handler-route',
|
||||
priority: options?.priority || 50,
|
||||
match: { domains, ports },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: handler
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-built handlers for common cases
|
||||
export const SocketHandlers = {
|
||||
// Simple echo server
|
||||
echo: (socket: net.Socket) => {
|
||||
socket.on('data', data => socket.write(data));
|
||||
},
|
||||
|
||||
// TCP proxy
|
||||
proxy: (targetHost: string, targetPort: number) => (socket: net.Socket) => {
|
||||
const target = net.connect(targetPort, targetHost);
|
||||
socket.pipe(target);
|
||||
target.pipe(socket);
|
||||
socket.on('close', () => target.destroy());
|
||||
target.on('close', () => socket.destroy());
|
||||
},
|
||||
|
||||
// Line-based protocol
|
||||
lineProtocol: (handler: (line: string, socket: net.Socket) => void) => (socket: net.Socket) => {
|
||||
let buffer = '';
|
||||
socket.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
lines.forEach(line => handler(line, socket));
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Custom Protocol
|
||||
```typescript
|
||||
{
|
||||
name: 'custom-protocol',
|
||||
match: { ports: 9000 },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket) => {
|
||||
socket.write('READY\n');
|
||||
socket.on('data', (data) => {
|
||||
const cmd = data.toString().trim();
|
||||
if (cmd === 'PING') socket.write('PONG\n');
|
||||
else if (cmd === 'QUIT') socket.end();
|
||||
else socket.write('ERROR: Unknown command\n');
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Simple TCP Proxy
|
||||
```typescript
|
||||
{
|
||||
name: 'tcp-proxy',
|
||||
match: { ports: 8080, domains: 'proxy.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.proxy('backend.local', 3000)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: WebSocket with Custom Auth
|
||||
```typescript
|
||||
{
|
||||
name: 'custom-websocket',
|
||||
match: { ports: [80, 443], path: '/ws' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: async (socket) => {
|
||||
// Read HTTP headers
|
||||
const headers = await readHttpHeaders(socket);
|
||||
|
||||
// Custom auth check
|
||||
if (!headers.authorization || !validateToken(headers.authorization)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.end();
|
||||
return;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Proceed with WebSocket upgrade
|
||||
const ws = new WebSocket(socket, headers);
|
||||
// ... handle WebSocket
|
||||
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 of This Approach
|
||||
### Integration with PROXY Protocol
|
||||
|
||||
1. **Dead Simple API**: Just pass a function that gets the socket
|
||||
2. **No New Classes**: No ForwardingHandler subclass needed
|
||||
3. **Minimal Changes**: Only touches type definitions and one handler method
|
||||
4. **Full Power**: Users have complete control over the socket
|
||||
5. **Backward Compatible**: No changes to existing functionality
|
||||
6. **Easy to Test**: Just test the socket handler functions directly
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
2. **Security Checks**:
|
||||
```typescript
|
||||
// Automatically uses real client IP if available
|
||||
const clientIP = wrappedSocket.remoteAddress;
|
||||
if (!this.securityManager.isIPAllowed(clientIP)) {
|
||||
wrappedSocket.destroy();
|
||||
}
|
||||
```
|
||||
|
||||
1. Add `'socket-handler'` to `TRouteActionType` (5 minutes)
|
||||
2. Add `socketHandler?: TSocketHandler` to `IRouteAction` (5 minutes)
|
||||
3. Add socket-handler case in `RouteConnectionHandler.handleConnection()` (15 minutes)
|
||||
4. Add helper functions (optional, 30 minutes)
|
||||
5. Write tests (2 hours)
|
||||
6. Update documentation (1 hour)
|
||||
3. **Connection Records**:
|
||||
```typescript
|
||||
const record = this.connectionManager.createConnection(wrappedSocket);
|
||||
// ConnectionManager uses wrappedSocket.remoteAddress transparently
|
||||
```
|
||||
|
||||
**Total implementation time: ~4 hours** (vs 6 weeks for the complex version)
|
||||
### 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);
|
||||
```
|
||||
|
||||
## What We're NOT Doing
|
||||
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
|
||||
|
||||
- ❌ Creating new ForwardingHandler classes
|
||||
- ❌ Complex context objects with utils
|
||||
- ❌ HTTP request handling for socket handlers
|
||||
- ❌ Complex protocol detection mechanisms
|
||||
- ❌ Middleware patterns
|
||||
- ❌ Lifecycle hooks
|
||||
connection.on('closed', (reason) => {
|
||||
logger.log('Connection closed', connection.getMetrics());
|
||||
});
|
||||
```
|
||||
|
||||
Keep it simple. The user just wants to handle a socket.
|
||||
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
|
||||
|
||||
## Success Criteria
|
||||
### Migration Path
|
||||
|
||||
- ✅ Users can define a route with `type: 'socket-handler'`
|
||||
- ✅ Users can provide a function that receives the socket
|
||||
- ✅ The function is called when a connection matches the route
|
||||
- ✅ Error handling prevents crashes
|
||||
- ✅ No performance impact on existing routes
|
||||
- ✅ Clean, simple API that's easy to understand
|
||||
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
|
||||
|
||||
## Implementation Notes (Completed)
|
||||
|
||||
### What Was Implemented
|
||||
1. **Type Definitions** - Added 'socket-handler' to TRouteActionType and TSocketHandler type
|
||||
2. **Route Handler** - Added socket-handler case in RouteConnectionHandler switch statement
|
||||
3. **Error Handling** - Both sync and async errors are caught and logged
|
||||
4. **Initial Data Handling** - Initial chunks are re-emitted to handler's listeners
|
||||
5. **Helper Functions** - Added createSocketHandlerRoute and pre-built handlers (echo, proxy, etc.)
|
||||
6. **Full Test Coverage** - All test cases pass including async handlers and error handling
|
||||
|
||||
### Key Implementation Details
|
||||
- Socket handlers require initial data from client to trigger routing (not TLS handshake)
|
||||
- The handler receives the raw socket after route matching
|
||||
- Both sync and async handlers are supported
|
||||
- Errors in handlers terminate the connection gracefully
|
||||
- Helper utilities provide common patterns (echo server, TCP proxy, line protocol)
|
||||
|
||||
### Usage Notes
|
||||
- Clients must send initial data to trigger the handler (even just a newline)
|
||||
- The socket is passed directly to the handler function
|
||||
- Handler has complete control over the socket lifecycle
|
||||
- No special context object needed - keeps it simple
|
||||
|
||||
**Total implementation time: ~3 hours**
|
||||
- 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
|
764
readme.plan2.md
764
readme.plan2.md
@ -1,764 +0,0 @@
|
||||
# SmartProxy Simplification Plan: Unify Action Types
|
||||
|
||||
## Summary
|
||||
Complete removal of 'redirect', 'block', and 'static' action types, leaving only 'forward' and 'socket-handler'. All old code will be deleted entirely - no migration paths or backwards compatibility. Socket handlers will be enhanced to receive IRouteContext as a second parameter.
|
||||
|
||||
## Goal
|
||||
Create a dramatically simpler SmartProxy with only two action types, where everything is either proxied (forward) or handled by custom code (socket-handler).
|
||||
|
||||
## Current State
|
||||
```typescript
|
||||
export type TRouteActionType = 'forward' | 'redirect' | 'block' | 'static' | 'socket-handler';
|
||||
export type TSocketHandler = (socket: plugins.net.Socket) => void | Promise<void>;
|
||||
```
|
||||
|
||||
## Target State
|
||||
```typescript
|
||||
export type TRouteActionType = 'forward' | 'socket-handler';
|
||||
export type TSocketHandler = (socket: plugins.net.Socket, context: IRouteContext) => void | Promise<void>;
|
||||
```
|
||||
|
||||
## Benefits
|
||||
1. **Simpler API** - Only two action types to understand
|
||||
2. **Unified handling** - Everything is either forwarding or custom socket handling
|
||||
3. **More flexible** - Socket handlers can do anything the old types did and more
|
||||
4. **Less code** - Remove specialized handlers and their dependencies
|
||||
5. **Context aware** - Socket handlers get access to route context (domain, port, clientIp, etc.)
|
||||
6. **Clean codebase** - No legacy code or migration paths
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Code to Remove
|
||||
|
||||
### 1.1 Action Type Handlers
|
||||
- `RouteConnectionHandler.handleRedirectAction()`
|
||||
- `RouteConnectionHandler.handleBlockAction()`
|
||||
- `RouteConnectionHandler.handleStaticAction()`
|
||||
|
||||
### 1.2 Handler Classes
|
||||
- `RedirectHandler` class (http-proxy/handlers/)
|
||||
- `StaticHandler` class (http-proxy/handlers/)
|
||||
|
||||
### 1.3 Type Definitions
|
||||
- 'redirect', 'block', 'static' from TRouteActionType
|
||||
- IRouteRedirect interface
|
||||
- IRouteStatic interface
|
||||
- Related properties in IRouteAction
|
||||
|
||||
### 1.4 Helper Functions
|
||||
- `createStaticFileRoute()`
|
||||
- Any other helpers that create redirect/block/static routes
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create Predefined Socket Handlers
|
||||
|
||||
### 2.1 Block Handler
|
||||
```typescript
|
||||
export const SocketHandlers = {
|
||||
// ... existing handlers
|
||||
|
||||
/**
|
||||
* Block connection immediately
|
||||
*/
|
||||
block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
// Can use context for logging or custom messages
|
||||
const finalMessage = message || `Connection blocked from ${context.clientIp}`;
|
||||
if (finalMessage) {
|
||||
socket.write(finalMessage);
|
||||
}
|
||||
socket.end();
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP block response
|
||||
*/
|
||||
httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
// Can customize message based on context
|
||||
const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`;
|
||||
const finalMessage = message || defaultMessage;
|
||||
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${finalMessage}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${finalMessage.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
finalMessage
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Redirect Handler
|
||||
```typescript
|
||||
export const SocketHandlers = {
|
||||
// ... existing handlers
|
||||
|
||||
/**
|
||||
* HTTP redirect handler
|
||||
*/
|
||||
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
let buffer = '';
|
||||
|
||||
socket.once('data', (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Parse HTTP request
|
||||
const lines = buffer.split('\r\n');
|
||||
const requestLine = lines[0];
|
||||
const [method, path] = requestLine.split(' ');
|
||||
|
||||
// Use domain from context (more reliable than Host header)
|
||||
const domain = context.domain || 'localhost';
|
||||
const port = context.port;
|
||||
|
||||
// Replace placeholders in location using context
|
||||
let finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(port))
|
||||
.replace('{path}', path)
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 Benefits of Context in Socket Handlers
|
||||
With routeContext as a second parameter, socket handlers can:
|
||||
- Access client IP for logging or rate limiting
|
||||
- Use domain information for multi-tenant handling
|
||||
- Check if connection is TLS and what version
|
||||
- Access route name/ID for metrics
|
||||
- Build more intelligent responses based on context
|
||||
|
||||
Example advanced handler:
|
||||
```typescript
|
||||
const rateLimitHandler = (maxRequests: number) => {
|
||||
const ipCounts = new Map<string, number>();
|
||||
|
||||
return (socket: net.Socket, context: IRouteContext) => {
|
||||
const count = (ipCounts.get(context.clientIp) || 0) + 1;
|
||||
ipCounts.set(context.clientIp, count);
|
||||
|
||||
if (count > maxRequests) {
|
||||
socket.write(`Rate limit exceeded for ${context.clientIp}\n`);
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Process request...
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Update Helper Functions
|
||||
|
||||
### 3.1 Update createHttpToHttpsRedirect
|
||||
```typescript
|
||||
export function createHttpToHttpsRedirect(
|
||||
domains: string | string[],
|
||||
httpsPort: number = 443,
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
match: {
|
||||
ports: options.match?.ports || 80,
|
||||
domains
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
|
||||
},
|
||||
...options
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Update createSocketHandlerRoute
|
||||
```typescript
|
||||
export function createSocketHandlerRoute(
|
||||
domains: string | string[],
|
||||
ports: TPortRange,
|
||||
handler: TSocketHandler,
|
||||
options: { name?: string; priority?: number; path?: string } = {}
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options.name || 'socket-handler-route',
|
||||
priority: options.priority !== undefined ? options.priority : 50,
|
||||
match: {
|
||||
domains,
|
||||
ports,
|
||||
...(options.path && { path: options.path })
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: handler
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Core Implementation Changes
|
||||
|
||||
### 4.1 Update Route Connection Handler
|
||||
```typescript
|
||||
// Remove these methods:
|
||||
// - handleRedirectAction()
|
||||
// - handleBlockAction()
|
||||
// - handleStaticAction()
|
||||
|
||||
// Update switch statement to only have:
|
||||
switch (route.action.type) {
|
||||
case 'forward':
|
||||
return this.handleForwardAction(socket, record, route, initialChunk);
|
||||
|
||||
case 'socket-handler':
|
||||
this.handleSocketHandlerAction(socket, record, route, initialChunk);
|
||||
return;
|
||||
|
||||
default:
|
||||
logger.log('error', `Unknown action type '${(route.action as any).type}'`);
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'unknown_action');
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Update Socket Handler to Pass Context
|
||||
```typescript
|
||||
private async handleSocketHandlerAction(
|
||||
socket: plugins.net.Socket,
|
||||
record: IConnectionRecord,
|
||||
route: IRouteConfig,
|
||||
initialChunk?: Buffer
|
||||
): Promise<void> {
|
||||
const connectionId = record.id;
|
||||
|
||||
// Create route context for the handler
|
||||
const routeContext = this.createRouteContext({
|
||||
connectionId: record.id,
|
||||
port: record.localPort,
|
||||
domain: record.lockedDomain,
|
||||
clientIp: record.remoteIP,
|
||||
serverIp: socket.localAddress || '',
|
||||
isTls: record.isTLS || false,
|
||||
tlsVersion: record.tlsVersion,
|
||||
routeName: route.name,
|
||||
routeId: route.id,
|
||||
});
|
||||
|
||||
try {
|
||||
// Call the handler with socket AND context
|
||||
const result = route.action.socketHandler(socket, routeContext);
|
||||
|
||||
// Rest of implementation stays the same...
|
||||
} catch (error) {
|
||||
// Error handling...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Clean Up Imports and Exports
|
||||
- Remove imports of deleted handler classes
|
||||
- Update index.ts files to remove exports
|
||||
- Clean up any unused imports
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Test Updates
|
||||
|
||||
### 5.1 Remove Old Tests
|
||||
- Delete tests for redirect action type
|
||||
- Delete tests for block action type
|
||||
- Delete tests for static action type
|
||||
|
||||
### 5.2 Add New Socket Handler Tests
|
||||
- Test block socket handler with context
|
||||
- Test HTTP redirect socket handler with context
|
||||
- Test that context is properly passed to all handlers
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Documentation Updates
|
||||
|
||||
### 6.1 Update README.md
|
||||
- Remove documentation for redirect, block, static action types
|
||||
- Document the two remaining action types: forward and socket-handler
|
||||
- Add examples using socket handlers with context
|
||||
|
||||
### 6.2 Update Type Documentation
|
||||
```typescript
|
||||
/**
|
||||
* Route action types
|
||||
* - 'forward': Proxy the connection to a target host:port
|
||||
* - 'socket-handler': Pass the socket to a custom handler function
|
||||
*/
|
||||
export type TRouteActionType = 'forward' | 'socket-handler';
|
||||
|
||||
/**
|
||||
* Socket handler function
|
||||
* @param socket - The incoming socket connection
|
||||
* @param context - Route context with connection information
|
||||
*/
|
||||
export type TSocketHandler = (socket: net.Socket, context: IRouteContext) => void | Promise<void>;
|
||||
```
|
||||
|
||||
### 6.3 Example Documentation
|
||||
```typescript
|
||||
// Example: Block connections from specific IPs
|
||||
const ipBlocker = (socket: net.Socket, context: IRouteContext) => {
|
||||
if (context.clientIp.startsWith('192.168.')) {
|
||||
socket.write('Internal IPs not allowed\n');
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
// Forward to backend...
|
||||
};
|
||||
|
||||
// Example: Domain-based routing
|
||||
const domainRouter = (socket: net.Socket, context: IRouteContext) => {
|
||||
const backend = context.domain === 'api.example.com' ? 'api-server' : 'web-server';
|
||||
// Forward to appropriate backend...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Update TSocketHandler type** (15 minutes)
|
||||
- Add IRouteContext as second parameter
|
||||
- Update type definition in route-types.ts
|
||||
|
||||
2. **Update socket handler implementation** (30 minutes)
|
||||
- Create routeContext in handleSocketHandlerAction
|
||||
- Pass context to socket handler function
|
||||
- Update all existing socket handlers in route-helpers.ts
|
||||
|
||||
3. **Remove old action types** (30 minutes)
|
||||
- Remove 'redirect', 'block', 'static' from TRouteActionType
|
||||
- Remove IRouteRedirect, IRouteStatic interfaces
|
||||
- Clean up IRouteAction interface
|
||||
|
||||
4. **Delete old handlers** (45 minutes)
|
||||
- Delete handleRedirectAction, handleBlockAction, handleStaticAction methods
|
||||
- Delete RedirectHandler and StaticHandler classes
|
||||
- Remove imports and exports
|
||||
|
||||
5. **Update route connection handler** (30 minutes)
|
||||
- Simplify switch statement to only handle 'forward' and 'socket-handler'
|
||||
- Remove all references to deleted action types
|
||||
|
||||
6. **Create new socket handlers** (30 minutes)
|
||||
- Implement SocketHandlers.block() with context
|
||||
- Implement SocketHandlers.httpBlock() with context
|
||||
- Implement SocketHandlers.httpRedirect() with context
|
||||
|
||||
7. **Update helper functions** (30 minutes)
|
||||
- Update createHttpToHttpsRedirect to use socket handler
|
||||
- Delete createStaticFileRoute entirely
|
||||
- Update any other affected helpers
|
||||
|
||||
8. **Clean up tests** (1.5 hours)
|
||||
- Delete all tests for removed action types
|
||||
- Update socket handler tests to verify context parameter
|
||||
- Add new tests for block/redirect socket handlers
|
||||
|
||||
9. **Update documentation** (30 minutes)
|
||||
- Update README.md
|
||||
- Update type documentation
|
||||
- Add examples of context usage
|
||||
|
||||
**Total estimated time: ~5 hours**
|
||||
|
||||
---
|
||||
|
||||
## Considerations
|
||||
|
||||
### Benefits
|
||||
- **Dramatically simpler API** - Only 2 action types instead of 5
|
||||
- **Consistent handling model** - Everything is either forwarding or custom handling
|
||||
- **More powerful** - Socket handlers with context can do much more than old static types
|
||||
- **Less code to maintain** - Removing hundreds of lines of specialized handler code
|
||||
- **Better extensibility** - Easy to add new socket handlers for any use case
|
||||
- **Context awareness** - All handlers get full connection context
|
||||
|
||||
### Trade-offs
|
||||
- Static file serving removed (users should use nginx/apache behind proxy)
|
||||
- HTTP-specific logic (redirects) now in socket handlers (but more flexible)
|
||||
- Slightly more verbose configuration for simple blocks/redirects
|
||||
|
||||
### Why This Approach
|
||||
1. **Simplicity wins** - Two concepts are easier to understand than five
|
||||
2. **Power through context** - Socket handlers with context are more capable
|
||||
3. **Clean break** - No migration paths means cleaner code
|
||||
4. **Future proof** - Easy to add new handlers without changing core
|
||||
|
||||
---
|
||||
|
||||
## Code Examples: Before and After
|
||||
|
||||
### Block Action
|
||||
```typescript
|
||||
// BEFORE
|
||||
{
|
||||
action: { type: 'block' }
|
||||
}
|
||||
|
||||
// AFTER
|
||||
{
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.block()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Redirect
|
||||
```typescript
|
||||
// BEFORE
|
||||
{
|
||||
action: {
|
||||
type: 'redirect',
|
||||
redirect: {
|
||||
to: 'https://{domain}:443{path}',
|
||||
status: 301
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER
|
||||
{
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Handler with Context
|
||||
```typescript
|
||||
// NEW CAPABILITY - Access to full context
|
||||
{
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket, context) => {
|
||||
console.log(`Connection from ${context.clientIp} to ${context.domain}:${context.port}`);
|
||||
// Custom handling based on context...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation Tasks
|
||||
|
||||
### Step 1: Update TSocketHandler Type (15 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/models/route-types.ts`
|
||||
- [x] Find line 14: `export type TSocketHandler = (socket: plugins.net.Socket) => void | Promise<void>;`
|
||||
- [x] Import IRouteContext at top of file: `import type { IRouteContext } from '../../../core/models/route-context.js';`
|
||||
- [x] Update TSocketHandler to: `export type TSocketHandler = (socket: plugins.net.Socket, context: IRouteContext) => void | Promise<void>;`
|
||||
- [x] Save file
|
||||
|
||||
### Step 2: Update Socket Handler Implementation (30 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/route-connection-handler.ts`
|
||||
- [x] Find `handleSocketHandlerAction` method (around line 790)
|
||||
- [x] Add route context creation after line 809:
|
||||
```typescript
|
||||
// Create route context for the handler
|
||||
const routeContext = this.createRouteContext({
|
||||
connectionId: record.id,
|
||||
port: record.localPort,
|
||||
domain: record.lockedDomain,
|
||||
clientIp: record.remoteIP,
|
||||
serverIp: socket.localAddress || '',
|
||||
isTls: record.isTLS || false,
|
||||
tlsVersion: record.tlsVersion,
|
||||
routeName: route.name,
|
||||
routeId: route.id,
|
||||
});
|
||||
```
|
||||
- [x] Update line 812 from `const result = route.action.socketHandler(socket);`
|
||||
- [x] To: `const result = route.action.socketHandler(socket, routeContext);`
|
||||
- [x] Save file
|
||||
|
||||
### Step 3: Update Existing Socket Handlers in route-helpers.ts (20 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/utils/route-helpers.ts`
|
||||
- [x] Update `echo` handler (line 856):
|
||||
- From: `echo: (socket: plugins.net.Socket) => {`
|
||||
- To: `echo: (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [x] Update `proxy` handler (line 864):
|
||||
- From: `proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket) => {`
|
||||
- To: `proxy: (targetHost: string, targetPort: number) => (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [x] Update `lineProtocol` handler (line 879):
|
||||
- From: `lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket) => {`
|
||||
- To: `lineProtocol: (handler: (line: string, socket: plugins.net.Socket) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [ ] Update `httpResponse` handler (line 896):
|
||||
- From: `httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket) => {`
|
||||
- To: `httpResponse: (statusCode: number, body: string) => (socket: plugins.net.Socket, context: IRouteContext) => {`
|
||||
- [ ] Save file
|
||||
|
||||
### Step 4: Remove Old Action Types from Type Definitions (15 minutes)
|
||||
- [ ] Open `ts/proxies/smart-proxy/models/route-types.ts`
|
||||
- [ ] Find line with TRouteActionType (around line 10)
|
||||
- [ ] Change from: `export type TRouteActionType = 'forward' | 'redirect' | 'block' | 'static' | 'socket-handler';`
|
||||
- [ ] To: `export type TRouteActionType = 'forward' | 'socket-handler';`
|
||||
- [ ] Find and delete IRouteRedirect interface (around line 123-126)
|
||||
- [ ] Find and delete IRouteStatic interface (if exists)
|
||||
- [ ] Find IRouteAction interface
|
||||
- [ ] Remove these properties:
|
||||
- `redirect?: IRouteRedirect;`
|
||||
- `static?: IRouteStatic;`
|
||||
- [ ] Save file
|
||||
|
||||
### Step 5: Delete Handler Classes (15 minutes)
|
||||
- [ ] Delete file: `ts/proxies/http-proxy/handlers/redirect-handler.ts`
|
||||
- [ ] Delete file: `ts/proxies/http-proxy/handlers/static-handler.ts`
|
||||
- [ ] Open `ts/proxies/http-proxy/handlers/index.ts`
|
||||
- [ ] Delete all content (the file only exports RedirectHandler and StaticHandler)
|
||||
- [ ] Save empty file or delete it
|
||||
|
||||
### Step 6: Remove Handler Methods from RouteConnectionHandler (30 minutes)
|
||||
- [ ] Open `ts/proxies/smart-proxy/route-connection-handler.ts`
|
||||
- [ ] Find and delete entire `handleRedirectAction` method (around line 723)
|
||||
- [ ] Find and delete entire `handleBlockAction` method (around line 750)
|
||||
- [ ] Find and delete entire `handleStaticAction` method (around line 773)
|
||||
- [ ] Remove imports at top:
|
||||
- `import { RedirectHandler, StaticHandler } from '../http-proxy/handlers/index.js';`
|
||||
- [ ] Save file
|
||||
|
||||
### Step 7: Update Switch Statement (15 minutes)
|
||||
- [ ] Still in `route-connection-handler.ts`
|
||||
- [ ] Find switch statement (around line 388)
|
||||
- [ ] Remove these cases:
|
||||
- `case 'redirect': return this.handleRedirectAction(...)`
|
||||
- `case 'block': return this.handleBlockAction(...)`
|
||||
- `case 'static': this.handleStaticAction(...); return;`
|
||||
- [ ] Verify only 'forward' and 'socket-handler' cases remain
|
||||
- [ ] Save file
|
||||
|
||||
### Step 8: Add New Socket Handlers to route-helpers.ts (30 minutes)
|
||||
- [ ] Open `ts/proxies/smart-proxy/utils/route-helpers.ts`
|
||||
- [ ] Add import at top: `import type { IRouteContext } from '../../../core/models/route-context.js';`
|
||||
- [ ] Add to SocketHandlers object:
|
||||
```typescript
|
||||
/**
|
||||
* Block connection immediately
|
||||
*/
|
||||
block: (message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
const finalMessage = message || `Connection blocked from ${context.clientIp}`;
|
||||
if (finalMessage) {
|
||||
socket.write(finalMessage);
|
||||
}
|
||||
socket.end();
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP block response
|
||||
*/
|
||||
httpBlock: (statusCode: number = 403, message?: string) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
const defaultMessage = `Access forbidden for ${context.domain || context.clientIp}`;
|
||||
const finalMessage = message || defaultMessage;
|
||||
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${finalMessage}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${finalMessage.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
finalMessage
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP redirect handler
|
||||
*/
|
||||
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
let buffer = '';
|
||||
|
||||
socket.once('data', (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
const lines = buffer.split('\r\n');
|
||||
const requestLine = lines[0];
|
||||
const [method, path] = requestLine.split(' ');
|
||||
|
||||
const domain = context.domain || 'localhost';
|
||||
const port = context.port;
|
||||
|
||||
let finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(port))
|
||||
.replace('{path}', path)
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
});
|
||||
}
|
||||
```
|
||||
- [x] Save file
|
||||
|
||||
### Step 9: Update Helper Functions (20 minutes)
|
||||
- [x] Still in `route-helpers.ts`
|
||||
- [x] Update `createHttpToHttpsRedirect` function (around line 109):
|
||||
- Change the action to use socket handler:
|
||||
```typescript
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
|
||||
}
|
||||
```
|
||||
- [x] Delete entire `createStaticFileRoute` function (lines 277-322)
|
||||
- [x] Save file
|
||||
|
||||
### Step 10: Update Test Files (1.5 hours)
|
||||
#### 10.1 Update Socket Handler Tests
|
||||
- [x] Open `test/test.socket-handler.ts`
|
||||
- [x] Update all handler functions to accept context parameter
|
||||
- [x] Open `test/test.socket-handler.simple.ts`
|
||||
- [x] Update handler to accept context parameter
|
||||
- [x] Open `test/test.socket-handler-race.ts`
|
||||
- [x] Update handler to accept context parameter
|
||||
|
||||
#### 10.2 Find and Update/Delete Redirect Tests
|
||||
- [x] Search for files containing `type: 'redirect'` in test directory
|
||||
- [x] For each file:
|
||||
- [x] If it's a redirect-specific test, delete the file
|
||||
- [x] If it's a mixed test, update redirect actions to use socket handlers
|
||||
- [x] Files to check:
|
||||
- [x] `test/test.route-redirects.ts` - deleted entire file
|
||||
- [x] `test/test.forwarding.ts` - update any redirect tests
|
||||
- [x] `test/test.forwarding.examples.ts` - update any redirect tests
|
||||
- [x] `test/test.route-config.ts` - update any redirect tests
|
||||
|
||||
#### 10.3 Find and Update/Delete Block Tests
|
||||
- [x] Search for files containing `type: 'block'` in test directory
|
||||
- [x] Update or delete as appropriate
|
||||
|
||||
#### 10.4 Find and Delete Static Tests
|
||||
- [x] Search for files containing `type: 'static'` in test directory
|
||||
- [x] Delete static-specific test files
|
||||
- [x] Remove static tests from mixed test files
|
||||
|
||||
### Step 11: Clean Up Imports and Exports (20 minutes)
|
||||
- [x] Open `ts/proxies/smart-proxy/utils/index.ts`
|
||||
- [x] Ensure route-helpers.ts is exported
|
||||
- [x] Remove any exports of deleted functions
|
||||
- [x] Open `ts/index.ts`
|
||||
- [x] Remove any exports of deleted types/interfaces
|
||||
- [x] Search for any remaining imports of RedirectHandler or StaticHandler
|
||||
- [x] Remove any found imports
|
||||
|
||||
### Step 12: Documentation Updates (30 minutes)
|
||||
- [x] Update README.md:
|
||||
- [x] Remove any mention of redirect, block, static action types
|
||||
- [x] Add examples of socket handlers with context
|
||||
- [x] Document the two action types: forward and socket-handler
|
||||
- [x] Update any JSDoc comments in modified files
|
||||
- [x] Add examples showing context usage
|
||||
|
||||
### Step 13: Final Verification (15 minutes)
|
||||
- [x] Run build: `pnpm build`
|
||||
- [x] Fix any compilation errors
|
||||
- [x] Run tests: `pnpm test`
|
||||
- [x] Fix any failing tests
|
||||
- [x] Search codebase for any remaining references to:
|
||||
- [x] 'redirect' action type
|
||||
- [x] 'block' action type
|
||||
- [x] 'static' action type
|
||||
- [x] RedirectHandler
|
||||
- [x] StaticHandler
|
||||
- [x] IRouteRedirect
|
||||
- [x] IRouteStatic
|
||||
|
||||
### Step 14: Test New Functionality (30 minutes)
|
||||
- [x] Create test for block socket handler with context
|
||||
- [x] Create test for httpBlock socket handler with context
|
||||
- [x] Create test for httpRedirect socket handler with context
|
||||
- [x] Verify context is properly passed in all scenarios
|
||||
|
||||
---
|
||||
|
||||
## Files to be Modified/Deleted
|
||||
|
||||
### Files to Modify:
|
||||
1. `ts/proxies/smart-proxy/models/route-types.ts` - Update types
|
||||
2. `ts/proxies/smart-proxy/route-connection-handler.ts` - Remove handlers, update switch
|
||||
3. `ts/proxies/smart-proxy/utils/route-helpers.ts` - Update handlers, add new ones
|
||||
4. `ts/proxies/http-proxy/handlers/index.ts` - Remove exports
|
||||
5. Various test files - Update to use socket handlers
|
||||
|
||||
### Files to Delete:
|
||||
1. `ts/proxies/http-proxy/handlers/redirect-handler.ts`
|
||||
2. `ts/proxies/http-proxy/handlers/static-handler.ts`
|
||||
3. `test/test.route-redirects.ts` (likely)
|
||||
4. Any static-specific test files
|
||||
|
||||
### Test Files Requiring Updates (15 files found):
|
||||
- test/test.acme-http01-challenge.ts
|
||||
- test/test.logger-error-handling.ts
|
||||
- test/test.port80-management.node.ts
|
||||
- test/test.route-update-callback.node.ts
|
||||
- test/test.acme-state-manager.node.ts
|
||||
- test/test.acme-route-creation.ts
|
||||
- test/test.forwarding.ts
|
||||
- test/test.route-redirects.ts
|
||||
- test/test.forwarding.examples.ts
|
||||
- test/test.acme-simple.ts
|
||||
- test/test.acme-http-challenge.ts
|
||||
- test/test.certificate-provisioning.ts
|
||||
- test/test.route-config.ts
|
||||
- test/test.route-utils.ts
|
||||
- test/test.certificate-simple.ts
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
- ✅ Only 'forward' and 'socket-handler' action types remain
|
||||
- ✅ Socket handlers receive IRouteContext as second parameter
|
||||
- ✅ All old handler code completely removed
|
||||
- ✅ Redirect functionality works via context-aware socket handlers
|
||||
- ✅ Block functionality works via context-aware socket handlers
|
||||
- ✅ All tests updated and passing
|
||||
- ✅ Documentation updated with new examples
|
||||
- ✅ No performance regression
|
||||
- ✅ Cleaner, simpler codebase
|
@ -1,86 +0,0 @@
|
||||
# SmartProxy Module Problems
|
||||
|
||||
Based on test analysis, the following potential issues have been identified in the SmartProxy module:
|
||||
|
||||
## 1. HttpProxy Route Configuration Issue
|
||||
**Location**: `ts/proxies/http-proxy/http-proxy.ts:380`
|
||||
**Problem**: The HttpProxy is trying to read the 'type' property of an undefined object when updating route configurations.
|
||||
**Evidence**: `test.http-forwarding-fix.ts` fails with:
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'type')
|
||||
at HttpProxy.updateRouteConfigs (/mnt/data/lossless/push.rocks/smartproxy/ts/proxies/http-proxy/http-proxy.ts:380:24)
|
||||
```
|
||||
**Impact**: Routes with `useHttpProxy` configuration may not work properly.
|
||||
|
||||
## 2. Connection Forwarding Issues
|
||||
**Problem**: Basic TCP forwarding appears to not be working correctly after the simplification to just 'forward' and 'socket-handler' action types.
|
||||
**Evidence**: Multiple forwarding tests timeout waiting for data to be forwarded:
|
||||
- `test.forwarding-fix-verification.ts` - times out waiting for forwarded data
|
||||
- `test.connection-forwarding.ts` - times out on SNI-based forwarding
|
||||
**Impact**: The 'forward' action type may not be properly forwarding connections to target servers.
|
||||
|
||||
## 3. Missing Certificate Manager Methods
|
||||
**Problem**: Tests expect `provisionAllCertificates` method on certificate manager but it may not exist or may not be properly initialized.
|
||||
**Evidence**: Multiple tests fail with "this.certManager.provisionAllCertificates is not a function"
|
||||
**Impact**: Certificate provisioning may not work as expected.
|
||||
|
||||
## 4. Route Update Mechanism
|
||||
**Problem**: The route update mechanism may have issues preserving certificate manager callbacks and other state.
|
||||
**Evidence**: Tests specifically designed to verify callback preservation after route updates.
|
||||
**Impact**: Dynamic route updates might break certificate management functionality.
|
||||
|
||||
## 5. Route-Specific Security Not Fully Implemented
|
||||
**Problem**: While the route definitions support security configurations (ipAllowList, ipBlockList, authentication), these are not being enforced at the route level.
|
||||
**Evidence**:
|
||||
- SecurityManager has methods like `isIPAuthorized` for route-specific security
|
||||
- Route connection handler only checks global IP validation, not route-specific security rules
|
||||
- No evidence of route.action.security being checked when handling connections
|
||||
**Impact**: Route-specific security rules defined in configuration are not enforced, potentially allowing unauthorized access.
|
||||
**Status**: ✅ FIXED - Route-specific IP allow/block lists are now enforced when a route is matched. Authentication is logged as not enforceable for non-terminated connections.
|
||||
**Additional Fix**: Removed security checks from route matching logic - security is now properly enforced AFTER a route is matched, not during matching.
|
||||
|
||||
## 6. Security Property Location Consolidation
|
||||
**Problem**: Security was defined in two places - route.security and route.action.security - causing confusion.
|
||||
**Status**: ✅ FIXED - Consolidated to only route.security. Removed action.security from types and updated all references.
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Verify Forward Action Implementation**: Check that the 'forward' action type properly establishes bidirectional data flow between client and target server. ✅ FIXED - Basic forwarding now works correctly.
|
||||
|
||||
2. **Fix HttpProxy Route Handling**: Ensure that route objects passed to HttpProxy.updateRouteConfigs have the expected structure with all required properties. ✅ FIXED - Routes now preserve their structure.
|
||||
|
||||
3. **Review Certificate Manager API**: Ensure all expected methods exist and are properly documented.
|
||||
|
||||
4. **Add Integration Tests**: Many unit tests are testing internal implementation details. Consider adding more integration tests that test the public API.
|
||||
|
||||
5. **Implement Route-Specific Security**: Add security checks when a route is matched to enforce route-specific IP allow/block lists and authentication rules. ✅ FIXED - IP allow/block lists are now enforced at the route level.
|
||||
|
||||
6. **Fix TLS Detection Logic**: The connection handler was treating all connections as TLS. This has been partially fixed but needs proper testing for all TLS modes.
|
||||
|
||||
## 7. HTTP Domain Matching Issue
|
||||
**Problem**: Routes with domain restrictions fail to match HTTP connections because domain information is only available after HTTP headers are received, but route matching happens immediately upon connection.
|
||||
**Evidence**:
|
||||
- `test.http-port8080-forwarding.ts` - "No route found for connection on port 8080" despite having a matching route
|
||||
- HTTP connections provide domain info via the Host header, which arrives after the initial TCP connection
|
||||
- Route matching in `handleInitialData` happens before HTTP headers are parsed
|
||||
**Impact**: HTTP routes with domain restrictions cannot be matched, forcing users to remove domain restrictions for HTTP routes.
|
||||
**Root Cause**: For non-TLS connections, SmartProxy attempts to match routes immediately, but the domain information needed for matching is only available after parsing HTTP headers.
|
||||
**Status**: ✅ FIXED - Added skipDomainCheck parameter to route matching for HTTP proxy ports. When a port is configured with useHttpProxy and the connection is not TLS, domain validation is skipped at the initial route matching stage, allowing the HttpProxy to handle domain-based routing after headers are received.
|
||||
|
||||
## 8. HttpProxy Plain HTTP Forwarding Issue
|
||||
**Problem**: HttpProxy is an HTTPS server but SmartProxy forwards plain HTTP connections to it via `useHttpProxy` configuration.
|
||||
**Evidence**:
|
||||
- `test.http-port8080-forwarding.ts` - Connection immediately closed after forwarding to HttpProxy
|
||||
- HttpProxy is created with `http2.createSecureServer` expecting TLS connections
|
||||
- SmartProxy forwards raw HTTP data to HttpProxy's HTTPS port
|
||||
**Impact**: Plain HTTP connections cannot be handled by HttpProxy, despite `useHttpProxy` configuration suggesting this should work.
|
||||
**Root Cause**: Design mismatch - HttpProxy is designed for HTTPS/TLS termination, not plain HTTP forwarding.
|
||||
**Status**: Documented. The `useHttpProxy` configuration should only be used for ports that receive TLS connections requiring termination. For plain HTTP forwarding, use direct forwarding without HttpProxy.
|
||||
|
||||
## 9. Route Security Configuration Location Issue
|
||||
**Problem**: Tests were placing security configuration in `route.action.security` instead of `route.security`.
|
||||
**Evidence**:
|
||||
- `test.route-security.ts` - IP block list test failing because security was in wrong location
|
||||
- IRouteConfig interface defines security at route level, not inside action
|
||||
**Impact**: Security rules defined in action.security were ignored, causing tests to fail.
|
||||
**Status**: ✅ FIXED - Updated tests to place security configuration at the correct location (route.security).
|
341
readme.routing.md
Normal file
341
readme.routing.md
Normal file
@ -0,0 +1,341 @@
|
||||
# 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.
|
79
test/core/routing/test.domain-matcher.ts
Normal file
79
test/core/routing/test.domain-matcher.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DomainMatcher } from '../../../ts/core/routing/matchers/domain.js';
|
||||
|
||||
tap.test('DomainMatcher - exact match', async () => {
|
||||
expect(DomainMatcher.match('example.com', 'example.com')).toEqual(true);
|
||||
expect(DomainMatcher.match('example.com', 'example.net')).toEqual(false);
|
||||
expect(DomainMatcher.match('sub.example.com', 'example.com')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('DomainMatcher - case insensitive', async () => {
|
||||
expect(DomainMatcher.match('Example.COM', 'example.com')).toEqual(true);
|
||||
expect(DomainMatcher.match('example.com', 'EXAMPLE.COM')).toEqual(true);
|
||||
expect(DomainMatcher.match('ExAmPlE.cOm', 'eXaMpLe.CoM')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('DomainMatcher - wildcard matching', async () => {
|
||||
// Leading wildcard
|
||||
expect(DomainMatcher.match('*.example.com', 'sub.example.com')).toEqual(true);
|
||||
expect(DomainMatcher.match('*.example.com', 'deep.sub.example.com')).toEqual(true);
|
||||
expect(DomainMatcher.match('*.example.com', 'example.com')).toEqual(false);
|
||||
|
||||
// Multiple wildcards
|
||||
expect(DomainMatcher.match('*.*.example.com', 'a.b.example.com')).toEqual(true);
|
||||
expect(DomainMatcher.match('api.*.example.com', 'api.v1.example.com')).toEqual(true);
|
||||
|
||||
// Trailing wildcard
|
||||
expect(DomainMatcher.match('example.*', 'example.com')).toEqual(true);
|
||||
expect(DomainMatcher.match('example.*', 'example.net')).toEqual(true);
|
||||
expect(DomainMatcher.match('example.*', 'example.co.uk')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('DomainMatcher - FQDN normalization', async () => {
|
||||
expect(DomainMatcher.match('example.com.', 'example.com')).toEqual(true);
|
||||
expect(DomainMatcher.match('example.com', 'example.com.')).toEqual(true);
|
||||
expect(DomainMatcher.match('example.com.', 'example.com.')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('DomainMatcher - edge cases', async () => {
|
||||
expect(DomainMatcher.match('', 'example.com')).toEqual(false);
|
||||
expect(DomainMatcher.match('example.com', '')).toEqual(false);
|
||||
expect(DomainMatcher.match('', '')).toEqual(false);
|
||||
expect(DomainMatcher.match(null as any, 'example.com')).toEqual(false);
|
||||
expect(DomainMatcher.match('example.com', null as any)).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('DomainMatcher - specificity calculation', async () => {
|
||||
// Exact domains are most specific
|
||||
const exactScore = DomainMatcher.calculateSpecificity('api.example.com');
|
||||
const wildcardScore = DomainMatcher.calculateSpecificity('*.example.com');
|
||||
const leadingWildcardScore = DomainMatcher.calculateSpecificity('*.com');
|
||||
|
||||
expect(exactScore).toBeGreaterThan(wildcardScore);
|
||||
expect(wildcardScore).toBeGreaterThan(leadingWildcardScore);
|
||||
|
||||
// More segments = more specific
|
||||
const threeSegments = DomainMatcher.calculateSpecificity('api.v1.example.com');
|
||||
const twoSegments = DomainMatcher.calculateSpecificity('example.com');
|
||||
expect(threeSegments).toBeGreaterThan(twoSegments);
|
||||
});
|
||||
|
||||
tap.test('DomainMatcher - findAllMatches', async () => {
|
||||
const patterns = [
|
||||
'example.com',
|
||||
'*.example.com',
|
||||
'api.example.com',
|
||||
'*.api.example.com',
|
||||
'*'
|
||||
];
|
||||
|
||||
const matches = DomainMatcher.findAllMatches(patterns, 'v1.api.example.com');
|
||||
|
||||
// Should match: *.example.com, *.api.example.com, *
|
||||
expect(matches).toHaveLength(3);
|
||||
expect(matches[0]).toEqual('*.api.example.com'); // Most specific
|
||||
expect(matches[1]).toEqual('*.example.com');
|
||||
expect(matches[2]).toEqual('*'); // Least specific
|
||||
});
|
||||
|
||||
tap.start();
|
118
test/core/routing/test.ip-matcher.ts
Normal file
118
test/core/routing/test.ip-matcher.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { IpMatcher } from '../../../ts/core/routing/matchers/ip.js';
|
||||
|
||||
tap.test('IpMatcher - exact match', async () => {
|
||||
expect(IpMatcher.match('192.168.1.1', '192.168.1.1')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.1', '192.168.1.2')).toEqual(false);
|
||||
expect(IpMatcher.match('10.0.0.1', '10.0.0.1')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - CIDR notation', async () => {
|
||||
// /24 subnet
|
||||
expect(IpMatcher.match('192.168.1.0/24', '192.168.1.1')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.0/24', '192.168.1.255')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.0/24', '192.168.2.1')).toEqual(false);
|
||||
|
||||
// /16 subnet
|
||||
expect(IpMatcher.match('10.0.0.0/16', '10.0.1.1')).toEqual(true);
|
||||
expect(IpMatcher.match('10.0.0.0/16', '10.0.255.255')).toEqual(true);
|
||||
expect(IpMatcher.match('10.0.0.0/16', '10.1.0.1')).toEqual(false);
|
||||
|
||||
// /32 (single host)
|
||||
expect(IpMatcher.match('192.168.1.1/32', '192.168.1.1')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.1/32', '192.168.1.2')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - wildcard matching', async () => {
|
||||
expect(IpMatcher.match('192.168.1.*', '192.168.1.1')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.*', '192.168.1.255')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.*', '192.168.2.1')).toEqual(false);
|
||||
|
||||
expect(IpMatcher.match('192.168.*.*', '192.168.0.1')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.*.*', '192.168.255.255')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.*.*', '192.169.0.1')).toEqual(false);
|
||||
|
||||
expect(IpMatcher.match('*.*.*.*', '1.2.3.4')).toEqual(true);
|
||||
expect(IpMatcher.match('*.*.*.*', '255.255.255.255')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - range matching', async () => {
|
||||
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.1')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.5')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.10')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.11')).toEqual(false);
|
||||
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.0')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - IPv6-mapped IPv4', async () => {
|
||||
expect(IpMatcher.match('192.168.1.1', '::ffff:192.168.1.1')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.0/24', '::ffff:192.168.1.100')).toEqual(true);
|
||||
expect(IpMatcher.match('192.168.1.*', '::FFFF:192.168.1.50')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - IP validation', async () => {
|
||||
expect(IpMatcher.isValidIpv4('192.168.1.1')).toEqual(true);
|
||||
expect(IpMatcher.isValidIpv4('255.255.255.255')).toEqual(true);
|
||||
expect(IpMatcher.isValidIpv4('0.0.0.0')).toEqual(true);
|
||||
|
||||
expect(IpMatcher.isValidIpv4('256.1.1.1')).toEqual(false);
|
||||
expect(IpMatcher.isValidIpv4('1.1.1')).toEqual(false);
|
||||
expect(IpMatcher.isValidIpv4('1.1.1.1.1')).toEqual(false);
|
||||
expect(IpMatcher.isValidIpv4('1.1.1.a')).toEqual(false);
|
||||
expect(IpMatcher.isValidIpv4('01.1.1.1')).toEqual(false); // No leading zeros
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - isAuthorized', async () => {
|
||||
// Empty lists - allow all
|
||||
expect(IpMatcher.isAuthorized('192.168.1.1')).toEqual(true);
|
||||
|
||||
// Allow list only
|
||||
const allowList = ['192.168.1.0/24', '10.0.0.0/16'];
|
||||
expect(IpMatcher.isAuthorized('192.168.1.100', allowList)).toEqual(true);
|
||||
expect(IpMatcher.isAuthorized('10.0.50.1', allowList)).toEqual(true);
|
||||
expect(IpMatcher.isAuthorized('172.16.0.1', allowList)).toEqual(false);
|
||||
|
||||
// Block list only
|
||||
const blockList = ['192.168.1.100', '10.0.0.0/24'];
|
||||
expect(IpMatcher.isAuthorized('192.168.1.100', [], blockList)).toEqual(false);
|
||||
expect(IpMatcher.isAuthorized('10.0.0.50', [], blockList)).toEqual(false);
|
||||
expect(IpMatcher.isAuthorized('192.168.1.101', [], blockList)).toEqual(true);
|
||||
|
||||
// Both lists - block takes precedence
|
||||
expect(IpMatcher.isAuthorized('192.168.1.100', allowList, ['192.168.1.100'])).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - specificity calculation', async () => {
|
||||
// Exact IPs are most specific
|
||||
const exactScore = IpMatcher.calculateSpecificity('192.168.1.1');
|
||||
const cidr32Score = IpMatcher.calculateSpecificity('192.168.1.1/32');
|
||||
const cidr24Score = IpMatcher.calculateSpecificity('192.168.1.0/24');
|
||||
const cidr16Score = IpMatcher.calculateSpecificity('192.168.0.0/16');
|
||||
const wildcardScore = IpMatcher.calculateSpecificity('192.168.1.*');
|
||||
const rangeScore = IpMatcher.calculateSpecificity('192.168.1.1-192.168.1.10');
|
||||
|
||||
expect(exactScore).toBeGreaterThan(cidr24Score);
|
||||
expect(cidr32Score).toBeGreaterThan(cidr24Score);
|
||||
expect(cidr24Score).toBeGreaterThan(cidr16Score);
|
||||
expect(rangeScore).toBeGreaterThan(wildcardScore);
|
||||
});
|
||||
|
||||
tap.test('IpMatcher - edge cases', async () => {
|
||||
// Empty/null inputs
|
||||
expect(IpMatcher.match('', '192.168.1.1')).toEqual(false);
|
||||
expect(IpMatcher.match('192.168.1.1', '')).toEqual(false);
|
||||
expect(IpMatcher.match(null as any, '192.168.1.1')).toEqual(false);
|
||||
expect(IpMatcher.match('192.168.1.1', null as any)).toEqual(false);
|
||||
|
||||
// Invalid CIDR
|
||||
expect(IpMatcher.match('192.168.1.0/33', '192.168.1.1')).toEqual(false);
|
||||
expect(IpMatcher.match('192.168.1.0/-1', '192.168.1.1')).toEqual(false);
|
||||
expect(IpMatcher.match('192.168.1.0/', '192.168.1.1')).toEqual(false);
|
||||
|
||||
// Invalid ranges
|
||||
expect(IpMatcher.match('192.168.1.10-192.168.1.1', '192.168.1.5')).toEqual(false); // Start > end
|
||||
expect(IpMatcher.match('192.168.1.1-', '192.168.1.5')).toEqual(false);
|
||||
expect(IpMatcher.match('-192.168.1.10', '192.168.1.5')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.start();
|
127
test/core/routing/test.path-matcher.ts
Normal file
127
test/core/routing/test.path-matcher.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { PathMatcher } from '../../../ts/core/routing/matchers/path.js';
|
||||
|
||||
tap.test('PathMatcher - exact match', async () => {
|
||||
const result = PathMatcher.match('/api/users', '/api/users');
|
||||
expect(result.matches).toEqual(true);
|
||||
expect(result.pathMatch).toEqual('/api/users');
|
||||
expect(result.pathRemainder).toEqual('');
|
||||
expect(result.params).toEqual({});
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - no match', async () => {
|
||||
const result = PathMatcher.match('/api/users', '/api/posts');
|
||||
expect(result.matches).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - parameter extraction', async () => {
|
||||
const result = PathMatcher.match('/users/:id/profile', '/users/123/profile');
|
||||
expect(result.matches).toEqual(true);
|
||||
expect(result.params).toEqual({ id: '123' });
|
||||
expect(result.pathMatch).toEqual('/users/123/profile');
|
||||
expect(result.pathRemainder).toEqual('');
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - multiple parameters', async () => {
|
||||
const result = PathMatcher.match('/api/:version/users/:id', '/api/v2/users/456');
|
||||
expect(result.matches).toEqual(true);
|
||||
expect(result.params).toEqual({ version: 'v2', id: '456' });
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - trailing slash normalization', async () => {
|
||||
// Both with trailing slash
|
||||
let result = PathMatcher.match('/api/users/', '/api/users/');
|
||||
expect(result.matches).toEqual(true);
|
||||
|
||||
// Pattern with, path without
|
||||
result = PathMatcher.match('/api/users/', '/api/users');
|
||||
expect(result.matches).toEqual(true);
|
||||
|
||||
// Pattern without, path with
|
||||
result = PathMatcher.match('/api/users', '/api/users/');
|
||||
expect(result.matches).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - root path handling', async () => {
|
||||
const result = PathMatcher.match('/', '/');
|
||||
expect(result.matches).toEqual(true);
|
||||
expect(result.pathMatch).toEqual('/');
|
||||
expect(result.pathRemainder).toEqual('');
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - specificity calculation', async () => {
|
||||
// Exact paths are most specific
|
||||
const exactScore = PathMatcher.calculateSpecificity('/api/v1/users');
|
||||
const paramScore = PathMatcher.calculateSpecificity('/api/:version/users');
|
||||
const wildcardScore = PathMatcher.calculateSpecificity('/api/*');
|
||||
|
||||
expect(exactScore).toBeGreaterThan(paramScore);
|
||||
expect(paramScore).toBeGreaterThan(wildcardScore);
|
||||
|
||||
// More segments = more specific
|
||||
const deepPath = PathMatcher.calculateSpecificity('/api/v1/users/profile/settings');
|
||||
const shallowPath = PathMatcher.calculateSpecificity('/api/users');
|
||||
expect(deepPath).toBeGreaterThan(shallowPath);
|
||||
|
||||
// More static segments = more specific
|
||||
const moreStatic = PathMatcher.calculateSpecificity('/api/v1/users/:id');
|
||||
const lessStatic = PathMatcher.calculateSpecificity('/api/:version/:resource/:id');
|
||||
expect(moreStatic).toBeGreaterThan(lessStatic);
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - findAllMatches', async () => {
|
||||
const patterns = [
|
||||
'/api/users',
|
||||
'/api/users/:id',
|
||||
'/api/users/:id/profile',
|
||||
'/api/*',
|
||||
'/*'
|
||||
];
|
||||
|
||||
const matches = PathMatcher.findAllMatches(patterns, '/api/users/123/profile');
|
||||
|
||||
// With the stricter path matching, /api/users won't match /api/users/123/profile
|
||||
// Only patterns with wildcards, parameters, or exact matches will work
|
||||
expect(matches).toHaveLength(4);
|
||||
|
||||
// Verify all expected patterns are in the results
|
||||
const matchedPatterns = matches.map(m => m.pattern);
|
||||
expect(matchedPatterns).not.toContain('/api/users'); // This won't match anymore (no prefix matching)
|
||||
expect(matchedPatterns).toContain('/api/users/:id');
|
||||
expect(matchedPatterns).toContain('/api/users/:id/profile');
|
||||
expect(matchedPatterns).toContain('/api/*');
|
||||
expect(matchedPatterns).toContain('/*');
|
||||
|
||||
// Verify parameters were extracted correctly for parameterized patterns
|
||||
const paramsById = matches.find(m => m.pattern === '/api/users/:id');
|
||||
const paramsByIdProfile = matches.find(m => m.pattern === '/api/users/:id/profile');
|
||||
expect(paramsById?.result.params).toEqual({ id: '123' });
|
||||
expect(paramsByIdProfile?.result.params).toEqual({ id: '123' });
|
||||
});
|
||||
|
||||
tap.test('PathMatcher - edge cases', async () => {
|
||||
// Empty patterns
|
||||
expect(PathMatcher.match('', '/api/users').matches).toEqual(false);
|
||||
expect(PathMatcher.match('/api/users', '').matches).toEqual(false);
|
||||
expect(PathMatcher.match('', '').matches).toEqual(false);
|
||||
|
||||
// Null/undefined
|
||||
expect(PathMatcher.match(null as any, '/api/users').matches).toEqual(false);
|
||||
expect(PathMatcher.match('/api/users', null as any).matches).toEqual(false);
|
||||
});
|
||||
|
||||
tap.start();
|
200
test/core/utils/test.async-utils.ts
Normal file
200
test/core/utils/test.async-utils.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
delay,
|
||||
retryWithBackoff,
|
||||
withTimeout,
|
||||
parallelLimit,
|
||||
debounceAsync,
|
||||
AsyncMutex,
|
||||
CircuitBreaker
|
||||
} from '../../../ts/core/utils/async-utils.js';
|
||||
|
||||
tap.test('delay should pause execution for specified milliseconds', async () => {
|
||||
const startTime = Date.now();
|
||||
await delay(100);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Allow some tolerance for timing
|
||||
expect(elapsed).toBeGreaterThan(90);
|
||||
expect(elapsed).toBeLessThan(150);
|
||||
});
|
||||
|
||||
tap.test('retryWithBackoff should retry failed operations', async () => {
|
||||
let attempts = 0;
|
||||
const operation = async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const result = await retryWithBackoff(operation, {
|
||||
maxAttempts: 3,
|
||||
initialDelay: 10
|
||||
});
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(attempts).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('retryWithBackoff should throw after max attempts', async () => {
|
||||
let attempts = 0;
|
||||
const operation = async () => {
|
||||
attempts++;
|
||||
throw new Error('Always fails');
|
||||
};
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await retryWithBackoff(operation, {
|
||||
maxAttempts: 2,
|
||||
initialDelay: 10
|
||||
});
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toEqual('Always fails');
|
||||
expect(attempts).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('withTimeout should complete operations within timeout', async () => {
|
||||
const operation = async () => {
|
||||
await delay(50);
|
||||
return 'completed';
|
||||
};
|
||||
|
||||
const result = await withTimeout(operation, 100);
|
||||
expect(result).toEqual('completed');
|
||||
});
|
||||
|
||||
tap.test('withTimeout should throw on timeout', async () => {
|
||||
const operation = async () => {
|
||||
await delay(200);
|
||||
return 'never happens';
|
||||
};
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await withTimeout(operation, 50);
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('timed out');
|
||||
});
|
||||
|
||||
tap.test('parallelLimit should respect concurrency limit', async () => {
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const items = [1, 2, 3, 4, 5, 6];
|
||||
const operation = async (item: number) => {
|
||||
concurrent++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
await delay(50);
|
||||
concurrent--;
|
||||
return item * 2;
|
||||
};
|
||||
|
||||
const results = await parallelLimit(items, operation, 2);
|
||||
|
||||
expect(results).toEqual([2, 4, 6, 8, 10, 12]);
|
||||
expect(maxConcurrent).toBeLessThan(3);
|
||||
expect(maxConcurrent).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('debounceAsync should debounce function calls', async () => {
|
||||
let callCount = 0;
|
||||
const fn = async (value: string) => {
|
||||
callCount++;
|
||||
return value;
|
||||
};
|
||||
|
||||
const debounced = debounceAsync(fn, 50);
|
||||
|
||||
// Make multiple calls quickly
|
||||
debounced('a');
|
||||
debounced('b');
|
||||
debounced('c');
|
||||
const result = await debounced('d');
|
||||
|
||||
// Wait a bit to ensure no more calls
|
||||
await delay(100);
|
||||
|
||||
expect(result).toEqual('d');
|
||||
expect(callCount).toEqual(1); // Only the last call should execute
|
||||
});
|
||||
|
||||
tap.test('AsyncMutex should ensure exclusive access', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
const results: number[] = [];
|
||||
|
||||
const operation = async (value: number) => {
|
||||
await mutex.runExclusive(async () => {
|
||||
results.push(value);
|
||||
await delay(10);
|
||||
results.push(value * 10);
|
||||
});
|
||||
};
|
||||
|
||||
// Run operations concurrently
|
||||
await Promise.all([
|
||||
operation(1),
|
||||
operation(2),
|
||||
operation(3)
|
||||
]);
|
||||
|
||||
// Results should show sequential execution
|
||||
expect(results).toEqual([1, 10, 2, 20, 3, 30]);
|
||||
});
|
||||
|
||||
tap.test('CircuitBreaker should open after failures', async () => {
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 2,
|
||||
resetTimeout: 100
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
const failingOperation = async () => {
|
||||
attempt++;
|
||||
throw new Error('Test failure');
|
||||
};
|
||||
|
||||
// First two failures
|
||||
for (let i = 0; i < 2; i++) {
|
||||
try {
|
||||
await breaker.execute(failingOperation);
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
expect(breaker.isOpen()).toBeTrue();
|
||||
|
||||
// Next attempt should fail immediately
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await breaker.execute(failingOperation);
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error?.message).toEqual('Circuit breaker is open');
|
||||
expect(attempt).toEqual(2); // Operation not called when circuit is open
|
||||
|
||||
// Wait for reset timeout
|
||||
await delay(150);
|
||||
|
||||
// Circuit should be half-open now, allowing one attempt
|
||||
const successOperation = async () => 'success';
|
||||
const result = await breaker.execute(successOperation);
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(breaker.getState()).toEqual('closed');
|
||||
});
|
||||
|
||||
tap.start();
|
206
test/core/utils/test.binary-heap.ts
Normal file
206
test/core/utils/test.binary-heap.ts
Normal file
@ -0,0 +1,206 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { BinaryHeap } from '../../../ts/core/utils/binary-heap.js';
|
||||
|
||||
interface TestItem {
|
||||
id: string;
|
||||
priority: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
tap.test('should create empty heap', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
expect(heap.size).toEqual(0);
|
||||
expect(heap.isEmpty()).toBeTrue();
|
||||
expect(heap.peek()).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should insert and extract in correct order', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
heap.insert(1);
|
||||
heap.insert(9);
|
||||
heap.insert(4);
|
||||
|
||||
expect(heap.size).toEqual(6);
|
||||
|
||||
// Extract in ascending order
|
||||
expect(heap.extract()).toEqual(1);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
expect(heap.extract()).toEqual(4);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(7);
|
||||
expect(heap.extract()).toEqual(9);
|
||||
expect(heap.extract()).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should work with custom objects and comparator', async () => {
|
||||
const heap = new BinaryHeap<TestItem>(
|
||||
(a, b) => a.priority - b.priority,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
heap.insert({ id: 'c', priority: 8, value: 'eight' });
|
||||
heap.insert({ id: 'd', priority: 1, value: 'one' });
|
||||
|
||||
const first = heap.extract();
|
||||
expect(first?.priority).toEqual(1);
|
||||
expect(first?.value).toEqual('one');
|
||||
|
||||
const second = heap.extract();
|
||||
expect(second?.priority).toEqual(2);
|
||||
expect(second?.value).toEqual('two');
|
||||
});
|
||||
|
||||
tap.test('should support reverse order (max heap)', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => b - a);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
heap.insert(1);
|
||||
heap.insert(9);
|
||||
|
||||
// Extract in descending order
|
||||
expect(heap.extract()).toEqual(9);
|
||||
expect(heap.extract()).toEqual(7);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
});
|
||||
|
||||
tap.test('should extract by predicate', async () => {
|
||||
const heap = new BinaryHeap<TestItem>((a, b) => a.priority - b.priority);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
heap.insert({ id: 'c', priority: 8, value: 'eight' });
|
||||
|
||||
const extracted = heap.extractIf(item => item.id === 'b');
|
||||
expect(extracted?.id).toEqual('b');
|
||||
expect(heap.size).toEqual(2);
|
||||
|
||||
// Should not find it again
|
||||
const notFound = heap.extractIf(item => item.id === 'b');
|
||||
expect(notFound).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should extract by key', async () => {
|
||||
const heap = new BinaryHeap<TestItem>(
|
||||
(a, b) => a.priority - b.priority,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
heap.insert({ id: 'c', priority: 8, value: 'eight' });
|
||||
|
||||
expect(heap.hasKey('b')).toBeTrue();
|
||||
|
||||
const extracted = heap.extractByKey('b');
|
||||
expect(extracted?.id).toEqual('b');
|
||||
expect(heap.size).toEqual(2);
|
||||
expect(heap.hasKey('b')).toBeFalse();
|
||||
|
||||
// Should not find it again
|
||||
const notFound = heap.extractByKey('b');
|
||||
expect(notFound).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should throw when using key operations without extractKey', async () => {
|
||||
const heap = new BinaryHeap<TestItem>((a, b) => a.priority - b.priority);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
heap.extractByKey('a');
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('extractKey function must be provided');
|
||||
});
|
||||
|
||||
tap.test('should handle duplicates correctly', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(5);
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
|
||||
expect(heap.size).toEqual(5);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(5);
|
||||
expect(heap.extract()).toEqual(7);
|
||||
});
|
||||
|
||||
tap.test('should convert to array without modifying heap', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
heap.insert(5);
|
||||
heap.insert(3);
|
||||
heap.insert(7);
|
||||
|
||||
const array = heap.toArray();
|
||||
expect(array).toContain(3);
|
||||
expect(array).toContain(5);
|
||||
expect(array).toContain(7);
|
||||
expect(array.length).toEqual(3);
|
||||
|
||||
// Heap should still be intact
|
||||
expect(heap.size).toEqual(3);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('should clear the heap', async () => {
|
||||
const heap = new BinaryHeap<TestItem>(
|
||||
(a, b) => a.priority - b.priority,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
heap.insert({ id: 'a', priority: 5, value: 'five' });
|
||||
heap.insert({ id: 'b', priority: 2, value: 'two' });
|
||||
|
||||
expect(heap.size).toEqual(2);
|
||||
expect(heap.hasKey('a')).toBeTrue();
|
||||
|
||||
heap.clear();
|
||||
|
||||
expect(heap.size).toEqual(0);
|
||||
expect(heap.isEmpty()).toBeTrue();
|
||||
expect(heap.hasKey('a')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should handle complex extraction patterns', async () => {
|
||||
const heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
|
||||
// Insert numbers 1-10 in random order
|
||||
[8, 3, 5, 9, 1, 7, 4, 10, 2, 6].forEach(n => heap.insert(n));
|
||||
|
||||
// Extract some in order
|
||||
expect(heap.extract()).toEqual(1);
|
||||
expect(heap.extract()).toEqual(2);
|
||||
|
||||
// Insert more
|
||||
heap.insert(0);
|
||||
heap.insert(1.5);
|
||||
|
||||
// Continue extracting
|
||||
expect(heap.extract()).toEqual(0);
|
||||
expect(heap.extract()).toEqual(1.5);
|
||||
expect(heap.extract()).toEqual(3);
|
||||
|
||||
// Verify remaining size (10 - 2 extracted + 2 inserted - 3 extracted = 7)
|
||||
expect(heap.size).toEqual(7);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -1,207 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
EventSystem,
|
||||
ProxyEvents,
|
||||
ComponentType
|
||||
} from '../../../ts/core/utils/event-system.js';
|
||||
|
||||
// Setup function for creating a new event system
|
||||
function setupEventSystem(): { eventSystem: EventSystem, receivedEvents: any[] } {
|
||||
const eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id');
|
||||
const receivedEvents: any[] = [];
|
||||
return { eventSystem, receivedEvents };
|
||||
}
|
||||
|
||||
tap.test('Event System - certificate events with correct structure', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up listeners
|
||||
eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'issued',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
eventSystem.on(ProxyEvents.CERTIFICATE_RENEWED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'renewed',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Emit events
|
||||
eventSystem.emitCertificateIssued({
|
||||
domain: 'example.com',
|
||||
certificate: 'cert-content',
|
||||
privateKey: 'key-content',
|
||||
expiryDate: new Date('2025-01-01')
|
||||
});
|
||||
|
||||
eventSystem.emitCertificateRenewed({
|
||||
domain: 'example.com',
|
||||
certificate: 'new-cert-content',
|
||||
privateKey: 'new-key-content',
|
||||
expiryDate: new Date('2026-01-01'),
|
||||
isRenewal: true
|
||||
});
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(2);
|
||||
|
||||
// Check issuance event
|
||||
expect(receivedEvents[0].type).toEqual('issued');
|
||||
expect(receivedEvents[0].data.domain).toEqual('example.com');
|
||||
expect(receivedEvents[0].data.certificate).toEqual('cert-content');
|
||||
expect(receivedEvents[0].data.componentType).toEqual(ComponentType.SMART_PROXY);
|
||||
expect(receivedEvents[0].data.componentId).toEqual('test-id');
|
||||
expect(typeof receivedEvents[0].data.timestamp).toEqual('number');
|
||||
|
||||
// Check renewal event
|
||||
expect(receivedEvents[1].type).toEqual('renewed');
|
||||
expect(receivedEvents[1].data.domain).toEqual('example.com');
|
||||
expect(receivedEvents[1].data.isRenewal).toEqual(true);
|
||||
expect(receivedEvents[1].data.expiryDate).toEqual(new Date('2026-01-01'));
|
||||
});
|
||||
|
||||
tap.test('Event System - component lifecycle events', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up listeners
|
||||
eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'started',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
eventSystem.on(ProxyEvents.COMPONENT_STOPPED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'stopped',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Emit events
|
||||
eventSystem.emitComponentStarted('TestComponent', '1.0.0');
|
||||
eventSystem.emitComponentStopped('TestComponent');
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(2);
|
||||
|
||||
// Check started event
|
||||
expect(receivedEvents[0].type).toEqual('started');
|
||||
expect(receivedEvents[0].data.name).toEqual('TestComponent');
|
||||
expect(receivedEvents[0].data.version).toEqual('1.0.0');
|
||||
|
||||
// Check stopped event
|
||||
expect(receivedEvents[1].type).toEqual('stopped');
|
||||
expect(receivedEvents[1].data.name).toEqual('TestComponent');
|
||||
});
|
||||
|
||||
tap.test('Event System - connection events', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up listeners
|
||||
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'established',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
eventSystem.on(ProxyEvents.CONNECTION_CLOSED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'closed',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Emit events
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-123',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443,
|
||||
isTls: true,
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
eventSystem.emitConnectionClosed({
|
||||
connectionId: 'conn-123',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(2);
|
||||
|
||||
// Check established event
|
||||
expect(receivedEvents[0].type).toEqual('established');
|
||||
expect(receivedEvents[0].data.connectionId).toEqual('conn-123');
|
||||
expect(receivedEvents[0].data.clientIp).toEqual('192.168.1.1');
|
||||
expect(receivedEvents[0].data.port).toEqual(443);
|
||||
expect(receivedEvents[0].data.isTls).toEqual(true);
|
||||
|
||||
// Check closed event
|
||||
expect(receivedEvents[1].type).toEqual('closed');
|
||||
expect(receivedEvents[1].data.connectionId).toEqual('conn-123');
|
||||
});
|
||||
|
||||
tap.test('Event System - once and off subscription methods', async () => {
|
||||
const { eventSystem, receivedEvents } = setupEventSystem();
|
||||
|
||||
// Set up a listener that should fire only once
|
||||
eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => {
|
||||
receivedEvents.push({
|
||||
type: 'once',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Set up a persistent listener
|
||||
const persistentHandler = (data: any) => {
|
||||
receivedEvents.push({
|
||||
type: 'persistent',
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
|
||||
|
||||
// First event should trigger both listeners
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-1',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Second event should only trigger the persistent listener
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-2',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Unsubscribe the persistent listener
|
||||
eventSystem.off(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler);
|
||||
|
||||
// Third event should not trigger any listeners
|
||||
eventSystem.emitConnectionEstablished({
|
||||
connectionId: 'conn-3',
|
||||
clientIp: '192.168.1.1',
|
||||
port: 443
|
||||
});
|
||||
|
||||
// Verify events
|
||||
expect(receivedEvents.length).toEqual(3);
|
||||
expect(receivedEvents[0].type).toEqual('once');
|
||||
expect(receivedEvents[0].data.connectionId).toEqual('conn-1');
|
||||
|
||||
expect(receivedEvents[1].type).toEqual('persistent');
|
||||
expect(receivedEvents[1].data.connectionId).toEqual('conn-1');
|
||||
|
||||
expect(receivedEvents[2].type).toEqual('persistent');
|
||||
expect(receivedEvents[2].data.connectionId).toEqual('conn-2');
|
||||
});
|
||||
|
||||
export default tap.start();
|
185
test/core/utils/test.fs-utils.ts
Normal file
185
test/core/utils/test.fs-utils.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as path from 'path';
|
||||
import { AsyncFileSystem } from '../../../ts/core/utils/fs-utils.js';
|
||||
|
||||
// Use a temporary directory for tests
|
||||
const testDir = path.join(process.cwd(), '.nogit', 'test-fs-utils');
|
||||
const testFile = path.join(testDir, 'test.txt');
|
||||
const testJsonFile = path.join(testDir, 'test.json');
|
||||
|
||||
tap.test('should create and check directory existence', async () => {
|
||||
// Ensure directory
|
||||
await AsyncFileSystem.ensureDir(testDir);
|
||||
|
||||
// Check it exists
|
||||
const exists = await AsyncFileSystem.exists(testDir);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Check it's a directory
|
||||
const isDir = await AsyncFileSystem.isDirectory(testDir);
|
||||
expect(isDir).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should write and read text files', async () => {
|
||||
const testContent = 'Hello, async filesystem!';
|
||||
|
||||
// Write file
|
||||
await AsyncFileSystem.writeFile(testFile, testContent);
|
||||
|
||||
// Check file exists
|
||||
const exists = await AsyncFileSystem.exists(testFile);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Read file
|
||||
const content = await AsyncFileSystem.readFile(testFile);
|
||||
expect(content).toEqual(testContent);
|
||||
|
||||
// Check it's a file
|
||||
const isFile = await AsyncFileSystem.isFile(testFile);
|
||||
expect(isFile).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should write and read JSON files', async () => {
|
||||
const testData = {
|
||||
name: 'Test',
|
||||
value: 42,
|
||||
nested: {
|
||||
array: [1, 2, 3]
|
||||
}
|
||||
};
|
||||
|
||||
// Write JSON
|
||||
await AsyncFileSystem.writeJSON(testJsonFile, testData);
|
||||
|
||||
// Read JSON
|
||||
const readData = await AsyncFileSystem.readJSON(testJsonFile);
|
||||
expect(readData).toEqual(testData);
|
||||
});
|
||||
|
||||
tap.test('should copy files', async () => {
|
||||
const copyFile = path.join(testDir, 'copy.txt');
|
||||
|
||||
// Copy file
|
||||
await AsyncFileSystem.copyFile(testFile, copyFile);
|
||||
|
||||
// Check copy exists
|
||||
const exists = await AsyncFileSystem.exists(copyFile);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Check content matches
|
||||
const content = await AsyncFileSystem.readFile(copyFile);
|
||||
const originalContent = await AsyncFileSystem.readFile(testFile);
|
||||
expect(content).toEqual(originalContent);
|
||||
});
|
||||
|
||||
tap.test('should move files', async () => {
|
||||
const moveFile = path.join(testDir, 'moved.txt');
|
||||
const copyFile = path.join(testDir, 'copy.txt');
|
||||
|
||||
// Move file
|
||||
await AsyncFileSystem.moveFile(copyFile, moveFile);
|
||||
|
||||
// Check moved file exists
|
||||
const movedExists = await AsyncFileSystem.exists(moveFile);
|
||||
expect(movedExists).toBeTrue();
|
||||
|
||||
// Check original doesn't exist
|
||||
const originalExists = await AsyncFileSystem.exists(copyFile);
|
||||
expect(originalExists).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should list files in directory', async () => {
|
||||
const files = await AsyncFileSystem.listFiles(testDir);
|
||||
|
||||
expect(files).toContain('test.txt');
|
||||
expect(files).toContain('test.json');
|
||||
expect(files).toContain('moved.txt');
|
||||
});
|
||||
|
||||
tap.test('should list files with full paths', async () => {
|
||||
const files = await AsyncFileSystem.listFilesFullPath(testDir);
|
||||
|
||||
const fileNames = files.map(f => path.basename(f));
|
||||
expect(fileNames).toContain('test.txt');
|
||||
expect(fileNames).toContain('test.json');
|
||||
|
||||
// All paths should be absolute
|
||||
files.forEach(file => {
|
||||
expect(path.isAbsolute(file)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should get file stats', async () => {
|
||||
const stats = await AsyncFileSystem.getStats(testFile);
|
||||
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats?.isFile()).toBeTrue();
|
||||
expect(stats?.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should handle non-existent files gracefully', async () => {
|
||||
const nonExistent = path.join(testDir, 'does-not-exist.txt');
|
||||
|
||||
// Check existence
|
||||
const exists = await AsyncFileSystem.exists(nonExistent);
|
||||
expect(exists).toBeFalse();
|
||||
|
||||
// Get stats should return null
|
||||
const stats = await AsyncFileSystem.getStats(nonExistent);
|
||||
expect(stats).toBeNull();
|
||||
|
||||
// Remove should not throw
|
||||
await AsyncFileSystem.remove(nonExistent);
|
||||
});
|
||||
|
||||
tap.test('should remove files', async () => {
|
||||
// Remove a file
|
||||
await AsyncFileSystem.remove(testFile);
|
||||
|
||||
// Check it's gone
|
||||
const exists = await AsyncFileSystem.exists(testFile);
|
||||
expect(exists).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should ensure file exists', async () => {
|
||||
const ensureFile = path.join(testDir, 'ensure.txt');
|
||||
|
||||
// Ensure file
|
||||
await AsyncFileSystem.ensureFile(ensureFile);
|
||||
|
||||
// Check it exists
|
||||
const exists = await AsyncFileSystem.exists(ensureFile);
|
||||
expect(exists).toBeTrue();
|
||||
|
||||
// Check it's empty
|
||||
const content = await AsyncFileSystem.readFile(ensureFile);
|
||||
expect(content).toEqual('');
|
||||
});
|
||||
|
||||
tap.test('should recursively list files', async () => {
|
||||
// Create subdirectory with file
|
||||
const subDir = path.join(testDir, 'subdir');
|
||||
const subFile = path.join(subDir, 'nested.txt');
|
||||
|
||||
await AsyncFileSystem.ensureDir(subDir);
|
||||
await AsyncFileSystem.writeFile(subFile, 'nested content');
|
||||
|
||||
// List recursively
|
||||
const files = await AsyncFileSystem.listFilesRecursive(testDir);
|
||||
|
||||
// Should include files from subdirectory
|
||||
const fileNames = files.map(f => path.relative(testDir, f));
|
||||
expect(fileNames).toContain('test.json');
|
||||
expect(fileNames).toContain(path.join('subdir', 'nested.txt'));
|
||||
});
|
||||
|
||||
tap.test('should clean up test directory', async () => {
|
||||
// Remove entire test directory
|
||||
await AsyncFileSystem.removeDir(testDir);
|
||||
|
||||
// Check it's gone
|
||||
const exists = await AsyncFileSystem.exists(testDir);
|
||||
expect(exists).toBeFalse();
|
||||
});
|
||||
|
||||
tap.start();
|
252
test/core/utils/test.lifecycle-component.ts
Normal file
252
test/core/utils/test.lifecycle-component.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { LifecycleComponent } from '../../../ts/core/utils/lifecycle-component.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Test implementation of LifecycleComponent
|
||||
class TestComponent extends LifecycleComponent {
|
||||
public timerCallCount = 0;
|
||||
public intervalCallCount = 0;
|
||||
public cleanupCalled = false;
|
||||
public testEmitter = new EventEmitter();
|
||||
public listenerCallCount = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setupTimers();
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
private setupTimers() {
|
||||
// Set up a timeout
|
||||
this.setTimeout(() => {
|
||||
this.timerCallCount++;
|
||||
}, 100);
|
||||
|
||||
// Set up an interval
|
||||
this.setInterval(() => {
|
||||
this.intervalCallCount++;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
this.addEventListener(this.testEmitter, 'test-event', () => {
|
||||
this.listenerCallCount++;
|
||||
});
|
||||
}
|
||||
|
||||
protected async onCleanup(): Promise<void> {
|
||||
this.cleanupCalled = true;
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testSetTimeout(handler: Function, timeout: number): NodeJS.Timeout {
|
||||
return this.setTimeout(handler, timeout);
|
||||
}
|
||||
|
||||
public testSetInterval(handler: Function, interval: number): NodeJS.Timeout {
|
||||
return this.setInterval(handler, interval);
|
||||
}
|
||||
|
||||
public testClearTimeout(timer: NodeJS.Timeout): void {
|
||||
return this.clearTimeout(timer);
|
||||
}
|
||||
|
||||
public testClearInterval(timer: NodeJS.Timeout): void {
|
||||
return this.clearInterval(timer);
|
||||
}
|
||||
|
||||
public testAddEventListener(target: any, event: string, handler: Function, options?: { once?: boolean }): void {
|
||||
return this.addEventListener(target, event, handler, options);
|
||||
}
|
||||
|
||||
public testIsShuttingDown(): boolean {
|
||||
return this.isShuttingDownState();
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should manage timers properly', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Wait for timers to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(component.timerCallCount).toEqual(1);
|
||||
expect(component.intervalCallCount).toBeGreaterThan(2);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should manage event listeners properly', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Emit events
|
||||
component.testEmitter.emit('test-event');
|
||||
component.testEmitter.emit('test-event');
|
||||
|
||||
expect(component.listenerCallCount).toEqual(2);
|
||||
|
||||
// Cleanup and verify listeners are removed
|
||||
await component.cleanup();
|
||||
|
||||
component.testEmitter.emit('test-event');
|
||||
expect(component.listenerCallCount).toEqual(2); // Should not increase
|
||||
});
|
||||
|
||||
tap.test('should prevent timer execution after cleanup', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
let laterCallCount = 0;
|
||||
component.testSetTimeout(() => {
|
||||
laterCallCount++;
|
||||
}, 100);
|
||||
|
||||
// Cleanup immediately
|
||||
await component.cleanup();
|
||||
|
||||
// Wait for timer that would have fired
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
expect(laterCallCount).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should handle child components', async () => {
|
||||
class ParentComponent extends LifecycleComponent {
|
||||
public child: TestComponent;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.child = new TestComponent();
|
||||
this.registerChildComponent(this.child);
|
||||
}
|
||||
}
|
||||
|
||||
const parent = new ParentComponent();
|
||||
|
||||
// Wait for child timers
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(parent.child.timerCallCount).toEqual(1);
|
||||
|
||||
// Cleanup parent should cleanup child
|
||||
await parent.cleanup();
|
||||
|
||||
expect(parent.child.cleanupCalled).toBeTrue();
|
||||
expect(parent.child.testIsShuttingDown()).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should handle multiple cleanup calls gracefully', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Call cleanup multiple times
|
||||
const promises = [
|
||||
component.cleanup(),
|
||||
component.cleanup(),
|
||||
component.cleanup()
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Should only clean up once
|
||||
expect(component.cleanupCalled).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should clear specific timers', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
let callCount = 0;
|
||||
const timer = component.testSetTimeout(() => {
|
||||
callCount++;
|
||||
}, 100);
|
||||
|
||||
// Clear the timer
|
||||
component.testClearTimeout(timer);
|
||||
|
||||
// Wait and verify it didn't fire
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
expect(callCount).toEqual(0);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should clear specific intervals', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
let callCount = 0;
|
||||
const interval = component.testSetInterval(() => {
|
||||
callCount++;
|
||||
}, 50);
|
||||
|
||||
// Let it run a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
|
||||
const countBeforeClear = callCount;
|
||||
expect(countBeforeClear).toBeGreaterThan(1);
|
||||
|
||||
// Clear the interval
|
||||
component.testClearInterval(interval);
|
||||
|
||||
// Wait and verify it stopped
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(callCount).toEqual(countBeforeClear);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should handle once event listeners', async () => {
|
||||
const component = new TestComponent();
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
let callCount = 0;
|
||||
const handler = () => {
|
||||
callCount++;
|
||||
};
|
||||
|
||||
component.testAddEventListener(emitter, 'once-event', handler, { once: true });
|
||||
|
||||
// Check listener count before emit
|
||||
const beforeCount = emitter.listenerCount('once-event');
|
||||
expect(beforeCount).toEqual(1);
|
||||
|
||||
// Emit once - the listener should fire and auto-remove
|
||||
emitter.emit('once-event');
|
||||
expect(callCount).toEqual(1);
|
||||
|
||||
// Check listener was auto-removed
|
||||
const afterCount = emitter.listenerCount('once-event');
|
||||
expect(afterCount).toEqual(0);
|
||||
|
||||
// Emit again - should not increase count
|
||||
emitter.emit('once-event');
|
||||
expect(callCount).toEqual(1);
|
||||
|
||||
await component.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should not create timers when shutting down', async () => {
|
||||
const component = new TestComponent();
|
||||
|
||||
// Start cleanup
|
||||
const cleanupPromise = component.cleanup();
|
||||
|
||||
// Try to create timers during shutdown
|
||||
let timerFired = false;
|
||||
let intervalFired = false;
|
||||
|
||||
component.testSetTimeout(() => {
|
||||
timerFired = true;
|
||||
}, 10);
|
||||
|
||||
component.testSetInterval(() => {
|
||||
intervalFired = true;
|
||||
}, 10);
|
||||
|
||||
await cleanupPromise;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(timerFired).toBeFalse();
|
||||
expect(intervalFired).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -1,110 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as routeUtils from '../../../ts/core/utils/route-utils.js';
|
||||
|
||||
// Test domain matching
|
||||
tap.test('Route Utils - Domain Matching - exact domains', async () => {
|
||||
expect(routeUtils.matchDomain('example.com', 'example.com')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - Domain Matching - wildcard domains', async () => {
|
||||
expect(routeUtils.matchDomain('*.example.com', 'sub.example.com')).toEqual(true);
|
||||
expect(routeUtils.matchDomain('*.example.com', 'another.sub.example.com')).toEqual(true);
|
||||
expect(routeUtils.matchDomain('*.example.com', 'example.com')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - Domain Matching - case insensitivity', async () => {
|
||||
expect(routeUtils.matchDomain('example.com', 'EXAMPLE.com')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - Domain Matching - multiple domain patterns', async () => {
|
||||
expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'example.com')).toEqual(true);
|
||||
expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'sub.test.com')).toEqual(true);
|
||||
expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'something.else')).toEqual(false);
|
||||
});
|
||||
|
||||
// Test path matching
|
||||
tap.test('Route Utils - Path Matching - exact paths', async () => {
|
||||
expect(routeUtils.matchPath('/api/users', '/api/users')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - Path Matching - wildcard paths', async () => {
|
||||
expect(routeUtils.matchPath('/api/*', '/api/users')).toEqual(true);
|
||||
expect(routeUtils.matchPath('/api/*', '/api/products')).toEqual(true);
|
||||
expect(routeUtils.matchPath('/api/*', '/something/else')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - Path Matching - complex wildcard patterns', async () => {
|
||||
expect(routeUtils.matchPath('/api/*/details', '/api/users/details')).toEqual(true);
|
||||
expect(routeUtils.matchPath('/api/*/details', '/api/products/details')).toEqual(true);
|
||||
expect(routeUtils.matchPath('/api/*/details', '/api/users/other')).toEqual(false);
|
||||
});
|
||||
|
||||
// Test IP matching
|
||||
tap.test('Route Utils - IP Matching - exact IPs', async () => {
|
||||
expect(routeUtils.matchIpPattern('192.168.1.1', '192.168.1.1')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - IP Matching - wildcard IPs', async () => {
|
||||
expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.1.100')).toEqual(true);
|
||||
expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.2.1')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - IP Matching - CIDR notation', async () => {
|
||||
expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.1.100')).toEqual(true);
|
||||
expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.2.1')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - IP Matching - IPv6-mapped IPv4 addresses', async () => {
|
||||
expect(routeUtils.matchIpPattern('192.168.1.1', '::ffff:192.168.1.1')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Route Utils - IP Matching - IP authorization with allow/block lists', async () => {
|
||||
// With allow and block lists
|
||||
expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'], ['192.168.1.5'])).toEqual(true);
|
||||
expect(routeUtils.isIpAuthorized('192.168.1.5', ['192.168.1.*'], ['192.168.1.5'])).toEqual(false);
|
||||
|
||||
// With only allow list
|
||||
expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'])).toEqual(true);
|
||||
expect(routeUtils.isIpAuthorized('192.168.2.1', ['192.168.1.*'])).toEqual(false);
|
||||
|
||||
// With only block list
|
||||
expect(routeUtils.isIpAuthorized('192.168.1.5', undefined, ['192.168.1.5'])).toEqual(false);
|
||||
expect(routeUtils.isIpAuthorized('192.168.1.1', undefined, ['192.168.1.5'])).toEqual(true);
|
||||
|
||||
// With wildcard in allow list
|
||||
expect(routeUtils.isIpAuthorized('192.168.1.1', ['*'], ['192.168.1.5'])).toEqual(true);
|
||||
});
|
||||
|
||||
// Test route specificity calculation
|
||||
tap.test('Route Utils - Route Specificity - calculating correctly', async () => {
|
||||
const basicRoute = { domains: 'example.com' };
|
||||
const pathRoute = { domains: 'example.com', path: '/api' };
|
||||
const wildcardPathRoute = { domains: 'example.com', path: '/api/*' };
|
||||
const headerRoute = { domains: 'example.com', headers: { 'content-type': 'application/json' } };
|
||||
const complexRoute = {
|
||||
domains: 'example.com',
|
||||
path: '/api',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
clientIp: ['192.168.1.1']
|
||||
};
|
||||
|
||||
// Path routes should have higher specificity than domain-only routes
|
||||
expect(routeUtils.calculateRouteSpecificity(pathRoute) >
|
||||
routeUtils.calculateRouteSpecificity(basicRoute)).toEqual(true);
|
||||
|
||||
// Exact path routes should have higher specificity than wildcard path routes
|
||||
expect(routeUtils.calculateRouteSpecificity(pathRoute) >
|
||||
routeUtils.calculateRouteSpecificity(wildcardPathRoute)).toEqual(true);
|
||||
|
||||
// Routes with headers should have higher specificity than routes without
|
||||
expect(routeUtils.calculateRouteSpecificity(headerRoute) >
|
||||
routeUtils.calculateRouteSpecificity(basicRoute)).toEqual(true);
|
||||
|
||||
// Complex routes should have the highest specificity
|
||||
expect(routeUtils.calculateRouteSpecificity(complexRoute) >
|
||||
routeUtils.calculateRouteSpecificity(pathRoute)).toEqual(true);
|
||||
expect(routeUtils.calculateRouteSpecificity(complexRoute) >
|
||||
routeUtils.calculateRouteSpecificity(headerRoute)).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -92,7 +92,7 @@ tap.test('should create ACME challenge route', async (tools) => {
|
||||
await proxy.start();
|
||||
|
||||
// Verify the challenge route is in the proxy's routes
|
||||
const proxyRoutes = proxy.routeManager.getAllRoutes();
|
||||
const proxyRoutes = proxy.routeManager.getRoutes();
|
||||
const foundChallengeRoute = proxyRoutes.find((r: any) => r.name === 'acme-challenge');
|
||||
|
||||
expect(foundChallengeRoute).toBeDefined();
|
||||
|
@ -13,8 +13,11 @@ tap.test('AcmeStateManager should track challenge routes correctly', async (tool
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static',
|
||||
handler: async () => ({ status: 200, body: 'challenge' })
|
||||
type: 'socket-handler',
|
||||
socketHandler: async (socket, context) => {
|
||||
// Mock handler that would write the challenge response
|
||||
socket.end('challenge response');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -46,7 +49,7 @@ tap.test('AcmeStateManager should track port allocations', async (tools) => {
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -58,7 +61,7 @@ tap.test('AcmeStateManager should track port allocations', async (tools) => {
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -97,7 +100,7 @@ tap.test('AcmeStateManager should select primary route by priority', async (tool
|
||||
ports: 80
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -108,7 +111,7 @@ tap.test('AcmeStateManager should select primary route by priority', async (tool
|
||||
ports: 80
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -119,7 +122,7 @@ tap.test('AcmeStateManager should select primary route by priority', async (tool
|
||||
ports: 80
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -149,7 +152,7 @@ tap.test('AcmeStateManager should handle clear operation', async (tools) => {
|
||||
ports: [80, 443]
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
@ -159,7 +162,7 @@ tap.test('AcmeStateManager should handle clear operation', async (tools) => {
|
||||
ports: 8080
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
type: 'socket-handler'
|
||||
}
|
||||
};
|
||||
|
||||
|
242
test/test.connect-disconnect-cleanup.node.ts
Normal file
242
test/test.connect-disconnect-cleanup.node.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import SmartProxy and configurations
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
tap.test('should handle clients that connect and immediately disconnect without sending data', async () => {
|
||||
console.log('\n=== Testing Connect-Disconnect Cleanup ===');
|
||||
|
||||
// Create a SmartProxy instance
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8560],
|
||||
enableDetailedLogging: false,
|
||||
initialDataTimeout: 5000, // 5 second timeout for initial data
|
||||
routes: [{
|
||||
name: 'test-route',
|
||||
match: { ports: 8560 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('✓ Proxy started on port 8560');
|
||||
|
||||
// Helper to get active connection count
|
||||
const getActiveConnections = () => {
|
||||
const connectionManager = (proxy as any).connectionManager;
|
||||
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||
};
|
||||
|
||||
const initialCount = getActiveConnections();
|
||||
console.log(`Initial connection count: ${initialCount}`);
|
||||
|
||||
// Test 1: Connect and immediately disconnect without sending data
|
||||
console.log('\n--- Test 1: Immediate disconnect ---');
|
||||
const connectionCounts: number[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const client = new net.Socket();
|
||||
|
||||
// Connect and immediately destroy
|
||||
client.connect(8560, 'localhost', () => {
|
||||
// Connected - immediately destroy without sending data
|
||||
client.destroy();
|
||||
});
|
||||
|
||||
// Wait a tiny bit
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const count = getActiveConnections();
|
||||
connectionCounts.push(count);
|
||||
if ((i + 1) % 5 === 0) {
|
||||
console.log(`After ${i + 1} connect/disconnect cycles: ${count} active connections`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const afterImmediateDisconnect = getActiveConnections();
|
||||
console.log(`After immediate disconnect test: ${afterImmediateDisconnect} active connections`);
|
||||
|
||||
// Test 2: Connect, wait a bit, then disconnect without sending data
|
||||
console.log('\n--- Test 2: Delayed disconnect ---');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
// Ignore errors
|
||||
});
|
||||
|
||||
client.connect(8560, 'localhost', () => {
|
||||
// Wait 100ms then disconnect without sending data
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Check count immediately
|
||||
const duringDelayed = getActiveConnections();
|
||||
console.log(`During delayed disconnect test: ${duringDelayed} active connections`);
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const afterDelayedDisconnect = getActiveConnections();
|
||||
console.log(`After delayed disconnect test: ${afterDelayedDisconnect} active connections`);
|
||||
|
||||
// Test 3: Mix of immediate and delayed disconnects
|
||||
console.log('\n--- Test 3: Mixed disconnect patterns ---');
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8560, 'localhost', () => {
|
||||
if (i % 2 === 0) {
|
||||
// Half disconnect immediately
|
||||
client.destroy();
|
||||
} else {
|
||||
// Half wait 50ms
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Failsafe timeout
|
||||
setTimeout(() => resolve(), 200);
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
await Promise.all(promises);
|
||||
|
||||
const duringMixed = getActiveConnections();
|
||||
console.log(`During mixed test: ${duringMixed} active connections`);
|
||||
|
||||
// Final cleanup wait
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const finalCount = getActiveConnections();
|
||||
console.log(`\nFinal connection count: ${finalCount}`);
|
||||
|
||||
// Stop the proxy
|
||||
await proxy.stop();
|
||||
console.log('✓ Proxy stopped');
|
||||
|
||||
// Verify all connections were cleaned up
|
||||
expect(finalCount).toEqual(initialCount);
|
||||
expect(afterImmediateDisconnect).toEqual(initialCount);
|
||||
expect(afterDelayedDisconnect).toEqual(initialCount);
|
||||
|
||||
// Check that connections didn't accumulate during the test
|
||||
const maxCount = Math.max(...connectionCounts);
|
||||
console.log(`\nMax connection count during immediate disconnect test: ${maxCount}`);
|
||||
expect(maxCount).toBeLessThan(3); // Should stay very low
|
||||
|
||||
console.log('\n✅ PASS: Connect-disconnect cleanup working correctly!');
|
||||
});
|
||||
|
||||
tap.test('should handle clients that error during connection', async () => {
|
||||
console.log('\n=== Testing Connection Error Cleanup ===');
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8561],
|
||||
enableDetailedLogging: false,
|
||||
routes: [{
|
||||
name: 'test-route',
|
||||
match: { ports: 8561 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
await proxy.start();
|
||||
console.log('✓ Proxy started on port 8561');
|
||||
|
||||
const getActiveConnections = () => {
|
||||
const connectionManager = (proxy as any).connectionManager;
|
||||
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||
};
|
||||
|
||||
const initialCount = getActiveConnections();
|
||||
console.log(`Initial connection count: ${initialCount}`);
|
||||
|
||||
// Create connections that will error
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Connect to proxy
|
||||
client.connect(8561, 'localhost', () => {
|
||||
// Force an error by writing invalid data then destroying
|
||||
try {
|
||||
client.write(Buffer.alloc(1024 * 1024)); // Large write
|
||||
client.destroy();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => resolve(), 500);
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log('✓ All error connections completed');
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const finalCount = getActiveConnections();
|
||||
console.log(`Final connection count: ${finalCount}`);
|
||||
|
||||
await proxy.stop();
|
||||
console.log('✓ Proxy stopped');
|
||||
|
||||
expect(finalCount).toEqual(initialCount);
|
||||
|
||||
console.log('\n✅ PASS: Connection error cleanup working correctly!');
|
||||
});
|
||||
|
||||
tap.start();
|
279
test/test.connection-cleanup-comprehensive.node.ts
Normal file
279
test/test.connection-cleanup-comprehensive.node.ts
Normal file
@ -0,0 +1,279 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import SmartProxy and configurations
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
||||
console.log('\n=== Comprehensive Connection Cleanup Test ===');
|
||||
|
||||
// Create a SmartProxy instance
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8570, 8571], // One for immediate routing, one for TLS
|
||||
enableDetailedLogging: false,
|
||||
initialDataTimeout: 2000,
|
||||
socketTimeout: 5000,
|
||||
routes: [
|
||||
{
|
||||
name: 'non-tls-route',
|
||||
match: { ports: 8570 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tls-route',
|
||||
match: { ports: 8571 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port
|
||||
},
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('✓ Proxy started on ports 8570 (non-TLS) and 8571 (TLS)');
|
||||
|
||||
// Helper to get active connection count
|
||||
const getActiveConnections = () => {
|
||||
const connectionManager = (proxy as any).connectionManager;
|
||||
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||
};
|
||||
|
||||
const initialCount = getActiveConnections();
|
||||
console.log(`Initial connection count: ${initialCount}`);
|
||||
|
||||
// Test 1: Rapid ECONNREFUSED retries (from original issue)
|
||||
console.log('\n--- Test 1: Rapid ECONNREFUSED retries ---');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
client.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8570, 'localhost', () => {
|
||||
// Send data to trigger routing
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
if ((i + 1) % 5 === 0) {
|
||||
const count = getActiveConnections();
|
||||
console.log(`After ${i + 1} ECONNREFUSED retries: ${count} active connections`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Connect without sending data (immediate disconnect)
|
||||
console.log('\n--- Test 2: Connect without sending data ---');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
// Ignore
|
||||
});
|
||||
|
||||
// Connect to non-TLS port and immediately disconnect
|
||||
client.connect(8570, 'localhost', () => {
|
||||
client.destroy();
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const afterNoData = getActiveConnections();
|
||||
console.log(`After connect-without-data test: ${afterNoData} active connections`);
|
||||
|
||||
// Test 3: TLS connections that disconnect before handshake
|
||||
console.log('\n--- Test 3: TLS early disconnect ---');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
// Ignore
|
||||
});
|
||||
|
||||
// Connect to TLS port but disconnect before sending handshake
|
||||
client.connect(8571, 'localhost', () => {
|
||||
// Wait 50ms then disconnect (before initial data timeout)
|
||||
setTimeout(() => {
|
||||
client.destroy();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const afterTlsEarly = getActiveConnections();
|
||||
console.log(`After TLS early disconnect test: ${afterTlsEarly} active connections`);
|
||||
|
||||
// Test 4: Mixed pattern - simulating real-world chaos
|
||||
console.log('\n--- Test 4: Mixed chaos pattern ---');
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
const port = i % 2 === 0 ? 8570 : 8571;
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(port, 'localhost', () => {
|
||||
const scenario = i % 5;
|
||||
|
||||
switch (scenario) {
|
||||
case 0:
|
||||
// Immediate disconnect
|
||||
client.destroy();
|
||||
break;
|
||||
case 1:
|
||||
// Send data then disconnect
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
setTimeout(() => client.destroy(), 20);
|
||||
break;
|
||||
case 2:
|
||||
// Disconnect after delay
|
||||
setTimeout(() => client.destroy(), 100);
|
||||
break;
|
||||
case 3:
|
||||
// Send partial TLS handshake
|
||||
if (port === 8571) {
|
||||
client.write(Buffer.from([0x16, 0x03, 0x01])); // Partial TLS
|
||||
}
|
||||
setTimeout(() => client.destroy(), 50);
|
||||
break;
|
||||
case 4:
|
||||
// Just let it timeout
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Failsafe
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 500);
|
||||
}));
|
||||
|
||||
// Small delay between connections
|
||||
if (i % 5 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log('✓ Chaos test completed');
|
||||
|
||||
// Wait for any cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const afterChaos = getActiveConnections();
|
||||
console.log(`After chaos test: ${afterChaos} active connections`);
|
||||
|
||||
// Test 5: NFTables route (should cleanup properly)
|
||||
console.log('\n--- Test 5: NFTables route cleanup ---');
|
||||
const nftProxy = new SmartProxy({
|
||||
ports: [8572],
|
||||
enableDetailedLogging: false,
|
||||
routes: [{
|
||||
name: 'nftables-route',
|
||||
match: { ports: 8572 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
await nftProxy.start();
|
||||
|
||||
const getNftConnections = () => {
|
||||
const connectionManager = (nftProxy as any).connectionManager;
|
||||
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||
};
|
||||
|
||||
// Create NFTables connections
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
// Ignore
|
||||
});
|
||||
|
||||
client.connect(8572, 'localhost', () => {
|
||||
setTimeout(() => client.destroy(), 50);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const nftFinal = getNftConnections();
|
||||
console.log(`NFTables connections after test: ${nftFinal}`);
|
||||
|
||||
await nftProxy.stop();
|
||||
|
||||
// Final check on main proxy
|
||||
const finalCount = getActiveConnections();
|
||||
console.log(`\nFinal connection count: ${finalCount}`);
|
||||
|
||||
// Stop the proxy
|
||||
await proxy.stop();
|
||||
console.log('✓ Proxy stopped');
|
||||
|
||||
// Verify all connections were cleaned up
|
||||
expect(finalCount).toEqual(initialCount);
|
||||
expect(afterNoData).toEqual(initialCount);
|
||||
expect(afterTlsEarly).toEqual(initialCount);
|
||||
expect(afterChaos).toEqual(initialCount);
|
||||
expect(nftFinal).toEqual(0);
|
||||
|
||||
console.log('\n✅ PASS: Comprehensive connection cleanup test passed!');
|
||||
console.log('All connection scenarios properly cleaned up:');
|
||||
console.log('- ECONNREFUSED rapid retries');
|
||||
console.log('- Connect without sending data');
|
||||
console.log('- TLS early disconnect');
|
||||
console.log('- Mixed chaos patterns');
|
||||
console.log('- NFTables connections');
|
||||
});
|
||||
|
||||
tap.start();
|
@ -54,10 +54,17 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
|
||||
findMatchingRoute: (criteria: any) => ({
|
||||
route: mockSettings.routes[0]
|
||||
}),
|
||||
getAllRoutes: () => mockSettings.routes,
|
||||
getRoutes: () => mockSettings.routes,
|
||||
getRoutesForPort: (port: number) => mockSettings.routes.filter(r => {
|
||||
const ports = Array.isArray(r.match.ports) ? r.match.ports : [r.match.ports];
|
||||
return ports.includes(port);
|
||||
return ports.some(p => {
|
||||
if (typeof p === 'number') {
|
||||
return p === port;
|
||||
} else if (p && typeof p === 'object' && 'from' in p && 'to' in p) {
|
||||
return port >= p.from && port <= p.to;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
@ -101,6 +108,8 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
|
||||
resume: () => {},
|
||||
removeListener: function() { return this; },
|
||||
emit: () => {},
|
||||
setNoDelay: () => {},
|
||||
setKeepAlive: () => {},
|
||||
_dataHandler: null as any
|
||||
} as any;
|
||||
|
||||
@ -173,10 +182,17 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
|
||||
findMatchingRoute: (criteria: any) => ({
|
||||
route: mockSettings.routes[0]
|
||||
}),
|
||||
getAllRoutes: () => mockSettings.routes,
|
||||
getRoutes: () => mockSettings.routes,
|
||||
getRoutesForPort: (port: number) => mockSettings.routes.filter(r => {
|
||||
const ports = Array.isArray(r.match.ports) ? r.match.ports : [r.match.ports];
|
||||
return ports.includes(port);
|
||||
return ports.some(p => {
|
||||
if (typeof p === 'number') {
|
||||
return p === port;
|
||||
} else if (p && typeof p === 'object' && 'from' in p && 'to' in p) {
|
||||
return port >= p.from && port <= p.to;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
@ -211,6 +227,8 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
|
||||
resume: () => {},
|
||||
removeListener: function() { return this; },
|
||||
emit: () => {},
|
||||
setNoDelay: () => {},
|
||||
setKeepAlive: () => {},
|
||||
_dataHandler: null as any
|
||||
} as any;
|
||||
|
||||
|
@ -26,7 +26,6 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
||||
proxy.settings.enableDetailedLogging = true;
|
||||
|
||||
// Override the HttpProxy initialization to avoid actual HttpProxy setup
|
||||
const mockHttpProxy = { available: true };
|
||||
proxy['httpProxyBridge'].initialize = async () => {
|
||||
console.log('Mock: HttpProxyBridge initialized');
|
||||
};
|
||||
@ -35,6 +34,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
||||
};
|
||||
proxy['httpProxyBridge'].stop = async () => {
|
||||
console.log('Mock: HttpProxyBridge stopped');
|
||||
return Promise.resolve(); // Ensure it returns a resolved promise
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
@ -45,15 +45,14 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
||||
forwardedToHttpProxy = true;
|
||||
connectionPath = 'httpproxy';
|
||||
console.log('Mock: Connection forwarded to HttpProxy with args:', args[0], 'on port:', args[2]?.localPort);
|
||||
// Just close the connection for the test
|
||||
args[1].end(); // socket.end()
|
||||
// Properly close the connection for the test
|
||||
const socket = args[1];
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
const originalGetHttpProxy = proxy['httpProxyBridge'].getHttpProxy;
|
||||
proxy['httpProxyBridge'].getHttpProxy = () => {
|
||||
console.log('Mock: getHttpProxy called, returning:', mockHttpProxy);
|
||||
return mockHttpProxy;
|
||||
};
|
||||
// Mock getHttpProxy to indicate HttpProxy is available
|
||||
(proxy as any).httpProxyBridge.getHttpProxy = () => ({ available: true });
|
||||
|
||||
// Make a connection to port 8080
|
||||
const client = new net.Socket();
|
||||
@ -78,13 +77,16 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
||||
expect(connectionPath).toEqual('httpproxy');
|
||||
|
||||
client.destroy();
|
||||
|
||||
// Restore original method before stopping
|
||||
(proxy as any).httpProxyBridge.forwardToHttpProxy = originalForward;
|
||||
|
||||
console.log('About to stop proxy...');
|
||||
await proxy.stop();
|
||||
console.log('Proxy stopped');
|
||||
|
||||
// Wait a bit to ensure port is released
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Restore original method
|
||||
(proxy as any).httpProxyBridge.forwardToHttpProxy = originalForward;
|
||||
});
|
||||
|
||||
// Test that verifies the fix detects non-TLS connections
|
||||
@ -128,8 +130,10 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
|
||||
proxy['httpProxyBridge'].forwardToHttpProxy = async function(...args: any[]) {
|
||||
httpProxyForwardCalled = true;
|
||||
console.log('HttpProxy forward called with connectionId:', args[0]);
|
||||
// Just end the connection
|
||||
args[1].end();
|
||||
// Properly close the connection
|
||||
const socket = args[1];
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
// Mock HttpProxyBridge methods
|
||||
@ -141,6 +145,7 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
|
||||
};
|
||||
proxy['httpProxyBridge'].stop = async () => {
|
||||
console.log('Mock: HttpProxyBridge stopped');
|
||||
return Promise.resolve(); // Ensure it returns a resolved promise
|
||||
};
|
||||
|
||||
// Mock getHttpProxy to return a truthy value
|
||||
|
@ -63,9 +63,21 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Making HTTP request to proxy...');
|
||||
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const req = http.request(options, (res) => resolve(res));
|
||||
req.on('error', reject);
|
||||
const req = http.request(options, (res) => {
|
||||
console.log('Got response from proxy:', res.statusCode);
|
||||
resolve(res);
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
console.error('Request error:', err);
|
||||
reject(err);
|
||||
});
|
||||
req.setTimeout(5000, () => {
|
||||
console.error('Request timeout');
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
|
||||
@ -85,6 +97,9 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
targetServer.close(() => resolve());
|
||||
});
|
||||
|
||||
// Wait a bit to ensure port is fully released
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
});
|
||||
|
||||
tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
|
||||
@ -135,15 +150,30 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Making HTTP request to proxy...');
|
||||
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const req = http.request(options, (res) => resolve(res));
|
||||
req.on('error', reject);
|
||||
const req = http.request(options, (res) => {
|
||||
console.log('Got response from proxy:', res.statusCode);
|
||||
resolve(res);
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
console.error('Request error:', err);
|
||||
reject(err);
|
||||
});
|
||||
req.setTimeout(5000, () => {
|
||||
console.error('Request timeout');
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
|
||||
let responseData = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', chunk => responseData += chunk);
|
||||
response.on('data', chunk => {
|
||||
console.log('Received data chunk:', chunk);
|
||||
responseData += chunk;
|
||||
});
|
||||
await new Promise(resolve => response.on('end', resolve));
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
@ -154,6 +184,9 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
targetServer.close(() => resolve());
|
||||
});
|
||||
|
||||
// Wait a bit to ensure port is fully released
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@ -82,13 +82,16 @@ tap.test('setup HttpProxy function-based targets test environment', async (tools
|
||||
|
||||
// Test static host/port routes
|
||||
tap.test('should support static host/port routes', async () => {
|
||||
// Get proxy port first
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
name: 'static-route',
|
||||
priority: 100,
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
ports: 0
|
||||
ports: proxyPort
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@ -102,9 +105,6 @@ tap.test('should support static host/port routes', async () => {
|
||||
|
||||
await httpProxy.updateRouteConfigs(routes);
|
||||
|
||||
// Get proxy port using the improved getListeningPort() method
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
|
||||
// Make request to proxy
|
||||
const response = await makeRequest({
|
||||
hostname: 'localhost',
|
||||
@ -124,13 +124,14 @@ tap.test('should support static host/port routes', async () => {
|
||||
|
||||
// Test function-based host
|
||||
tap.test('should support function-based host', async () => {
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
name: 'function-host-route',
|
||||
priority: 100,
|
||||
match: {
|
||||
domains: 'function.example.com',
|
||||
ports: 0
|
||||
ports: proxyPort
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@ -147,9 +148,6 @@ tap.test('should support function-based host', async () => {
|
||||
|
||||
await httpProxy.updateRouteConfigs(routes);
|
||||
|
||||
// Get proxy port using the improved getListeningPort() method
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
|
||||
// Make request to proxy
|
||||
const response = await makeRequest({
|
||||
hostname: 'localhost',
|
||||
@ -169,13 +167,14 @@ tap.test('should support function-based host', async () => {
|
||||
|
||||
// Test function-based port
|
||||
tap.test('should support function-based port', async () => {
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
name: 'function-port-route',
|
||||
priority: 100,
|
||||
match: {
|
||||
domains: 'function-port.example.com',
|
||||
ports: 0
|
||||
ports: proxyPort
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@ -192,9 +191,6 @@ tap.test('should support function-based port', async () => {
|
||||
|
||||
await httpProxy.updateRouteConfigs(routes);
|
||||
|
||||
// Get proxy port using the improved getListeningPort() method
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
|
||||
// Make request to proxy
|
||||
const response = await makeRequest({
|
||||
hostname: 'localhost',
|
||||
@ -214,13 +210,14 @@ tap.test('should support function-based port', async () => {
|
||||
|
||||
// Test function-based host AND port
|
||||
tap.test('should support function-based host AND port', async () => {
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
name: 'function-both-route',
|
||||
priority: 100,
|
||||
match: {
|
||||
domains: 'function-both.example.com',
|
||||
ports: 0
|
||||
ports: proxyPort
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@ -238,9 +235,6 @@ tap.test('should support function-based host AND port', async () => {
|
||||
|
||||
await httpProxy.updateRouteConfigs(routes);
|
||||
|
||||
// Get proxy port using the improved getListeningPort() method
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
|
||||
// Make request to proxy
|
||||
const response = await makeRequest({
|
||||
hostname: 'localhost',
|
||||
@ -260,13 +254,14 @@ tap.test('should support function-based host AND port', async () => {
|
||||
|
||||
// Test context-based routing with path
|
||||
tap.test('should support context-based routing with path', async () => {
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
name: 'context-path-route',
|
||||
priority: 100,
|
||||
match: {
|
||||
domains: 'context.example.com',
|
||||
ports: 0
|
||||
ports: proxyPort
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@ -287,9 +282,6 @@ tap.test('should support context-based routing with path', async () => {
|
||||
|
||||
await httpProxy.updateRouteConfigs(routes);
|
||||
|
||||
// Get proxy port using the improved getListeningPort() method
|
||||
const proxyPort = httpProxy.getListeningPort();
|
||||
|
||||
// Make request to proxy with /api path
|
||||
const apiResponse = await makeRequest({
|
||||
hostname: 'localhost',
|
||||
|
@ -591,13 +591,6 @@ tap.test('cleanup', async () => {
|
||||
|
||||
// Exit handler removed to prevent interference with test cleanup
|
||||
|
||||
// Add a post-hook to force exit after tap completion
|
||||
tap.test('teardown', async () => {
|
||||
// Force exit after all tests complete
|
||||
setTimeout(() => {
|
||||
console.log('[TEST] Force exit after tap completion');
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
});
|
||||
// Teardown test removed - let tap handle proper cleanup
|
||||
|
||||
export default tap.start();
|
146
test/test.long-lived-connections.ts
Normal file
146
test/test.long-lived-connections.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
let testProxy: SmartProxy;
|
||||
let targetServer: net.Server;
|
||||
|
||||
// Create a simple echo server as target
|
||||
tap.test('setup test environment', async () => {
|
||||
// Create target server that echoes data back
|
||||
targetServer = net.createServer((socket) => {
|
||||
console.log('Target server: client connected');
|
||||
|
||||
// Echo data back
|
||||
socket.on('data', (data) => {
|
||||
console.log(`Target server received: ${data.toString().trim()}`);
|
||||
socket.write(data);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
console.log('Target server: client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
targetServer.listen(9876, () => {
|
||||
console.log('Target server listening on port 9876');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create proxy with simple TCP forwarding (no TLS)
|
||||
testProxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'tcp-forward-test',
|
||||
match: {
|
||||
ports: 8888 // Plain TCP port
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9876
|
||||
}
|
||||
// No TLS configuration - just plain TCP forwarding
|
||||
}
|
||||
}],
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9876
|
||||
}
|
||||
},
|
||||
enableDetailedLogging: true,
|
||||
keepAliveTreatment: 'extended', // Allow long-lived connections
|
||||
inactivityTimeout: 3600000, // 1 hour
|
||||
socketTimeout: 3600000, // 1 hour
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelay: 1000
|
||||
});
|
||||
|
||||
await testProxy.start();
|
||||
});
|
||||
|
||||
tap.test('should keep WebSocket-like connection open for extended period', async (tools) => {
|
||||
tools.timeout(65000); // 65 second test timeout
|
||||
|
||||
const client = new net.Socket();
|
||||
let messagesReceived = 0;
|
||||
let connectionClosed = false;
|
||||
|
||||
// Connect to proxy
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.connect(8888, 'localhost', () => {
|
||||
console.log('Client connected to proxy');
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
// Set up data handler
|
||||
client.on('data', (data) => {
|
||||
console.log(`Client received: ${data.toString().trim()}`);
|
||||
messagesReceived++;
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
console.log('Client connection closed');
|
||||
connectionClosed = true;
|
||||
});
|
||||
|
||||
// Send initial handshake-like data
|
||||
client.write('HELLO\n');
|
||||
|
||||
// Wait for response
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(messagesReceived).toEqual(1);
|
||||
|
||||
// Simulate WebSocket-like keep-alive pattern
|
||||
// Send periodic messages over 60 seconds
|
||||
const startTime = Date.now();
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!connectionClosed && Date.now() - startTime < 60000) {
|
||||
console.log('Sending ping...');
|
||||
client.write('PING\n');
|
||||
} else {
|
||||
clearInterval(pingInterval);
|
||||
}
|
||||
}, 10000); // Every 10 seconds
|
||||
|
||||
// Wait for 61 seconds
|
||||
await new Promise(resolve => setTimeout(resolve, 61000));
|
||||
|
||||
// Clean up interval
|
||||
clearInterval(pingInterval);
|
||||
|
||||
// Connection should still be open
|
||||
expect(connectionClosed).toEqual(false);
|
||||
|
||||
// Should have received responses (1 hello + 6 pings)
|
||||
expect(messagesReceived).toBeGreaterThan(5);
|
||||
|
||||
// Close connection gracefully
|
||||
client.end();
|
||||
|
||||
// Wait for close
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(connectionClosed).toEqual(true);
|
||||
});
|
||||
|
||||
// NOTE: Half-open connections are not supported due to proxy chain architecture
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await testProxy.stop();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
targetServer.close(() => {
|
||||
console.log('Target server closed');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default tap.start();
|
195
test/test.proxy-chain-simple.node.ts
Normal file
195
test/test.proxy-chain-simple.node.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import SmartProxy and configurations
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
tap.test('simple proxy chain test - identify connection accumulation', async () => {
|
||||
console.log('\n=== Simple Proxy Chain Test ===');
|
||||
console.log('Setup: Client → SmartProxy1 (8590) → SmartProxy2 (8591) → Backend (down)');
|
||||
|
||||
// Create backend server that accepts and immediately closes connections
|
||||
const backend = net.createServer((socket) => {
|
||||
console.log('Backend: Connection received, closing immediately');
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
backend.listen(9998, () => {
|
||||
console.log('✓ Backend server started on port 9998 (closes connections immediately)');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create SmartProxy2 (downstream)
|
||||
const proxy2 = new SmartProxy({
|
||||
ports: [8591],
|
||||
enableDetailedLogging: true,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
name: 'to-backend',
|
||||
match: { ports: 8591 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9998 // Backend that closes immediately
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Create SmartProxy1 (upstream)
|
||||
const proxy1 = new SmartProxy({
|
||||
ports: [8590],
|
||||
enableDetailedLogging: true,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
name: 'to-proxy2',
|
||||
match: { ports: 8590 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8591 // Forward to proxy2
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
await proxy2.start();
|
||||
console.log('✓ SmartProxy2 started on port 8591');
|
||||
|
||||
await proxy1.start();
|
||||
console.log('✓ SmartProxy1 started on port 8590');
|
||||
|
||||
// Helper to get connection counts
|
||||
const getConnectionCounts = () => {
|
||||
const conn1 = (proxy1 as any).connectionManager;
|
||||
const conn2 = (proxy2 as any).connectionManager;
|
||||
return {
|
||||
proxy1: conn1 ? conn1.getConnectionCount() : 0,
|
||||
proxy2: conn2 ? conn2.getConnectionCount() : 0
|
||||
};
|
||||
};
|
||||
|
||||
console.log('\n--- Making 5 sequential connections ---');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
console.log(`\n=== Connection ${i + 1} ===`);
|
||||
|
||||
const counts = getConnectionCounts();
|
||||
console.log(`Before: Proxy1=${counts.proxy1}, Proxy2=${counts.proxy2}`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
let dataReceived = false;
|
||||
|
||||
client.on('data', (data) => {
|
||||
console.log(`Client received data: ${data.toString()}`);
|
||||
dataReceived = true;
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.log(`Client error: ${err.code}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
console.log(`Client closed (data received: ${dataReceived})`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8590, 'localhost', () => {
|
||||
console.log('Client connected to Proxy1');
|
||||
// Send HTTP request
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
});
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
console.log('Client timeout, destroying');
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Wait a bit and check counts
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const afterCounts = getConnectionCounts();
|
||||
console.log(`After: Proxy1=${afterCounts.proxy1}, Proxy2=${afterCounts.proxy2}`);
|
||||
|
||||
if (afterCounts.proxy1 > 0 || afterCounts.proxy2 > 0) {
|
||||
console.log('⚠️ WARNING: Connections not cleaned up!');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- Test with backend completely down ---');
|
||||
|
||||
// Stop backend
|
||||
backend.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
console.log('✓ Backend stopped');
|
||||
|
||||
// Make more connections with backend down
|
||||
for (let i = 0; i < 3; i++) {
|
||||
console.log(`\n=== Connection ${i + 6} (backend down) ===`);
|
||||
|
||||
const counts = getConnectionCounts();
|
||||
console.log(`Before: Proxy1=${counts.proxy1}, Proxy2=${counts.proxy2}`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8590, 'localhost', () => {
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const afterCounts = getConnectionCounts();
|
||||
console.log(`After: Proxy1=${afterCounts.proxy1}, Proxy2=${afterCounts.proxy2}`);
|
||||
}
|
||||
|
||||
// Final check
|
||||
console.log('\n--- Final Check ---');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const finalCounts = getConnectionCounts();
|
||||
console.log(`Final counts: Proxy1=${finalCounts.proxy1}, Proxy2=${finalCounts.proxy2}`);
|
||||
|
||||
await proxy1.stop();
|
||||
await proxy2.stop();
|
||||
|
||||
// Verify
|
||||
if (finalCounts.proxy1 > 0 || finalCounts.proxy2 > 0) {
|
||||
console.log('\n❌ FAIL: Connections accumulated!');
|
||||
} else {
|
||||
console.log('\n✅ PASS: No connection accumulation');
|
||||
}
|
||||
|
||||
expect(finalCounts.proxy1).toEqual(0);
|
||||
expect(finalCounts.proxy2).toEqual(0);
|
||||
});
|
||||
|
||||
tap.start();
|
368
test/test.proxy-chaining-accumulation.node.ts
Normal file
368
test/test.proxy-chaining-accumulation.node.ts
Normal file
@ -0,0 +1,368 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import SmartProxy and configurations
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
tap.test('should handle proxy chaining without connection accumulation', async () => {
|
||||
console.log('\n=== Testing Proxy Chaining Connection Accumulation ===');
|
||||
console.log('Setup: Client → SmartProxy1 → SmartProxy2 → Backend (down)');
|
||||
|
||||
// Create SmartProxy2 (downstream proxy)
|
||||
const proxy2 = new SmartProxy({
|
||||
ports: [8581],
|
||||
enableDetailedLogging: false,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
name: 'backend-route',
|
||||
match: { ports: 8581 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent backend
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Create SmartProxy1 (upstream proxy)
|
||||
const proxy1 = new SmartProxy({
|
||||
ports: [8580],
|
||||
enableDetailedLogging: false,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
name: 'chain-route',
|
||||
match: { ports: 8580 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8581 // Forward to proxy2
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Start both proxies
|
||||
await proxy2.start();
|
||||
console.log('✓ SmartProxy2 started on port 8581');
|
||||
|
||||
await proxy1.start();
|
||||
console.log('✓ SmartProxy1 started on port 8580');
|
||||
|
||||
// Helper to get connection counts
|
||||
const getConnectionCounts = () => {
|
||||
const conn1 = (proxy1 as any).connectionManager;
|
||||
const conn2 = (proxy2 as any).connectionManager;
|
||||
return {
|
||||
proxy1: conn1 ? conn1.getConnectionCount() : 0,
|
||||
proxy2: conn2 ? conn2.getConnectionCount() : 0
|
||||
};
|
||||
};
|
||||
|
||||
const initialCounts = getConnectionCounts();
|
||||
console.log(`\nInitial connection counts - Proxy1: ${initialCounts.proxy1}, Proxy2: ${initialCounts.proxy2}`);
|
||||
|
||||
// Test 1: Single connection attempt
|
||||
console.log('\n--- Test 1: Single connection through chain ---');
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.log(`Client received error: ${err.code}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
console.log('Client connection closed');
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8580, 'localhost', () => {
|
||||
console.log('Client connected to Proxy1');
|
||||
// Send data to trigger routing
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
});
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Check connections after single attempt
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
let counts = getConnectionCounts();
|
||||
console.log(`After single connection - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
|
||||
|
||||
// Test 2: Multiple simultaneous connections
|
||||
console.log('\n--- Test 2: Multiple simultaneous connections ---');
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8580, 'localhost', () => {
|
||||
// Send data
|
||||
client.write(`GET /test${i} HTTP/1.1\r\nHost: test.com\r\n\r\n`);
|
||||
});
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 500);
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log('✓ All simultaneous connections completed');
|
||||
|
||||
// Check connections
|
||||
counts = getConnectionCounts();
|
||||
console.log(`After simultaneous connections - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
|
||||
|
||||
// Test 3: Rapid serial connections (simulating retries)
|
||||
console.log('\n--- Test 3: Rapid serial connections (retries) ---');
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8580, 'localhost', () => {
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
// Quick disconnect to simulate retry behavior
|
||||
setTimeout(() => client.destroy(), 50);
|
||||
});
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
if ((i + 1) % 5 === 0) {
|
||||
counts = getConnectionCounts();
|
||||
console.log(`After ${i + 1} retries - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
|
||||
}
|
||||
|
||||
// Small delay between retries
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// Test 4: Long-lived connection attempt
|
||||
console.log('\n--- Test 4: Long-lived connection attempt ---');
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
console.log('Long-lived client closed');
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8580, 'localhost', () => {
|
||||
console.log('Long-lived client connected');
|
||||
// Send data periodically
|
||||
const interval = setInterval(() => {
|
||||
if (!client.destroyed && client.writable) {
|
||||
client.write('PING\r\n');
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Close after 2 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
client.destroy();
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Final check
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const finalCounts = getConnectionCounts();
|
||||
console.log(`\nFinal connection counts - Proxy1: ${finalCounts.proxy1}, Proxy2: ${finalCounts.proxy2}`);
|
||||
|
||||
// Monitor for a bit to see if connections are cleaned up
|
||||
console.log('\nMonitoring connection cleanup...');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
counts = getConnectionCounts();
|
||||
console.log(`After ${(i + 1) * 0.5}s - Proxy1: ${counts.proxy1}, Proxy2: ${counts.proxy2}`);
|
||||
}
|
||||
|
||||
// Stop proxies
|
||||
await proxy1.stop();
|
||||
console.log('\n✓ SmartProxy1 stopped');
|
||||
|
||||
await proxy2.stop();
|
||||
console.log('✓ SmartProxy2 stopped');
|
||||
|
||||
// Analysis
|
||||
console.log('\n=== Analysis ===');
|
||||
if (finalCounts.proxy1 > 0 || finalCounts.proxy2 > 0) {
|
||||
console.log('❌ FAIL: Connections accumulated!');
|
||||
console.log(`Proxy1 leaked ${finalCounts.proxy1} connections`);
|
||||
console.log(`Proxy2 leaked ${finalCounts.proxy2} connections`);
|
||||
} else {
|
||||
console.log('✅ PASS: No connection accumulation detected');
|
||||
}
|
||||
|
||||
// Verify
|
||||
expect(finalCounts.proxy1).toEqual(0);
|
||||
expect(finalCounts.proxy2).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should handle proxy chain with HTTP traffic', async () => {
|
||||
console.log('\n=== Testing Proxy Chain with HTTP Traffic ===');
|
||||
|
||||
// Create SmartProxy2 with HTTP handling
|
||||
const proxy2 = new SmartProxy({
|
||||
ports: [8583],
|
||||
useHttpProxy: [8583], // Enable HTTP proxy handling
|
||||
httpProxyPort: 8584,
|
||||
enableDetailedLogging: false,
|
||||
routes: [{
|
||||
name: 'http-backend',
|
||||
match: { ports: 8583 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent backend
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Create SmartProxy1 with HTTP handling
|
||||
const proxy1 = new SmartProxy({
|
||||
ports: [8582],
|
||||
useHttpProxy: [8582], // Enable HTTP proxy handling
|
||||
httpProxyPort: 8585,
|
||||
enableDetailedLogging: false,
|
||||
routes: [{
|
||||
name: 'http-chain',
|
||||
match: { ports: 8582 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8583 // Forward to proxy2
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
await proxy2.start();
|
||||
console.log('✓ SmartProxy2 (HTTP) started on port 8583');
|
||||
|
||||
await proxy1.start();
|
||||
console.log('✓ SmartProxy1 (HTTP) started on port 8582');
|
||||
|
||||
// Helper to get connection counts
|
||||
const getConnectionCounts = () => {
|
||||
const conn1 = (proxy1 as any).connectionManager;
|
||||
const conn2 = (proxy2 as any).connectionManager;
|
||||
return {
|
||||
proxy1: conn1 ? conn1.getConnectionCount() : 0,
|
||||
proxy2: conn2 ? conn2.getConnectionCount() : 0
|
||||
};
|
||||
};
|
||||
|
||||
console.log('\nSending HTTP requests through chain...');
|
||||
|
||||
// Make HTTP requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
let responseData = '';
|
||||
|
||||
client.on('data', (data) => {
|
||||
responseData += data.toString();
|
||||
// Check if we got a complete HTTP response
|
||||
if (responseData.includes('\r\n\r\n')) {
|
||||
console.log(`Response ${i + 1}: ${responseData.split('\r\n')[0]}`);
|
||||
client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8582, 'localhost', () => {
|
||||
client.write(`GET /test${i} HTTP/1.1\r\nHost: test.com\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const finalCounts = getConnectionCounts();
|
||||
console.log(`\nFinal HTTP proxy counts - Proxy1: ${finalCounts.proxy1}, Proxy2: ${finalCounts.proxy2}`);
|
||||
|
||||
await proxy1.stop();
|
||||
await proxy2.stop();
|
||||
|
||||
expect(finalCounts.proxy1).toEqual(0);
|
||||
expect(finalCounts.proxy2).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -1,185 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy, type IRouteConfig } from '../ts/index.js';
|
||||
|
||||
/**
|
||||
* Test that concurrent route updates complete successfully and maintain consistency
|
||||
* This replaces the previous implementation-specific mutex tests with behavior-based tests
|
||||
*/
|
||||
tap.test('should handle concurrent route updates correctly', async (tools) => {
|
||||
tools.timeout(15000);
|
||||
|
||||
const initialRoute: IRouteConfig = {
|
||||
name: 'base-route',
|
||||
match: { ports: 8080 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 }
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [initialRoute]
|
||||
});
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Create many concurrent updates to stress test the system
|
||||
const updatePromises: Promise<void>[] = [];
|
||||
const routeNames: string[] = [];
|
||||
|
||||
// Launch 20 concurrent updates
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const routeName = `concurrent-route-${i}`;
|
||||
routeNames.push(routeName);
|
||||
|
||||
const updatePromise = proxy.updateRoutes([
|
||||
initialRoute,
|
||||
{
|
||||
name: routeName,
|
||||
match: { ports: 9000 + i },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 4000 + i }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
updatePromises.push(updatePromise);
|
||||
}
|
||||
|
||||
// All updates should complete without errors
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// Verify the final state is consistent
|
||||
const finalRoutes = proxy.routeManager.getAllRoutes();
|
||||
|
||||
// Should have base route plus one of the concurrent routes
|
||||
expect(finalRoutes.length).toEqual(2);
|
||||
expect(finalRoutes.some(r => r.name === 'base-route')).toBeTrue();
|
||||
|
||||
// One of the concurrent routes should have won
|
||||
const concurrentRoute = finalRoutes.find(r => r.name?.startsWith('concurrent-route-'));
|
||||
expect(concurrentRoute).toBeTruthy();
|
||||
expect(routeNames).toContain(concurrentRoute!.name);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test rapid sequential route updates
|
||||
*/
|
||||
tap.test('should handle rapid sequential route updates', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'initial',
|
||||
match: { ports: 8081 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Perform rapid sequential updates
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await proxy.updateRoutes([{
|
||||
name: 'changing-route',
|
||||
match: { ports: 8081 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 + i }
|
||||
}
|
||||
}]);
|
||||
}
|
||||
|
||||
// Verify final state
|
||||
const finalRoutes = proxy.routeManager.getAllRoutes();
|
||||
expect(finalRoutes.length).toEqual(1);
|
||||
expect(finalRoutes[0].name).toEqual('changing-route');
|
||||
expect((finalRoutes[0].action as any).target.port).toEqual(3009);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test that port management remains consistent during concurrent updates
|
||||
*/
|
||||
tap.test('should maintain port consistency during concurrent updates', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'port-test',
|
||||
match: { ports: 8082 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Create updates that add and remove ports
|
||||
const updates: Promise<void>[] = [];
|
||||
|
||||
// Some updates add new ports
|
||||
for (let i = 0; i < 5; i++) {
|
||||
updates.push(proxy.updateRoutes([
|
||||
{
|
||||
name: 'port-test',
|
||||
match: { ports: 8082 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: `new-port-${i}`,
|
||||
match: { ports: 9100 + i },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 4000 + i }
|
||||
}
|
||||
}
|
||||
]));
|
||||
}
|
||||
|
||||
// Some updates remove ports
|
||||
for (let i = 0; i < 5; i++) {
|
||||
updates.push(proxy.updateRoutes([
|
||||
{
|
||||
name: 'port-test',
|
||||
match: { ports: 8082 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'localhost', port: 3000 }
|
||||
}
|
||||
}
|
||||
]));
|
||||
}
|
||||
|
||||
// Wait for all updates
|
||||
await Promise.all(updates);
|
||||
|
||||
// Give time for port cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify final state
|
||||
const finalRoutes = proxy.routeManager.getAllRoutes();
|
||||
const listeningPorts = proxy['portManager'].getListeningPorts();
|
||||
|
||||
// Should only have the base port listening
|
||||
expect(listeningPorts).toContain(8082);
|
||||
|
||||
// Routes should be consistent
|
||||
expect(finalRoutes.some(r => r.name === 'port-test')).toBeTrue();
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
201
test/test.rapid-retry-cleanup.node.ts
Normal file
201
test/test.rapid-retry-cleanup.node.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import SmartProxy and configurations
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
tap.test('should handle rapid connection retries without leaking connections', async () => {
|
||||
console.log('\n=== Testing Rapid Connection Retry Cleanup ===');
|
||||
|
||||
// Create a SmartProxy instance
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8550],
|
||||
enableDetailedLogging: false,
|
||||
maxConnectionLifetime: 10000,
|
||||
socketTimeout: 5000,
|
||||
routes: [{
|
||||
name: 'test-route',
|
||||
match: { ports: 8550 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 9999 // Non-existent port to force connection failures
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('✓ Proxy started on port 8550');
|
||||
|
||||
// Helper to get active connection count
|
||||
const getActiveConnections = () => {
|
||||
const connectionManager = (proxy as any).connectionManager;
|
||||
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||
};
|
||||
|
||||
// Track connection counts
|
||||
const connectionCounts: number[] = [];
|
||||
const initialCount = getActiveConnections();
|
||||
console.log(`Initial connection count: ${initialCount}`);
|
||||
|
||||
// Simulate rapid retries
|
||||
const retryCount = 20;
|
||||
const retryDelay = 50; // 50ms between retries
|
||||
let successfulConnections = 0;
|
||||
let failedConnections = 0;
|
||||
|
||||
console.log(`\nSimulating ${retryCount} rapid connection attempts...`);
|
||||
|
||||
for (let i = 0; i < retryCount; i++) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
failedConnections++;
|
||||
client.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8550, 'localhost', () => {
|
||||
// Send some data to trigger routing
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
successfulConnections++;
|
||||
});
|
||||
|
||||
// Force close after a short time
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Small delay between retries
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
|
||||
// Check connection count after each attempt
|
||||
const currentCount = getActiveConnections();
|
||||
connectionCounts.push(currentCount);
|
||||
|
||||
if ((i + 1) % 5 === 0) {
|
||||
console.log(`After ${i + 1} attempts: ${currentCount} active connections`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nConnection attempts complete:`);
|
||||
console.log(`- Successful: ${successfulConnections}`);
|
||||
console.log(`- Failed: ${failedConnections}`);
|
||||
|
||||
// Wait a bit for any pending cleanups
|
||||
console.log('\nWaiting for cleanup...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Check final connection count
|
||||
const finalCount = getActiveConnections();
|
||||
console.log(`\nFinal connection count: ${finalCount}`);
|
||||
|
||||
// Analyze connection count trend
|
||||
const maxCount = Math.max(...connectionCounts);
|
||||
const avgCount = connectionCounts.reduce((a, b) => a + b, 0) / connectionCounts.length;
|
||||
|
||||
console.log(`\nConnection count statistics:`);
|
||||
console.log(`- Maximum: ${maxCount}`);
|
||||
console.log(`- Average: ${avgCount.toFixed(2)}`);
|
||||
console.log(`- Initial: ${initialCount}`);
|
||||
console.log(`- Final: ${finalCount}`);
|
||||
|
||||
// Stop the proxy
|
||||
await proxy.stop();
|
||||
console.log('\n✓ Proxy stopped');
|
||||
|
||||
// Verify results
|
||||
expect(finalCount).toEqual(initialCount);
|
||||
expect(maxCount).toBeLessThan(10); // Should not accumulate many connections
|
||||
|
||||
console.log('\n✅ PASS: Connection cleanup working correctly under rapid retries!');
|
||||
});
|
||||
|
||||
tap.test('should handle routing failures without leaking connections', async () => {
|
||||
console.log('\n=== Testing Routing Failure Cleanup ===');
|
||||
|
||||
// Create a SmartProxy instance with no routes
|
||||
const proxy = new SmartProxy({
|
||||
ports: [8551],
|
||||
enableDetailedLogging: false,
|
||||
maxConnectionLifetime: 10000,
|
||||
socketTimeout: 5000,
|
||||
routes: [] // No routes - all connections will fail routing
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
await proxy.start();
|
||||
console.log('✓ Proxy started on port 8551 with no routes');
|
||||
|
||||
// Helper to get active connection count
|
||||
const getActiveConnections = () => {
|
||||
const connectionManager = (proxy as any).connectionManager;
|
||||
return connectionManager ? connectionManager.getConnectionCount() : 0;
|
||||
};
|
||||
|
||||
const initialCount = getActiveConnections();
|
||||
console.log(`Initial connection count: ${initialCount}`);
|
||||
|
||||
// Create multiple connections that will fail routing
|
||||
const connectionPromises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
connectionPromises.push(new Promise<void>((resolve) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
client.on('error', () => {
|
||||
client.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect(8551, 'localhost', () => {
|
||||
// Send data to trigger routing (which will fail)
|
||||
client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n');
|
||||
});
|
||||
|
||||
// Force close after a short time
|
||||
setTimeout(() => {
|
||||
if (!client.destroyed) {
|
||||
client.destroy();
|
||||
}
|
||||
resolve();
|
||||
}, 500);
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all connections to complete
|
||||
await Promise.all(connectionPromises);
|
||||
console.log('✓ All connection attempts completed');
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const finalCount = getActiveConnections();
|
||||
console.log(`Final connection count: ${finalCount}`);
|
||||
|
||||
// Stop the proxy
|
||||
await proxy.stop();
|
||||
console.log('✓ Proxy stopped');
|
||||
|
||||
// Verify no connections leaked
|
||||
expect(finalCount).toEqual(initialCount);
|
||||
|
||||
console.log('\n✅ PASS: Routing failures cleaned up correctly!');
|
||||
});
|
||||
|
||||
tap.start();
|
@ -434,11 +434,12 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const trailingSlashPathRoute: IRouteConfig = {
|
||||
// Test prefix matching with wildcard (not trailing slash)
|
||||
const prefixPathRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
domains: 'example.com',
|
||||
ports: 80,
|
||||
path: '/api/'
|
||||
path: '/api/*'
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@ -469,10 +470,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
expect(routeMatchesPath(exactPathRoute, '/api/users')).toBeFalse();
|
||||
expect(routeMatchesPath(exactPathRoute, '/app')).toBeFalse();
|
||||
|
||||
// Test trailing slash path matching
|
||||
expect(routeMatchesPath(trailingSlashPathRoute, '/api/')).toBeTrue();
|
||||
expect(routeMatchesPath(trailingSlashPathRoute, '/api/users')).toBeTrue();
|
||||
expect(routeMatchesPath(trailingSlashPathRoute, '/app/')).toBeFalse();
|
||||
// Test prefix path matching with wildcard
|
||||
expect(routeMatchesPath(prefixPathRoute, '/api/')).toBeFalse(); // Wildcard requires content after /api/
|
||||
expect(routeMatchesPath(prefixPathRoute, '/api/users')).toBeTrue();
|
||||
expect(routeMatchesPath(prefixPathRoute, '/app/')).toBeFalse();
|
||||
|
||||
// Test wildcard path matching
|
||||
expect(routeMatchesPath(wildcardPathRoute, '/api/users')).toBeTrue();
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
import * as http from 'http';
|
||||
import { ProxyRouter, type RouterResult } from '../ts/routing/router/proxy-router.js';
|
||||
import { HttpRouter, type RouterResult } from '../ts/routing/router/http-router.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
// Test proxies and configurations
|
||||
let router: ProxyRouter;
|
||||
let router: HttpRouter;
|
||||
|
||||
// Sample hostname for testing
|
||||
const TEST_DOMAIN = 'example.com';
|
||||
@ -23,33 +23,40 @@ function createMockRequest(host: string, url: string = '/'): http.IncomingMessag
|
||||
return req;
|
||||
}
|
||||
|
||||
// Helper: Creates a test proxy configuration
|
||||
function createProxyConfig(
|
||||
// Helper: Creates a test route configuration
|
||||
function createRouteConfig(
|
||||
hostname: string,
|
||||
destinationIp: string = '10.0.0.1',
|
||||
destinationPort: number = 8080
|
||||
): tsclass.network.IReverseProxyConfig {
|
||||
): IRouteConfig {
|
||||
return {
|
||||
hostName: hostname,
|
||||
publicKey: 'mock-cert',
|
||||
privateKey: 'mock-key',
|
||||
destinationIps: [destinationIp],
|
||||
destinationPorts: [destinationPort],
|
||||
} as tsclass.network.IReverseProxyConfig;
|
||||
name: `route-${hostname}`,
|
||||
match: {
|
||||
domains: [hostname],
|
||||
ports: 443
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: destinationIp,
|
||||
port: destinationPort
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// SETUP: Create a ProxyRouter instance
|
||||
tap.test('setup proxy router test environment', async () => {
|
||||
router = new ProxyRouter();
|
||||
// SETUP: Create an HttpRouter instance
|
||||
tap.test('setup http router test environment', async () => {
|
||||
router = new HttpRouter();
|
||||
|
||||
// Initialize with empty config
|
||||
router.setNewProxyConfigs([]);
|
||||
router.setRoutes([]);
|
||||
});
|
||||
|
||||
// Test basic routing by hostname
|
||||
tap.test('should route requests by hostname', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.setNewProxyConfigs([config]);
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
router.setRoutes([config]);
|
||||
|
||||
const req = createMockRequest(TEST_DOMAIN);
|
||||
const result = router.routeReq(req);
|
||||
@ -60,8 +67,8 @@ tap.test('should route requests by hostname', async () => {
|
||||
|
||||
// Test handling of hostname with port number
|
||||
tap.test('should handle hostname with port number', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.setNewProxyConfigs([config]);
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
router.setRoutes([config]);
|
||||
|
||||
const req = createMockRequest(`${TEST_DOMAIN}:443`);
|
||||
const result = router.routeReq(req);
|
||||
@ -72,8 +79,8 @@ tap.test('should handle hostname with port number', async () => {
|
||||
|
||||
// Test case-insensitive hostname matching
|
||||
tap.test('should perform case-insensitive hostname matching', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN.toLowerCase());
|
||||
router.setNewProxyConfigs([config]);
|
||||
const config = createRouteConfig(TEST_DOMAIN.toLowerCase());
|
||||
router.setRoutes([config]);
|
||||
|
||||
const req = createMockRequest(TEST_DOMAIN.toUpperCase());
|
||||
const result = router.routeReq(req);
|
||||
@ -84,8 +91,8 @@ tap.test('should perform case-insensitive hostname matching', async () => {
|
||||
|
||||
// Test handling of unmatched hostnames
|
||||
tap.test('should return undefined for unmatched hostnames', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.setNewProxyConfigs([config]);
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
router.setRoutes([config]);
|
||||
|
||||
const req = createMockRequest('unknown.domain.com');
|
||||
const result = router.routeReq(req);
|
||||
@ -95,18 +102,16 @@ tap.test('should return undefined for unmatched hostnames', async () => {
|
||||
|
||||
// Test adding path patterns
|
||||
tap.test('should match requests using path patterns', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.setNewProxyConfigs([config]);
|
||||
|
||||
// Add a path pattern to the config
|
||||
router.setPathPattern(config, '/api/users');
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
config.match.path = '/api/users';
|
||||
router.setRoutes([config]);
|
||||
|
||||
// Test that path matches
|
||||
const req1 = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||
const result1 = router.routeReqWithDetails(req1);
|
||||
|
||||
expect(result1).toBeTruthy();
|
||||
expect(result1.config).toEqual(config);
|
||||
expect(result1.route).toEqual(config);
|
||||
expect(result1.pathMatch).toEqual('/api/users');
|
||||
|
||||
// Test that non-matching path doesn't match
|
||||
@ -118,17 +123,16 @@ tap.test('should match requests using path patterns', async () => {
|
||||
|
||||
// Test handling wildcard patterns
|
||||
tap.test('should support wildcard path patterns', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.setNewProxyConfigs([config]);
|
||||
|
||||
router.setPathPattern(config, '/api/*');
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
config.match.path = '/api/*';
|
||||
router.setRoutes([config]);
|
||||
|
||||
// Test with path that matches the wildcard pattern
|
||||
const req = createMockRequest(TEST_DOMAIN, '/api/users/123');
|
||||
const result = router.routeReqWithDetails(req);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.config).toEqual(config);
|
||||
expect(result.route).toEqual(config);
|
||||
expect(result.pathMatch).toEqual('/api');
|
||||
|
||||
// Print the actual value to diagnose issues
|
||||
@ -139,31 +143,31 @@ tap.test('should support wildcard path patterns', async () => {
|
||||
|
||||
// Test extracting path parameters
|
||||
tap.test('should extract path parameters from URL', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.setNewProxyConfigs([config]);
|
||||
|
||||
router.setPathPattern(config, '/users/:id/profile');
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
config.match.path = '/users/:id/profile';
|
||||
router.setRoutes([config]);
|
||||
|
||||
const req = createMockRequest(TEST_DOMAIN, '/users/123/profile');
|
||||
const result = router.routeReqWithDetails(req);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.config).toEqual(config);
|
||||
expect(result.route).toEqual(config);
|
||||
expect(result.pathParams).toBeTruthy();
|
||||
expect(result.pathParams.id).toEqual('123');
|
||||
});
|
||||
|
||||
// Test multiple configs for same hostname with different paths
|
||||
tap.test('should support multiple configs for same hostname with different paths', async () => {
|
||||
const apiConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||
const webConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||
const apiConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||
apiConfig.match.path = '/api';
|
||||
apiConfig.name = 'api-route';
|
||||
|
||||
const webConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||
webConfig.match.path = '/web';
|
||||
webConfig.name = 'web-route';
|
||||
|
||||
// Add both configs
|
||||
router.setNewProxyConfigs([apiConfig, webConfig]);
|
||||
|
||||
// Set different path patterns
|
||||
router.setPathPattern(apiConfig, '/api');
|
||||
router.setPathPattern(webConfig, '/web');
|
||||
router.setRoutes([apiConfig, webConfig]);
|
||||
|
||||
// Test API path routes to API config
|
||||
const apiReq = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||
@ -186,8 +190,8 @@ tap.test('should support multiple configs for same hostname with different paths
|
||||
|
||||
// Test wildcard subdomains
|
||||
tap.test('should match wildcard subdomains', async () => {
|
||||
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
|
||||
router.setNewProxyConfigs([wildcardConfig]);
|
||||
const wildcardConfig = createRouteConfig(TEST_WILDCARD);
|
||||
router.setRoutes([wildcardConfig]);
|
||||
|
||||
// Test that subdomain.example.com matches *.example.com
|
||||
const req = createMockRequest('subdomain.example.com');
|
||||
@ -199,8 +203,8 @@ tap.test('should match wildcard subdomains', async () => {
|
||||
|
||||
// Test TLD wildcards (example.*)
|
||||
tap.test('should match TLD wildcards', async () => {
|
||||
const tldWildcardConfig = createProxyConfig('example.*');
|
||||
router.setNewProxyConfigs([tldWildcardConfig]);
|
||||
const tldWildcardConfig = createRouteConfig('example.*');
|
||||
router.setRoutes([tldWildcardConfig]);
|
||||
|
||||
// Test that example.com matches example.*
|
||||
const req1 = createMockRequest('example.com');
|
||||
@ -222,8 +226,8 @@ tap.test('should match TLD wildcards', async () => {
|
||||
|
||||
// Test complex pattern matching (*.lossless*)
|
||||
tap.test('should match complex wildcard patterns', async () => {
|
||||
const complexWildcardConfig = createProxyConfig('*.lossless*');
|
||||
router.setNewProxyConfigs([complexWildcardConfig]);
|
||||
const complexWildcardConfig = createRouteConfig('*.lossless*');
|
||||
router.setRoutes([complexWildcardConfig]);
|
||||
|
||||
// Test that sub.lossless.com matches *.lossless*
|
||||
const req1 = createMockRequest('sub.lossless.com');
|
||||
@ -245,10 +249,10 @@ tap.test('should match complex wildcard patterns', async () => {
|
||||
|
||||
// Test default configuration fallback
|
||||
tap.test('should fall back to default configuration', async () => {
|
||||
const defaultConfig = createProxyConfig('*');
|
||||
const specificConfig = createProxyConfig(TEST_DOMAIN);
|
||||
const defaultConfig = createRouteConfig('*');
|
||||
const specificConfig = createRouteConfig(TEST_DOMAIN);
|
||||
|
||||
router.setNewProxyConfigs([defaultConfig, specificConfig]);
|
||||
router.setRoutes([defaultConfig, specificConfig]);
|
||||
|
||||
// Test specific domain routes to specific config
|
||||
const specificReq = createMockRequest(TEST_DOMAIN);
|
||||
@ -265,10 +269,10 @@ tap.test('should fall back to default configuration', async () => {
|
||||
|
||||
// Test priority between exact and wildcard matches
|
||||
tap.test('should prioritize exact hostname over wildcard', async () => {
|
||||
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
|
||||
const exactConfig = createProxyConfig(TEST_SUBDOMAIN);
|
||||
const wildcardConfig = createRouteConfig(TEST_WILDCARD);
|
||||
const exactConfig = createRouteConfig(TEST_SUBDOMAIN);
|
||||
|
||||
router.setNewProxyConfigs([wildcardConfig, exactConfig]);
|
||||
router.setRoutes([wildcardConfig, exactConfig]);
|
||||
|
||||
// Test that exact match takes priority
|
||||
const req = createMockRequest(TEST_SUBDOMAIN);
|
||||
@ -279,11 +283,11 @@ tap.test('should prioritize exact hostname over wildcard', async () => {
|
||||
|
||||
// Test adding and removing configurations
|
||||
tap.test('should manage configurations correctly', async () => {
|
||||
router.setNewProxyConfigs([]);
|
||||
router.setRoutes([]);
|
||||
|
||||
// Add a config
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.addProxyConfig(config);
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
router.setRoutes([config]);
|
||||
|
||||
// Verify routing works
|
||||
const req = createMockRequest(TEST_DOMAIN);
|
||||
@ -292,8 +296,7 @@ tap.test('should manage configurations correctly', async () => {
|
||||
expect(result).toEqual(config);
|
||||
|
||||
// Remove the config and verify it no longer routes
|
||||
const removed = router.removeProxyConfig(TEST_DOMAIN);
|
||||
expect(removed).toBeTrue();
|
||||
router.setRoutes([]);
|
||||
|
||||
result = router.routeReq(req);
|
||||
expect(result).toBeUndefined();
|
||||
@ -301,13 +304,16 @@ tap.test('should manage configurations correctly', async () => {
|
||||
|
||||
// Test path pattern specificity
|
||||
tap.test('should prioritize more specific path patterns', async () => {
|
||||
const genericConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||
const specificConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||
const genericConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||
genericConfig.match.path = '/api/*';
|
||||
genericConfig.name = 'generic-api';
|
||||
|
||||
router.setNewProxyConfigs([genericConfig, specificConfig]);
|
||||
const specificConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||
specificConfig.match.path = '/api/users';
|
||||
specificConfig.name = 'specific-api';
|
||||
specificConfig.priority = 10; // Higher priority
|
||||
|
||||
router.setPathPattern(genericConfig, '/api/*');
|
||||
router.setPathPattern(specificConfig, '/api/users');
|
||||
router.setRoutes([genericConfig, specificConfig]);
|
||||
|
||||
// The more specific '/api/users' should match before the '/api/*' wildcard
|
||||
const req = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||
@ -316,24 +322,29 @@ tap.test('should prioritize more specific path patterns', async () => {
|
||||
expect(result).toEqual(specificConfig);
|
||||
});
|
||||
|
||||
// Test getHostnames method
|
||||
tap.test('should retrieve all configured hostnames', async () => {
|
||||
router.setNewProxyConfigs([
|
||||
createProxyConfig(TEST_DOMAIN),
|
||||
createProxyConfig(TEST_SUBDOMAIN)
|
||||
]);
|
||||
// Test multiple hostnames
|
||||
tap.test('should handle multiple configured hostnames', async () => {
|
||||
const routes = [
|
||||
createRouteConfig(TEST_DOMAIN),
|
||||
createRouteConfig(TEST_SUBDOMAIN)
|
||||
];
|
||||
router.setRoutes(routes);
|
||||
|
||||
const hostnames = router.getHostnames();
|
||||
// Test first domain routes correctly
|
||||
const req1 = createMockRequest(TEST_DOMAIN);
|
||||
const result1 = router.routeReq(req1);
|
||||
expect(result1).toEqual(routes[0]);
|
||||
|
||||
expect(hostnames.length).toEqual(2);
|
||||
expect(hostnames).toContain(TEST_DOMAIN.toLowerCase());
|
||||
expect(hostnames).toContain(TEST_SUBDOMAIN.toLowerCase());
|
||||
// Test second domain routes correctly
|
||||
const req2 = createMockRequest(TEST_SUBDOMAIN);
|
||||
const result2 = router.routeReq(req2);
|
||||
expect(result2).toEqual(routes[1]);
|
||||
});
|
||||
|
||||
// Test handling missing host header
|
||||
tap.test('should handle missing host header', async () => {
|
||||
const defaultConfig = createProxyConfig('*');
|
||||
router.setNewProxyConfigs([defaultConfig]);
|
||||
const defaultConfig = createRouteConfig('*');
|
||||
router.setRoutes([defaultConfig]);
|
||||
|
||||
const req = createMockRequest('');
|
||||
req.headers.host = undefined;
|
||||
@ -345,16 +356,15 @@ tap.test('should handle missing host header', async () => {
|
||||
|
||||
// Test complex path parameters
|
||||
tap.test('should handle complex path parameters', async () => {
|
||||
const config = createProxyConfig(TEST_DOMAIN);
|
||||
router.setNewProxyConfigs([config]);
|
||||
|
||||
router.setPathPattern(config, '/api/:version/users/:userId/posts/:postId');
|
||||
const config = createRouteConfig(TEST_DOMAIN);
|
||||
config.match.path = '/api/:version/users/:userId/posts/:postId';
|
||||
router.setRoutes([config]);
|
||||
|
||||
const req = createMockRequest(TEST_DOMAIN, '/api/v1/users/123/posts/456');
|
||||
const result = router.routeReqWithDetails(req);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.config).toEqual(config);
|
||||
expect(result.route).toEqual(config);
|
||||
expect(result.pathParams).toBeTruthy();
|
||||
expect(result.pathParams.version).toEqual('v1');
|
||||
expect(result.pathParams.userId).toEqual('123');
|
||||
@ -367,10 +377,10 @@ tap.test('should handle many configurations efficiently', async () => {
|
||||
|
||||
// Create many configs with different hostnames
|
||||
for (let i = 0; i < 100; i++) {
|
||||
configs.push(createProxyConfig(`host-${i}.example.com`));
|
||||
configs.push(createRouteConfig(`host-${i}.example.com`));
|
||||
}
|
||||
|
||||
router.setNewProxyConfigs(configs);
|
||||
router.setRoutes(configs);
|
||||
|
||||
// Test middle of the list to avoid best/worst case
|
||||
const req = createMockRequest('host-50.example.com');
|
||||
@ -382,11 +392,12 @@ tap.test('should handle many configurations efficiently', async () => {
|
||||
// Test cleanup
|
||||
tap.test('cleanup proxy router test environment', async () => {
|
||||
// Clear all configurations
|
||||
router.setNewProxyConfigs([]);
|
||||
router.setRoutes([]);
|
||||
|
||||
// Verify empty state
|
||||
expect(router.getHostnames().length).toEqual(0);
|
||||
expect(router.getProxyConfigs().length).toEqual(0);
|
||||
// Verify empty state by testing that no routes match
|
||||
const req = createMockRequest(TEST_DOMAIN);
|
||||
const result = router.routeReq(req);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -1,88 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
/**
|
||||
* Simple test to check route manager initialization with ACME
|
||||
*/
|
||||
tap.test('should properly initialize with ACME configuration', async (tools) => {
|
||||
const settings = {
|
||||
routes: [
|
||||
{
|
||||
name: 'secure-route',
|
||||
match: {
|
||||
ports: [8443],
|
||||
domains: 'test.example.com'
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto' as const,
|
||||
acme: {
|
||||
email: 'ssl@bleu.de',
|
||||
challengePort: 8080
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
acme: {
|
||||
email: 'ssl@bleu.de',
|
||||
port: 8080,
|
||||
useProduction: false,
|
||||
enabled: true
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Replace the certificate manager creation to avoid real ACME requests
|
||||
(proxy as any).createCertificateManager = async () => {
|
||||
return {
|
||||
setUpdateRoutesCallback: () => {},
|
||||
setHttpProxy: () => {},
|
||||
setGlobalAcmeDefaults: () => {},
|
||||
setAcmeStateManager: () => {},
|
||||
initialize: async () => {
|
||||
// Using logger would be better but in test we'll keep console.log
|
||||
console.log('Mock certificate manager initialized');
|
||||
},
|
||||
provisionAllCertificates: async () => {
|
||||
console.log('Mock certificate provisioning');
|
||||
},
|
||||
stop: async () => {
|
||||
console.log('Mock certificate manager stopped');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Mock NFTables
|
||||
(proxy as any).nftablesManager = {
|
||||
provisionRoute: async () => {},
|
||||
deprovisionRoute: async () => {},
|
||||
updateRoute: async () => {},
|
||||
getStatus: async () => ({}),
|
||||
stop: async () => {}
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Verify proxy started successfully
|
||||
expect(proxy).toBeDefined();
|
||||
|
||||
// Verify route manager has routes
|
||||
const routeManager = (proxy as any).routeManager;
|
||||
expect(routeManager).toBeDefined();
|
||||
expect(routeManager.getAllRoutes().length).toBeGreaterThan(0);
|
||||
|
||||
// Verify the route exists with correct domain
|
||||
const routes = routeManager.getAllRoutes();
|
||||
const secureRoute = routes.find((r: any) => r.name === 'secure-route');
|
||||
expect(secureRoute).toBeDefined();
|
||||
expect(secureRoute.match.domains).toEqual('test.example.com');
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
366
test/test.wrapped-socket.ts
Normal file
366
test/test.wrapped-socket.ts
Normal file
@ -0,0 +1,366 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { WrappedSocket } from '../ts/core/models/wrapped-socket.js';
|
||||
import * as net from 'net';
|
||||
|
||||
tap.test('WrappedSocket - should wrap a regular socket', async () => {
|
||||
// Create a simple test server
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
|
||||
// Wrap the socket
|
||||
const wrappedSocket = new WrappedSocket(clientSocket);
|
||||
|
||||
// Test initial state - should use underlying socket values
|
||||
expect(wrappedSocket.remoteAddress).toEqual(clientSocket.remoteAddress);
|
||||
expect(wrappedSocket.remotePort).toEqual(clientSocket.remotePort);
|
||||
expect(wrappedSocket.localAddress).toEqual(clientSocket.localAddress);
|
||||
expect(wrappedSocket.localPort).toEqual(clientSocket.localPort);
|
||||
expect(wrappedSocket.isFromTrustedProxy).toBeFalse();
|
||||
|
||||
// Clean up
|
||||
clientSocket.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
tap.test('WrappedSocket - should provide real client info when set', async () => {
|
||||
// Create a simple test server
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
|
||||
// Wrap the socket with initial proxy info
|
||||
const wrappedSocket = new WrappedSocket(clientSocket, '192.168.1.100', 54321);
|
||||
|
||||
// Test that real client info is returned
|
||||
expect(wrappedSocket.remoteAddress).toEqual('192.168.1.100');
|
||||
expect(wrappedSocket.remotePort).toEqual(54321);
|
||||
expect(wrappedSocket.isFromTrustedProxy).toBeTrue();
|
||||
|
||||
// Local info should still come from underlying socket
|
||||
expect(wrappedSocket.localAddress).toEqual(clientSocket.localAddress);
|
||||
expect(wrappedSocket.localPort).toEqual(clientSocket.localPort);
|
||||
|
||||
// Clean up
|
||||
clientSocket.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
tap.test('WrappedSocket - should update proxy info via setProxyInfo', async () => {
|
||||
// Create a simple test server
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
|
||||
// Wrap the socket without initial proxy info
|
||||
const wrappedSocket = new WrappedSocket(clientSocket);
|
||||
|
||||
// Initially should use underlying socket
|
||||
expect(wrappedSocket.isFromTrustedProxy).toBeFalse();
|
||||
expect(wrappedSocket.remoteAddress).toEqual(clientSocket.remoteAddress);
|
||||
|
||||
// Update proxy info
|
||||
wrappedSocket.setProxyInfo('10.0.0.5', 12345);
|
||||
|
||||
// Now should return proxy info
|
||||
expect(wrappedSocket.remoteAddress).toEqual('10.0.0.5');
|
||||
expect(wrappedSocket.remotePort).toEqual(12345);
|
||||
expect(wrappedSocket.isFromTrustedProxy).toBeTrue();
|
||||
|
||||
// Clean up
|
||||
clientSocket.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
tap.test('WrappedSocket - should correctly determine IP family', async () => {
|
||||
// Create a simple test server
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
|
||||
// Test IPv4
|
||||
const wrappedSocketIPv4 = new WrappedSocket(clientSocket, '192.168.1.1', 80);
|
||||
expect(wrappedSocketIPv4.remoteFamily).toEqual('IPv4');
|
||||
|
||||
// Test IPv6
|
||||
const wrappedSocketIPv6 = new WrappedSocket(clientSocket, '2001:0db8:85a3:0000:0000:8a2e:0370:7334', 443);
|
||||
expect(wrappedSocketIPv6.remoteFamily).toEqual('IPv6');
|
||||
|
||||
// Test fallback to underlying socket
|
||||
const wrappedSocketNoProxy = new WrappedSocket(clientSocket);
|
||||
expect(wrappedSocketNoProxy.remoteFamily).toEqual(clientSocket.remoteFamily);
|
||||
|
||||
// Clean up
|
||||
clientSocket.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
tap.test('WrappedSocket - should forward events correctly', async () => {
|
||||
// Create a simple echo server
|
||||
let serverConnection: net.Socket;
|
||||
const server = net.createServer((socket) => {
|
||||
serverConnection = socket;
|
||||
socket.on('data', (data) => {
|
||||
socket.write(data); // Echo back
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
|
||||
// Wrap the socket
|
||||
const wrappedSocket = new WrappedSocket(clientSocket);
|
||||
|
||||
// Set up event tracking
|
||||
let connectReceived = false;
|
||||
let dataReceived = false;
|
||||
let endReceived = false;
|
||||
let closeReceived = false;
|
||||
|
||||
wrappedSocket.on('connect', () => {
|
||||
connectReceived = true;
|
||||
});
|
||||
|
||||
wrappedSocket.on('data', (chunk) => {
|
||||
dataReceived = true;
|
||||
expect(chunk.toString()).toEqual('test data');
|
||||
});
|
||||
|
||||
wrappedSocket.on('end', () => {
|
||||
endReceived = true;
|
||||
});
|
||||
|
||||
wrappedSocket.on('close', () => {
|
||||
closeReceived = true;
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await new Promise<void>((resolve) => {
|
||||
if (clientSocket.readyState === 'open') {
|
||||
resolve();
|
||||
} else {
|
||||
clientSocket.once('connect', () => resolve());
|
||||
}
|
||||
});
|
||||
|
||||
// Send data
|
||||
wrappedSocket.write('test data');
|
||||
|
||||
// Wait for echo
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Close the connection
|
||||
serverConnection.end();
|
||||
|
||||
// Wait for events
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify all events were received
|
||||
expect(dataReceived).toBeTrue();
|
||||
expect(endReceived).toBeTrue();
|
||||
expect(closeReceived).toBeTrue();
|
||||
|
||||
// Clean up
|
||||
server.close();
|
||||
});
|
||||
|
||||
tap.test('WrappedSocket - should pass through socket methods', async () => {
|
||||
// Create a simple test server
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
await new Promise<void>((resolve) => {
|
||||
clientSocket.once('connect', () => resolve());
|
||||
});
|
||||
|
||||
// Wrap the socket
|
||||
const wrappedSocket = new WrappedSocket(clientSocket);
|
||||
|
||||
// Test various pass-through methods
|
||||
expect(wrappedSocket.readable).toEqual(clientSocket.readable);
|
||||
expect(wrappedSocket.writable).toEqual(clientSocket.writable);
|
||||
expect(wrappedSocket.destroyed).toEqual(clientSocket.destroyed);
|
||||
expect(wrappedSocket.bytesRead).toEqual(clientSocket.bytesRead);
|
||||
expect(wrappedSocket.bytesWritten).toEqual(clientSocket.bytesWritten);
|
||||
|
||||
// Test method calls
|
||||
wrappedSocket.pause();
|
||||
expect(clientSocket.isPaused()).toBeTrue();
|
||||
|
||||
wrappedSocket.resume();
|
||||
expect(clientSocket.isPaused()).toBeFalse();
|
||||
|
||||
// Test setTimeout
|
||||
let timeoutCalled = false;
|
||||
wrappedSocket.setTimeout(100, () => {
|
||||
timeoutCalled = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
expect(timeoutCalled).toBeTrue();
|
||||
|
||||
// Clean up
|
||||
wrappedSocket.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
tap.test('WrappedSocket - should handle write and pipe operations', async () => {
|
||||
// Create a simple echo server
|
||||
const server = net.createServer((socket) => {
|
||||
socket.pipe(socket); // Echo everything back
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
await new Promise<void>((resolve) => {
|
||||
clientSocket.once('connect', () => resolve());
|
||||
});
|
||||
|
||||
// Wrap the socket
|
||||
const wrappedSocket = new WrappedSocket(clientSocket);
|
||||
|
||||
// Test write with callback
|
||||
const writeResult = wrappedSocket.write('test', 'utf8', () => {
|
||||
// Write completed
|
||||
});
|
||||
expect(typeof writeResult).toEqual('boolean');
|
||||
|
||||
// Test pipe
|
||||
const { PassThrough } = await import('stream');
|
||||
const passThrough = new PassThrough();
|
||||
const piped = wrappedSocket.pipe(passThrough);
|
||||
expect(piped).toEqual(passThrough);
|
||||
|
||||
// Clean up
|
||||
wrappedSocket.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
tap.test('WrappedSocket - should handle encoding and address methods', async () => {
|
||||
// Create a simple test server
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
await new Promise<void>((resolve) => {
|
||||
clientSocket.once('connect', () => resolve());
|
||||
});
|
||||
|
||||
// Wrap the socket
|
||||
const wrappedSocket = new WrappedSocket(clientSocket);
|
||||
|
||||
// Test setEncoding
|
||||
wrappedSocket.setEncoding('utf8');
|
||||
|
||||
// Test address method
|
||||
const addr = wrappedSocket.address();
|
||||
expect(addr).toEqual(clientSocket.address());
|
||||
|
||||
// Test cork/uncork (if available)
|
||||
wrappedSocket.cork();
|
||||
wrappedSocket.uncork();
|
||||
|
||||
// Clean up
|
||||
wrappedSocket.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
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 = {
|
||||
routes: [],
|
||||
defaults: {
|
||||
security: {
|
||||
maxConnections: 100
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const securityManager = new SecurityManager(settings);
|
||||
const timeoutManager = new TimeoutManager(settings);
|
||||
const connectionManager = new ConnectionManager(settings, securityManager, timeoutManager);
|
||||
|
||||
// Create a simple test server
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, 'localhost', () => resolve());
|
||||
});
|
||||
|
||||
const serverPort = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Create a client connection
|
||||
const clientSocket = net.connect(serverPort, 'localhost');
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise<void>((resolve) => {
|
||||
clientSocket.once('connect', () => resolve());
|
||||
});
|
||||
|
||||
// Wrap with proxy info
|
||||
const wrappedSocket = new WrappedSocket(clientSocket, '203.0.113.45', 65432);
|
||||
|
||||
// Create connection using wrapped socket
|
||||
const record = connectionManager.createConnection(wrappedSocket);
|
||||
|
||||
expect(record).toBeTruthy();
|
||||
expect(record!.remoteIP).toEqual('203.0.113.45'); // Should use the real client IP
|
||||
expect(record!.localPort).toEqual(clientSocket.localPort);
|
||||
|
||||
// Clean up
|
||||
connectionManager.cleanupConnection(record!, 'test-complete');
|
||||
server.close();
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '19.5.3',
|
||||
version: '19.5.19',
|
||||
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.'
|
||||
}
|
||||
|
@ -1,34 +0,0 @@
|
||||
// Port80Handler removed - use SmartCertManager instead
|
||||
import { Port80HandlerEvents } from './types.js';
|
||||
import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from './types.js';
|
||||
|
||||
/**
|
||||
* Subscribers callback definitions for Port80Handler events
|
||||
*/
|
||||
export interface Port80HandlerSubscribers {
|
||||
onCertificateIssued?: (data: ICertificateData) => void;
|
||||
onCertificateRenewed?: (data: ICertificateData) => void;
|
||||
onCertificateFailed?: (data: ICertificateFailure) => void;
|
||||
onCertificateExpiring?: (data: ICertificateExpiring) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to Port80Handler events based on provided callbacks
|
||||
*/
|
||||
export function subscribeToPort80Handler(
|
||||
handler: any,
|
||||
subscribers: Port80HandlerSubscribers
|
||||
): void {
|
||||
if (subscribers.onCertificateIssued) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, subscribers.onCertificateIssued);
|
||||
}
|
||||
if (subscribers.onCertificateRenewed) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, subscribers.onCertificateRenewed);
|
||||
}
|
||||
if (subscribers.onCertificateFailed) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, subscribers.onCertificateFailed);
|
||||
}
|
||||
if (subscribers.onCertificateExpiring) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, subscribers.onCertificateExpiring);
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Shared types for certificate management and domain options
|
||||
*/
|
||||
|
||||
/**
|
||||
* Domain forwarding configuration
|
||||
*/
|
||||
export interface IForwardConfig {
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain configuration options
|
||||
*/
|
||||
export interface IDomainOptions {
|
||||
domainName: string;
|
||||
sslRedirect: boolean; // if true redirects the request to port 443
|
||||
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
|
||||
forward?: IForwardConfig; // forwards all http requests to that target
|
||||
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate data that can be emitted via events or set from outside
|
||||
*/
|
||||
export interface ICertificateData {
|
||||
domain: string;
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
expiryDate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the Port80Handler
|
||||
*/
|
||||
export enum Port80HandlerEvents {
|
||||
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||
CERTIFICATE_FAILED = 'certificate-failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||
MANAGER_STARTED = 'manager-started',
|
||||
MANAGER_STOPPED = 'manager-stopped',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate failure payload type
|
||||
*/
|
||||
export interface ICertificateFailure {
|
||||
domain: string;
|
||||
error: string;
|
||||
isRenewal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate expiry payload type
|
||||
*/
|
||||
export interface ICertificateExpiring {
|
||||
domain: string;
|
||||
expiryDate: Date;
|
||||
daysRemaining: number;
|
||||
}
|
||||
/**
|
||||
* Forwarding configuration for specific domains in ACME setup
|
||||
*/
|
||||
export interface IDomainForwardConfig {
|
||||
domain: string;
|
||||
forwardConfig?: IForwardConfig;
|
||||
acmeForwardConfig?: IForwardConfig;
|
||||
sslRedirect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified ACME configuration options used across proxies and handlers
|
||||
*/
|
||||
export interface IAcmeOptions {
|
||||
accountEmail?: string; // Email for Let's Encrypt account
|
||||
enabled?: boolean; // Whether ACME is enabled
|
||||
port?: number; // Port to listen on for ACME challenges (default: 80)
|
||||
useProduction?: boolean; // Use production environment (default: staging)
|
||||
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
|
||||
renewThresholdDays?: number; // Days before expiry to renew certificates
|
||||
renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
|
||||
autoRenew?: boolean; // Whether to automatically renew certificates
|
||||
certificateStore?: string; // Directory to store certificates
|
||||
skipConfiguredCerts?: boolean; // Skip domains with existing certificates
|
||||
domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs
|
||||
}
|
@ -5,3 +5,5 @@
|
||||
export * from './common-types.js';
|
||||
export * from './socket-augmentation.js';
|
||||
export * from './route-context.js';
|
||||
export * from './wrapped-socket.js';
|
||||
export * from './socket-types.js';
|
||||
|
21
ts/core/models/socket-types.ts
Normal file
21
ts/core/models/socket-types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import * as net from 'net';
|
||||
import { WrappedSocket } from './wrapped-socket.js';
|
||||
|
||||
/**
|
||||
* Type guard to check if a socket is a WrappedSocket
|
||||
*/
|
||||
export function isWrappedSocket(socket: net.Socket | WrappedSocket): socket is WrappedSocket {
|
||||
return socket instanceof WrappedSocket || 'socket' in socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the underlying socket from either a Socket or WrappedSocket
|
||||
*/
|
||||
export function getUnderlyingSocket(socket: net.Socket | WrappedSocket): net.Socket {
|
||||
return isWrappedSocket(socket) ? socket.socket : socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type that represents either a regular socket or a wrapped socket
|
||||
*/
|
||||
export type AnySocket = net.Socket | WrappedSocket;
|
99
ts/core/models/wrapped-socket.ts
Normal file
99
ts/core/models/wrapped-socket.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
/**
|
||||
* WrappedSocket 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.
|
||||
*
|
||||
* This implementation uses a Proxy to delegate all properties and methods
|
||||
* to the underlying socket while allowing override of specific properties.
|
||||
*/
|
||||
export class WrappedSocket {
|
||||
public readonly socket: plugins.net.Socket;
|
||||
private realClientIP?: string;
|
||||
private realClientPort?: number;
|
||||
|
||||
// Make TypeScript happy by declaring the Socket methods that will be proxied
|
||||
[key: string]: any;
|
||||
|
||||
constructor(
|
||||
socket: plugins.net.Socket,
|
||||
realClientIP?: string,
|
||||
realClientPort?: number
|
||||
) {
|
||||
this.socket = socket;
|
||||
this.realClientIP = realClientIP;
|
||||
this.realClientPort = realClientPort;
|
||||
|
||||
// Create a proxy that delegates everything to the 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;
|
||||
}
|
||||
if (prop === 'socket') {
|
||||
return target.socket;
|
||||
}
|
||||
if (prop === 'realClientIP') {
|
||||
return target.realClientIP;
|
||||
}
|
||||
if (prop === 'realClientPort') {
|
||||
return target.realClientPort;
|
||||
}
|
||||
if (prop === 'isFromTrustedProxy') {
|
||||
return target.isFromTrustedProxy;
|
||||
}
|
||||
if (prop === 'setProxyInfo') {
|
||||
return target.setProxyInfo.bind(target);
|
||||
}
|
||||
|
||||
// For all other properties/methods, delegate to the underlying socket
|
||||
const value = target.socket[prop as keyof plugins.net.Socket];
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(target.socket);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
// Set on the underlying socket
|
||||
(target.socket as any)[prop] = value;
|
||||
return true;
|
||||
}
|
||||
}) as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
21
ts/core/routing/index.ts
Normal file
21
ts/core/routing/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Unified routing module
|
||||
* Provides all routing functionality in a centralized location
|
||||
*/
|
||||
|
||||
// Export all types
|
||||
export * from './types.js';
|
||||
|
||||
// Export all matchers
|
||||
export * from './matchers/index.js';
|
||||
|
||||
// Export specificity calculator
|
||||
export * from './specificity.js';
|
||||
|
||||
// Export route management
|
||||
export * from './route-manager.js';
|
||||
export * from './route-utils.js';
|
||||
|
||||
// Convenience re-exports
|
||||
export { matchers } from './matchers/index.js';
|
||||
export { RouteSpecificity } from './specificity.js';
|
119
ts/core/routing/matchers/domain.ts
Normal file
119
ts/core/routing/matchers/domain.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import type { IMatcher, IDomainMatchOptions } from '../types.js';
|
||||
|
||||
/**
|
||||
* DomainMatcher provides comprehensive domain matching functionality
|
||||
* Supporting exact matches, wildcards, and case-insensitive matching
|
||||
*/
|
||||
export class DomainMatcher implements IMatcher<boolean, IDomainMatchOptions> {
|
||||
private static wildcardToRegex(pattern: string): RegExp {
|
||||
// Escape special regex characters except *
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Replace * with regex equivalent
|
||||
const regexPattern = escaped.replace(/\*/g, '.*');
|
||||
return new RegExp(`^${regexPattern}$`, 'i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a domain pattern against a hostname
|
||||
* @param pattern The pattern to match (supports wildcards like *.example.com)
|
||||
* @param hostname The hostname to test
|
||||
* @param options Matching options
|
||||
* @returns true if the hostname matches the pattern
|
||||
*/
|
||||
static match(
|
||||
pattern: string,
|
||||
hostname: string,
|
||||
options: IDomainMatchOptions = {}
|
||||
): boolean {
|
||||
// Handle null/undefined cases
|
||||
if (!pattern || !hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize inputs
|
||||
const normalizedPattern = pattern.toLowerCase().trim();
|
||||
const normalizedHostname = hostname.toLowerCase().trim();
|
||||
|
||||
// Remove trailing dots (FQDN normalization)
|
||||
const cleanPattern = normalizedPattern.replace(/\.$/, '');
|
||||
const cleanHostname = normalizedHostname.replace(/\.$/, '');
|
||||
|
||||
// Exact match (most common case)
|
||||
if (cleanPattern === cleanHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard matching
|
||||
if (options.allowWildcards !== false && cleanPattern.includes('*')) {
|
||||
const regex = this.wildcardToRegex(cleanPattern);
|
||||
return regex.test(cleanHostname);
|
||||
}
|
||||
|
||||
// No match
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern contains wildcards
|
||||
*/
|
||||
static isWildcardPattern(pattern: string): boolean {
|
||||
return pattern.includes('*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the specificity of a domain pattern
|
||||
* Higher values mean more specific patterns
|
||||
*/
|
||||
static calculateSpecificity(pattern: string): number {
|
||||
if (!pattern) return 0;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Exact domains are most specific
|
||||
if (!pattern.includes('*')) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
// Count domain segments
|
||||
const segments = pattern.split('.');
|
||||
score += segments.length * 10;
|
||||
|
||||
// Penalize wildcards based on position
|
||||
if (pattern.startsWith('*')) {
|
||||
score -= 50; // Leading wildcard is very generic
|
||||
} else if (pattern.includes('*')) {
|
||||
score -= 20; // Wildcard elsewhere is less generic
|
||||
}
|
||||
|
||||
// Bonus for longer patterns
|
||||
score += pattern.length;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all matching patterns from a list
|
||||
* Returns patterns sorted by specificity (most specific first)
|
||||
*/
|
||||
static findAllMatches(
|
||||
patterns: string[],
|
||||
hostname: string,
|
||||
options: IDomainMatchOptions = {}
|
||||
): string[] {
|
||||
const matches = patterns.filter(pattern =>
|
||||
this.match(pattern, hostname, options)
|
||||
);
|
||||
|
||||
// Sort by specificity (highest first)
|
||||
return matches.sort((a, b) =>
|
||||
this.calculateSpecificity(b) - this.calculateSpecificity(a)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method for interface compliance
|
||||
*/
|
||||
match(pattern: string, hostname: string, options?: IDomainMatchOptions): boolean {
|
||||
return DomainMatcher.match(pattern, hostname, options);
|
||||
}
|
||||
}
|
120
ts/core/routing/matchers/header.ts
Normal file
120
ts/core/routing/matchers/header.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import type { IMatcher, IHeaderMatchOptions } from '../types.js';
|
||||
|
||||
/**
|
||||
* HeaderMatcher provides HTTP header matching functionality
|
||||
* Supporting exact matches, patterns, and case-insensitive matching
|
||||
*/
|
||||
export class HeaderMatcher implements IMatcher<boolean, IHeaderMatchOptions> {
|
||||
/**
|
||||
* Match a header value against a pattern
|
||||
* @param pattern The pattern to match
|
||||
* @param value The header value to test
|
||||
* @param options Matching options
|
||||
* @returns true if the value matches the pattern
|
||||
*/
|
||||
static match(
|
||||
pattern: string,
|
||||
value: string | undefined,
|
||||
options: IHeaderMatchOptions = {}
|
||||
): boolean {
|
||||
// Handle missing header
|
||||
if (value === undefined || value === null) {
|
||||
return pattern === '' || pattern === null || pattern === undefined;
|
||||
}
|
||||
|
||||
// Convert to string and normalize
|
||||
const normalizedPattern = String(pattern);
|
||||
const normalizedValue = String(value);
|
||||
|
||||
// Apply case sensitivity
|
||||
const comparePattern = options.caseInsensitive !== false
|
||||
? normalizedPattern.toLowerCase()
|
||||
: normalizedPattern;
|
||||
const compareValue = options.caseInsensitive !== false
|
||||
? normalizedValue.toLowerCase()
|
||||
: normalizedValue;
|
||||
|
||||
// Exact match
|
||||
if (options.exactMatch !== false) {
|
||||
return comparePattern === compareValue;
|
||||
}
|
||||
|
||||
// Pattern matching (simple wildcard support)
|
||||
if (comparePattern.includes('*')) {
|
||||
const regex = new RegExp(
|
||||
'^' + comparePattern.replace(/\*/g, '.*') + '$',
|
||||
options.caseInsensitive !== false ? 'i' : ''
|
||||
);
|
||||
return regex.test(normalizedValue);
|
||||
}
|
||||
|
||||
// Contains match (if not exact match mode)
|
||||
return compareValue.includes(comparePattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match multiple headers against a set of required headers
|
||||
* @param requiredHeaders Headers that must match
|
||||
* @param actualHeaders Actual request headers
|
||||
* @param options Matching options
|
||||
* @returns true if all required headers match
|
||||
*/
|
||||
static matchAll(
|
||||
requiredHeaders: Record<string, string>,
|
||||
actualHeaders: Record<string, string | string[] | undefined>,
|
||||
options: IHeaderMatchOptions = {}
|
||||
): boolean {
|
||||
for (const [name, pattern] of Object.entries(requiredHeaders)) {
|
||||
const headerName = options.caseInsensitive !== false
|
||||
? name.toLowerCase()
|
||||
: name;
|
||||
|
||||
// Find the actual header (case-insensitive search if needed)
|
||||
let actualValue: string | undefined;
|
||||
if (options.caseInsensitive !== false) {
|
||||
const actualKey = Object.keys(actualHeaders).find(
|
||||
key => key.toLowerCase() === headerName
|
||||
);
|
||||
const rawValue = actualKey ? actualHeaders[actualKey] : undefined;
|
||||
// Handle array values (multiple headers with same name)
|
||||
actualValue = Array.isArray(rawValue) ? rawValue.join(', ') : rawValue;
|
||||
} else {
|
||||
const rawValue = actualHeaders[name];
|
||||
// Handle array values (multiple headers with same name)
|
||||
actualValue = Array.isArray(rawValue) ? rawValue.join(', ') : rawValue;
|
||||
}
|
||||
|
||||
// Check if this header matches
|
||||
if (!this.match(pattern, actualValue, options)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the specificity of header requirements
|
||||
* More headers = more specific
|
||||
*/
|
||||
static calculateSpecificity(headers: Record<string, string>): number {
|
||||
const count = Object.keys(headers).length;
|
||||
let score = count * 10;
|
||||
|
||||
// Bonus for headers without wildcards (more specific)
|
||||
for (const value of Object.values(headers)) {
|
||||
if (!value.includes('*')) {
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method for interface compliance
|
||||
*/
|
||||
match(pattern: string, value: string, options?: IHeaderMatchOptions): boolean {
|
||||
return HeaderMatcher.match(pattern, value, options);
|
||||
}
|
||||
}
|
22
ts/core/routing/matchers/index.ts
Normal file
22
ts/core/routing/matchers/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Unified matching utilities for the routing system
|
||||
* All route matching logic should use these matchers for consistency
|
||||
*/
|
||||
|
||||
export * from './domain.js';
|
||||
export * from './path.js';
|
||||
export * from './ip.js';
|
||||
export * from './header.js';
|
||||
|
||||
// Re-export for convenience
|
||||
import { DomainMatcher } from './domain.js';
|
||||
import { PathMatcher } from './path.js';
|
||||
import { IpMatcher } from './ip.js';
|
||||
import { HeaderMatcher } from './header.js';
|
||||
|
||||
export const matchers = {
|
||||
domain: DomainMatcher,
|
||||
path: PathMatcher,
|
||||
ip: IpMatcher,
|
||||
header: HeaderMatcher
|
||||
} as const;
|
207
ts/core/routing/matchers/ip.ts
Normal file
207
ts/core/routing/matchers/ip.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import type { IMatcher, IIpMatchOptions } from '../types.js';
|
||||
|
||||
/**
|
||||
* IpMatcher provides comprehensive IP address matching functionality
|
||||
* Supporting exact matches, CIDR notation, ranges, and wildcards
|
||||
*/
|
||||
export class IpMatcher implements IMatcher<boolean, IIpMatchOptions> {
|
||||
/**
|
||||
* Check if a value is a valid IPv4 address
|
||||
*/
|
||||
static isValidIpv4(ip: string): boolean {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
|
||||
return parts.every(part => {
|
||||
const num = parseInt(part, 10);
|
||||
return !isNaN(num) && num >= 0 && num <= 255 && part === num.toString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a valid IPv6 address (simplified check)
|
||||
*/
|
||||
static isValidIpv6(ip: string): boolean {
|
||||
// Basic IPv6 validation - can be enhanced
|
||||
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|(([0-9a-fA-F]{1,4}:){1,7}|:):|(([0-9a-fA-F]{1,4}:){1,6}|::):[0-9a-fA-F]{1,4})$/;
|
||||
return ipv6Regex.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert IP address to numeric value for comparison
|
||||
*/
|
||||
private static ipToNumber(ip: string): number {
|
||||
const parts = ip.split('.');
|
||||
return parts.reduce((acc, part, index) => {
|
||||
return acc + (parseInt(part, 10) << (8 * (3 - index)));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a CIDR notation pattern
|
||||
*/
|
||||
static matchCidr(cidr: string, ip: string): boolean {
|
||||
const [range, bits] = cidr.split('/');
|
||||
if (!bits || !this.isValidIpv4(range) || !this.isValidIpv4(ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rangeMask = parseInt(bits, 10);
|
||||
if (isNaN(rangeMask) || rangeMask < 0 || rangeMask > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rangeNum = this.ipToNumber(range);
|
||||
const ipNum = this.ipToNumber(ip);
|
||||
const mask = (-1 << (32 - rangeMask)) >>> 0;
|
||||
|
||||
return (rangeNum & mask) === (ipNum & mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a wildcard pattern
|
||||
*/
|
||||
static matchWildcard(pattern: string, ip: string): boolean {
|
||||
if (!this.isValidIpv4(ip)) return false;
|
||||
|
||||
const patternParts = pattern.split('.');
|
||||
const ipParts = ip.split('.');
|
||||
|
||||
if (patternParts.length !== 4) return false;
|
||||
|
||||
return patternParts.every((part, index) => {
|
||||
if (part === '*') return true;
|
||||
return part === ipParts[index];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a range (e.g., "192.168.1.1-192.168.1.100")
|
||||
*/
|
||||
static matchRange(range: string, ip: string): boolean {
|
||||
const [start, end] = range.split('-').map(s => s.trim());
|
||||
|
||||
if (!start || !end || !this.isValidIpv4(start) || !this.isValidIpv4(end) || !this.isValidIpv4(ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startNum = this.ipToNumber(start);
|
||||
const endNum = this.ipToNumber(end);
|
||||
const ipNum = this.ipToNumber(ip);
|
||||
|
||||
return ipNum >= startNum && ipNum <= endNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP pattern against an IP address
|
||||
* Supports multiple formats:
|
||||
* - Exact match: "192.168.1.1"
|
||||
* - CIDR: "192.168.1.0/24"
|
||||
* - Wildcard: "192.168.1.*"
|
||||
* - Range: "192.168.1.1-192.168.1.100"
|
||||
*/
|
||||
static match(
|
||||
pattern: string,
|
||||
ip: string,
|
||||
options: IIpMatchOptions = {}
|
||||
): boolean {
|
||||
// Handle null/undefined cases
|
||||
if (!pattern || !ip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize inputs
|
||||
const normalizedPattern = pattern.trim();
|
||||
const normalizedIp = ip.trim();
|
||||
|
||||
// Extract IPv4 from IPv6-mapped addresses (::ffff:192.168.1.1)
|
||||
const ipv4Match = normalizedIp.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/i);
|
||||
const testIp = ipv4Match ? ipv4Match[1] : normalizedIp;
|
||||
|
||||
// Exact match
|
||||
if (normalizedPattern === testIp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CIDR notation
|
||||
if (options.allowCidr !== false && normalizedPattern.includes('/')) {
|
||||
return this.matchCidr(normalizedPattern, testIp);
|
||||
}
|
||||
|
||||
// Wildcard matching
|
||||
if (normalizedPattern.includes('*')) {
|
||||
return this.matchWildcard(normalizedPattern, testIp);
|
||||
}
|
||||
|
||||
// Range matching
|
||||
if (options.allowRanges !== false && normalizedPattern.includes('-')) {
|
||||
return this.matchRange(normalizedPattern, testIp);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is authorized based on allow and block lists
|
||||
*/
|
||||
static isAuthorized(
|
||||
ip: string,
|
||||
allowList: string[] = [],
|
||||
blockList: string[] = []
|
||||
): boolean {
|
||||
// If IP is in block list, deny
|
||||
if (blockList.some(pattern => this.match(pattern, ip))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If allow list is empty, allow all (except blocked)
|
||||
if (allowList.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If allow list exists, IP must match
|
||||
return allowList.some(pattern => this.match(pattern, ip));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the specificity of an IP pattern
|
||||
* Higher values mean more specific patterns
|
||||
*/
|
||||
static calculateSpecificity(pattern: string): number {
|
||||
if (!pattern) return 0;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Exact IPs are most specific
|
||||
if (this.isValidIpv4(pattern) || this.isValidIpv6(pattern)) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
// CIDR notation
|
||||
if (pattern.includes('/')) {
|
||||
const [, bits] = pattern.split('/');
|
||||
const maskBits = parseInt(bits, 10);
|
||||
if (!isNaN(maskBits)) {
|
||||
score += maskBits; // Higher mask = more specific
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard patterns
|
||||
const wildcards = (pattern.match(/\*/g) || []).length;
|
||||
score -= wildcards * 20; // More wildcards = less specific
|
||||
|
||||
// Range patterns are somewhat specific
|
||||
if (pattern.includes('-')) {
|
||||
score += 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method for interface compliance
|
||||
*/
|
||||
match(pattern: string, ip: string, options?: IIpMatchOptions): boolean {
|
||||
return IpMatcher.match(pattern, ip, options);
|
||||
}
|
||||
}
|
184
ts/core/routing/matchers/path.ts
Normal file
184
ts/core/routing/matchers/path.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import type { IMatcher, IPathMatchResult } from '../types.js';
|
||||
|
||||
/**
|
||||
* PathMatcher provides comprehensive path matching functionality
|
||||
* Supporting exact matches, wildcards, and parameter extraction
|
||||
*/
|
||||
export class PathMatcher implements IMatcher<IPathMatchResult> {
|
||||
/**
|
||||
* Convert a path pattern to a regex and extract parameter names
|
||||
* Supports:
|
||||
* - Exact paths: /api/users
|
||||
* - Wildcards: /api/*
|
||||
* - Parameters: /api/users/:id
|
||||
* - Mixed: /api/users/:id/*
|
||||
*/
|
||||
private static patternToRegex(pattern: string): {
|
||||
regex: RegExp;
|
||||
paramNames: string[]
|
||||
} {
|
||||
const paramNames: string[] = [];
|
||||
let regexPattern = pattern;
|
||||
|
||||
// Escape special regex characters except : and *
|
||||
regexPattern = regexPattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Handle path parameters (:param)
|
||||
regexPattern = regexPattern.replace(/:(\w+)/g, (match, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)'; // Match any non-slash characters
|
||||
});
|
||||
|
||||
// Handle wildcards
|
||||
regexPattern = regexPattern.replace(/\*/g, '(.*)');
|
||||
|
||||
// Ensure the pattern matches from start
|
||||
regexPattern = `^${regexPattern}`;
|
||||
|
||||
// If pattern doesn't end with wildcard, ensure it matches to end
|
||||
// But only for patterns that don't have parameters or wildcards
|
||||
if (!pattern.includes('*') && !pattern.includes(':') && !pattern.endsWith('/')) {
|
||||
regexPattern = `${regexPattern}$`;
|
||||
}
|
||||
|
||||
return {
|
||||
regex: new RegExp(regexPattern),
|
||||
paramNames
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path pattern against a request path
|
||||
* @param pattern The pattern to match
|
||||
* @param path The request path to test
|
||||
* @returns Match result with params and remainder
|
||||
*/
|
||||
static match(pattern: string, path: string): IPathMatchResult {
|
||||
// Handle null/undefined cases
|
||||
if (!pattern || !path) {
|
||||
return { matches: false };
|
||||
}
|
||||
|
||||
// Normalize paths (remove trailing slashes unless it's just "/")
|
||||
const normalizedPattern = pattern === '/' ? '/' : pattern.replace(/\/$/, '');
|
||||
const normalizedPath = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||
|
||||
// Exact match (most common case)
|
||||
if (normalizedPattern === normalizedPath) {
|
||||
return {
|
||||
matches: true,
|
||||
pathMatch: normalizedPath,
|
||||
pathRemainder: '',
|
||||
params: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern matching (wildcards and parameters)
|
||||
const { regex, paramNames } = this.patternToRegex(normalizedPattern);
|
||||
const match = normalizedPath.match(regex);
|
||||
|
||||
if (!match) {
|
||||
return { matches: false };
|
||||
}
|
||||
|
||||
// Extract parameters
|
||||
const params: Record<string, string> = {};
|
||||
paramNames.forEach((name, index) => {
|
||||
params[name] = match[index + 1];
|
||||
});
|
||||
|
||||
// Calculate path match and remainder
|
||||
let pathMatch = match[0];
|
||||
let pathRemainder = normalizedPath.substring(pathMatch.length);
|
||||
|
||||
// Handle wildcard captures
|
||||
if (normalizedPattern.includes('*') && match.length > paramNames.length + 1) {
|
||||
const wildcardCapture = match[match.length - 1];
|
||||
if (wildcardCapture) {
|
||||
pathRemainder = wildcardCapture;
|
||||
pathMatch = normalizedPath.substring(0, normalizedPath.length - wildcardCapture.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up path match (remove trailing slash if present)
|
||||
if (pathMatch !== '/' && pathMatch.endsWith('/')) {
|
||||
pathMatch = pathMatch.slice(0, -1);
|
||||
}
|
||||
|
||||
return {
|
||||
matches: true,
|
||||
pathMatch,
|
||||
pathRemainder,
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern contains parameters or wildcards
|
||||
*/
|
||||
static isDynamicPattern(pattern: string): boolean {
|
||||
return pattern.includes(':') || pattern.includes('*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the specificity of a path pattern
|
||||
* Higher values mean more specific patterns
|
||||
*/
|
||||
static calculateSpecificity(pattern: string): number {
|
||||
if (!pattern) return 0;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Exact paths are most specific
|
||||
if (!this.isDynamicPattern(pattern)) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
// Count path segments
|
||||
const segments = pattern.split('/').filter(s => s.length > 0);
|
||||
score += segments.length * 10;
|
||||
|
||||
// Count static segments (more static = more specific)
|
||||
const staticSegments = segments.filter(s => !s.startsWith(':') && s !== '*');
|
||||
score += staticSegments.length * 20;
|
||||
|
||||
// Penalize wildcards and parameters
|
||||
const wildcards = (pattern.match(/\*/g) || []).length;
|
||||
const params = (pattern.match(/:/g) || []).length;
|
||||
score -= wildcards * 30; // Wildcards are very generic
|
||||
score -= params * 10; // Parameters are somewhat generic
|
||||
|
||||
// Bonus for longer patterns
|
||||
score += pattern.length;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all matching patterns from a list
|
||||
* Returns patterns sorted by specificity (most specific first)
|
||||
*/
|
||||
static findAllMatches(patterns: string[], path: string): Array<{
|
||||
pattern: string;
|
||||
result: IPathMatchResult;
|
||||
}> {
|
||||
const matches = patterns
|
||||
.map(pattern => ({
|
||||
pattern,
|
||||
result: this.match(pattern, path)
|
||||
}))
|
||||
.filter(({ result }) => result.matches);
|
||||
|
||||
// Sort by specificity (highest first)
|
||||
return matches.sort((a, b) =>
|
||||
this.calculateSpecificity(b.pattern) - this.calculateSpecificity(a.pattern)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method for interface compliance
|
||||
*/
|
||||
match(pattern: string, path: string): IPathMatchResult {
|
||||
return PathMatcher.match(pattern, path);
|
||||
}
|
||||
}
|
@ -7,20 +7,15 @@ import type {
|
||||
IRouteContext
|
||||
} from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import {
|
||||
matchDomain,
|
||||
matchRouteDomain,
|
||||
matchPath,
|
||||
matchIpPattern,
|
||||
matchIpCidr,
|
||||
ipToNumber,
|
||||
isIpAuthorized,
|
||||
calculateRouteSpecificity
|
||||
} from './route-utils.js';
|
||||
import { DomainMatcher, PathMatcher, IpMatcher } from './matchers/index.js';
|
||||
|
||||
/**
|
||||
* Result of route matching
|
||||
* Result of route lookup
|
||||
*/
|
||||
export interface IRouteMatchResult {
|
||||
export interface IRouteLookupResult {
|
||||
route: IRouteConfig;
|
||||
// Additional match parameters (path, query, etc.)
|
||||
params?: Record<string, string>;
|
||||
@ -219,7 +214,7 @@ export class SharedRouteManager extends plugins.EventEmitter {
|
||||
/**
|
||||
* Find the matching route for a connection
|
||||
*/
|
||||
public findMatchingRoute(context: IRouteContext): IRouteMatchResult | null {
|
||||
public findMatchingRoute(context: IRouteContext): IRouteLookupResult | null {
|
||||
// Get routes for this port if using port-based filtering
|
||||
const routesToCheck = context.port
|
||||
? (this.portMap.get(context.port) || [])
|
||||
@ -258,21 +253,21 @@ export class SharedRouteManager extends plugins.EventEmitter {
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
if (!domains.some(domainPattern => this.matchDomain(domainPattern, context.domain!))) {
|
||||
if (!domains.some(domainPattern => DomainMatcher.match(domainPattern, context.domain!))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check path match if specified
|
||||
if (route.match.path && context.path) {
|
||||
if (!this.matchPath(route.match.path, context.path)) {
|
||||
if (!PathMatcher.match(route.match.path, context.path).matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check client IP match if specified
|
||||
if (route.match.clientIp && context.clientIp) {
|
||||
if (!route.match.clientIp.some(ip => this.matchIpPattern(ip, context.clientIp))) {
|
||||
if (!route.match.clientIp.some(ip => IpMatcher.match(ip, context.clientIp))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -311,45 +306,7 @@ export class SharedRouteManager extends plugins.EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a domain pattern against a domain
|
||||
* @deprecated Use the matchDomain function from route-utils.js instead
|
||||
*/
|
||||
public matchDomain(pattern: string, domain: string): boolean {
|
||||
return matchDomain(pattern, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path pattern against a path
|
||||
* @deprecated Use the matchPath function from route-utils.js instead
|
||||
*/
|
||||
public matchPath(pattern: string, path: string): boolean {
|
||||
return matchPath(pattern, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP pattern against a pattern
|
||||
* @deprecated Use the matchIpPattern function from route-utils.js instead
|
||||
*/
|
||||
public matchIpPattern(pattern: string, ip: string): boolean {
|
||||
return matchIpPattern(pattern, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a CIDR pattern
|
||||
* @deprecated Use the matchIpCidr function from route-utils.js instead
|
||||
*/
|
||||
public matchIpCidr(cidr: string, ip: string): boolean {
|
||||
return matchIpCidr(cidr, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IP address to a numeric value
|
||||
* @deprecated Use the ipToNumber function from route-utils.js instead
|
||||
*/
|
||||
private ipToNumber(ip: string): number {
|
||||
return ipToNumber(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the route configuration and return any warnings
|
||||
@ -479,11 +436,4 @@ export class SharedRouteManager extends plugins.EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route1 is more specific than route2
|
||||
* @deprecated Use the calculateRouteSpecificity function from route-utils.js instead
|
||||
*/
|
||||
private isRouteMoreSpecific(match1: IRouteMatch, match2: IRouteMatch): boolean {
|
||||
return calculateRouteSpecificity(match1) > calculateRouteSpecificity(match2);
|
||||
}
|
||||
}
|
88
ts/core/routing/route-utils.ts
Normal file
88
ts/core/routing/route-utils.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Route matching utilities for SmartProxy components
|
||||
*
|
||||
* This file provides utility functions that use the unified matchers
|
||||
* and additional route-specific utilities.
|
||||
*/
|
||||
|
||||
import { DomainMatcher, PathMatcher, IpMatcher, HeaderMatcher } from './matchers/index.js';
|
||||
import { RouteSpecificity } from './specificity.js';
|
||||
import type { IRouteSpecificity } from './types.js';
|
||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
|
||||
/**
|
||||
* Match domains from a route against a given domain
|
||||
*
|
||||
* @param domains Array or single domain pattern to match against
|
||||
* @param domain Domain to match
|
||||
* @returns Whether the domain matches any of the patterns
|
||||
*/
|
||||
export function matchRouteDomain(domains: string | string[] | undefined, domain: string | undefined): boolean {
|
||||
// If no domains specified in the route, match all domains
|
||||
if (!domains) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no domain in the request, can't match domain-specific routes
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const patterns = Array.isArray(domains) ? domains : [domains];
|
||||
return patterns.some(pattern => DomainMatcher.match(pattern, domain));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Calculate route specificity score
|
||||
* Higher score means more specific matching criteria
|
||||
*
|
||||
* @param match Match criteria to evaluate
|
||||
* @returns Numeric specificity score
|
||||
*/
|
||||
export function calculateRouteSpecificity(match: {
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
clientIp?: string[];
|
||||
tlsVersion?: string[];
|
||||
headers?: Record<string, string | RegExp>;
|
||||
}): number {
|
||||
let score = 0;
|
||||
|
||||
// Path specificity using PathMatcher
|
||||
if (match.path) {
|
||||
score += PathMatcher.calculateSpecificity(match.path);
|
||||
}
|
||||
|
||||
// Domain specificity using DomainMatcher
|
||||
if (match.domains) {
|
||||
const domains = Array.isArray(match.domains) ? match.domains : [match.domains];
|
||||
// Use the highest specificity among all domains
|
||||
const domainScore = Math.max(...domains.map(d => DomainMatcher.calculateSpecificity(d)));
|
||||
score += domainScore;
|
||||
}
|
||||
|
||||
// Headers specificity using HeaderMatcher
|
||||
if (match.headers) {
|
||||
const stringHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(match.headers)) {
|
||||
stringHeaders[key] = value instanceof RegExp ? value.source : value;
|
||||
}
|
||||
score += HeaderMatcher.calculateSpecificity(stringHeaders);
|
||||
}
|
||||
|
||||
// Client IP adds some specificity
|
||||
if (match.clientIp && match.clientIp.length > 0) {
|
||||
// Use the first IP pattern for specificity
|
||||
score += IpMatcher.calculateSpecificity(match.clientIp[0]);
|
||||
}
|
||||
|
||||
// TLS version adds minimal specificity
|
||||
if (match.tlsVersion && match.tlsVersion.length > 0) {
|
||||
score += match.tlsVersion.length * 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
141
ts/core/routing/specificity.ts
Normal file
141
ts/core/routing/specificity.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import type { IRouteSpecificity } from './types.js';
|
||||
import { DomainMatcher, PathMatcher, IpMatcher, HeaderMatcher } from './matchers/index.js';
|
||||
|
||||
/**
|
||||
* Unified route specificity calculator
|
||||
* Provides consistent specificity scoring across all routing components
|
||||
*/
|
||||
export class RouteSpecificity {
|
||||
/**
|
||||
* Calculate the total specificity score for a route
|
||||
* Higher scores indicate more specific routes that should match first
|
||||
*/
|
||||
static calculate(route: IRouteConfig): IRouteSpecificity {
|
||||
const specificity: IRouteSpecificity = {
|
||||
pathSpecificity: 0,
|
||||
domainSpecificity: 0,
|
||||
ipSpecificity: 0,
|
||||
headerSpecificity: 0,
|
||||
tlsSpecificity: 0,
|
||||
totalScore: 0
|
||||
};
|
||||
|
||||
// Path specificity
|
||||
if (route.match.path) {
|
||||
specificity.pathSpecificity = PathMatcher.calculateSpecificity(route.match.path);
|
||||
}
|
||||
|
||||
// Domain specificity
|
||||
if (route.match.domains) {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
// Use the highest specificity among all domains
|
||||
specificity.domainSpecificity = Math.max(
|
||||
...domains.map(d => DomainMatcher.calculateSpecificity(d))
|
||||
);
|
||||
}
|
||||
|
||||
// IP specificity (clientIp is an array of IPs)
|
||||
if (route.match.clientIp && route.match.clientIp.length > 0) {
|
||||
// Use the first IP pattern for specificity calculation
|
||||
specificity.ipSpecificity = IpMatcher.calculateSpecificity(route.match.clientIp[0]);
|
||||
}
|
||||
|
||||
// Header specificity (convert RegExp values to strings)
|
||||
if (route.match.headers) {
|
||||
const stringHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(route.match.headers)) {
|
||||
stringHeaders[key] = value instanceof RegExp ? value.source : value;
|
||||
}
|
||||
specificity.headerSpecificity = HeaderMatcher.calculateSpecificity(stringHeaders);
|
||||
}
|
||||
|
||||
// TLS version specificity
|
||||
if (route.match.tlsVersion && route.match.tlsVersion.length > 0) {
|
||||
specificity.tlsSpecificity = route.match.tlsVersion.length * 10;
|
||||
}
|
||||
|
||||
// Calculate total score with weights
|
||||
specificity.totalScore =
|
||||
specificity.pathSpecificity * 3 + // Path is most important
|
||||
specificity.domainSpecificity * 2 + // Domain is second
|
||||
specificity.ipSpecificity * 1.5 + // IP is moderately important
|
||||
specificity.headerSpecificity * 1 + // Headers are less important
|
||||
specificity.tlsSpecificity * 0.5; // TLS is least important
|
||||
|
||||
return specificity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two routes and determine which is more specific
|
||||
* @returns positive if route1 is more specific, negative if route2 is more specific, 0 if equal
|
||||
*/
|
||||
static compare(route1: IRouteConfig, route2: IRouteConfig): number {
|
||||
const spec1 = this.calculate(route1);
|
||||
const spec2 = this.calculate(route2);
|
||||
|
||||
// First compare by total score
|
||||
if (spec1.totalScore !== spec2.totalScore) {
|
||||
return spec1.totalScore - spec2.totalScore;
|
||||
}
|
||||
|
||||
// If total scores are equal, compare by individual components
|
||||
// Path is most important tiebreaker
|
||||
if (spec1.pathSpecificity !== spec2.pathSpecificity) {
|
||||
return spec1.pathSpecificity - spec2.pathSpecificity;
|
||||
}
|
||||
|
||||
// Then domain
|
||||
if (spec1.domainSpecificity !== spec2.domainSpecificity) {
|
||||
return spec1.domainSpecificity - spec2.domainSpecificity;
|
||||
}
|
||||
|
||||
// Then IP
|
||||
if (spec1.ipSpecificity !== spec2.ipSpecificity) {
|
||||
return spec1.ipSpecificity - spec2.ipSpecificity;
|
||||
}
|
||||
|
||||
// Then headers
|
||||
if (spec1.headerSpecificity !== spec2.headerSpecificity) {
|
||||
return spec1.headerSpecificity - spec2.headerSpecificity;
|
||||
}
|
||||
|
||||
// Finally TLS
|
||||
return spec1.tlsSpecificity - spec2.tlsSpecificity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort routes by specificity (most specific first)
|
||||
*/
|
||||
static sort(routes: IRouteConfig[]): IRouteConfig[] {
|
||||
return [...routes].sort((a, b) => this.compare(b, a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most specific route from a list
|
||||
*/
|
||||
static findMostSpecific(routes: IRouteConfig[]): IRouteConfig | null {
|
||||
if (routes.length === 0) return null;
|
||||
|
||||
return routes.reduce((most, current) =>
|
||||
this.compare(current, most) > 0 ? current : most
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route has any matching criteria
|
||||
*/
|
||||
static hasMatchCriteria(route: IRouteConfig): boolean {
|
||||
const match = route.match;
|
||||
return !!(
|
||||
match.domains ||
|
||||
match.path ||
|
||||
match.clientIp?.length ||
|
||||
match.headers ||
|
||||
match.tlsVersion?.length
|
||||
);
|
||||
}
|
||||
}
|
49
ts/core/routing/types.ts
Normal file
49
ts/core/routing/types.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Core routing types used throughout the routing system
|
||||
*/
|
||||
|
||||
export interface IPathMatchResult {
|
||||
matches: boolean;
|
||||
params?: Record<string, string>;
|
||||
pathMatch?: string;
|
||||
pathRemainder?: string;
|
||||
}
|
||||
|
||||
export interface IRouteMatchResult {
|
||||
matches: boolean;
|
||||
score: number;
|
||||
specificity: number;
|
||||
matchedCriteria: string[];
|
||||
}
|
||||
|
||||
export interface IDomainMatchOptions {
|
||||
allowWildcards?: boolean;
|
||||
caseInsensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface IIpMatchOptions {
|
||||
allowCidr?: boolean;
|
||||
allowRanges?: boolean;
|
||||
}
|
||||
|
||||
export interface IHeaderMatchOptions {
|
||||
caseInsensitive?: boolean;
|
||||
exactMatch?: boolean;
|
||||
}
|
||||
|
||||
export interface IRouteSpecificity {
|
||||
pathSpecificity: number;
|
||||
domainSpecificity: number;
|
||||
ipSpecificity: number;
|
||||
headerSpecificity: number;
|
||||
tlsSpecificity: number;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
export interface IMatcher<T = any, O = any> {
|
||||
match(pattern: string, value: string, options?: O): T | boolean;
|
||||
}
|
||||
|
||||
export interface IAsyncMatcher<T = any, O = any> {
|
||||
match(pattern: string, value: string, options?: O): Promise<T | boolean>;
|
||||
}
|
275
ts/core/utils/async-utils.ts
Normal file
275
ts/core/utils/async-utils.ts
Normal file
@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Async utility functions for SmartProxy
|
||||
* Provides non-blocking alternatives to synchronous operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delays execution for the specified number of milliseconds
|
||||
* Non-blocking alternative to busy wait loops
|
||||
* @param ms - Number of milliseconds to delay
|
||||
* @returns Promise that resolves after the delay
|
||||
*/
|
||||
export async function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an async operation with exponential backoff
|
||||
* @param fn - The async function to retry
|
||||
* @param options - Retry options
|
||||
* @returns The result of the function or throws the last error
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
factor?: number;
|
||||
onRetry?: (attempt: number, error: Error) => void;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
initialDelay = 100,
|
||||
maxDelay = 10000,
|
||||
factor = 2,
|
||||
onRetry
|
||||
} = options;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
let currentDelay = initialDelay;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (onRetry) {
|
||||
onRetry(attempt, error);
|
||||
}
|
||||
|
||||
await delay(currentDelay);
|
||||
currentDelay = Math.min(currentDelay * factor, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Retry failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async operation with a timeout
|
||||
* @param fn - The async function to execute
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @param timeoutError - Optional custom timeout error
|
||||
* @returns The result of the function or throws timeout error
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutError?: Error
|
||||
): Promise<T> {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(timeoutError || new Error(`Operation timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([fn(), timeoutPromise]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run multiple async operations in parallel with a concurrency limit
|
||||
* @param items - Array of items to process
|
||||
* @param fn - Async function to run for each item
|
||||
* @param concurrency - Maximum number of concurrent operations
|
||||
* @returns Array of results in the same order as input
|
||||
*/
|
||||
export async function parallelLimit<T, R>(
|
||||
items: T[],
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
concurrency: number
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
const executing: Set<Promise<void>> = new Set();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const promise = fn(items[i], i).then(result => {
|
||||
results[i] = result;
|
||||
executing.delete(promise);
|
||||
});
|
||||
|
||||
executing.add(promise);
|
||||
|
||||
if (executing.size >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce an async function
|
||||
* @param fn - The async function to debounce
|
||||
* @param delayMs - Delay in milliseconds
|
||||
* @returns Debounced function with cancel method
|
||||
*/
|
||||
export function debounceAsync<T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
delayMs: number
|
||||
): T & { cancel: () => void } {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let lastPromise: Promise<any> | null = null;
|
||||
|
||||
const debounced = ((...args: Parameters<T>) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
lastPromise = new Promise((resolve, reject) => {
|
||||
timeoutId = setTimeout(async () => {
|
||||
timeoutId = null;
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, delayMs);
|
||||
});
|
||||
|
||||
return lastPromise;
|
||||
}) as any;
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced as T & { cancel: () => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mutex for ensuring exclusive access to a resource
|
||||
*/
|
||||
export class AsyncMutex {
|
||||
private queue: Array<() => void> = [];
|
||||
private locked = false;
|
||||
|
||||
async acquire(): Promise<() => void> {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
return () => this.release();
|
||||
}
|
||||
|
||||
return new Promise<() => void>(resolve => {
|
||||
this.queue.push(() => {
|
||||
resolve(() => this.release());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const release = await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker for protecting against cascading failures
|
||||
*/
|
||||
export class CircuitBreaker {
|
||||
private failureCount = 0;
|
||||
private lastFailureTime = 0;
|
||||
private state: 'closed' | 'open' | 'half-open' = 'closed';
|
||||
|
||||
constructor(
|
||||
private options: {
|
||||
failureThreshold: number;
|
||||
resetTimeout: number;
|
||||
onStateChange?: (state: 'closed' | 'open' | 'half-open') => void;
|
||||
}
|
||||
) {}
|
||||
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.state === 'open') {
|
||||
if (Date.now() - this.lastFailureTime > this.options.resetTimeout) {
|
||||
this.setState('half-open');
|
||||
} else {
|
||||
throw new Error('Circuit breaker is open');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onFailure();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private onSuccess(): void {
|
||||
this.failureCount = 0;
|
||||
if (this.state !== 'closed') {
|
||||
this.setState('closed');
|
||||
}
|
||||
}
|
||||
|
||||
private onFailure(): void {
|
||||
this.failureCount++;
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
if (this.failureCount >= this.options.failureThreshold) {
|
||||
this.setState('open');
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: 'closed' | 'open' | 'half-open'): void {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
if (this.options.onStateChange) {
|
||||
this.options.onStateChange(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.state === 'open';
|
||||
}
|
||||
|
||||
getState(): 'closed' | 'open' | 'half-open' {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.onSuccess();
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.onFailure();
|
||||
}
|
||||
}
|
225
ts/core/utils/binary-heap.ts
Normal file
225
ts/core/utils/binary-heap.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* A binary heap implementation for efficient priority queue operations
|
||||
* Supports O(log n) insert and extract operations
|
||||
*/
|
||||
export class BinaryHeap<T> {
|
||||
private heap: T[] = [];
|
||||
private keyMap?: Map<string, number>; // For efficient key-based lookups
|
||||
|
||||
constructor(
|
||||
private compareFn: (a: T, b: T) => number,
|
||||
private extractKey?: (item: T) => string
|
||||
) {
|
||||
if (extractKey) {
|
||||
this.keyMap = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current size of the heap
|
||||
*/
|
||||
public get size(): number {
|
||||
return this.heap.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the heap is empty
|
||||
*/
|
||||
public isEmpty(): boolean {
|
||||
return this.heap.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek at the top element without removing it
|
||||
*/
|
||||
public peek(): T | undefined {
|
||||
return this.heap[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new item into the heap
|
||||
* O(log n) time complexity
|
||||
*/
|
||||
public insert(item: T): void {
|
||||
const index = this.heap.length;
|
||||
this.heap.push(item);
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
const key = this.extractKey(item);
|
||||
this.keyMap.set(key, index);
|
||||
}
|
||||
|
||||
this.bubbleUp(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the top element from the heap
|
||||
* O(log n) time complexity
|
||||
*/
|
||||
public extract(): T | undefined {
|
||||
if (this.heap.length === 0) return undefined;
|
||||
if (this.heap.length === 1) {
|
||||
const item = this.heap.pop()!;
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.delete(this.extractKey(item));
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
const result = this.heap[0];
|
||||
const lastItem = this.heap.pop()!;
|
||||
this.heap[0] = lastItem;
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.delete(this.extractKey(result));
|
||||
this.keyMap.set(this.extractKey(lastItem), 0);
|
||||
}
|
||||
|
||||
this.bubbleDown(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an element that matches the predicate
|
||||
* O(n) time complexity for search, O(log n) for extraction
|
||||
*/
|
||||
public extractIf(predicate: (item: T) => boolean): T | undefined {
|
||||
const index = this.heap.findIndex(predicate);
|
||||
if (index === -1) return undefined;
|
||||
|
||||
return this.extractAt(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an element by its key (if extractKey was provided)
|
||||
* O(log n) time complexity
|
||||
*/
|
||||
public extractByKey(key: string): T | undefined {
|
||||
if (!this.keyMap || !this.extractKey) {
|
||||
throw new Error('extractKey function must be provided to use key-based extraction');
|
||||
}
|
||||
|
||||
const index = this.keyMap.get(key);
|
||||
if (index === undefined) return undefined;
|
||||
|
||||
return this.extractAt(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in the heap
|
||||
* O(1) time complexity
|
||||
*/
|
||||
public hasKey(key: string): boolean {
|
||||
if (!this.keyMap) return false;
|
||||
return this.keyMap.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements as an array (does not modify heap)
|
||||
* O(n) time complexity
|
||||
*/
|
||||
public toArray(): T[] {
|
||||
return [...this.heap];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the heap
|
||||
*/
|
||||
public clear(): void {
|
||||
this.heap = [];
|
||||
if (this.keyMap) {
|
||||
this.keyMap.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract element at specific index
|
||||
*/
|
||||
private extractAt(index: number): T {
|
||||
const item = this.heap[index];
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.delete(this.extractKey(item));
|
||||
}
|
||||
|
||||
if (index === this.heap.length - 1) {
|
||||
this.heap.pop();
|
||||
return item;
|
||||
}
|
||||
|
||||
const lastItem = this.heap.pop()!;
|
||||
this.heap[index] = lastItem;
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.set(this.extractKey(lastItem), index);
|
||||
}
|
||||
|
||||
// Try bubbling up first
|
||||
const parentIndex = Math.floor((index - 1) / 2);
|
||||
if (parentIndex >= 0 && this.compareFn(this.heap[index], this.heap[parentIndex]) < 0) {
|
||||
this.bubbleUp(index);
|
||||
} else {
|
||||
this.bubbleDown(index);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bubble up element at given index to maintain heap property
|
||||
*/
|
||||
private bubbleUp(index: number): void {
|
||||
while (index > 0) {
|
||||
const parentIndex = Math.floor((index - 1) / 2);
|
||||
|
||||
if (this.compareFn(this.heap[index], this.heap[parentIndex]) >= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.swap(index, parentIndex);
|
||||
index = parentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bubble down element at given index to maintain heap property
|
||||
*/
|
||||
private bubbleDown(index: number): void {
|
||||
const length = this.heap.length;
|
||||
|
||||
while (true) {
|
||||
const leftChild = 2 * index + 1;
|
||||
const rightChild = 2 * index + 2;
|
||||
let smallest = index;
|
||||
|
||||
if (leftChild < length &&
|
||||
this.compareFn(this.heap[leftChild], this.heap[smallest]) < 0) {
|
||||
smallest = leftChild;
|
||||
}
|
||||
|
||||
if (rightChild < length &&
|
||||
this.compareFn(this.heap[rightChild], this.heap[smallest]) < 0) {
|
||||
smallest = rightChild;
|
||||
}
|
||||
|
||||
if (smallest === index) break;
|
||||
|
||||
this.swap(index, smallest);
|
||||
index = smallest;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two elements in the heap
|
||||
*/
|
||||
private swap(i: number, j: number): void {
|
||||
const temp = this.heap[i];
|
||||
this.heap[i] = this.heap[j];
|
||||
this.heap[j] = temp;
|
||||
|
||||
if (this.keyMap && this.extractKey) {
|
||||
this.keyMap.set(this.extractKey(this.heap[i]), i);
|
||||
this.keyMap.set(this.extractKey(this.heap[j]), j);
|
||||
}
|
||||
}
|
||||
}
|
425
ts/core/utils/enhanced-connection-pool.ts
Normal file
425
ts/core/utils/enhanced-connection-pool.ts
Normal file
@ -0,0 +1,425 @@
|
||||
import { LifecycleComponent } from './lifecycle-component.js';
|
||||
import { BinaryHeap } from './binary-heap.js';
|
||||
import { AsyncMutex } from './async-utils.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Interface for pooled connection
|
||||
*/
|
||||
export interface IPooledConnection<T> {
|
||||
id: string;
|
||||
connection: T;
|
||||
createdAt: number;
|
||||
lastUsedAt: number;
|
||||
useCount: number;
|
||||
inUse: boolean;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the connection pool
|
||||
*/
|
||||
export interface IConnectionPoolOptions<T> {
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
acquireTimeout?: number;
|
||||
idleTimeout?: number;
|
||||
maxUseCount?: number;
|
||||
validateOnAcquire?: boolean;
|
||||
validateOnReturn?: boolean;
|
||||
queueTimeout?: number;
|
||||
connectionFactory: () => Promise<T>;
|
||||
connectionValidator?: (connection: T) => Promise<boolean>;
|
||||
connectionDestroyer?: (connection: T) => Promise<void>;
|
||||
onConnectionError?: (error: Error, connection?: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for queued acquire request
|
||||
*/
|
||||
interface IAcquireRequest<T> {
|
||||
id: string;
|
||||
priority: number;
|
||||
timestamp: number;
|
||||
resolve: (connection: IPooledConnection<T>) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced connection pool with priority queue, backpressure, and lifecycle management
|
||||
*/
|
||||
export class EnhancedConnectionPool<T> extends LifecycleComponent {
|
||||
private readonly options: Required<Omit<IConnectionPoolOptions<T>, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>> & Pick<IConnectionPoolOptions<T>, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>;
|
||||
private readonly availableConnections: IPooledConnection<T>[] = [];
|
||||
private readonly activeConnections: Map<string, IPooledConnection<T>> = new Map();
|
||||
private readonly waitQueue: BinaryHeap<IAcquireRequest<T>>;
|
||||
private readonly mutex = new AsyncMutex();
|
||||
private readonly eventEmitter = new EventEmitter();
|
||||
|
||||
private connectionIdCounter = 0;
|
||||
private requestIdCounter = 0;
|
||||
private isClosing = false;
|
||||
|
||||
// Metrics
|
||||
private metrics = {
|
||||
connectionsCreated: 0,
|
||||
connectionsDestroyed: 0,
|
||||
connectionsAcquired: 0,
|
||||
connectionsReleased: 0,
|
||||
acquireTimeouts: 0,
|
||||
validationFailures: 0,
|
||||
queueHighWaterMark: 0,
|
||||
};
|
||||
|
||||
constructor(options: IConnectionPoolOptions<T>) {
|
||||
super();
|
||||
|
||||
this.options = {
|
||||
minSize: 0,
|
||||
maxSize: 10,
|
||||
acquireTimeout: 30000,
|
||||
idleTimeout: 300000, // 5 minutes
|
||||
maxUseCount: Infinity,
|
||||
validateOnAcquire: true,
|
||||
validateOnReturn: false,
|
||||
queueTimeout: 60000,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Initialize priority queue (higher priority = extracted first)
|
||||
this.waitQueue = new BinaryHeap<IAcquireRequest<T>>(
|
||||
(a, b) => b.priority - a.priority || a.timestamp - b.timestamp,
|
||||
(item) => item.id
|
||||
);
|
||||
|
||||
// Start maintenance cycle
|
||||
this.startMaintenance();
|
||||
|
||||
// Initialize minimum connections
|
||||
this.initializeMinConnections();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize minimum number of connections
|
||||
*/
|
||||
private async initializeMinConnections(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < this.options.minSize; i++) {
|
||||
promises.push(
|
||||
this.createConnection()
|
||||
.then(conn => {
|
||||
this.availableConnections.push(conn);
|
||||
})
|
||||
.catch(err => {
|
||||
if (this.options.onConnectionError) {
|
||||
this.options.onConnectionError(err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start maintenance timer for idle connection cleanup
|
||||
*/
|
||||
private startMaintenance(): void {
|
||||
this.setInterval(() => {
|
||||
this.performMaintenance();
|
||||
}, 30000); // Every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform maintenance tasks
|
||||
*/
|
||||
private async performMaintenance(): Promise<void> {
|
||||
await this.mutex.runExclusive(async () => {
|
||||
const now = Date.now();
|
||||
const toRemove: IPooledConnection<T>[] = [];
|
||||
|
||||
// Check for idle connections beyond minimum size
|
||||
for (let i = this.availableConnections.length - 1; i >= 0; i--) {
|
||||
const conn = this.availableConnections[i];
|
||||
|
||||
// Keep minimum connections
|
||||
if (this.availableConnections.length <= this.options.minSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove idle connections
|
||||
if (now - conn.lastUsedAt > this.options.idleTimeout) {
|
||||
toRemove.push(conn);
|
||||
this.availableConnections.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy idle connections
|
||||
for (const conn of toRemove) {
|
||||
await this.destroyConnection(conn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a connection from the pool
|
||||
*/
|
||||
public async acquire(priority: number = 0, timeout?: number): Promise<IPooledConnection<T>> {
|
||||
if (this.isClosing) {
|
||||
throw new Error('Connection pool is closing');
|
||||
}
|
||||
|
||||
return this.mutex.runExclusive(async () => {
|
||||
// Try to get an available connection
|
||||
const connection = await this.tryAcquireConnection();
|
||||
if (connection) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
// Check if we can create a new connection
|
||||
const totalConnections = this.availableConnections.length + this.activeConnections.size;
|
||||
if (totalConnections < this.options.maxSize) {
|
||||
try {
|
||||
const newConnection = await this.createConnection();
|
||||
return this.checkoutConnection(newConnection);
|
||||
} catch (err) {
|
||||
// Fall through to queue if creation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Add to wait queue
|
||||
return this.queueAcquireRequest(priority, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire an available connection
|
||||
*/
|
||||
private async tryAcquireConnection(): Promise<IPooledConnection<T> | null> {
|
||||
while (this.availableConnections.length > 0) {
|
||||
const connection = this.availableConnections.shift()!;
|
||||
|
||||
// Check if connection exceeded max use count
|
||||
if (connection.useCount >= this.options.maxUseCount) {
|
||||
await this.destroyConnection(connection);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate connection if required
|
||||
if (this.options.validateOnAcquire && this.options.connectionValidator) {
|
||||
try {
|
||||
const isValid = await this.options.connectionValidator(connection.connection);
|
||||
if (!isValid) {
|
||||
this.metrics.validationFailures++;
|
||||
await this.destroyConnection(connection);
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
this.metrics.validationFailures++;
|
||||
await this.destroyConnection(connection);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return this.checkoutConnection(connection);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a connection for use
|
||||
*/
|
||||
private checkoutConnection(connection: IPooledConnection<T>): IPooledConnection<T> {
|
||||
connection.inUse = true;
|
||||
connection.lastUsedAt = Date.now();
|
||||
connection.useCount++;
|
||||
|
||||
this.activeConnections.set(connection.id, connection);
|
||||
this.metrics.connectionsAcquired++;
|
||||
|
||||
this.eventEmitter.emit('acquire', connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an acquire request
|
||||
*/
|
||||
private queueAcquireRequest(priority: number, timeout?: number): Promise<IPooledConnection<T>> {
|
||||
return new Promise<IPooledConnection<T>>((resolve, reject) => {
|
||||
const request: IAcquireRequest<T> = {
|
||||
id: `req-${this.requestIdCounter++}`,
|
||||
priority,
|
||||
timestamp: Date.now(),
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
|
||||
// Set timeout
|
||||
const timeoutMs = timeout || this.options.queueTimeout;
|
||||
request.timeoutHandle = this.setTimeout(() => {
|
||||
if (this.waitQueue.extractByKey(request.id)) {
|
||||
this.metrics.acquireTimeouts++;
|
||||
reject(new Error(`Connection acquire timeout after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.waitQueue.insert(request);
|
||||
this.metrics.queueHighWaterMark = Math.max(
|
||||
this.metrics.queueHighWaterMark,
|
||||
this.waitQueue.size
|
||||
);
|
||||
|
||||
this.eventEmitter.emit('enqueue', { queueSize: this.waitQueue.size });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a connection back to the pool
|
||||
*/
|
||||
public async release(connection: IPooledConnection<T>): Promise<void> {
|
||||
return this.mutex.runExclusive(async () => {
|
||||
if (!connection.inUse || !this.activeConnections.has(connection.id)) {
|
||||
throw new Error('Connection is not active');
|
||||
}
|
||||
|
||||
this.activeConnections.delete(connection.id);
|
||||
connection.inUse = false;
|
||||
connection.lastUsedAt = Date.now();
|
||||
this.metrics.connectionsReleased++;
|
||||
|
||||
// Check if connection should be destroyed
|
||||
if (connection.useCount >= this.options.maxUseCount) {
|
||||
await this.destroyConnection(connection);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate on return if required
|
||||
if (this.options.validateOnReturn && this.options.connectionValidator) {
|
||||
try {
|
||||
const isValid = await this.options.connectionValidator(connection.connection);
|
||||
if (!isValid) {
|
||||
await this.destroyConnection(connection);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await this.destroyConnection(connection);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are waiting requests
|
||||
const request = this.waitQueue.extract();
|
||||
if (request) {
|
||||
this.clearTimeout(request.timeoutHandle!);
|
||||
request.resolve(this.checkoutConnection(connection));
|
||||
this.eventEmitter.emit('dequeue', { queueSize: this.waitQueue.size });
|
||||
} else {
|
||||
// Return to available pool
|
||||
this.availableConnections.push(connection);
|
||||
this.eventEmitter.emit('release', connection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connection
|
||||
*/
|
||||
private async createConnection(): Promise<IPooledConnection<T>> {
|
||||
const rawConnection = await this.options.connectionFactory();
|
||||
|
||||
const connection: IPooledConnection<T> = {
|
||||
id: `conn-${this.connectionIdCounter++}`,
|
||||
connection: rawConnection,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now(),
|
||||
useCount: 0,
|
||||
inUse: false,
|
||||
};
|
||||
|
||||
this.metrics.connectionsCreated++;
|
||||
this.eventEmitter.emit('create', connection);
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a connection
|
||||
*/
|
||||
private async destroyConnection(connection: IPooledConnection<T>): Promise<void> {
|
||||
try {
|
||||
if (this.options.connectionDestroyer) {
|
||||
await this.options.connectionDestroyer(connection.connection);
|
||||
}
|
||||
|
||||
this.metrics.connectionsDestroyed++;
|
||||
this.eventEmitter.emit('destroy', connection);
|
||||
} catch (err) {
|
||||
if (this.options.onConnectionError) {
|
||||
this.options.onConnectionError(err as Error, connection.connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current pool statistics
|
||||
*/
|
||||
public getStats() {
|
||||
return {
|
||||
available: this.availableConnections.length,
|
||||
active: this.activeConnections.size,
|
||||
waiting: this.waitQueue.size,
|
||||
total: this.availableConnections.length + this.activeConnections.size,
|
||||
...this.metrics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to pool events
|
||||
*/
|
||||
public on(event: string, listener: Function): void {
|
||||
this.addEventListener(this.eventEmitter, event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the pool and cleanup resources
|
||||
*/
|
||||
protected async onCleanup(): Promise<void> {
|
||||
this.isClosing = true;
|
||||
|
||||
// Clear the wait queue
|
||||
while (!this.waitQueue.isEmpty()) {
|
||||
const request = this.waitQueue.extract();
|
||||
if (request) {
|
||||
this.clearTimeout(request.timeoutHandle!);
|
||||
request.reject(new Error('Connection pool is closing'));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to be released (with timeout)
|
||||
const timeout = 30000;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (this.activeConnections.size > 0 && Date.now() - startTime < timeout) {
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, 100);
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Destroy all connections
|
||||
const allConnections = [
|
||||
...this.availableConnections,
|
||||
...this.activeConnections.values(),
|
||||
];
|
||||
|
||||
await Promise.all(allConnections.map(conn => this.destroyConnection(conn)));
|
||||
|
||||
this.availableConnections.length = 0;
|
||||
this.activeConnections.clear();
|
||||
}
|
||||
}
|
@ -1,376 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
ICertificateData,
|
||||
ICertificateFailure,
|
||||
ICertificateExpiring
|
||||
} from '../models/common-types.js';
|
||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import { Port80HandlerEvents } from '../models/common-types.js';
|
||||
|
||||
/**
|
||||
* Standardized event names used throughout the system
|
||||
*/
|
||||
export enum ProxyEvents {
|
||||
// Certificate events
|
||||
CERTIFICATE_ISSUED = 'certificate:issued',
|
||||
CERTIFICATE_RENEWED = 'certificate:renewed',
|
||||
CERTIFICATE_FAILED = 'certificate:failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate:expiring',
|
||||
|
||||
// Component lifecycle events
|
||||
COMPONENT_STARTED = 'component:started',
|
||||
COMPONENT_STOPPED = 'component:stopped',
|
||||
|
||||
// Connection events
|
||||
CONNECTION_ESTABLISHED = 'connection:established',
|
||||
CONNECTION_CLOSED = 'connection:closed',
|
||||
CONNECTION_ERROR = 'connection:error',
|
||||
|
||||
// Request events
|
||||
REQUEST_RECEIVED = 'request:received',
|
||||
REQUEST_COMPLETED = 'request:completed',
|
||||
REQUEST_ERROR = 'request:error',
|
||||
|
||||
// Route events
|
||||
ROUTE_MATCHED = 'route:matched',
|
||||
ROUTE_UPDATED = 'route:updated',
|
||||
ROUTE_ERROR = 'route:error',
|
||||
|
||||
// Security events
|
||||
SECURITY_BLOCKED = 'security:blocked',
|
||||
SECURITY_BREACH_ATTEMPT = 'security:breach-attempt',
|
||||
|
||||
// TLS events
|
||||
TLS_HANDSHAKE_STARTED = 'tls:handshake-started',
|
||||
TLS_HANDSHAKE_COMPLETED = 'tls:handshake-completed',
|
||||
TLS_HANDSHAKE_FAILED = 'tls:handshake-failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Component types for event metadata
|
||||
*/
|
||||
export enum ComponentType {
|
||||
SMART_PROXY = 'smart-proxy',
|
||||
NETWORK_PROXY = 'network-proxy',
|
||||
NFTABLES_PROXY = 'nftables-proxy',
|
||||
PORT80_HANDLER = 'port80-handler',
|
||||
CERTIFICATE_MANAGER = 'certificate-manager',
|
||||
ROUTE_MANAGER = 'route-manager',
|
||||
CONNECTION_MANAGER = 'connection-manager',
|
||||
TLS_MANAGER = 'tls-manager',
|
||||
SECURITY_MANAGER = 'security-manager'
|
||||
}
|
||||
|
||||
/**
|
||||
* Base event data interface
|
||||
*/
|
||||
export interface IEventData {
|
||||
timestamp: number;
|
||||
componentType: ComponentType;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate event data
|
||||
*/
|
||||
export interface ICertificateEventData extends IEventData, ICertificateData {
|
||||
isRenewal?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate failure event data
|
||||
*/
|
||||
export interface ICertificateFailureEventData extends IEventData, ICertificateFailure {}
|
||||
|
||||
/**
|
||||
* Certificate expiring event data
|
||||
*/
|
||||
export interface ICertificateExpiringEventData extends IEventData, ICertificateExpiring {}
|
||||
|
||||
/**
|
||||
* Component lifecycle event data
|
||||
*/
|
||||
export interface IComponentEventData extends IEventData {
|
||||
name: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection event data
|
||||
*/
|
||||
export interface IConnectionEventData extends IEventData {
|
||||
connectionId: string;
|
||||
clientIp: string;
|
||||
serverIp?: string;
|
||||
port: number;
|
||||
isTls?: boolean;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request event data
|
||||
*/
|
||||
export interface IRequestEventData extends IEventData {
|
||||
connectionId: string;
|
||||
requestId: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
statusCode?: number;
|
||||
duration?: number;
|
||||
routeId?: string;
|
||||
routeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route event data
|
||||
*/
|
||||
export interface IRouteEventData extends IEventData {
|
||||
route: IRouteConfig;
|
||||
context?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security event data
|
||||
*/
|
||||
export interface ISecurityEventData extends IEventData {
|
||||
clientIp: string;
|
||||
reason: string;
|
||||
routeId?: string;
|
||||
routeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS event data
|
||||
*/
|
||||
export interface ITlsEventData extends IEventData {
|
||||
connectionId: string;
|
||||
domain?: string;
|
||||
clientIp: string;
|
||||
tlsVersion?: string;
|
||||
cipherSuite?: string;
|
||||
sniHostname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for event system
|
||||
*/
|
||||
export interface IEventLogger {
|
||||
info: (message: string, ...args: any[]) => void;
|
||||
warn: (message: string, ...args: any[]) => void;
|
||||
error: (message: string, ...args: any[]) => void;
|
||||
debug?: (message: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler type
|
||||
*/
|
||||
export type EventHandler<T> = (data: T) => void;
|
||||
|
||||
/**
|
||||
* Helper class to standardize event emission and handling
|
||||
* across all system components
|
||||
*/
|
||||
export class EventSystem {
|
||||
private emitter: plugins.EventEmitter;
|
||||
private componentType: ComponentType;
|
||||
private componentId: string;
|
||||
private logger?: IEventLogger;
|
||||
|
||||
constructor(
|
||||
componentType: ComponentType,
|
||||
componentId: string = '',
|
||||
logger?: IEventLogger
|
||||
) {
|
||||
this.emitter = new plugins.EventEmitter();
|
||||
this.componentType = componentType;
|
||||
this.componentId = componentId;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate issued event
|
||||
*/
|
||||
public emitCertificateIssued(data: Omit<ICertificateEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Certificate issued for ${data.domain}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_ISSUED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate renewed event
|
||||
*/
|
||||
public emitCertificateRenewed(data: Omit<ICertificateEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Certificate renewed for ${data.domain}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_RENEWED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate failed event
|
||||
*/
|
||||
public emitCertificateFailed(data: Omit<ICertificateFailureEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateFailureEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.error?.(`Certificate issuance failed for ${data.domain}: ${data.error}`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_FAILED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a certificate expiring event
|
||||
*/
|
||||
public emitCertificateExpiring(data: Omit<ICertificateExpiringEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: ICertificateExpiringEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.warn?.(`Certificate expiring for ${data.domain} in ${data.daysRemaining} days`);
|
||||
this.emitter.emit(ProxyEvents.CERTIFICATE_EXPIRING, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a component started event
|
||||
*/
|
||||
public emitComponentStarted(name: string, version?: string): void {
|
||||
const eventData: IComponentEventData = {
|
||||
name,
|
||||
version,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Component ${name} started${version ? ` (v${version})` : ''}`);
|
||||
this.emitter.emit(ProxyEvents.COMPONENT_STARTED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a component stopped event
|
||||
*/
|
||||
public emitComponentStopped(name: string): void {
|
||||
const eventData: IComponentEventData = {
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.info?.(`Component ${name} stopped`);
|
||||
this.emitter.emit(ProxyEvents.COMPONENT_STOPPED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a connection established event
|
||||
*/
|
||||
public emitConnectionEstablished(data: Omit<IConnectionEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IConnectionEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Connection ${data.connectionId} established from ${data.clientIp} on port ${data.port}`);
|
||||
this.emitter.emit(ProxyEvents.CONNECTION_ESTABLISHED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a connection closed event
|
||||
*/
|
||||
public emitConnectionClosed(data: Omit<IConnectionEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IConnectionEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Connection ${data.connectionId} closed`);
|
||||
this.emitter.emit(ProxyEvents.CONNECTION_CLOSED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a route matched event
|
||||
*/
|
||||
public emitRouteMatched(data: Omit<IRouteEventData, 'timestamp' | 'componentType' | 'componentId'>): void {
|
||||
const eventData: IRouteEventData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
componentType: this.componentType,
|
||||
componentId: this.componentId
|
||||
};
|
||||
|
||||
this.logger?.debug?.(`Route matched: ${data.route.name || data.route.id || 'unnamed'}`);
|
||||
this.emitter.emit(ProxyEvents.ROUTE_MATCHED, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event
|
||||
*/
|
||||
public on<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.on(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event once
|
||||
*/
|
||||
public once<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.once(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event
|
||||
*/
|
||||
public off<T>(event: ProxyEvents, handler: EventHandler<T>): void {
|
||||
this.emitter.off(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Port80Handler events to standard proxy events
|
||||
*/
|
||||
public subscribePort80HandlerEvents(handler: any): void {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => {
|
||||
this.emitCertificateIssued({
|
||||
...data,
|
||||
isRenewal: false,
|
||||
source: 'port80handler'
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => {
|
||||
this.emitCertificateRenewed({
|
||||
...data,
|
||||
isRenewal: true,
|
||||
source: 'port80handler'
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (data: ICertificateFailure) => {
|
||||
this.emitCertificateFailed(data);
|
||||
});
|
||||
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data: ICertificateExpiring) => {
|
||||
this.emitCertificateExpiring(data);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
// Port80Handler has been removed - use SmartCertManager instead
|
||||
import { Port80HandlerEvents } from '../models/common-types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { Port80HandlerEvents };
|
||||
|
||||
/**
|
||||
* @deprecated Use SmartCertManager instead
|
||||
*/
|
||||
export interface IPort80HandlerSubscribers {
|
||||
onCertificateIssued?: (data: any) => void;
|
||||
onCertificateRenewed?: (data: any) => void;
|
||||
onCertificateFailed?: (data: any) => void;
|
||||
onCertificateExpiring?: (data: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use SmartCertManager instead
|
||||
*/
|
||||
export function subscribeToPort80Handler(
|
||||
handler: any,
|
||||
subscribers: IPort80HandlerSubscribers
|
||||
): void {
|
||||
console.warn('subscribeToPort80Handler is deprecated - use SmartCertManager instead');
|
||||
}
|
270
ts/core/utils/fs-utils.ts
Normal file
270
ts/core/utils/fs-utils.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Async filesystem utilities for SmartProxy
|
||||
* Provides non-blocking alternatives to synchronous filesystem operations
|
||||
*/
|
||||
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
export class AsyncFileSystem {
|
||||
/**
|
||||
* Check if a file or directory exists
|
||||
* @param path - Path to check
|
||||
* @returns Promise resolving to true if exists, false otherwise
|
||||
*/
|
||||
static async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await plugins.fs.promises.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists, creating it if necessary
|
||||
* @param dirPath - Directory path to ensure
|
||||
* @returns Promise that resolves when directory is ensured
|
||||
*/
|
||||
static async ensureDir(dirPath: string): Promise<void> {
|
||||
await plugins.fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file as string
|
||||
* @param filePath - Path to the file
|
||||
* @param encoding - File encoding (default: utf8)
|
||||
* @returns Promise resolving to file contents
|
||||
*/
|
||||
static async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
||||
return plugins.fs.promises.readFile(filePath, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file as buffer
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise resolving to file buffer
|
||||
*/
|
||||
static async readFileBuffer(filePath: string): Promise<Buffer> {
|
||||
return plugins.fs.promises.readFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write string data to a file
|
||||
* @param filePath - Path to the file
|
||||
* @param data - String data to write
|
||||
* @param encoding - File encoding (default: utf8)
|
||||
* @returns Promise that resolves when file is written
|
||||
*/
|
||||
static async writeFile(filePath: string, data: string, encoding: BufferEncoding = 'utf8'): Promise<void> {
|
||||
// Ensure directory exists
|
||||
const dir = plugins.path.dirname(filePath);
|
||||
await this.ensureDir(dir);
|
||||
await plugins.fs.promises.writeFile(filePath, data, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write buffer data to a file
|
||||
* @param filePath - Path to the file
|
||||
* @param data - Buffer data to write
|
||||
* @returns Promise that resolves when file is written
|
||||
*/
|
||||
static async writeFileBuffer(filePath: string, data: Buffer): Promise<void> {
|
||||
const dir = plugins.path.dirname(filePath);
|
||||
await this.ensureDir(dir);
|
||||
await plugins.fs.promises.writeFile(filePath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise that resolves when file is removed
|
||||
*/
|
||||
static async remove(filePath: string): Promise<void> {
|
||||
try {
|
||||
await plugins.fs.promises.unlink(filePath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// File doesn't exist, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a directory and all its contents
|
||||
* @param dirPath - Path to the directory
|
||||
* @returns Promise that resolves when directory is removed
|
||||
*/
|
||||
static async removeDir(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await plugins.fs.promises.rm(dirPath, { recursive: true, force: true });
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON from a file
|
||||
* @param filePath - Path to the JSON file
|
||||
* @returns Promise resolving to parsed JSON
|
||||
*/
|
||||
static async readJSON<T = any>(filePath: string): Promise<T> {
|
||||
const content = await this.readFile(filePath);
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write JSON to a file
|
||||
* @param filePath - Path to the file
|
||||
* @param data - Data to write as JSON
|
||||
* @param pretty - Whether to pretty-print JSON (default: true)
|
||||
* @returns Promise that resolves when file is written
|
||||
*/
|
||||
static async writeJSON(filePath: string, data: any, pretty = true): Promise<void> {
|
||||
const jsonString = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
||||
await this.writeFile(filePath, jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file from source to destination
|
||||
* @param source - Source file path
|
||||
* @param destination - Destination file path
|
||||
* @returns Promise that resolves when file is copied
|
||||
*/
|
||||
static async copyFile(source: string, destination: string): Promise<void> {
|
||||
const destDir = plugins.path.dirname(destination);
|
||||
await this.ensureDir(destDir);
|
||||
await plugins.fs.promises.copyFile(source, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move/rename a file
|
||||
* @param source - Source file path
|
||||
* @param destination - Destination file path
|
||||
* @returns Promise that resolves when file is moved
|
||||
*/
|
||||
static async moveFile(source: string, destination: string): Promise<void> {
|
||||
const destDir = plugins.path.dirname(destination);
|
||||
await this.ensureDir(destDir);
|
||||
await plugins.fs.promises.rename(source, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file stats
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise resolving to file stats or null if doesn't exist
|
||||
*/
|
||||
static async getStats(filePath: string): Promise<plugins.fs.Stats | null> {
|
||||
try {
|
||||
return await plugins.fs.promises.stat(filePath);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory
|
||||
* @param dirPath - Directory path
|
||||
* @returns Promise resolving to array of filenames
|
||||
*/
|
||||
static async listFiles(dirPath: string): Promise<string[]> {
|
||||
try {
|
||||
return await plugins.fs.promises.readdir(dirPath);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory with full paths
|
||||
* @param dirPath - Directory path
|
||||
* @returns Promise resolving to array of full file paths
|
||||
*/
|
||||
static async listFilesFullPath(dirPath: string): Promise<string[]> {
|
||||
const files = await this.listFiles(dirPath);
|
||||
return files.map(file => plugins.path.join(dirPath, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively list all files in a directory
|
||||
* @param dirPath - Directory path
|
||||
* @param fileList - Accumulator for file list (used internally)
|
||||
* @returns Promise resolving to array of all file paths
|
||||
*/
|
||||
static async listFilesRecursive(dirPath: string, fileList: string[] = []): Promise<string[]> {
|
||||
const files = await this.listFiles(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = plugins.path.join(dirPath, file);
|
||||
const stats = await this.getStats(filePath);
|
||||
|
||||
if (stats?.isDirectory()) {
|
||||
await this.listFilesRecursive(filePath, fileList);
|
||||
} else if (stats?.isFile()) {
|
||||
fileList.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a read stream for a file
|
||||
* @param filePath - Path to the file
|
||||
* @param options - Stream options
|
||||
* @returns Read stream
|
||||
*/
|
||||
static createReadStream(filePath: string, options?: Parameters<typeof plugins.fs.createReadStream>[1]): plugins.fs.ReadStream {
|
||||
return plugins.fs.createReadStream(filePath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a write stream for a file
|
||||
* @param filePath - Path to the file
|
||||
* @param options - Stream options
|
||||
* @returns Write stream
|
||||
*/
|
||||
static createWriteStream(filePath: string, options?: Parameters<typeof plugins.fs.createWriteStream>[1]): plugins.fs.WriteStream {
|
||||
return plugins.fs.createWriteStream(filePath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a file exists, creating an empty file if necessary
|
||||
* @param filePath - Path to the file
|
||||
* @returns Promise that resolves when file is ensured
|
||||
*/
|
||||
static async ensureFile(filePath: string): Promise<void> {
|
||||
const exists = await this.exists(filePath);
|
||||
if (!exists) {
|
||||
await this.writeFile(filePath, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a directory
|
||||
* @param path - Path to check
|
||||
* @returns Promise resolving to true if directory, false otherwise
|
||||
*/
|
||||
static async isDirectory(path: string): Promise<boolean> {
|
||||
const stats = await this.getStats(path);
|
||||
return stats?.isDirectory() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a file
|
||||
* @param path - Path to check
|
||||
* @returns Promise resolving to true if file, false otherwise
|
||||
*/
|
||||
static async isFile(path: string): Promise<boolean> {
|
||||
const stats = await this.getStats(path);
|
||||
return stats?.isFile() ?? false;
|
||||
}
|
||||
}
|
@ -2,14 +2,16 @@
|
||||
* Core utility functions
|
||||
*/
|
||||
|
||||
export * from './event-utils.js';
|
||||
export * from './validation-utils.js';
|
||||
export * from './ip-utils.js';
|
||||
export * from './template-utils.js';
|
||||
export * from './route-manager.js';
|
||||
export * from './route-utils.js';
|
||||
export * from './security-utils.js';
|
||||
export * from './shared-security-manager.js';
|
||||
export * from './event-system.js';
|
||||
export * from './websocket-utils.js';
|
||||
export * from './logger.js';
|
||||
export * from './async-utils.js';
|
||||
export * from './fs-utils.js';
|
||||
export * from './lifecycle-component.js';
|
||||
export * from './binary-heap.js';
|
||||
export * from './enhanced-connection-pool.js';
|
||||
export * from './socket-utils.js';
|
||||
|
251
ts/core/utils/lifecycle-component.ts
Normal file
251
ts/core/utils/lifecycle-component.ts
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Base class for components that need proper resource lifecycle management
|
||||
* Provides automatic cleanup of timers and event listeners to prevent memory leaks
|
||||
*/
|
||||
export abstract class LifecycleComponent {
|
||||
private timers: Set<NodeJS.Timeout> = new Set();
|
||||
private intervals: Set<NodeJS.Timeout> = new Set();
|
||||
private listeners: Array<{
|
||||
target: any;
|
||||
event: string;
|
||||
handler: Function;
|
||||
actualHandler?: Function; // The actual handler registered (may be wrapped)
|
||||
once?: boolean;
|
||||
}> = [];
|
||||
private childComponents: Set<LifecycleComponent> = new Set();
|
||||
protected isShuttingDown = false;
|
||||
private cleanupPromise?: Promise<void>;
|
||||
|
||||
/**
|
||||
* Create a managed setTimeout that will be automatically cleaned up
|
||||
*/
|
||||
protected setTimeout(handler: Function, timeout: number): NodeJS.Timeout {
|
||||
if (this.isShuttingDown) {
|
||||
// Return a dummy timer if shutting down
|
||||
const dummyTimer = setTimeout(() => {}, 0);
|
||||
if (typeof dummyTimer.unref === 'function') {
|
||||
dummyTimer.unref();
|
||||
}
|
||||
return dummyTimer;
|
||||
}
|
||||
|
||||
const wrappedHandler = () => {
|
||||
this.timers.delete(timer);
|
||||
if (!this.isShuttingDown) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(wrappedHandler, timeout);
|
||||
this.timers.add(timer);
|
||||
|
||||
// Allow process to exit even with timer
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a managed setInterval that will be automatically cleaned up
|
||||
*/
|
||||
protected setInterval(handler: Function, interval: number): NodeJS.Timeout {
|
||||
if (this.isShuttingDown) {
|
||||
// Return a dummy timer if shutting down
|
||||
const dummyTimer = setInterval(() => {}, interval);
|
||||
if (typeof dummyTimer.unref === 'function') {
|
||||
dummyTimer.unref();
|
||||
}
|
||||
clearInterval(dummyTimer); // Clear immediately since we don't need it
|
||||
return dummyTimer;
|
||||
}
|
||||
|
||||
const wrappedHandler = () => {
|
||||
if (!this.isShuttingDown) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(wrappedHandler, interval);
|
||||
this.intervals.add(timer);
|
||||
|
||||
// Allow process to exit even with timer
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a managed timeout
|
||||
*/
|
||||
protected clearTimeout(timer: NodeJS.Timeout): void {
|
||||
clearTimeout(timer);
|
||||
this.timers.delete(timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a managed interval
|
||||
*/
|
||||
protected clearInterval(timer: NodeJS.Timeout): void {
|
||||
clearInterval(timer);
|
||||
this.intervals.delete(timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a managed event listener that will be automatically removed on cleanup
|
||||
*/
|
||||
protected addEventListener(
|
||||
target: any,
|
||||
event: string,
|
||||
handler: Function,
|
||||
options?: { once?: boolean }
|
||||
): void {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For 'once' listeners, we need to wrap the handler to remove it from our tracking
|
||||
let actualHandler = handler;
|
||||
if (options?.once) {
|
||||
actualHandler = (...args: any[]) => {
|
||||
// Call the original handler
|
||||
handler(...args);
|
||||
|
||||
// Remove from our internal tracking
|
||||
const index = this.listeners.findIndex(
|
||||
l => l.target === target && l.event === event && l.handler === handler
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Support both EventEmitter and DOM-style event targets
|
||||
if (typeof target.on === 'function') {
|
||||
if (options?.once) {
|
||||
target.once(event, actualHandler);
|
||||
} else {
|
||||
target.on(event, actualHandler);
|
||||
}
|
||||
} else if (typeof target.addEventListener === 'function') {
|
||||
target.addEventListener(event, actualHandler, options);
|
||||
} else {
|
||||
throw new Error('Target must support on() or addEventListener()');
|
||||
}
|
||||
|
||||
// Store both the original handler and the actual handler registered
|
||||
this.listeners.push({
|
||||
target,
|
||||
event,
|
||||
handler,
|
||||
actualHandler, // The handler that was actually registered (may be wrapped)
|
||||
once: options?.once
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific event listener
|
||||
*/
|
||||
protected removeEventListener(target: any, event: string, handler: Function): void {
|
||||
// Remove from target
|
||||
if (typeof target.removeListener === 'function') {
|
||||
target.removeListener(event, handler);
|
||||
} else if (typeof target.removeEventListener === 'function') {
|
||||
target.removeEventListener(event, handler);
|
||||
}
|
||||
|
||||
// Remove from our tracking
|
||||
const index = this.listeners.findIndex(
|
||||
l => l.target === target && l.event === event && l.handler === handler
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a child component that should be cleaned up when this component is cleaned up
|
||||
*/
|
||||
protected registerChildComponent(component: LifecycleComponent): void {
|
||||
this.childComponents.add(component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a child component
|
||||
*/
|
||||
protected unregisterChildComponent(component: LifecycleComponent): void {
|
||||
this.childComponents.delete(component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to implement component-specific cleanup logic
|
||||
*/
|
||||
protected async onCleanup(): Promise<void> {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all managed resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
// Return existing cleanup promise if already cleaning up
|
||||
if (this.cleanupPromise) {
|
||||
return this.cleanupPromise;
|
||||
}
|
||||
|
||||
this.cleanupPromise = this.performCleanup();
|
||||
return this.cleanupPromise;
|
||||
}
|
||||
|
||||
private async performCleanup(): Promise<void> {
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// First, clean up child components
|
||||
const childCleanupPromises: Promise<void>[] = [];
|
||||
for (const child of this.childComponents) {
|
||||
childCleanupPromises.push(child.cleanup());
|
||||
}
|
||||
await Promise.all(childCleanupPromises);
|
||||
this.childComponents.clear();
|
||||
|
||||
// Clear all timers
|
||||
for (const timer of this.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.timers.clear();
|
||||
|
||||
// Clear all intervals
|
||||
for (const timer of this.intervals) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.intervals.clear();
|
||||
|
||||
// Remove all event listeners
|
||||
for (const { target, event, handler, actualHandler } of this.listeners) {
|
||||
// Use actualHandler if available (for wrapped handlers), otherwise use the original handler
|
||||
const handlerToRemove = actualHandler || handler;
|
||||
|
||||
// All listeners need to be removed, including 'once' listeners that might not have fired
|
||||
if (typeof target.removeListener === 'function') {
|
||||
target.removeListener(event, handlerToRemove);
|
||||
} else if (typeof target.removeEventListener === 'function') {
|
||||
target.removeEventListener(event, handlerToRemove);
|
||||
}
|
||||
}
|
||||
this.listeners = [];
|
||||
|
||||
// Call subclass cleanup
|
||||
await this.onCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the component is shutting down
|
||||
*/
|
||||
protected isShuttingDownState(): boolean {
|
||||
return this.isShuttingDown;
|
||||
}
|
||||
}
|
@ -1,312 +0,0 @@
|
||||
/**
|
||||
* Route matching utilities for SmartProxy components
|
||||
*
|
||||
* Contains shared logic for domain matching, path matching, and IP matching
|
||||
* to be used by different proxy components throughout the system.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Match a domain pattern against a domain
|
||||
*
|
||||
* @param pattern Domain pattern with optional wildcards (e.g., "*.example.com")
|
||||
* @param domain Domain to match against the pattern
|
||||
* @returns Whether the domain matches the pattern
|
||||
*/
|
||||
export function matchDomain(pattern: string, domain: string): boolean {
|
||||
// Handle exact match (case-insensitive)
|
||||
if (pattern.toLowerCase() === domain.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle wildcard pattern
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
return regex.test(domain);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match domains from a route against a given domain
|
||||
*
|
||||
* @param domains Array or single domain pattern to match against
|
||||
* @param domain Domain to match
|
||||
* @returns Whether the domain matches any of the patterns
|
||||
*/
|
||||
export function matchRouteDomain(domains: string | string[] | undefined, domain: string | undefined): boolean {
|
||||
// If no domains specified in the route, match all domains
|
||||
if (!domains) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no domain in the request, can't match domain-specific routes
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const patterns = Array.isArray(domains) ? domains : [domains];
|
||||
return patterns.some(pattern => matchDomain(pattern, domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path pattern against a path
|
||||
*
|
||||
* @param pattern Path pattern with optional wildcards
|
||||
* @param path Path to match against the pattern
|
||||
* @returns Whether the path matches the pattern
|
||||
*/
|
||||
export function matchPath(pattern: string, path: string): boolean {
|
||||
// Handle exact match
|
||||
if (pattern === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle simple wildcard at the end (like /api/*)
|
||||
if (pattern.endsWith('*')) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
// Handle more complex wildcard patterns
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*') // Convert * to .*
|
||||
.replace(/\//g, '\\/'); // Escape slashes
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CIDR notation into subnet and mask bits
|
||||
*
|
||||
* @param cidr CIDR string (e.g., "192.168.1.0/24")
|
||||
* @returns Object with subnet and bits, or null if invalid
|
||||
*/
|
||||
export function parseCidr(cidr: string): { subnet: string; bits: number } | null {
|
||||
try {
|
||||
const [subnet, bitsStr] = cidr.split('/');
|
||||
const bits = parseInt(bitsStr, 10);
|
||||
|
||||
if (isNaN(bits) || bits < 0 || bits > 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { subnet, bits };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IP address to a numeric value
|
||||
*
|
||||
* @param ip IPv4 address string (e.g., "192.168.1.1")
|
||||
* @returns Numeric representation of the IP
|
||||
*/
|
||||
export function ipToNumber(ip: string): number {
|
||||
// Handle IPv6-mapped IPv4 addresses (::ffff:192.168.1.1)
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7);
|
||||
}
|
||||
|
||||
const parts = ip.split('.').map(part => parseInt(part, 10));
|
||||
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a CIDR pattern
|
||||
*
|
||||
* @param cidr CIDR pattern (e.g., "192.168.1.0/24")
|
||||
* @param ip IP to match against the pattern
|
||||
* @returns Whether the IP is in the CIDR range
|
||||
*/
|
||||
export function matchIpCidr(cidr: string, ip: string): boolean {
|
||||
const parsed = parseCidr(cidr);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { subnet, bits } = parsed;
|
||||
|
||||
// Normalize IPv6-mapped IPv4 addresses
|
||||
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||
const normalizedSubnet = subnet.startsWith('::ffff:') ? subnet.substring(7) : subnet;
|
||||
|
||||
// Convert IP addresses to numeric values
|
||||
const ipNum = ipToNumber(normalizedIp);
|
||||
const subnetNum = ipToNumber(normalizedSubnet);
|
||||
|
||||
// Calculate subnet mask
|
||||
const maskNum = ~(2 ** (32 - bits) - 1);
|
||||
|
||||
// Check if IP is in subnet
|
||||
return (ipNum & maskNum) === (subnetNum & maskNum);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP pattern against an IP
|
||||
*
|
||||
* @param pattern IP pattern (exact, CIDR, or with wildcards)
|
||||
* @param ip IP to match against the pattern
|
||||
* @returns Whether the IP matches the pattern
|
||||
*/
|
||||
export function matchIpPattern(pattern: string, ip: string): boolean {
|
||||
// Normalize IPv6-mapped IPv4 addresses
|
||||
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||
const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern;
|
||||
|
||||
// Handle exact match with all variations
|
||||
if (pattern === ip || normalizedPattern === normalizedIp ||
|
||||
pattern === normalizedIp || normalizedPattern === ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle "all" wildcard
|
||||
if (pattern === '*' || normalizedPattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle CIDR notation (e.g., 192.168.1.0/24)
|
||||
if (pattern.includes('/')) {
|
||||
return matchIpCidr(pattern, normalizedIp) ||
|
||||
(normalizedPattern !== pattern && matchIpCidr(normalizedPattern, normalizedIp));
|
||||
}
|
||||
|
||||
// Handle glob pattern (e.g., 192.168.1.*)
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (regex.test(ip) || regex.test(normalizedIp)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If pattern was normalized, also test with normalized pattern
|
||||
if (normalizedPattern !== pattern) {
|
||||
const normalizedRegexPattern = normalizedPattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
|
||||
const normalizedRegex = new RegExp(`^${normalizedRegexPattern}$`);
|
||||
return normalizedRegex.test(ip) || normalizedRegex.test(normalizedIp);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against allowed and blocked IP patterns
|
||||
*
|
||||
* @param ip IP to check
|
||||
* @param ipAllowList Array of allowed IP patterns
|
||||
* @param ipBlockList Array of blocked IP patterns
|
||||
* @returns Whether the IP is allowed
|
||||
*/
|
||||
export function isIpAuthorized(
|
||||
ip: string,
|
||||
ipAllowList: string[] = ['*'],
|
||||
ipBlockList: string[] = []
|
||||
): boolean {
|
||||
// Check blocked IPs first
|
||||
if (ipBlockList.length > 0) {
|
||||
for (const pattern of ipBlockList) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
return false; // IP is blocked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are allowed IPs, check them
|
||||
if (ipAllowList.length > 0) {
|
||||
// Special case: if '*' is in allowed IPs, all non-blocked IPs are allowed
|
||||
if (ipAllowList.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const pattern of ipAllowList) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
return true; // IP is allowed
|
||||
}
|
||||
}
|
||||
return false; // IP not in allowed list
|
||||
}
|
||||
|
||||
// No allowed IPs specified, so IP is allowed by default
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an HTTP header pattern against a header value
|
||||
*
|
||||
* @param pattern Expected header value (string or RegExp)
|
||||
* @param value Actual header value
|
||||
* @returns Whether the header matches the pattern
|
||||
*/
|
||||
export function matchHeader(pattern: string | RegExp, value: string): boolean {
|
||||
if (typeof pattern === 'string') {
|
||||
return pattern === value;
|
||||
} else if (pattern instanceof RegExp) {
|
||||
return pattern.test(value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate route specificity score
|
||||
* Higher score means more specific matching criteria
|
||||
*
|
||||
* @param match Match criteria to evaluate
|
||||
* @returns Numeric specificity score
|
||||
*/
|
||||
export function calculateRouteSpecificity(match: {
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
clientIp?: string[];
|
||||
tlsVersion?: string[];
|
||||
headers?: Record<string, string | RegExp>;
|
||||
}): number {
|
||||
let score = 0;
|
||||
|
||||
// Path is very specific
|
||||
if (match.path) {
|
||||
// More specific if it doesn't use wildcards
|
||||
score += match.path.includes('*') ? 3 : 4;
|
||||
}
|
||||
|
||||
// Domain is next most specific
|
||||
if (match.domains) {
|
||||
const domains = Array.isArray(match.domains) ? match.domains : [match.domains];
|
||||
// More domains or more specific domains (without wildcards) increase specificity
|
||||
score += domains.length;
|
||||
// Add bonus for exact domains (without wildcards)
|
||||
score += domains.some(d => !d.includes('*')) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Headers are quite specific
|
||||
if (match.headers) {
|
||||
score += Object.keys(match.headers).length * 2;
|
||||
}
|
||||
|
||||
// Client IP adds some specificity
|
||||
if (match.clientIp && match.clientIp.length > 0) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// TLS version adds minimal specificity
|
||||
if (match.tlsVersion && match.tlsVersion.length > 0) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
@ -1,9 +1,5 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
matchIpPattern,
|
||||
ipToNumber,
|
||||
matchIpCidr
|
||||
} from './route-utils.js';
|
||||
import { IpMatcher } from '../routing/matchers/ip.js';
|
||||
|
||||
/**
|
||||
* Security utilities for IP validation, rate limiting,
|
||||
@ -90,7 +86,7 @@ export function isIPAuthorized(
|
||||
// First check if IP is blocked - blocked IPs take precedence
|
||||
if (blockedIPs.length > 0) {
|
||||
for (const pattern of blockedIPs) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
if (IpMatcher.match(pattern, ip)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -104,7 +100,7 @@ export function isIPAuthorized(
|
||||
// Then check if IP is allowed in the explicit allow list
|
||||
if (allowedIPs.length > 0) {
|
||||
for (const pattern of allowedIPs) {
|
||||
if (matchIpPattern(pattern, ip)) {
|
||||
if (IpMatcher.match(pattern, ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
283
ts/core/utils/socket-utils.ts
Normal file
283
ts/core/utils/socket-utils.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
export interface CleanupOptions {
|
||||
immediate?: boolean; // Force immediate destruction
|
||||
allowDrain?: boolean; // Allow write buffer to drain
|
||||
gracePeriod?: number; // Ms to wait before force close
|
||||
}
|
||||
|
||||
export interface SafeSocketOptions {
|
||||
port: number;
|
||||
host: string;
|
||||
onError?: (error: Error) => void;
|
||||
onConnect?: () => void;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely cleanup a socket by removing all listeners and destroying it
|
||||
* @param socket The socket to cleanup
|
||||
* @param socketName Optional name for logging
|
||||
* @param options Cleanup options
|
||||
*/
|
||||
export function cleanupSocket(
|
||||
socket: plugins.net.Socket | plugins.tls.TLSSocket | null,
|
||||
socketName?: string,
|
||||
options: CleanupOptions = {}
|
||||
): Promise<void> {
|
||||
if (!socket || socket.destroyed) return Promise.resolve();
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
try {
|
||||
// Remove all event listeners
|
||||
socket.removeAllListeners();
|
||||
|
||||
// Destroy if not already destroyed
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error cleaning up socket${socketName ? ` (${socketName})` : ''}: ${err}`);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (options.immediate) {
|
||||
// Immediate cleanup (old behavior)
|
||||
socket.unpipe();
|
||||
cleanup();
|
||||
} else if (options.allowDrain && socket.writable) {
|
||||
// Allow pending writes to complete
|
||||
socket.end(() => cleanup());
|
||||
|
||||
// Force cleanup after grace period
|
||||
if (options.gracePeriod) {
|
||||
setTimeout(() => {
|
||||
if (!socket.destroyed) {
|
||||
cleanup();
|
||||
}
|
||||
}, options.gracePeriod);
|
||||
}
|
||||
} else {
|
||||
// Default: immediate cleanup
|
||||
socket.unpipe();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create independent cleanup handlers for paired sockets that support half-open connections
|
||||
* @param clientSocket The client socket
|
||||
* @param serverSocket The server socket
|
||||
* @param onBothClosed Callback when both sockets are closed
|
||||
* @returns Independent cleanup functions for each socket
|
||||
*/
|
||||
export function createIndependentSocketHandlers(
|
||||
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
onBothClosed: (reason: string) => void,
|
||||
options: { enableHalfOpen?: boolean } = {}
|
||||
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
|
||||
let clientClosed = false;
|
||||
let serverClosed = false;
|
||||
let clientReason = '';
|
||||
let serverReason = '';
|
||||
|
||||
const checkBothClosed = () => {
|
||||
if (clientClosed && serverClosed) {
|
||||
onBothClosed(`client: ${clientReason}, server: ${serverReason}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupClient = async (reason: string) => {
|
||||
if (clientClosed) return;
|
||||
clientClosed = true;
|
||||
clientReason = reason;
|
||||
|
||||
// Default behavior: close both sockets when one closes (required for proxy chains)
|
||||
if (!serverClosed && !options.enableHalfOpen) {
|
||||
serverSocket.destroy();
|
||||
}
|
||||
|
||||
// Half-open support (opt-in only)
|
||||
if (!serverClosed && serverSocket.writable && options.enableHalfOpen) {
|
||||
// Half-close: stop reading from client, let server finish
|
||||
clientSocket.pause();
|
||||
clientSocket.unpipe(serverSocket);
|
||||
await cleanupSocket(clientSocket, 'client', { allowDrain: true, gracePeriod: 5000 });
|
||||
} else {
|
||||
await cleanupSocket(clientSocket, 'client', { immediate: true });
|
||||
}
|
||||
|
||||
checkBothClosed();
|
||||
};
|
||||
|
||||
const cleanupServer = async (reason: string) => {
|
||||
if (serverClosed) return;
|
||||
serverClosed = true;
|
||||
serverReason = reason;
|
||||
|
||||
// Default behavior: close both sockets when one closes (required for proxy chains)
|
||||
if (!clientClosed && !options.enableHalfOpen) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
|
||||
// Half-open support (opt-in only)
|
||||
if (!clientClosed && clientSocket.writable && options.enableHalfOpen) {
|
||||
// Half-close: stop reading from server, let client finish
|
||||
serverSocket.pause();
|
||||
serverSocket.unpipe(clientSocket);
|
||||
await cleanupSocket(serverSocket, 'server', { allowDrain: true, gracePeriod: 5000 });
|
||||
} else {
|
||||
await cleanupSocket(serverSocket, 'server', { immediate: true });
|
||||
}
|
||||
|
||||
checkBothClosed();
|
||||
};
|
||||
|
||||
return { cleanupClient, cleanupServer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup socket error and close handlers with proper cleanup
|
||||
* @param socket The socket to setup handlers for
|
||||
* @param handleClose The cleanup function to call
|
||||
* @param handleTimeout Optional custom timeout handler
|
||||
* @param errorPrefix Optional prefix for error messages
|
||||
*/
|
||||
export function setupSocketHandlers(
|
||||
socket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
handleClose: (reason: string) => void,
|
||||
handleTimeout?: (socket: plugins.net.Socket | plugins.tls.TLSSocket) => void,
|
||||
errorPrefix?: string
|
||||
): void {
|
||||
socket.on('error', (error) => {
|
||||
const prefix = errorPrefix || 'Socket';
|
||||
handleClose(`${prefix}_error: ${error.message}`);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
const prefix = errorPrefix || 'socket';
|
||||
handleClose(`${prefix}_closed`);
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
if (handleTimeout) {
|
||||
handleTimeout(socket); // Custom timeout handling
|
||||
} else {
|
||||
// Default: just log, don't close
|
||||
console.warn(`Socket timeout: ${errorPrefix || 'socket'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup bidirectional data forwarding between two sockets with proper cleanup
|
||||
* @param clientSocket The client/incoming socket
|
||||
* @param serverSocket The server/outgoing socket
|
||||
* @param handlers Object containing optional handlers for data and cleanup
|
||||
* @returns Cleanup functions for both sockets
|
||||
*/
|
||||
export function setupBidirectionalForwarding(
|
||||
clientSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
serverSocket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
handlers: {
|
||||
onClientData?: (chunk: Buffer) => void;
|
||||
onServerData?: (chunk: Buffer) => void;
|
||||
onCleanup: (reason: string) => void;
|
||||
enableHalfOpen?: boolean;
|
||||
}
|
||||
): { cleanupClient: (reason: string) => Promise<void>, cleanupServer: (reason: string) => Promise<void> } {
|
||||
// Set up cleanup handlers
|
||||
const { cleanupClient, cleanupServer } = createIndependentSocketHandlers(
|
||||
clientSocket,
|
||||
serverSocket,
|
||||
handlers.onCleanup,
|
||||
{ enableHalfOpen: handlers.enableHalfOpen }
|
||||
);
|
||||
|
||||
// Set up error and close handlers
|
||||
setupSocketHandlers(clientSocket, cleanupClient, undefined, 'client');
|
||||
setupSocketHandlers(serverSocket, cleanupServer, undefined, 'server');
|
||||
|
||||
// Set up data forwarding with backpressure handling
|
||||
clientSocket.on('data', (chunk: Buffer) => {
|
||||
if (handlers.onClientData) {
|
||||
handlers.onClientData(chunk);
|
||||
}
|
||||
|
||||
if (serverSocket.writable) {
|
||||
const flushed = serverSocket.write(chunk);
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
clientSocket.pause();
|
||||
serverSocket.once('drain', () => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
serverSocket.on('data', (chunk: Buffer) => {
|
||||
if (handlers.onServerData) {
|
||||
handlers.onServerData(chunk);
|
||||
}
|
||||
|
||||
if (clientSocket.writable) {
|
||||
const flushed = clientSocket.write(chunk);
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
serverSocket.pause();
|
||||
clientSocket.once('drain', () => {
|
||||
if (!serverSocket.destroyed) {
|
||||
serverSocket.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { cleanupClient, cleanupServer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket with immediate error handling to prevent crashes
|
||||
* @param options Socket creation options
|
||||
* @returns The created socket
|
||||
*/
|
||||
export function createSocketWithErrorHandler(options: SafeSocketOptions): plugins.net.Socket {
|
||||
const { port, host, onError, onConnect, timeout } = options;
|
||||
|
||||
// Create socket with immediate error handler attachment
|
||||
const socket = new plugins.net.Socket();
|
||||
|
||||
// Attach error handler BEFORE connecting to catch immediate errors
|
||||
socket.on('error', (error) => {
|
||||
console.error(`Socket connection error to ${host}:${port}: ${error.message}`);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach connect handler if provided
|
||||
if (onConnect) {
|
||||
socket.on('connect', onConnect);
|
||||
}
|
||||
|
||||
// Set timeout if provided
|
||||
if (timeout) {
|
||||
socket.setTimeout(timeout);
|
||||
}
|
||||
|
||||
// Now attempt to connect - any immediate errors will be caught
|
||||
socket.connect(port, host);
|
||||
|
||||
return socket;
|
||||
}
|
@ -2,6 +2,7 @@ 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
|
||||
@ -40,12 +41,20 @@ export class HttpForwardingHandler extends ForwardingHandler {
|
||||
const remoteAddress = socket.remoteAddress || 'unknown';
|
||||
const localPort = socket.localPort || 80;
|
||||
|
||||
socket.on('close', (hadError) => {
|
||||
// Set up socket handlers with proper cleanup
|
||||
const handleClose = (reason: string) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
hadError
|
||||
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, {
|
||||
|
@ -2,6 +2,7 @@ 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)
|
||||
@ -47,128 +48,121 @@ export class HttpsPassthroughHandler extends ForwardingHandler {
|
||||
target: `${target.host}:${target.port}`
|
||||
});
|
||||
|
||||
// Create a connection to the target server
|
||||
const serverSocket = plugins.net.connect(target.port, target.host);
|
||||
|
||||
// Handle errors on the server socket
|
||||
serverSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Target connection error: ${error.message}`
|
||||
});
|
||||
|
||||
// Close the client socket if it's still open
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors on the client socket
|
||||
clientSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Client connection error: ${error.message}`
|
||||
});
|
||||
|
||||
// Close the server socket if it's still open
|
||||
if (!serverSocket.destroyed) {
|
||||
serverSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
// Forward data from client to server
|
||||
clientSocket.on('data', (data) => {
|
||||
bytesSent += data.length;
|
||||
|
||||
// Check if server socket is writable
|
||||
if (serverSocket.writable) {
|
||||
const flushed = serverSocket.write(data);
|
||||
// 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}`
|
||||
});
|
||||
|
||||
// Handle backpressure
|
||||
if (!flushed) {
|
||||
clientSocket.pause();
|
||||
serverSocket.once('drain', () => {
|
||||
clientSocket.resume();
|
||||
});
|
||||
// Clean up the client socket since we can't forward
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
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.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);
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||
direction: 'inbound',
|
||||
bytes: data.length,
|
||||
total: bytesReceived
|
||||
});
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
const handleClose = () => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
|
||||
if (!serverSocket.destroyed) {
|
||||
serverSocket.destroy();
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
bytesSent,
|
||||
bytesReceived
|
||||
});
|
||||
};
|
||||
|
||||
// Set up close handlers
|
||||
clientSocket.on('close', handleClose);
|
||||
serverSocket.on('close', handleClose);
|
||||
|
||||
// Set timeouts
|
||||
const timeout = this.getTimeout();
|
||||
clientSocket.setTimeout(timeout);
|
||||
serverSocket.setTimeout(timeout);
|
||||
|
||||
// Handle timeouts
|
||||
clientSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Client connection timeout'
|
||||
});
|
||||
handleClose();
|
||||
});
|
||||
|
||||
serverSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Server connection timeout'
|
||||
});
|
||||
handleClose();
|
||||
});
|
||||
}
|
||||
|
||||
@ -177,7 +171,7 @@ export class HttpsPassthroughHandler extends ForwardingHandler {
|
||||
* @param req The HTTP request
|
||||
* @param res The HTTP response
|
||||
*/
|
||||
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||
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');
|
||||
|
@ -2,6 +2,7 @@ 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
|
||||
@ -95,61 +96,34 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
||||
tls: true
|
||||
});
|
||||
|
||||
// Handle TLS errors
|
||||
tlsSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `TLS error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// The TLS socket will now emit HTTP traffic that can be processed
|
||||
// In a real implementation, we would create an HTTP parser and handle
|
||||
// the requests here, but for simplicity, we'll just log the data
|
||||
|
||||
// Variables to track connections
|
||||
let backendSocket: plugins.net.Socket | null = null;
|
||||
let dataBuffer = Buffer.alloc(0);
|
||||
let connectionEstablished = false;
|
||||
let forwardingSetup = false;
|
||||
|
||||
tlsSocket.on('data', (data) => {
|
||||
// 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'))) {
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// Simple example: forward the data to an HTTP server
|
||||
const socket = plugins.net.connect(target.port, target.host, () => {
|
||||
socket.write(dataBuffer);
|
||||
dataBuffer = Buffer.alloc(0);
|
||||
|
||||
// Set up bidirectional data flow
|
||||
tlsSocket.pipe(socket);
|
||||
socket.pipe(tlsSocket);
|
||||
// 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;
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Target connection error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
if (backendSocket && !backendSocket.destroyed) {
|
||||
backendSocket.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
// If forwarding is setup, setupBidirectionalForwarding will handle cleanup
|
||||
};
|
||||
|
||||
// Handle close
|
||||
tlsSocket.on('close', () => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress
|
||||
});
|
||||
});
|
||||
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
@ -160,9 +134,83 @@ export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
// 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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ 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
|
||||
@ -93,89 +94,28 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
||||
tls: true
|
||||
});
|
||||
|
||||
// Handle TLS errors
|
||||
tlsSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `TLS error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The TLS socket will now emit HTTP traffic that can be processed
|
||||
// In a real implementation, we would create an HTTP parser and handle
|
||||
// the requests here, but for simplicity, we'll just forward the data
|
||||
|
||||
// Get the target from configuration
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// Set up the connection to the HTTPS backend
|
||||
const connectToBackend = () => {
|
||||
const 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
|
||||
}, () => {
|
||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||
direction: 'outbound',
|
||||
target: `${target.host}:${target.port}`,
|
||||
tls: true
|
||||
});
|
||||
|
||||
// Set up bidirectional data flow
|
||||
tlsSocket.pipe(backendSocket);
|
||||
backendSocket.pipe(tlsSocket);
|
||||
});
|
||||
|
||||
backendSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Backend connection error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle close
|
||||
backendSocket.on('close', () => {
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
backendSocket.setTimeout(timeout);
|
||||
|
||||
backendSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Backend connection timeout'
|
||||
});
|
||||
|
||||
if (!backendSocket.destroyed) {
|
||||
backendSocket.destroy();
|
||||
}
|
||||
});
|
||||
// If connected to backend, setupBidirectionalForwarding will handle cleanup
|
||||
};
|
||||
|
||||
// Wait for the TLS handshake to complete before connecting to backend
|
||||
tlsSocket.on('secure', () => {
|
||||
connectToBackend();
|
||||
});
|
||||
|
||||
// Handle close
|
||||
tlsSocket.on('close', () => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress
|
||||
});
|
||||
});
|
||||
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
@ -186,10 +126,75 @@ export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
|
||||
|
18
ts/index.ts
18
ts/index.ts
@ -2,28 +2,18 @@
|
||||
* SmartProxy main module exports
|
||||
*/
|
||||
|
||||
// Legacy exports (to maintain backward compatibility)
|
||||
// Migrated to the new proxies structure
|
||||
// NFTables proxy exports
|
||||
export * from './proxies/nftables-proxy/index.js';
|
||||
|
||||
// Export HttpProxy elements selectively to avoid RouteManager ambiguity
|
||||
// Export HttpProxy elements
|
||||
export { HttpProxy, CertificateManager, ConnectionPool, RequestHandler, WebSocketHandler } from './proxies/http-proxy/index.js';
|
||||
export type { IMetricsTracker, MetricsTracker } from './proxies/http-proxy/index.js';
|
||||
// Export models except IAcmeOptions to avoid conflict
|
||||
export type { IHttpProxyOptions, ICertificateEntry, ILogger } from './proxies/http-proxy/models/types.js';
|
||||
export { RouteManager as HttpProxyRouteManager } from './proxies/http-proxy/models/types.js';
|
||||
|
||||
// Backward compatibility exports (deprecated)
|
||||
export { HttpProxy as NetworkProxy } from './proxies/http-proxy/index.js';
|
||||
export type { IHttpProxyOptions as INetworkProxyOptions } from './proxies/http-proxy/models/types.js';
|
||||
export { HttpProxyBridge as NetworkProxyBridge } from './proxies/smart-proxy/index.js';
|
||||
|
||||
// Certificate and Port80 modules have been removed - use SmartCertManager instead
|
||||
// Redirect module has been removed - use route-based redirects instead
|
||||
export { SharedRouteManager as HttpProxyRouteManager } from './core/routing/route-manager.js';
|
||||
|
||||
// Export SmartProxy elements selectively to avoid RouteManager ambiguity
|
||||
export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, HttpProxyBridge, RouteConnectionHandler, SmartCertManager } from './proxies/smart-proxy/index.js';
|
||||
export { RouteManager } from './proxies/smart-proxy/route-manager.js';
|
||||
export { SharedRouteManager as RouteManager } from './core/routing/route-manager.js';
|
||||
// Export smart-proxy models
|
||||
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js';
|
||||
export type { TSmartProxyCertProvisionObject } from './proxies/smart-proxy/models/interfaces.js';
|
||||
|
@ -4,11 +4,12 @@ import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import * as tls from 'tls';
|
||||
import * as url from 'url';
|
||||
import * as http2 from 'http2';
|
||||
|
||||
export { EventEmitter, fs, http, https, net, tls, url, http2 };
|
||||
export { EventEmitter, fs, http, https, net, path, tls, url, http2 };
|
||||
|
||||
// tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import { type IHttpProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './models/types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
|
||||
@ -17,6 +18,7 @@ export class CertificateManager {
|
||||
private certificateStoreDir: string;
|
||||
private logger: ILogger;
|
||||
private httpsServer: plugins.https.Server | null = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor(private options: IHttpProxyOptions) {
|
||||
this.certificateStoreDir = path.resolve(options.acme?.certificateStore || './certs');
|
||||
@ -24,6 +26,15 @@ export class CertificateManager {
|
||||
|
||||
this.logger.warn('CertificateManager is deprecated - use SmartCertManager instead');
|
||||
|
||||
// Initialize synchronously for backward compatibility but log warning
|
||||
this.initializeSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous initialization for backward compatibility
|
||||
* @deprecated This uses sync filesystem operations which block the event loop
|
||||
*/
|
||||
private initializeSync(): void {
|
||||
// Ensure certificate store directory exists
|
||||
try {
|
||||
if (!fs.existsSync(this.certificateStoreDir)) {
|
||||
@ -36,9 +47,28 @@ export class CertificateManager {
|
||||
|
||||
this.loadDefaultCertificates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Async initialization - preferred method
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Ensure certificate store directory exists
|
||||
try {
|
||||
await AsyncFileSystem.ensureDir(this.certificateStoreDir);
|
||||
this.logger.info(`Ensured certificate store directory: ${this.certificateStoreDir}`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to create certificate store directory: ${error}`);
|
||||
}
|
||||
|
||||
await this.loadDefaultCertificatesAsync();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads default certificates from the filesystem
|
||||
* @deprecated This uses sync filesystem operations which block the event loop
|
||||
*/
|
||||
public loadDefaultCertificates(): void {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@ -49,7 +79,28 @@ export class CertificateManager {
|
||||
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
||||
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
||||
};
|
||||
this.logger.info('Loaded default certificates from filesystem');
|
||||
this.logger.info('Loaded default certificates from filesystem (sync - deprecated)');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load default certificates: ${error}`);
|
||||
this.generateSelfSignedCertificate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads default certificates from the filesystem asynchronously
|
||||
*/
|
||||
public async loadDefaultCertificatesAsync(): Promise<void> {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
||||
|
||||
try {
|
||||
const [key, cert] = await Promise.all([
|
||||
AsyncFileSystem.readFile(path.join(certPath, 'key.pem')),
|
||||
AsyncFileSystem.readFile(path.join(certPath, 'cert.pem'))
|
||||
]);
|
||||
|
||||
this.defaultCertificates = { key, cert };
|
||||
this.logger.info('Loaded default certificates from filesystem (async)');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load default certificates: ${error}`);
|
||||
this.generateSelfSignedCertificate();
|
||||
|
@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { type IHttpProxyOptions, type IConnectionEntry, type ILogger, createLogger } from './models/types.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* Manages a pool of backend connections for efficient reuse
|
||||
@ -133,14 +134,7 @@ export class ConnectionPool {
|
||||
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
|
||||
connections.length > (this.options.connectionPoolSize || 50)) {
|
||||
|
||||
try {
|
||||
if (!connection.socket.destroyed) {
|
||||
connection.socket.end();
|
||||
connection.socket.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error destroying pooled connection to ${host}`, err);
|
||||
}
|
||||
cleanupSocket(connection.socket, `pool-${host}-idle`, { immediate: true }).catch(() => {});
|
||||
|
||||
connections.shift(); // Remove from pool
|
||||
removed++;
|
||||
@ -170,14 +164,7 @@ export class ConnectionPool {
|
||||
this.logger.debug(`Closing ${connections.length} connections to ${host}`);
|
||||
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
if (!connection.socket.destroyed) {
|
||||
connection.socket.end();
|
||||
connection.socket.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error closing connection to ${host}:`, error);
|
||||
}
|
||||
cleanupSocket(connection.socket, `pool-${host}-close`, { immediate: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,11 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
createLogger,
|
||||
RouteManager,
|
||||
convertLegacyConfigToRouteConfig
|
||||
} from './models/types.js';
|
||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import type {
|
||||
IHttpProxyOptions,
|
||||
ILogger,
|
||||
IReverseProxyConfig
|
||||
ILogger
|
||||
} from './models/types.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
||||
@ -16,8 +14,8 @@ import { CertificateManager } from './certificate-manager.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { RequestHandler, type IMetricsTracker } from './request-handler.js';
|
||||
import { WebSocketHandler } from './websocket-handler.js';
|
||||
import { ProxyRouter } from '../../routing/router/index.js';
|
||||
import { RouteRouter } from '../../routing/router/route-router.js';
|
||||
import { HttpRouter } from '../../routing/router/index.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
import { FunctionCache } from './function-cache.js';
|
||||
|
||||
/**
|
||||
@ -42,8 +40,7 @@ export class HttpProxy implements IMetricsTracker {
|
||||
private connectionPool: ConnectionPool;
|
||||
private requestHandler: RequestHandler;
|
||||
private webSocketHandler: WebSocketHandler;
|
||||
private legacyRouter = new ProxyRouter(); // Legacy router for backward compatibility
|
||||
private router = new RouteRouter(); // New modern router
|
||||
private router = new HttpRouter(); // Unified HTTP router
|
||||
private routeManager: RouteManager;
|
||||
private functionCache: FunctionCache;
|
||||
|
||||
@ -86,7 +83,6 @@ export class HttpProxy implements IMetricsTracker {
|
||||
// Defaults for SmartProxy integration
|
||||
connectionPoolSize: optionsArg.connectionPoolSize || 50,
|
||||
portProxyIntegration: optionsArg.portProxyIntegration || false,
|
||||
useExternalPort80Handler: optionsArg.useExternalPort80Handler || false,
|
||||
// Backend protocol (http1 or http2)
|
||||
backendProtocol: optionsArg.backendProtocol || 'http1',
|
||||
// Default ACME options
|
||||
@ -106,7 +102,11 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.logger = createLogger(this.options.logLevel);
|
||||
|
||||
// Initialize route manager
|
||||
this.routeManager = new RouteManager(this.logger);
|
||||
this.routeManager = new RouteManager({
|
||||
logger: this.logger,
|
||||
enableDetailedLogging: this.options.logLevel === 'debug',
|
||||
routes: []
|
||||
});
|
||||
|
||||
// Initialize function cache
|
||||
this.functionCache = new FunctionCache(this.logger, {
|
||||
@ -120,15 +120,13 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.requestHandler = new RequestHandler(
|
||||
this.options,
|
||||
this.connectionPool,
|
||||
this.legacyRouter, // Still use legacy router for backward compatibility
|
||||
this.routeManager,
|
||||
this.functionCache,
|
||||
this.router // Pass the new modern router as well
|
||||
this.router
|
||||
);
|
||||
this.webSocketHandler = new WebSocketHandler(
|
||||
this.options,
|
||||
this.connectionPool,
|
||||
this.legacyRouter,
|
||||
this.routes // Pass current routes to WebSocketHandler
|
||||
);
|
||||
|
||||
@ -428,65 +426,13 @@ export class HttpProxy implements IMetricsTracker {
|
||||
}
|
||||
}
|
||||
|
||||
// Create legacy proxy configs for the router
|
||||
// This is only needed for backward compatibility with ProxyRouter
|
||||
|
||||
const defaultPort = 443; // Default port for HTTPS when using 'preserve'
|
||||
// and will be removed in the future
|
||||
const legacyConfigs: IReverseProxyConfig[] = [];
|
||||
|
||||
for (const domain of currentHostnames) {
|
||||
// Find route for this domain
|
||||
const route = routes.find(r => {
|
||||
const domains = Array.isArray(r.match.domains) ? r.match.domains : [r.match.domains];
|
||||
return domains.includes(domain);
|
||||
});
|
||||
|
||||
if (!route || route.action.type !== 'forward' || !route.action.target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip routes with function-based targets - we'll handle them during request processing
|
||||
if (typeof route.action.target.host === 'function' || typeof route.action.target.port === 'function') {
|
||||
this.logger.info(`Domain ${domain} uses function-based targets - will be handled at request time`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract static target information
|
||||
const targetHosts = Array.isArray(route.action.target.host)
|
||||
? route.action.target.host
|
||||
: [route.action.target.host];
|
||||
|
||||
// Handle 'preserve' port value
|
||||
const targetPort = route.action.target.port === 'preserve' ? defaultPort : route.action.target.port;
|
||||
|
||||
// Get certificate information
|
||||
const certData = certificateUpdates.get(domain);
|
||||
const defaultCerts = this.certificateManager.getDefaultCertificates();
|
||||
|
||||
legacyConfigs.push({
|
||||
hostName: domain,
|
||||
destinationIps: targetHosts,
|
||||
destinationPorts: [targetPort],
|
||||
privateKey: certData?.key || defaultCerts.key,
|
||||
publicKey: certData?.cert || defaultCerts.cert
|
||||
});
|
||||
}
|
||||
|
||||
// Update the router with legacy configs
|
||||
// Handle both old and new router interfaces
|
||||
if (typeof this.router.setRoutes === 'function') {
|
||||
this.router.setRoutes(routes);
|
||||
} else if (typeof this.router.setNewProxyConfigs === 'function') {
|
||||
this.router.setNewProxyConfigs(legacyConfigs);
|
||||
} else {
|
||||
this.logger.warn('Router has no recognized configuration method');
|
||||
}
|
||||
// Update the router with new routes
|
||||
this.router.setRoutes(routes);
|
||||
|
||||
// Update WebSocket handler with new routes
|
||||
this.webSocketHandler.setRoutes(routes);
|
||||
|
||||
this.logger.info(`Route configuration updated with ${routes.length} routes and ${legacyConfigs.length} proxy configs`);
|
||||
this.logger.info(`Route configuration updated with ${routes.length} routes`);
|
||||
}
|
||||
|
||||
// Legacy methods have been removed.
|
||||
@ -519,13 +465,10 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.webSocketHandler.shutdown();
|
||||
|
||||
// Close all tracked sockets
|
||||
for (const socket of this.socketMap.getArray()) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (error) {
|
||||
this.logger.error('Error destroying socket', error);
|
||||
}
|
||||
}
|
||||
const socketCleanupPromises = this.socketMap.getArray().map(socket =>
|
||||
cleanupSocket(socket, 'http-proxy-stop', { immediate: true })
|
||||
);
|
||||
await Promise.all(socketCleanupPromises);
|
||||
|
||||
// Close all connection pool connections
|
||||
this.connectionPool.closeAllConnections();
|
||||
|
@ -13,7 +13,6 @@ export interface IAcmeOptions {
|
||||
skipConfiguredCerts?: boolean;
|
||||
}
|
||||
import type { IRouteConfig } from '../../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext } from '../../../core/models/route-context.js';
|
||||
|
||||
/**
|
||||
* Configuration options for HttpProxy
|
||||
@ -34,7 +33,6 @@ export interface IHttpProxyOptions {
|
||||
// Settings for SmartProxy integration
|
||||
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
|
||||
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by SmartProxy
|
||||
useExternalPort80Handler?: boolean; // @deprecated - use SmartCertManager instead
|
||||
// Protocol to use when proxying to backends: HTTP/1.x or HTTP/2
|
||||
backendProtocol?: 'http1' | 'http2';
|
||||
|
||||
@ -58,329 +56,7 @@ export interface ICertificateEntry {
|
||||
expires?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use IRouteConfig instead. This interface will be removed in a future release.
|
||||
*
|
||||
* IMPORTANT: This is a legacy interface maintained only for backward compatibility.
|
||||
* New code should use IRouteConfig for all configuration purposes.
|
||||
*
|
||||
* @see IRouteConfig for the modern, recommended configuration format
|
||||
*/
|
||||
export interface IReverseProxyConfig {
|
||||
/** Target hostnames/IPs to proxy requests to */
|
||||
destinationIps: string[];
|
||||
|
||||
/** Target ports to proxy requests to */
|
||||
destinationPorts: number[];
|
||||
|
||||
/** Hostname to match for routing */
|
||||
hostName: string;
|
||||
|
||||
/** SSL private key for this host (PEM format) */
|
||||
privateKey: string;
|
||||
|
||||
/** SSL public key/certificate for this host (PEM format) */
|
||||
publicKey: string;
|
||||
|
||||
/** Basic authentication configuration */
|
||||
authentication?: {
|
||||
type: 'Basic';
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
|
||||
/** Whether to rewrite the Host header to match the target */
|
||||
rewriteHostHeader?: boolean;
|
||||
|
||||
/**
|
||||
* Protocol to use when proxying to this backend: 'http1' or 'http2'.
|
||||
* Overrides the global backendProtocol option if set.
|
||||
*/
|
||||
backendProtocol?: 'http1' | 'http2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a legacy IReverseProxyConfig to the modern IRouteConfig format
|
||||
*
|
||||
* @deprecated This function is maintained for backward compatibility.
|
||||
* New code should create IRouteConfig objects directly.
|
||||
*
|
||||
* @param legacyConfig The legacy configuration to convert
|
||||
* @param proxyPort The port the proxy listens on
|
||||
* @returns A modern route configuration equivalent to the legacy config
|
||||
*/
|
||||
export function convertLegacyConfigToRouteConfig(
|
||||
legacyConfig: IReverseProxyConfig,
|
||||
proxyPort: number
|
||||
): IRouteConfig {
|
||||
// Create basic route configuration
|
||||
const routeConfig: IRouteConfig = {
|
||||
// Match properties
|
||||
match: {
|
||||
ports: proxyPort,
|
||||
domains: legacyConfig.hostName
|
||||
},
|
||||
|
||||
// Action properties
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: legacyConfig.destinationIps,
|
||||
port: legacyConfig.destinationPorts[0]
|
||||
},
|
||||
|
||||
// TLS mode is always 'terminate' for legacy configs
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: {
|
||||
key: legacyConfig.privateKey,
|
||||
cert: legacyConfig.publicKey
|
||||
}
|
||||
},
|
||||
|
||||
// Advanced options
|
||||
advanced: {
|
||||
// Rewrite host header if specified
|
||||
headers: legacyConfig.rewriteHostHeader ? { 'host': '{domain}' } : {}
|
||||
}
|
||||
},
|
||||
|
||||
// Metadata
|
||||
name: `Legacy Config - ${legacyConfig.hostName}`,
|
||||
priority: 0, // Default priority
|
||||
enabled: true
|
||||
};
|
||||
|
||||
// Add authentication if present
|
||||
if (legacyConfig.authentication) {
|
||||
routeConfig.security = {
|
||||
authentication: {
|
||||
type: 'basic',
|
||||
credentials: [{
|
||||
username: legacyConfig.authentication.user,
|
||||
password: legacyConfig.authentication.pass
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add backend protocol if specified
|
||||
if (legacyConfig.backendProtocol) {
|
||||
if (!routeConfig.action.options) {
|
||||
routeConfig.action.options = {};
|
||||
}
|
||||
routeConfig.action.options.backendProtocol = legacyConfig.backendProtocol;
|
||||
}
|
||||
|
||||
return routeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route manager for NetworkProxy
|
||||
* Handles route matching and configuration
|
||||
*/
|
||||
export class RouteManager {
|
||||
private routes: IRouteConfig[] = [];
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the routes configuration
|
||||
*/
|
||||
public updateRoutes(routes: IRouteConfig[]): void {
|
||||
// Sort routes by priority (higher first)
|
||||
this.routes = [...routes].sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
|
||||
this.logger.info(`Updated RouteManager with ${this.routes.length} routes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all routes
|
||||
*/
|
||||
public getRoutes(): IRouteConfig[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first matching route for a context
|
||||
*/
|
||||
public findMatchingRoute(context: IRouteContext): IRouteConfig | null {
|
||||
for (const route of this.routes) {
|
||||
if (this.matchesRoute(route, context)) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route matches the given context
|
||||
*/
|
||||
private matchesRoute(route: IRouteConfig, context: IRouteContext): boolean {
|
||||
// Skip disabled routes
|
||||
if (route.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check domain match if specified
|
||||
if (route.match.domains && context.domain) {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
if (!domains.some(domainPattern => this.matchDomain(domainPattern, context.domain!))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check path match if specified
|
||||
if (route.match.path && context.path) {
|
||||
if (!this.matchPath(route.match.path, context.path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check client IP match if specified
|
||||
if (route.match.clientIp && context.clientIp) {
|
||||
if (!route.match.clientIp.some(ip => this.matchIp(ip, context.clientIp))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check TLS version match if specified
|
||||
if (route.match.tlsVersion && context.tlsVersion) {
|
||||
if (!route.match.tlsVersion.includes(context.tlsVersion)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All criteria matched
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a domain pattern against a domain
|
||||
*/
|
||||
private matchDomain(pattern: string, domain: string): boolean {
|
||||
if (pattern === domain) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/\*/g, '.*');
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
return regex.test(domain);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path pattern against a path
|
||||
*/
|
||||
private matchPath(pattern: string, path: string): boolean {
|
||||
if (pattern === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.endsWith('*')) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP pattern against an IP
|
||||
* Supports exact matches, wildcard patterns, and CIDR notation
|
||||
*/
|
||||
private matchIp(pattern: string, ip: string): boolean {
|
||||
// Exact match
|
||||
if (pattern === ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard matching (e.g., 192.168.0.*)
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/\*/g, '.*');
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(ip);
|
||||
}
|
||||
|
||||
// CIDR matching (e.g., 192.168.0.0/24)
|
||||
if (pattern.includes('/')) {
|
||||
try {
|
||||
const [subnet, bits] = pattern.split('/');
|
||||
|
||||
// Convert IP addresses to numeric format for comparison
|
||||
const ipBinary = this.ipToBinary(ip);
|
||||
const subnetBinary = this.ipToBinary(subnet);
|
||||
|
||||
if (!ipBinary || !subnetBinary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the subnet mask from CIDR notation
|
||||
const mask = parseInt(bits, 10);
|
||||
if (isNaN(mask) || mask < 0 || mask > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the first 'mask' bits match between IP and subnet
|
||||
return ipBinary.slice(0, mask) === subnetBinary.slice(0, mask);
|
||||
} catch (error) {
|
||||
// If we encounter any error during CIDR matching, return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IP address to its binary representation
|
||||
* @param ip The IP address to convert
|
||||
* @returns Binary string representation or null if invalid
|
||||
*/
|
||||
private ipToBinary(ip: string): string | null {
|
||||
// Handle IPv4 addresses only for now
|
||||
const parts = ip.split('.');
|
||||
|
||||
// Validate IP format
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert each octet to 8-bit binary and concatenate
|
||||
try {
|
||||
return parts
|
||||
.map(part => {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num) || num < 0 || num > 255) {
|
||||
throw new Error('Invalid IP octet');
|
||||
}
|
||||
return num.toString(2).padStart(8, '0');
|
||||
})
|
||||
.join('');
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for connection tracking in the pool
|
||||
|
@ -4,11 +4,9 @@ import {
|
||||
type IHttpProxyOptions,
|
||||
type ILogger,
|
||||
createLogger,
|
||||
type IReverseProxyConfig,
|
||||
RouteManager
|
||||
} from './models/types.js';
|
||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { ProxyRouter } from '../../routing/router/index.js';
|
||||
import { ContextCreator } from './context-creator.js';
|
||||
import { HttpRequestHandler } from './http-request-handler.js';
|
||||
import { Http2RequestHandler } from './http2-request-handler.js';
|
||||
@ -48,10 +46,9 @@ export class RequestHandler {
|
||||
constructor(
|
||||
private options: IHttpProxyOptions,
|
||||
private connectionPool: ConnectionPool,
|
||||
private legacyRouter: ProxyRouter, // Legacy router for backward compatibility
|
||||
private routeManager?: RouteManager,
|
||||
private functionCache?: any, // FunctionCache - using any to avoid circular dependency
|
||||
private router?: any // RouteRouter - using any to avoid circular dependency
|
||||
private router?: any // HttpRouter - using any to avoid circular dependency
|
||||
) {
|
||||
this.logger = createLogger(options.logLevel || 'info');
|
||||
this.securityManager = new SecurityManager(this.logger);
|
||||
@ -373,7 +370,8 @@ export class RequestHandler {
|
||||
tlsVersion: req.socket.getTLSVersion?.() || undefined
|
||||
});
|
||||
|
||||
matchingRoute = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
||||
const matchResult = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
||||
matchingRoute = matchResult?.route || null;
|
||||
} catch (err) {
|
||||
this.logger.error('Error finding matching route', err);
|
||||
}
|
||||
@ -581,86 +579,11 @@ export class RequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Try modern router first, then fall back to legacy routing if needed
|
||||
if (this.router) {
|
||||
try {
|
||||
// Try to find a matching route using the modern router
|
||||
const route = this.router.routeReq(req);
|
||||
if (route && route.action.type === 'forward' && route.action.target) {
|
||||
// Handle this route similarly to RouteManager logic
|
||||
this.logger.debug(`Found matching route via modern router: ${route.name || 'unnamed'}`);
|
||||
|
||||
// No need to do anything here, we'll continue with legacy routing
|
||||
// The routeManager would have already found this route if applicable
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Error using modern router', err);
|
||||
// Continue with legacy routing
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy routing if no matching route found via RouteManager
|
||||
let proxyConfig: IReverseProxyConfig | undefined;
|
||||
try {
|
||||
proxyConfig = this.legacyRouter.routeReq(req);
|
||||
} catch (err) {
|
||||
this.logger.error('Error routing request with legacy router', err);
|
||||
res.statusCode = 500;
|
||||
res.end('Internal Server Error');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
if (!proxyConfig) {
|
||||
this.logger.warn(`No proxy configuration for host: ${req.headers.host}`);
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found: No proxy configuration for this host');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
// Determine protocol to backend (per-domain override or global)
|
||||
const backendProto = proxyConfig.backendProtocol || this.options.backendProtocol;
|
||||
if (backendProto === 'http2') {
|
||||
const destination = this.connectionPool.getNextTarget(
|
||||
proxyConfig.destinationIps,
|
||||
proxyConfig.destinationPorts[0]
|
||||
);
|
||||
const key = `${destination.host}:${destination.port}`;
|
||||
let session = this.h2Sessions.get(key);
|
||||
if (!session || session.closed || (session as any).destroyed) {
|
||||
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
|
||||
this.h2Sessions.set(key, session);
|
||||
session.on('error', () => this.h2Sessions.delete(key));
|
||||
session.on('close', () => this.h2Sessions.delete(key));
|
||||
}
|
||||
// Build headers for HTTP/2 request
|
||||
const hdrs: Record<string, any> = {
|
||||
':method': req.method,
|
||||
':path': req.url,
|
||||
':authority': `${destination.host}:${destination.port}`
|
||||
};
|
||||
for (const [hk, hv] of Object.entries(req.headers)) {
|
||||
if (typeof hv === 'string') hdrs[hk] = hv;
|
||||
}
|
||||
const h2Stream = session.request(hdrs);
|
||||
req.pipe(h2Stream);
|
||||
h2Stream.on('response', (hdrs2: any) => {
|
||||
const status = (hdrs2[':status'] as number) || 502;
|
||||
res.statusCode = status;
|
||||
// Copy headers from HTTP/2 response to HTTP/1 response
|
||||
for (const [hk, hv] of Object.entries(hdrs2)) {
|
||||
if (!hk.startsWith(':') && hv != null) {
|
||||
res.setHeader(hk, hv as string | string[]);
|
||||
}
|
||||
}
|
||||
h2Stream.pipe(res);
|
||||
});
|
||||
h2Stream.on('error', (err) => {
|
||||
res.statusCode = 502;
|
||||
res.end(`Bad Gateway: ${err.message}`);
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If no route was found, return 404
|
||||
this.logger.warn(`No route configuration for host: ${req.headers.host}`);
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found: No route configuration for this host');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -688,7 +611,8 @@ export class RequestHandler {
|
||||
let matchingRoute: IRouteConfig | null = null;
|
||||
if (this.routeManager) {
|
||||
try {
|
||||
matchingRoute = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
||||
const matchResult = this.routeManager.findMatchingRoute(toBaseContext(routeContext));
|
||||
matchingRoute = matchResult?.route || null;
|
||||
} catch (err) {
|
||||
this.logger.error('Error finding matching route for HTTP/2 request', err);
|
||||
}
|
||||
@ -812,104 +736,9 @@ export class RequestHandler {
|
||||
const method = headers[':method'] || 'GET';
|
||||
const path = headers[':path'] || '/';
|
||||
|
||||
// If configured to proxy to backends over HTTP/2, use HTTP/2 client sessions
|
||||
if (this.options.backendProtocol === 'http2') {
|
||||
const authority = headers[':authority'] as string || '';
|
||||
const host = authority.split(':')[0];
|
||||
const fakeReq: any = {
|
||||
headers: { host },
|
||||
method: headers[':method'],
|
||||
url: headers[':path'],
|
||||
socket: (stream.session as any).socket
|
||||
};
|
||||
// Try modern router first if available
|
||||
let route;
|
||||
if (this.router) {
|
||||
try {
|
||||
route = this.router.routeReq(fakeReq);
|
||||
if (route && route.action.type === 'forward' && route.action.target) {
|
||||
this.logger.debug(`Found matching HTTP/2 route via modern router: ${route.name || 'unnamed'}`);
|
||||
// The routeManager would have already found this route if applicable
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Error using modern router for HTTP/2', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy routing
|
||||
const proxyConfig = this.legacyRouter.routeReq(fakeReq);
|
||||
if (!proxyConfig) {
|
||||
stream.respond({ ':status': 404 });
|
||||
stream.end('Not Found');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
const destination = this.connectionPool.getNextTarget(proxyConfig.destinationIps, proxyConfig.destinationPorts[0]);
|
||||
|
||||
// Use the helper for HTTP/2 to HTTP/2 routing
|
||||
return Http2RequestHandler.handleHttp2WithHttp2Destination(
|
||||
stream,
|
||||
headers,
|
||||
destination,
|
||||
routeContext,
|
||||
this.h2Sessions,
|
||||
this.logger,
|
||||
this.metricsTracker
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine host for routing
|
||||
const authority = headers[':authority'] as string || '';
|
||||
const host = authority.split(':')[0];
|
||||
// Fake request object for routing
|
||||
const fakeReq: any = {
|
||||
headers: { host },
|
||||
method,
|
||||
url: path,
|
||||
socket: (stream.session as any).socket
|
||||
};
|
||||
// Try modern router first if available
|
||||
if (this.router) {
|
||||
try {
|
||||
const route = this.router.routeReq(fakeReq);
|
||||
if (route && route.action.type === 'forward' && route.action.target) {
|
||||
this.logger.debug(`Found matching HTTP/2 route via modern router: ${route.name || 'unnamed'}`);
|
||||
// The routeManager would have already found this route if applicable
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Error using modern router for HTTP/2', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy routing
|
||||
const proxyConfig = this.legacyRouter.routeReq(fakeReq as any);
|
||||
if (!proxyConfig) {
|
||||
stream.respond({ ':status': 404 });
|
||||
stream.end('Not Found');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
return;
|
||||
}
|
||||
|
||||
// Select backend target
|
||||
const destination = this.connectionPool.getNextTarget(
|
||||
proxyConfig.destinationIps,
|
||||
proxyConfig.destinationPorts[0]
|
||||
);
|
||||
|
||||
// Use the helper for HTTP/2 to HTTP/1 routing
|
||||
return Http2RequestHandler.handleHttp2WithHttp1Destination(
|
||||
stream,
|
||||
headers,
|
||||
destination,
|
||||
routeContext,
|
||||
this.logger,
|
||||
this.metricsTracker
|
||||
);
|
||||
} catch (err: any) {
|
||||
stream.respond({ ':status': 500 });
|
||||
stream.end('Internal Server Error');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
// No route was found
|
||||
stream.respond({ ':status': 404 });
|
||||
stream.end('Not Found: No route configuration for this request');
|
||||
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import '../../core/models/socket-augmentation.js';
|
||||
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger, type IReverseProxyConfig } from './models/types.js';
|
||||
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js';
|
||||
import { ConnectionPool } from './connection-pool.js';
|
||||
import { ProxyRouter, RouteRouter } from '../../routing/router/index.js';
|
||||
import { HttpRouter } from '../../routing/router/index.js';
|
||||
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import { toBaseContext } from '../../core/models/route-context.js';
|
||||
@ -19,21 +19,20 @@ export class WebSocketHandler {
|
||||
private wsServer: plugins.ws.WebSocketServer | null = null;
|
||||
private logger: ILogger;
|
||||
private contextCreator: ContextCreator = new ContextCreator();
|
||||
private routeRouter: RouteRouter | null = null;
|
||||
private router: HttpRouter | null = null;
|
||||
private securityManager: SecurityManager;
|
||||
|
||||
constructor(
|
||||
private options: IHttpProxyOptions,
|
||||
private connectionPool: ConnectionPool,
|
||||
private legacyRouter: ProxyRouter, // Legacy router for backward compatibility
|
||||
private routes: IRouteConfig[] = [] // Routes for modern router
|
||||
private routes: IRouteConfig[] = []
|
||||
) {
|
||||
this.logger = createLogger(options.logLevel || 'info');
|
||||
this.securityManager = new SecurityManager(this.logger, routes);
|
||||
|
||||
// Initialize modern router if we have routes
|
||||
// Initialize router if we have routes
|
||||
if (routes.length > 0) {
|
||||
this.routeRouter = new RouteRouter(routes, this.logger);
|
||||
this.router = new HttpRouter(routes, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,10 +43,10 @@ export class WebSocketHandler {
|
||||
this.routes = routes;
|
||||
|
||||
// Initialize or update the route router
|
||||
if (!this.routeRouter) {
|
||||
this.routeRouter = new RouteRouter(routes, this.logger);
|
||||
if (!this.router) {
|
||||
this.router = new HttpRouter(routes, this.logger);
|
||||
} else {
|
||||
this.routeRouter.setRoutes(routes);
|
||||
this.router.setRoutes(routes);
|
||||
}
|
||||
|
||||
// Update the security manager
|
||||
@ -139,8 +138,8 @@ export class WebSocketHandler {
|
||||
|
||||
// Try modern router first if available
|
||||
let route: IRouteConfig | undefined;
|
||||
if (this.routeRouter) {
|
||||
route = this.routeRouter.routeReq(req);
|
||||
if (this.router) {
|
||||
route = this.router.routeReq(req);
|
||||
}
|
||||
|
||||
// Define destination variables
|
||||
@ -227,20 +226,10 @@ export class WebSocketHandler {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fall back to legacy routing if no matching route found via modern router
|
||||
const proxyConfig = this.legacyRouter.routeReq(req);
|
||||
|
||||
if (!proxyConfig) {
|
||||
this.logger.warn(`No proxy configuration for WebSocket host: ${req.headers.host}`);
|
||||
wsIncoming.close(1008, 'No proxy configuration for this host');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get destination target using round-robin if multiple targets
|
||||
destination = this.connectionPool.getNextTarget(
|
||||
proxyConfig.destinationIps,
|
||||
proxyConfig.destinationPorts[0]
|
||||
);
|
||||
// No route found
|
||||
this.logger.warn(`No route configuration for WebSocket host: ${req.headers.host}`);
|
||||
wsIncoming.close(1008, 'No route configuration for this host');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build target URL with potential path rewriting
|
||||
|
@ -7,11 +7,12 @@ export { HttpProxy, CertificateManager, ConnectionPool, RequestHandler, WebSocke
|
||||
export type { IMetricsTracker, MetricsTracker } from './http-proxy/index.js';
|
||||
// Export http-proxy models except IAcmeOptions
|
||||
export type { IHttpProxyOptions, ICertificateEntry, ILogger } from './http-proxy/models/types.js';
|
||||
export { RouteManager as HttpProxyRouteManager } from './http-proxy/models/types.js';
|
||||
// RouteManager has been unified - use SharedRouteManager from core/routing
|
||||
export { SharedRouteManager as HttpProxyRouteManager } from '../core/routing/route-manager.js';
|
||||
|
||||
// Export SmartProxy with selective imports to avoid conflicts
|
||||
export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, HttpProxyBridge, RouteConnectionHandler } from './smart-proxy/index.js';
|
||||
export { RouteManager as SmartProxyRouteManager } from './smart-proxy/route-manager.js';
|
||||
export { SharedRouteManager as SmartProxyRouteManager } from '../core/routing/route-manager.js';
|
||||
export * from './smart-proxy/utils/index.js';
|
||||
// Export smart-proxy models except IAcmeOptions
|
||||
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './smart-proxy/models/index.js';
|
||||
|
@ -3,6 +3,8 @@ import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { delay } from '../../core/utils/async-utils.js';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import {
|
||||
NftBaseError,
|
||||
NftValidationError,
|
||||
@ -208,7 +210,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Wait before retry, unless it's the last attempt
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||
await delay(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -218,8 +220,13 @@ export class NfTablesProxy {
|
||||
|
||||
/**
|
||||
* Execute system command synchronously with multiple attempts
|
||||
* @deprecated This method blocks the event loop and should be avoided. Use executeWithRetry instead.
|
||||
* WARNING: This method contains a busy wait loop that will block the entire Node.js event loop!
|
||||
*/
|
||||
private executeWithRetrySync(command: string, maxRetries = 3, retryDelayMs = 1000): string {
|
||||
// Log deprecation warning
|
||||
console.warn('[DEPRECATION WARNING] executeWithRetrySync blocks the event loop and should not be used. Consider using the async executeWithRetry method instead.');
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
@ -231,10 +238,12 @@ export class NfTablesProxy {
|
||||
|
||||
// Wait before retry, unless it's the last attempt
|
||||
if (i < maxRetries - 1) {
|
||||
// A naive sleep in sync context
|
||||
// CRITICAL: This busy wait loop blocks the entire event loop!
|
||||
// This is a temporary fallback for sync contexts only.
|
||||
// TODO: Remove this method entirely and make all callers async
|
||||
const waitUntil = Date.now() + retryDelayMs;
|
||||
while (Date.now() < waitUntil) {
|
||||
// busy wait - not great, but this is a fallback method
|
||||
// Busy wait - blocks event loop
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -243,6 +252,26 @@ export class NfTablesProxy {
|
||||
throw new NftExecutionError(`Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute nftables commands with a temporary file
|
||||
* This helper handles the common pattern of writing rules to a temp file,
|
||||
* executing nftables with the file, and cleaning up
|
||||
*/
|
||||
private async executeWithTempFile(rulesetContent: string): Promise<void> {
|
||||
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
|
||||
|
||||
try {
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
} finally {
|
||||
// Always clean up the temp file
|
||||
await AsyncFileSystem.remove(this.tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if nftables is available and the required modules are loaded
|
||||
*/
|
||||
@ -545,15 +574,8 @@ export class NfTablesProxy {
|
||||
|
||||
// Only write and apply if we have rules to add
|
||||
if (rulesetContent) {
|
||||
// Write the ruleset to a temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added source IP filter rules for ${family}`);
|
||||
|
||||
@ -566,9 +588,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -663,13 +682,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the rules if we have any
|
||||
if (rulesetContent) {
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added advanced NAT rules for ${family}`);
|
||||
|
||||
@ -682,9 +695,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
@ -816,15 +826,8 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added port forwarding rules for ${family}`);
|
||||
|
||||
@ -837,9 +840,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -931,15 +931,7 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added port forwarding rules for ${family}`);
|
||||
|
||||
@ -952,9 +944,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -1027,15 +1016,8 @@ export class NfTablesProxy {
|
||||
|
||||
// Apply the ruleset if we have any rules
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
// Apply the ruleset using the helper
|
||||
await this.executeWithTempFile(rulesetContent);
|
||||
|
||||
this.log('info', `Added QoS rules for ${family}`);
|
||||
|
||||
@ -1048,9 +1030,6 @@ export class NfTablesProxy {
|
||||
await this.verifyRuleApplication(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -1615,25 +1594,27 @@ export class NfTablesProxy {
|
||||
// Apply the ruleset if we have any rules to delete
|
||||
if (rulesetContent) {
|
||||
// Write to temporary file
|
||||
fs.writeFileSync(this.tempFilePath, rulesetContent);
|
||||
await AsyncFileSystem.writeFile(this.tempFilePath, rulesetContent);
|
||||
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
|
||||
this.log('info', 'Removed all added rules');
|
||||
|
||||
// Mark all rules as removed
|
||||
this.rules.forEach(rule => {
|
||||
rule.added = false;
|
||||
rule.verified = false;
|
||||
});
|
||||
|
||||
// Remove temporary file
|
||||
fs.unlinkSync(this.tempFilePath);
|
||||
try {
|
||||
// Apply the ruleset
|
||||
await this.executeWithRetry(
|
||||
`${NfTablesProxy.NFT_CMD} -f ${this.tempFilePath}`,
|
||||
this.settings.maxRetries,
|
||||
this.settings.retryDelayMs
|
||||
);
|
||||
|
||||
this.log('info', 'Removed all added rules');
|
||||
|
||||
// Mark all rules as removed
|
||||
this.rules.forEach(rule => {
|
||||
rule.added = false;
|
||||
rule.verified = false;
|
||||
});
|
||||
} finally {
|
||||
// Remove temporary file
|
||||
await AsyncFileSystem.remove(this.tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up IP sets if we created any
|
||||
@ -1862,8 +1843,12 @@ export class NfTablesProxy {
|
||||
|
||||
/**
|
||||
* Synchronous version of cleanSlate
|
||||
* @deprecated This method blocks the event loop and should be avoided. Use cleanSlate() instead.
|
||||
* WARNING: This method uses execSync which blocks the entire Node.js event loop!
|
||||
*/
|
||||
public static cleanSlateSync(): void {
|
||||
console.warn('[DEPRECATION WARNING] cleanSlateSync blocks the event loop and should not be used. Consider using the async cleanSlate() method instead.');
|
||||
|
||||
try {
|
||||
// Check for rules with our comment pattern
|
||||
const stdout = execSync(`${NfTablesProxy.NFT_CMD} list ruleset`).toString();
|
||||
|
@ -1,36 +1,34 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { AsyncFileSystem } from '../../core/utils/fs-utils.js';
|
||||
import type { ICertificateData } from './certificate-manager.js';
|
||||
|
||||
export class CertStore {
|
||||
constructor(private certDir: string) {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await plugins.smartfile.fs.ensureDirSync(this.certDir);
|
||||
await AsyncFileSystem.ensureDir(this.certDir);
|
||||
}
|
||||
|
||||
public async getCertificate(routeName: string): Promise<ICertificateData | null> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
const metaPath = `${certPath}/meta.json`;
|
||||
|
||||
if (!await plugins.smartfile.fs.fileExistsSync(metaPath)) {
|
||||
if (!await AsyncFileSystem.exists(metaPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const metaFile = await plugins.smartfile.SmartFile.fromFilePath(metaPath);
|
||||
const meta = JSON.parse(metaFile.contents.toString());
|
||||
const meta = await AsyncFileSystem.readJSON(metaPath);
|
||||
|
||||
const certFile = await plugins.smartfile.SmartFile.fromFilePath(`${certPath}/cert.pem`);
|
||||
const cert = certFile.contents.toString();
|
||||
|
||||
const keyFile = await plugins.smartfile.SmartFile.fromFilePath(`${certPath}/key.pem`);
|
||||
const key = keyFile.contents.toString();
|
||||
const [cert, key] = await Promise.all([
|
||||
AsyncFileSystem.readFile(`${certPath}/cert.pem`),
|
||||
AsyncFileSystem.readFile(`${certPath}/key.pem`)
|
||||
]);
|
||||
|
||||
let ca: string | undefined;
|
||||
const caPath = `${certPath}/ca.pem`;
|
||||
if (await plugins.smartfile.fs.fileExistsSync(caPath)) {
|
||||
const caFile = await plugins.smartfile.SmartFile.fromFilePath(caPath);
|
||||
ca = caFile.contents.toString();
|
||||
if (await AsyncFileSystem.exists(caPath)) {
|
||||
ca = await AsyncFileSystem.readFile(caPath);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -51,14 +49,18 @@ export class CertStore {
|
||||
certData: ICertificateData
|
||||
): Promise<void> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
await plugins.smartfile.fs.ensureDirSync(certPath);
|
||||
await AsyncFileSystem.ensureDir(certPath);
|
||||
|
||||
// Save certificate files
|
||||
await plugins.smartfile.memory.toFs(certData.cert, `${certPath}/cert.pem`);
|
||||
await plugins.smartfile.memory.toFs(certData.key, `${certPath}/key.pem`);
|
||||
// Save certificate files in parallel
|
||||
const savePromises = [
|
||||
AsyncFileSystem.writeFile(`${certPath}/cert.pem`, certData.cert),
|
||||
AsyncFileSystem.writeFile(`${certPath}/key.pem`, certData.key)
|
||||
];
|
||||
|
||||
if (certData.ca) {
|
||||
await plugins.smartfile.memory.toFs(certData.ca, `${certPath}/ca.pem`);
|
||||
savePromises.push(
|
||||
AsyncFileSystem.writeFile(`${certPath}/ca.pem`, certData.ca)
|
||||
);
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
@ -68,13 +70,17 @@ export class CertStore {
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
await plugins.smartfile.memory.toFs(JSON.stringify(meta, null, 2), `${certPath}/meta.json`);
|
||||
savePromises.push(
|
||||
AsyncFileSystem.writeJSON(`${certPath}/meta.json`, meta)
|
||||
);
|
||||
|
||||
await Promise.all(savePromises);
|
||||
}
|
||||
|
||||
public async deleteCertificate(routeName: string): Promise<void> {
|
||||
const certPath = this.getCertPath(routeName);
|
||||
if (await plugins.smartfile.fs.fileExistsSync(certPath)) {
|
||||
await plugins.smartfile.fs.removeManySync([certPath]);
|
||||
if (await AsyncFileSystem.isDirectory(certPath)) {
|
||||
await AsyncFileSystem.removeDir(certPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,22 +3,46 @@ import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.
|
||||
import { SecurityManager } from './security-manager.js';
|
||||
import { TimeoutManager } from './timeout-manager.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
|
||||
/**
|
||||
* Manages connection lifecycle, tracking, and cleanup
|
||||
* Manages connection lifecycle, tracking, and cleanup with performance optimizations
|
||||
*/
|
||||
export class ConnectionManager {
|
||||
export class ConnectionManager extends LifecycleComponent {
|
||||
private connectionRecords: Map<string, IConnectionRecord> = new Map();
|
||||
private terminationStats: {
|
||||
incoming: Record<string, number>;
|
||||
outgoing: Record<string, number>;
|
||||
} = { incoming: {}, outgoing: {} };
|
||||
|
||||
// Performance optimization: Track connections needing inactivity check
|
||||
private nextInactivityCheck: Map<string, number> = new Map();
|
||||
|
||||
// Connection limits
|
||||
private readonly maxConnections: number;
|
||||
private readonly cleanupBatchSize: number = 100;
|
||||
|
||||
// Cleanup queue for batched processing
|
||||
private cleanupQueue: Set<string> = new Set();
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private settings: ISmartProxyOptions,
|
||||
private securityManager: SecurityManager,
|
||||
private timeoutManager: TimeoutManager
|
||||
) {}
|
||||
) {
|
||||
super();
|
||||
|
||||
// Set reasonable defaults for connection limits
|
||||
this.maxConnections = settings.defaults?.security?.maxConnections || 10000;
|
||||
|
||||
// Start inactivity check timer if not disabled
|
||||
if (!settings.disableInactivityCheck) {
|
||||
this.startInactivityCheckTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique connection ID
|
||||
@ -30,18 +54,31 @@ export class ConnectionManager {
|
||||
|
||||
/**
|
||||
* Create and track a new connection
|
||||
* Accepts either a regular net.Socket or a WrappedSocket for transparent PROXY protocol support
|
||||
*/
|
||||
public createConnection(socket: plugins.net.Socket): IConnectionRecord {
|
||||
public createConnection(socket: plugins.net.Socket | WrappedSocket): IConnectionRecord | null {
|
||||
// Enforce connection limit
|
||||
if (this.connectionRecords.size >= this.maxConnections) {
|
||||
logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, {
|
||||
currentConnections: this.connectionRecords.size,
|
||||
maxConnections: this.maxConnections,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
socket.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionId = this.generateConnectionId();
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
const localPort = socket.localPort || 0;
|
||||
const now = Date.now();
|
||||
|
||||
const record: IConnectionRecord = {
|
||||
id: connectionId,
|
||||
incoming: socket,
|
||||
outgoing: null,
|
||||
incomingStartTime: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
incomingStartTime: now,
|
||||
lastActivity: now,
|
||||
connectionClosed: false,
|
||||
pendingData: [],
|
||||
pendingDataSize: 0,
|
||||
@ -70,6 +107,42 @@ export class ConnectionManager {
|
||||
public trackConnection(connectionId: string, record: IConnectionRecord): void {
|
||||
this.connectionRecords.set(connectionId, record);
|
||||
this.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
|
||||
|
||||
// Schedule inactivity check
|
||||
if (!this.settings.disableInactivityCheck) {
|
||||
this.scheduleInactivityCheck(connectionId, record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next inactivity check for a connection
|
||||
*/
|
||||
private scheduleInactivityCheck(connectionId: string, record: IConnectionRecord): void {
|
||||
let timeout = this.settings.inactivityTimeout!;
|
||||
|
||||
if (record.hasKeepAlive) {
|
||||
if (this.settings.keepAliveTreatment === 'immortal') {
|
||||
// Don't schedule check for immortal connections
|
||||
return;
|
||||
} else if (this.settings.keepAliveTreatment === 'extended') {
|
||||
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
|
||||
timeout = timeout * multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const checkTime = Date.now() + timeout;
|
||||
this.nextInactivityCheck.set(connectionId, checkTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the inactivity check timer
|
||||
*/
|
||||
private startInactivityCheckTimer(): void {
|
||||
// Check every 30 seconds for connections that need inactivity check
|
||||
this.setInterval(() => {
|
||||
this.performOptimizedInactivityCheck();
|
||||
}, 30000);
|
||||
// Note: LifecycleComponent's setInterval already calls unref()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -98,18 +171,65 @@ export class ConnectionManager {
|
||||
*/
|
||||
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection cleanup initiated`, { connectionId: record.id, remoteIP: record.remoteIP, reason, component: 'connection-manager' });
|
||||
logger.log('info', `Connection cleanup initiated`, {
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
reason,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
record.incomingTerminationReason === null ||
|
||||
record.incomingTerminationReason === undefined
|
||||
) {
|
||||
if (record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
}
|
||||
|
||||
this.cleanupConnection(record, reason);
|
||||
// Add to cleanup queue for batched processing
|
||||
this.queueCleanup(record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a connection for cleanup
|
||||
*/
|
||||
private queueCleanup(connectionId: string): void {
|
||||
this.cleanupQueue.add(connectionId);
|
||||
|
||||
// Process immediately if queue is getting large
|
||||
if (this.cleanupQueue.size >= this.cleanupBatchSize) {
|
||||
this.processCleanupQueue();
|
||||
} else if (!this.cleanupTimer) {
|
||||
// Otherwise, schedule batch processing
|
||||
this.cleanupTimer = this.setTimeout(() => {
|
||||
this.processCleanupQueue();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the cleanup queue in batches
|
||||
*/
|
||||
private processCleanupQueue(): void {
|
||||
if (this.cleanupTimer) {
|
||||
this.clearTimeout(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize);
|
||||
this.cleanupQueue.clear();
|
||||
|
||||
for (const connectionId of toCleanup) {
|
||||
const record = this.connectionRecords.get(connectionId);
|
||||
if (record) {
|
||||
this.cleanupConnection(record, record.incomingTerminationReason || 'normal');
|
||||
}
|
||||
}
|
||||
|
||||
// If there are more in queue, schedule next batch
|
||||
if (this.cleanupQueue.size > 0) {
|
||||
this.cleanupTimer = this.setTimeout(() => {
|
||||
this.processCleanupQueue();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,6 +239,9 @@ export class ConnectionManager {
|
||||
if (!record.connectionClosed) {
|
||||
record.connectionClosed = true;
|
||||
|
||||
// Remove from inactivity check
|
||||
this.nextInactivityCheck.delete(record.id);
|
||||
|
||||
// Track connection termination
|
||||
this.securityManager.removeConnectionByIP(record.remoteIP, record.id);
|
||||
|
||||
@ -127,30 +250,71 @@ export class ConnectionManager {
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// Detailed logging data
|
||||
// Calculate metrics once
|
||||
const duration = Date.now() - record.incomingStartTime;
|
||||
const bytesReceived = record.bytesReceived;
|
||||
const bytesSent = record.bytesSent;
|
||||
const logData = {
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
localPort: record.localPort,
|
||||
reason,
|
||||
duration: plugins.prettyMs(duration),
|
||||
bytes: { in: record.bytesReceived, out: record.bytesSent },
|
||||
tls: record.isTLS,
|
||||
keepAlive: record.hasKeepAlive,
|
||||
usingNetworkProxy: record.usingNetworkProxy,
|
||||
domainSwitches: record.domainSwitches || 0,
|
||||
component: 'connection-manager'
|
||||
};
|
||||
|
||||
// Remove all data handlers to make sure we clean up properly
|
||||
if (record.incoming) {
|
||||
try {
|
||||
// Remove our safe data handler
|
||||
record.incoming.removeAllListeners('data');
|
||||
// Reset the handler references
|
||||
record.renegotiationHandler = undefined;
|
||||
} catch (err) {
|
||||
logger.log('error', `Error removing data handlers for connection ${record.id}: ${err}`, { connectionId: record.id, error: err, component: 'connection-manager' });
|
||||
logger.log('error', `Error removing data handlers: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming socket
|
||||
this.cleanupSocket(record, 'incoming', record.incoming);
|
||||
// Handle socket cleanup - check if sockets are still active
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
|
||||
// Handle outgoing socket
|
||||
if (record.outgoing) {
|
||||
this.cleanupSocket(record, 'outgoing', record.outgoing);
|
||||
if (record.incoming) {
|
||||
// Extract underlying socket if it's a WrappedSocket
|
||||
const incomingSocket = record.incoming instanceof WrappedSocket ? record.incoming.socket : record.incoming;
|
||||
if (!record.incoming.writable || record.incoming.destroyed) {
|
||||
// Socket is not active, clean up immediately
|
||||
cleanupPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming`, { immediate: true }));
|
||||
} else {
|
||||
// Socket is still active, allow graceful cleanup
|
||||
cleanupPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming`, { allowDrain: true, gracePeriod: 5000 }));
|
||||
}
|
||||
}
|
||||
|
||||
if (record.outgoing) {
|
||||
// Extract underlying socket if it's a WrappedSocket
|
||||
const outgoingSocket = record.outgoing instanceof WrappedSocket ? record.outgoing.socket : record.outgoing;
|
||||
if (!record.outgoing.writable || record.outgoing.destroyed) {
|
||||
// Socket is not active, clean up immediately
|
||||
cleanupPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing`, { immediate: true }));
|
||||
} else {
|
||||
// Socket is still active, allow graceful cleanup
|
||||
cleanupPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing`, { allowDrain: true, gracePeriod: 5000 }));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for cleanup to complete
|
||||
Promise.all(cleanupPromises).catch(err => {
|
||||
logger.log('error', `Error during socket cleanup: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
});
|
||||
|
||||
// Clear pendingData to avoid memory leaks
|
||||
record.pendingData = [];
|
||||
@ -162,28 +326,13 @@ export class ConnectionManager {
|
||||
// Log connection details
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info',
|
||||
`Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}). ` +
|
||||
`Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
|
||||
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` +
|
||||
`${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` +
|
||||
`${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}`,
|
||||
{
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
localPort: record.localPort,
|
||||
reason,
|
||||
duration: plugins.prettyMs(duration),
|
||||
bytes: { in: bytesReceived, out: bytesSent },
|
||||
tls: record.isTLS,
|
||||
keepAlive: record.hasKeepAlive,
|
||||
usingNetworkProxy: record.usingNetworkProxy,
|
||||
domainSwitches: record.domainSwitches || 0,
|
||||
component: 'connection-manager'
|
||||
}
|
||||
`Connection terminated: ${record.remoteIP}:${record.localPort} (${reason}) - ` +
|
||||
`${plugins.prettyMs(duration)}, IN: ${record.bytesReceived}B, OUT: ${record.bytesSent}B`,
|
||||
logData
|
||||
);
|
||||
} else {
|
||||
logger.log('info',
|
||||
`Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`,
|
||||
`Connection terminated: ${record.remoteIP} (${reason}). Active: ${this.connectionRecords.size}`,
|
||||
{
|
||||
connectionId: record.id,
|
||||
remoteIP: record.remoteIP,
|
||||
@ -196,40 +345,6 @@ export class ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to clean up a socket
|
||||
*/
|
||||
private cleanupSocket(record: IConnectionRecord, side: 'incoming' | 'outgoing', socket: plugins.net.Socket): void {
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
// Try graceful shutdown first, then force destroy after a short timeout
|
||||
socket.end();
|
||||
const socketTimeout = setTimeout(() => {
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error destroying ${side} socket for connection ${record.id}: ${err}`, { connectionId: record.id, side, error: err, component: 'connection-manager' });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Ensure the timeout doesn't block Node from exiting
|
||||
if (socketTimeout.unref) {
|
||||
socketTimeout.unref();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error closing ${side} socket for connection ${record.id}: ${err}`, { connectionId: record.id, side, error: err, component: 'connection-manager' });
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (destroyErr) {
|
||||
logger.log('error', `Error destroying ${side} socket for connection ${record.id}: ${destroyErr}`, { connectionId: record.id, side, error: destroyErr, component: 'connection-manager' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a generic error handler for incoming or outgoing sockets
|
||||
@ -238,49 +353,44 @@ export class ConnectionManager {
|
||||
return (err: Error) => {
|
||||
const code = (err as any).code;
|
||||
let reason = 'error';
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const connectionDuration = now - record.incomingStartTime;
|
||||
const lastActivityAge = now - record.lastActivity;
|
||||
|
||||
if (code === 'ECONNRESET') {
|
||||
reason = 'econnreset';
|
||||
logger.log('warn', `ECONNRESET on ${side} connection from ${record.remoteIP}. Error: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)}`, {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
} else if (code === 'ETIMEDOUT') {
|
||||
reason = 'etimedout';
|
||||
logger.log('warn', `ETIMEDOUT on ${side} connection from ${record.remoteIP}. Error: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)}`, {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
} else {
|
||||
logger.log('error', `Error on ${side} connection from ${record.remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)}`, {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
// Update activity tracking
|
||||
if (side === 'incoming') {
|
||||
record.lastActivity = now;
|
||||
this.scheduleInactivityCheck(record.id, record);
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason === null) {
|
||||
const errorData = {
|
||||
connectionId: record.id,
|
||||
side,
|
||||
remoteIP: record.remoteIP,
|
||||
error: err.message,
|
||||
duration: plugins.prettyMs(connectionDuration),
|
||||
lastActivity: plugins.prettyMs(lastActivityAge),
|
||||
component: 'connection-manager'
|
||||
};
|
||||
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
reason = 'econnreset';
|
||||
logger.log('warn', `ECONNRESET on ${side}: ${record.remoteIP}`, errorData);
|
||||
break;
|
||||
case 'ETIMEDOUT':
|
||||
reason = 'etimedout';
|
||||
logger.log('warn', `ETIMEDOUT on ${side}: ${record.remoteIP}`, errorData);
|
||||
break;
|
||||
default:
|
||||
logger.log('error', `Error on ${side}: ${record.remoteIP} - ${err.message}`, errorData);
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason == null) {
|
||||
record.outgoingTerminationReason = reason;
|
||||
this.incrementTerminationStat('outgoing', reason);
|
||||
}
|
||||
@ -303,13 +413,12 @@ export class ConnectionManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (side === 'incoming' && record.incomingTerminationReason === null) {
|
||||
if (side === 'incoming' && record.incomingTerminationReason == null) {
|
||||
record.incomingTerminationReason = 'normal';
|
||||
this.incrementTerminationStat('incoming', 'normal');
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
|
||||
} else if (side === 'outgoing' && record.outgoingTerminationReason == null) {
|
||||
record.outgoingTerminationReason = 'normal';
|
||||
this.incrementTerminationStat('outgoing', 'normal');
|
||||
// Record the time when outgoing socket closed.
|
||||
record.outgoingClosedTime = Date.now();
|
||||
}
|
||||
|
||||
@ -332,26 +441,29 @@ export class ConnectionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for stalled/inactive connections
|
||||
* Optimized inactivity check - only checks connections that are due
|
||||
*/
|
||||
public performInactivityCheck(): void {
|
||||
private performOptimizedInactivityCheck(): void {
|
||||
const now = Date.now();
|
||||
const connectionIds = [...this.connectionRecords.keys()];
|
||||
const connectionsToCheck: string[] = [];
|
||||
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (!record) continue;
|
||||
|
||||
// Skip inactivity check if disabled or for immortal keep-alive connections
|
||||
if (
|
||||
this.settings.disableInactivityCheck ||
|
||||
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
|
||||
) {
|
||||
// Find connections that need checking
|
||||
for (const [connectionId, checkTime] of this.nextInactivityCheck) {
|
||||
if (checkTime <= now) {
|
||||
connectionsToCheck.push(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Process only connections that need checking
|
||||
for (const connectionId of connectionsToCheck) {
|
||||
const record = this.connectionRecords.get(connectionId);
|
||||
if (!record || record.connectionClosed) {
|
||||
this.nextInactivityCheck.delete(connectionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const inactivityTime = now - record.lastActivity;
|
||||
|
||||
|
||||
// Use extended timeout for extended-treatment keep-alive connections
|
||||
let effectiveTimeout = this.settings.inactivityTimeout!;
|
||||
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
|
||||
@ -359,37 +471,37 @@ export class ConnectionManager {
|
||||
effectiveTimeout = effectiveTimeout * multiplier;
|
||||
}
|
||||
|
||||
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
|
||||
if (inactivityTime > effectiveTimeout) {
|
||||
// For keep-alive connections, issue a warning first
|
||||
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
|
||||
logger.log('warn', `Keep-alive connection ${id} from ${record.remoteIP} inactive for ${plugins.prettyMs(inactivityTime)}. Will close in 10 minutes if no activity.`, {
|
||||
connectionId: id,
|
||||
logger.log('warn', `Keep-alive connection inactive: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
inactiveFor: plugins.prettyMs(inactivityTime),
|
||||
closureWarning: '10 minutes',
|
||||
component: 'connection-manager'
|
||||
});
|
||||
|
||||
// Set warning flag and add grace period
|
||||
record.inactivityWarningIssued = true;
|
||||
record.lastActivity = now - (effectiveTimeout - 600000);
|
||||
|
||||
// Reschedule check for 10 minutes later
|
||||
this.nextInactivityCheck.set(connectionId, now + 600000);
|
||||
|
||||
// Try to stimulate activity with a probe packet
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
try {
|
||||
record.outgoing.write(Buffer.alloc(0));
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Sent probe packet to test keep-alive connection ${id}`, { connectionId: id, component: 'connection-manager' });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error sending probe packet to connection ${id}: ${err}`, { connectionId: id, error: err, component: 'connection-manager' });
|
||||
logger.log('error', `Error sending probe packet: ${err}`, {
|
||||
connectionId,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-keep-alive or after warning, close the connection
|
||||
logger.log('warn', `Closing inactive connection ${id} from ${record.remoteIP} (inactive for ${plugins.prettyMs(inactivityTime)}, keep-alive: ${record.hasKeepAlive ? 'Yes' : 'No'})`, {
|
||||
connectionId: id,
|
||||
// Close the connection
|
||||
logger.log('warn', `Closing inactive connection: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
inactiveFor: plugins.prettyMs(inactivityTime),
|
||||
hasKeepAlive: record.hasKeepAlive,
|
||||
@ -397,97 +509,108 @@ export class ConnectionManager {
|
||||
});
|
||||
this.cleanupConnection(record, 'inactivity');
|
||||
}
|
||||
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
|
||||
// If activity detected after warning, clear the warning
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection ${id} activity detected after inactivity warning`, {
|
||||
connectionId: id,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
record.inactivityWarningIssued = false;
|
||||
} else {
|
||||
// Reschedule next check
|
||||
this.scheduleInactivityCheck(connectionId, record);
|
||||
}
|
||||
|
||||
// Parity check: if outgoing socket closed and incoming remains active
|
||||
// Increased from 2 minutes to 30 minutes for long-lived connections
|
||||
if (
|
||||
record.outgoingClosedTime &&
|
||||
!record.incoming.destroyed &&
|
||||
!record.connectionClosed &&
|
||||
now - record.outgoingClosedTime > 120000
|
||||
now - record.outgoingClosedTime > 1800000 // 30 minutes
|
||||
) {
|
||||
logger.log('warn', `Parity check: Connection ${id} from ${record.remoteIP} has incoming socket still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing socket closed`, {
|
||||
connectionId: id,
|
||||
remoteIP: record.remoteIP,
|
||||
timeElapsed: plugins.prettyMs(now - record.outgoingClosedTime),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
this.cleanupConnection(record, 'parity_check');
|
||||
// Only close if no data activity for 10 minutes
|
||||
if (now - record.lastActivity > 600000) {
|
||||
logger.log('warn', `Parity check failed after extended timeout: ${record.remoteIP}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
timeElapsed: plugins.prettyMs(now - record.outgoingClosedTime),
|
||||
inactiveFor: plugins.prettyMs(now - record.lastActivity),
|
||||
component: 'connection-manager'
|
||||
});
|
||||
this.cleanupConnection(record, 'parity_check');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility
|
||||
*/
|
||||
public performInactivityCheck(): void {
|
||||
this.performOptimizedInactivityCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all connections (for shutdown)
|
||||
*/
|
||||
public clearConnections(): void {
|
||||
// Create a copy of the keys to avoid modification during iteration
|
||||
const connectionIds = [...this.connectionRecords.keys()];
|
||||
public async clearConnections(): Promise<void> {
|
||||
// Delegate to LifecycleComponent's cleanup
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override LifecycleComponent's onCleanup method
|
||||
*/
|
||||
protected async onCleanup(): Promise<void> {
|
||||
|
||||
// First pass: End all connections gracefully
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (record) {
|
||||
// Process connections in batches to avoid blocking
|
||||
const connections = Array.from(this.connectionRecords.values());
|
||||
const batchSize = 100;
|
||||
let index = 0;
|
||||
|
||||
const processBatch = () => {
|
||||
const batch = connections.slice(index, index + batchSize);
|
||||
|
||||
for (const record of batch) {
|
||||
try {
|
||||
// Clear any timers
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// End sockets gracefully
|
||||
if (record.incoming && !record.incoming.destroyed) {
|
||||
record.incoming.end();
|
||||
// Immediate destruction using socket-utils
|
||||
const shutdownPromises: Promise<void>[] = [];
|
||||
|
||||
if (record.incoming) {
|
||||
const incomingSocket = record.incoming instanceof WrappedSocket ? record.incoming.socket : record.incoming;
|
||||
shutdownPromises.push(cleanupSocket(incomingSocket, `${record.id}-incoming-shutdown`, { immediate: true }));
|
||||
}
|
||||
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
record.outgoing.end();
|
||||
if (record.outgoing) {
|
||||
const outgoingSocket = record.outgoing instanceof WrappedSocket ? record.outgoing.socket : record.outgoing;
|
||||
shutdownPromises.push(cleanupSocket(outgoingSocket, `${record.id}-outgoing-shutdown`, { immediate: true }));
|
||||
}
|
||||
|
||||
// Don't wait for shutdown cleanup in this batch processing
|
||||
Promise.all(shutdownPromises).catch(() => {});
|
||||
} catch (err) {
|
||||
logger.log('error', `Error during graceful end of connection ${id}: ${err}`, { connectionId: id, error: err, component: 'connection-manager' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Short delay to allow graceful ends to process
|
||||
setTimeout(() => {
|
||||
// Second pass: Force destroy everything
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (record) {
|
||||
try {
|
||||
// Remove all listeners to prevent memory leaks
|
||||
if (record.incoming) {
|
||||
record.incoming.removeAllListeners();
|
||||
if (!record.incoming.destroyed) {
|
||||
record.incoming.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
if (record.outgoing) {
|
||||
record.outgoing.removeAllListeners();
|
||||
if (!record.outgoing.destroyed) {
|
||||
record.outgoing.destroy();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `Error during forced destruction of connection ${id}: ${err}`, { connectionId: id, error: err, component: 'connection-manager' });
|
||||
}
|
||||
logger.log('error', `Error during connection cleanup: ${err}`, {
|
||||
connectionId: record.id,
|
||||
error: err,
|
||||
component: 'connection-manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all maps
|
||||
this.connectionRecords.clear();
|
||||
this.terminationStats = { incoming: {}, outgoing: {} };
|
||||
}, 100);
|
||||
index += batchSize;
|
||||
|
||||
// Continue with next batch if needed
|
||||
if (index < connections.length) {
|
||||
setImmediate(processBatch);
|
||||
} else {
|
||||
// Clear all maps
|
||||
this.connectionRecords.clear();
|
||||
this.nextInactivityCheck.clear();
|
||||
this.cleanupQueue.clear();
|
||||
this.terminationStats = { incoming: {}, outgoing: {} };
|
||||
}
|
||||
};
|
||||
|
||||
// Start batch processing
|
||||
setImmediate(processBatch);
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { HttpProxy } from '../http-proxy/index.js';
|
||||
import { setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
||||
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
|
||||
import type { IRouteConfig } from './models/route-types.js';
|
||||
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
|
||||
export class HttpProxyBridge {
|
||||
private httpProxy: HttpProxy | null = null;
|
||||
@ -97,7 +99,7 @@ export class HttpProxyBridge {
|
||||
*/
|
||||
public async forwardToHttpProxy(
|
||||
connectionId: string,
|
||||
socket: plugins.net.Socket,
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
initialChunk: Buffer,
|
||||
httpProxyPort: number,
|
||||
@ -123,22 +125,28 @@ export class HttpProxyBridge {
|
||||
proxySocket.write(initialChunk);
|
||||
}
|
||||
|
||||
// Pipe the sockets together
|
||||
socket.pipe(proxySocket);
|
||||
proxySocket.pipe(socket);
|
||||
// Use centralized bidirectional forwarding
|
||||
// Extract underlying socket if it's a WrappedSocket
|
||||
const underlyingSocket = socket instanceof WrappedSocket ? socket.socket : socket;
|
||||
|
||||
// Handle cleanup
|
||||
const cleanup = (reason: string) => {
|
||||
socket.unpipe(proxySocket);
|
||||
proxySocket.unpipe(socket);
|
||||
proxySocket.destroy();
|
||||
cleanupCallback(reason);
|
||||
};
|
||||
|
||||
socket.on('end', () => cleanup('socket_end'));
|
||||
socket.on('error', () => cleanup('socket_error'));
|
||||
proxySocket.on('end', () => cleanup('proxy_end'));
|
||||
proxySocket.on('error', () => cleanup('proxy_error'));
|
||||
setupBidirectionalForwarding(underlyingSocket, proxySocket, {
|
||||
onClientData: (chunk) => {
|
||||
// Update stats if needed
|
||||
if (record) {
|
||||
record.bytesReceived += chunk.length;
|
||||
}
|
||||
},
|
||||
onServerData: (chunk) => {
|
||||
// Update stats if needed
|
||||
if (record) {
|
||||
record.bytesSent += chunk.length;
|
||||
}
|
||||
},
|
||||
onCleanup: (reason) => {
|
||||
cleanupCallback(reason);
|
||||
},
|
||||
enableHalfOpen: false // Close both when one closes (required for proxy chains)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -17,7 +17,7 @@ export { TlsManager } from './tls-manager.js';
|
||||
export { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
|
||||
// Export route-based components
|
||||
export { RouteManager } from './route-manager.js';
|
||||
export { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
export { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
export { NFTablesManager } from './nftables-manager.js';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { WrappedSocket } from '../../../core/models/wrapped-socket.js';
|
||||
// Certificate types removed - define IAcmeOptions locally
|
||||
export interface IAcmeOptions {
|
||||
enabled?: boolean;
|
||||
@ -34,6 +35,11 @@ export interface ISmartProxyOptions {
|
||||
// Port configuration
|
||||
preserveSourceIP?: boolean; // Preserve client IP when forwarding
|
||||
|
||||
// PROXY protocol configuration
|
||||
proxyIPs?: string[]; // List of trusted proxy IPs that can send PROXY protocol
|
||||
acceptProxyProtocol?: boolean; // Global option to accept PROXY protocol (defaults based on proxyIPs)
|
||||
sendProxyProtocol?: boolean; // Global option to send PROXY protocol to all targets
|
||||
|
||||
// Global/default settings
|
||||
defaults?: {
|
||||
target?: {
|
||||
@ -128,8 +134,8 @@ export interface ISmartProxyOptions {
|
||||
*/
|
||||
export interface IConnectionRecord {
|
||||
id: string; // Unique connection identifier
|
||||
incoming: plugins.net.Socket;
|
||||
outgoing: plugins.net.Socket | null;
|
||||
incoming: plugins.net.Socket | WrappedSocket;
|
||||
outgoing: plugins.net.Socket | WrappedSocket | null;
|
||||
incomingStartTime: number;
|
||||
outgoingStartTime?: number;
|
||||
outgoingClosedTime?: number;
|
||||
|
@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import type { ISmartProxyOptions } from './models/interfaces.js';
|
||||
import { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
|
||||
/**
|
||||
* PortManager handles the dynamic creation and removal of port listeners
|
||||
@ -64,8 +65,7 @@ export class PortManager {
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
// Check if shutting down
|
||||
if (this.isShuttingDown) {
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
cleanupSocket(socket, 'port-manager-shutdown', { immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -2,14 +2,17 @@ import * as plugins from '../../plugins.js';
|
||||
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
// Route checking functions have been removed
|
||||
import type { IRouteConfig, IRouteAction, IRouteContext } from './models/route-types.js';
|
||||
import type { IRouteConfig, IRouteAction } from './models/route-types.js';
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import { ConnectionManager } from './connection-manager.js';
|
||||
import { SecurityManager } from './security-manager.js';
|
||||
import { TlsManager } from './tls-manager.js';
|
||||
import { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
import { TimeoutManager } from './timeout-manager.js';
|
||||
import { RouteManager } from './route-manager.js';
|
||||
import type { ForwardingHandler } from '../../forwarding/handlers/base-handler.js';
|
||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
||||
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
|
||||
|
||||
/**
|
||||
* Handles new connection processing and setup logic with support for route-based configuration
|
||||
@ -80,36 +83,52 @@ export class RouteConnectionHandler {
|
||||
const remoteIP = socket.remoteAddress || '';
|
||||
const localPort = socket.localPort || 0;
|
||||
|
||||
// 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`, {
|
||||
remoteIP,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate IP against rate limits and connection limits
|
||||
const ipValidation = this.securityManager.validateIP(remoteIP);
|
||||
// Note: For wrapped sockets, this will use the underlying socket IP until PROXY protocol is parsed
|
||||
const ipValidation = this.securityManager.validateIP(wrappedSocket.remoteAddress || '');
|
||||
if (!ipValidation.allowed) {
|
||||
logger.log('warn', `Connection rejected`, { remoteIP, reason: ipValidation.reason, component: 'route-handler' });
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
logger.log('warn', `Connection rejected`, { remoteIP: wrappedSocket.remoteAddress, reason: ipValidation.reason, component: 'route-handler' });
|
||||
cleanupSocket(wrappedSocket.socket, `rejected-${ipValidation.reason}`, { immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new connection record
|
||||
const record = this.connectionManager.createConnection(socket);
|
||||
// Create a new connection record with the wrapped socket
|
||||
const record = this.connectionManager.createConnection(wrappedSocket);
|
||||
if (!record) {
|
||||
// Connection was rejected due to limit - socket already destroyed by connection manager
|
||||
return;
|
||||
}
|
||||
const connectionId = record.id;
|
||||
|
||||
// Apply socket optimizations
|
||||
socket.setNoDelay(this.settings.noDelay);
|
||||
// Apply socket optimizations (apply to underlying socket)
|
||||
const underlyingSocket = wrappedSocket.socket;
|
||||
underlyingSocket.setNoDelay(this.settings.noDelay);
|
||||
|
||||
// Apply keep-alive settings if enabled
|
||||
if (this.settings.keepAlive) {
|
||||
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
||||
underlyingSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
||||
record.hasKeepAlive = true;
|
||||
|
||||
// Apply enhanced TCP keep-alive options if enabled
|
||||
if (this.settings.enableKeepAliveProbes) {
|
||||
try {
|
||||
// These are platform-specific and may not be available
|
||||
if ('setKeepAliveProbes' in socket) {
|
||||
(socket as any).setKeepAliveProbes(10);
|
||||
if ('setKeepAliveProbes' in underlyingSocket) {
|
||||
(underlyingSocket as any).setKeepAliveProbes(10);
|
||||
}
|
||||
if ('setKeepAliveInterval' in socket) {
|
||||
(socket as any).setKeepAliveInterval(1000);
|
||||
if ('setKeepAliveInterval' in underlyingSocket) {
|
||||
(underlyingSocket as any).setKeepAliveInterval(1000);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors - these are optional enhancements
|
||||
@ -147,19 +166,19 @@ export class RouteConnectionHandler {
|
||||
}
|
||||
|
||||
// Handle the connection - wait for initial data to determine if it's TLS
|
||||
this.handleInitialData(socket, record);
|
||||
this.handleInitialData(wrappedSocket, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle initial data from a connection to determine routing
|
||||
*/
|
||||
private handleInitialData(socket: plugins.net.Socket, record: IConnectionRecord): void {
|
||||
private handleInitialData(socket: plugins.net.Socket | WrappedSocket, record: IConnectionRecord): void {
|
||||
const connectionId = record.id;
|
||||
const localPort = record.localPort;
|
||||
let initialDataReceived = false;
|
||||
|
||||
// Check if any routes on this port require TLS handling
|
||||
const allRoutes = this.routeManager.getAllRoutes();
|
||||
const allRoutes = this.routeManager.getRoutes();
|
||||
const needsTlsHandling = allRoutes.some(route => {
|
||||
// Check if route matches this port
|
||||
const matchesPort = this.routeManager.getRoutesForPort(localPort).includes(route);
|
||||
@ -173,8 +192,35 @@ export class RouteConnectionHandler {
|
||||
|
||||
// If no routes require TLS handling and it's not port 443, route immediately
|
||||
if (!needsTlsHandling && localPort !== 443) {
|
||||
// Set up error handler
|
||||
socket.on('error', this.connectionManager.handleError('incoming', record));
|
||||
// Extract underlying socket for socket-utils functions
|
||||
const underlyingSocket = getUnderlyingSocket(socket);
|
||||
// Set up proper socket handlers for immediate routing
|
||||
setupSocketHandlers(
|
||||
underlyingSocket,
|
||||
(reason) => {
|
||||
// Only cleanup if connection hasn't been fully established
|
||||
// Check if outgoing connection exists and is connected
|
||||
if (!record.outgoing || record.outgoing.readyState !== 'open') {
|
||||
logger.log('debug', `Connection ${connectionId} closed during immediate routing: ${reason}`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
reason,
|
||||
hasOutgoing: !!record.outgoing,
|
||||
outgoingState: record.outgoing?.readyState,
|
||||
component: 'route-handler'
|
||||
});
|
||||
|
||||
// If there's a pending outgoing connection, destroy it
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
record.outgoing.destroy();
|
||||
}
|
||||
|
||||
this.connectionManager.cleanupConnection(record, reason);
|
||||
}
|
||||
},
|
||||
undefined, // Use default timeout handler
|
||||
'immediate-route-client'
|
||||
);
|
||||
|
||||
// Route immediately for non-TLS connections
|
||||
this.routeConnection(socket, record, '', undefined);
|
||||
@ -218,6 +264,37 @@ export class RouteConnectionHandler {
|
||||
// Set up error handler
|
||||
socket.on('error', this.connectionManager.handleError('incoming', record));
|
||||
|
||||
// Add close/end handlers to catch immediate disconnections
|
||||
socket.once('close', () => {
|
||||
if (!initialDataReceived) {
|
||||
logger.log('warn', `Connection ${connectionId} closed before sending initial data`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
component: 'route-handler'
|
||||
});
|
||||
if (initialTimeout) {
|
||||
clearTimeout(initialTimeout);
|
||||
initialTimeout = null;
|
||||
}
|
||||
this.connectionManager.cleanupConnection(record, 'closed_before_data');
|
||||
}
|
||||
});
|
||||
|
||||
socket.once('end', () => {
|
||||
if (!initialDataReceived) {
|
||||
logger.log('debug', `Connection ${connectionId} ended before sending initial data`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
component: 'route-handler'
|
||||
});
|
||||
if (initialTimeout) {
|
||||
clearTimeout(initialTimeout);
|
||||
initialTimeout = null;
|
||||
}
|
||||
// Don't cleanup on 'end' - wait for 'close'
|
||||
}
|
||||
});
|
||||
|
||||
// First data handler to capture initial TLS handshake
|
||||
socket.once('data', (chunk: Buffer) => {
|
||||
// Clear the initial timeout since we've received data
|
||||
@ -311,7 +388,7 @@ export class RouteConnectionHandler {
|
||||
* Route the connection based on match criteria
|
||||
*/
|
||||
private routeConnection(
|
||||
socket: plugins.net.Socket,
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
serverName: string,
|
||||
initialChunk?: Buffer
|
||||
@ -326,15 +403,21 @@ export class RouteConnectionHandler {
|
||||
// For HTTP proxy ports without TLS, skip domain check since domain info comes from HTTP headers
|
||||
const skipDomainCheck = isHttpProxyPort && !record.isTLS;
|
||||
|
||||
// Find matching route
|
||||
const routeMatch = this.routeManager.findMatchingRoute({
|
||||
// Create route context for matching
|
||||
const routeContext: IRouteContext = {
|
||||
port: localPort,
|
||||
domain: serverName,
|
||||
domain: skipDomainCheck ? undefined : serverName, // Skip domain if HTTP proxy without TLS
|
||||
clientIp: remoteIP,
|
||||
serverIp: socket.localAddress || '',
|
||||
path: undefined, // We don't have path info at this point
|
||||
isTls: record.isTLS,
|
||||
tlsVersion: undefined, // We don't extract TLS version yet
|
||||
skipDomainCheck: skipDomainCheck,
|
||||
});
|
||||
timestamp: Date.now(),
|
||||
connectionId: record.id
|
||||
};
|
||||
|
||||
// Find matching route
|
||||
const routeMatch = this.routeManager.findMatchingRoute(routeContext);
|
||||
|
||||
if (!routeMatch) {
|
||||
logger.log('warn', `No route found for ${serverName || 'connection'} on port ${localPort} (connection: ${connectionId})`, {
|
||||
@ -493,7 +576,7 @@ export class RouteConnectionHandler {
|
||||
* Handle a forward action for a route
|
||||
*/
|
||||
private handleForwardAction(
|
||||
socket: plugins.net.Socket,
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
route: IRouteConfig,
|
||||
initialChunk?: Buffer
|
||||
@ -547,6 +630,12 @@ export class RouteConnectionHandler {
|
||||
|
||||
// We don't close the socket - just let it remain open
|
||||
// The kernel-level NFTables rules will handle the actual forwarding
|
||||
|
||||
// Set up cleanup when the socket eventually closes
|
||||
socket.once('close', () => {
|
||||
this.connectionManager.cleanupConnection(record, 'nftables_closed');
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -688,7 +777,7 @@ export class RouteConnectionHandler {
|
||||
record,
|
||||
initialChunk,
|
||||
this.settings.httpProxyPort || 8443,
|
||||
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
||||
(reason) => this.connectionManager.cleanupConnection(record, reason)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -743,7 +832,7 @@ export class RouteConnectionHandler {
|
||||
record,
|
||||
initialChunk,
|
||||
this.settings.httpProxyPort || 8443,
|
||||
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
||||
(reason) => this.connectionManager.cleanupConnection(record, reason)
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
@ -804,7 +893,7 @@ export class RouteConnectionHandler {
|
||||
* Handle a socket-handler action for a route
|
||||
*/
|
||||
private async handleSocketHandlerAction(
|
||||
socket: plugins.net.Socket,
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
route: IRouteConfig,
|
||||
initialChunk?: Buffer
|
||||
@ -822,6 +911,38 @@ export class RouteConnectionHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track event listeners added by the handler so we can clean them up
|
||||
const originalOn = socket.on.bind(socket);
|
||||
const originalOnce = socket.once.bind(socket);
|
||||
const trackedListeners: Array<{event: string; listener: (...args: any[]) => void}> = [];
|
||||
|
||||
// Override socket.on to track listeners
|
||||
socket.on = function(event: string, listener: (...args: any[]) => void) {
|
||||
trackedListeners.push({event, listener});
|
||||
return originalOn(event, listener);
|
||||
} as any;
|
||||
|
||||
// Override socket.once to track listeners
|
||||
socket.once = function(event: string, listener: (...args: any[]) => void) {
|
||||
trackedListeners.push({event, listener});
|
||||
return originalOnce(event, listener);
|
||||
} as any;
|
||||
|
||||
// Set up automatic cleanup when socket closes
|
||||
const cleanupHandler = () => {
|
||||
// Remove all tracked listeners
|
||||
for (const {event, listener} of trackedListeners) {
|
||||
socket.removeListener(event, listener);
|
||||
}
|
||||
// Restore original methods
|
||||
socket.on = originalOn;
|
||||
socket.once = originalOnce;
|
||||
};
|
||||
|
||||
// Listen for socket close to trigger cleanup
|
||||
originalOnce('close', cleanupHandler);
|
||||
originalOnce('error', cleanupHandler);
|
||||
|
||||
// Create route context for the handler
|
||||
const routeContext = this.createRouteContext({
|
||||
connectionId: record.id,
|
||||
@ -836,8 +957,9 @@ export class RouteConnectionHandler {
|
||||
});
|
||||
|
||||
try {
|
||||
// Call the handler with socket AND context
|
||||
const result = route.action.socketHandler(socket, routeContext);
|
||||
// Call the handler with the appropriate socket (extract underlying if needed)
|
||||
const handlerSocket = getUnderlyingSocket(socket);
|
||||
const result = route.action.socketHandler(handlerSocket, routeContext);
|
||||
|
||||
// Handle async handlers properly
|
||||
if (result instanceof Promise) {
|
||||
@ -855,6 +977,8 @@ export class RouteConnectionHandler {
|
||||
error: error.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
// Remove all event listeners before destroying to prevent memory leaks
|
||||
socket.removeAllListeners();
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
@ -875,6 +999,8 @@ export class RouteConnectionHandler {
|
||||
error: error.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
// Remove all event listeners before destroying to prevent memory leaks
|
||||
socket.removeAllListeners();
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
@ -882,112 +1008,12 @@ export class RouteConnectionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup improved error handling for the outgoing connection
|
||||
*/
|
||||
private setupOutgoingErrorHandler(
|
||||
connectionId: string,
|
||||
targetSocket: plugins.net.Socket,
|
||||
record: IConnectionRecord,
|
||||
socket: plugins.net.Socket,
|
||||
finalTargetHost: string,
|
||||
finalTargetPort: number
|
||||
): void {
|
||||
targetSocket.once('error', (err) => {
|
||||
// This handler runs only once during the initial connection phase
|
||||
const code = (err as any).code;
|
||||
logger.log('error',
|
||||
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${err.message} (${code})`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
errorMessage: err.message,
|
||||
errorCode: code,
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
|
||||
// Resume the incoming socket to prevent it from hanging
|
||||
socket.resume();
|
||||
|
||||
// Log specific error types for easier debugging
|
||||
if (code === 'ECONNREFUSED') {
|
||||
logger.log('error',
|
||||
`Connection ${connectionId}: Target ${finalTargetHost}:${finalTargetPort} refused connection. Check if the target service is running and listening on that port.`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
recommendation: 'Check if the target service is running and listening on that port.',
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
} else if (code === 'ETIMEDOUT') {
|
||||
logger.log('error',
|
||||
`Connection ${connectionId} to ${finalTargetHost}:${finalTargetPort} timed out. Check network conditions, firewall rules, or if the target is too far away.`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
recommendation: 'Check network conditions, firewall rules, or if the target is too far away.',
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
} else if (code === 'ECONNRESET') {
|
||||
logger.log('error',
|
||||
`Connection ${connectionId} to ${finalTargetHost}:${finalTargetPort} was reset. The target might have closed the connection abruptly.`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
recommendation: 'The target might have closed the connection abruptly.',
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
} else if (code === 'EHOSTUNREACH') {
|
||||
logger.log('error',
|
||||
`Connection ${connectionId}: Host ${finalTargetHost} is unreachable. Check DNS settings, network routing, or firewall rules.`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
recommendation: 'Check DNS settings, network routing, or firewall rules.',
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
} else if (code === 'ENOTFOUND') {
|
||||
logger.log('error',
|
||||
`Connection ${connectionId}: DNS lookup failed for ${finalTargetHost}. Check your DNS settings or if the hostname is correct.`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
recommendation: 'Check your DNS settings or if the hostname is correct.',
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Clear any existing error handler after connection phase
|
||||
targetSocket.removeAllListeners('error');
|
||||
|
||||
// Re-add the normal error handler for established connections
|
||||
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
||||
|
||||
if (record.outgoingTerminationReason === null) {
|
||||
record.outgoingTerminationReason = 'connection_failed';
|
||||
this.connectionManager.incrementTerminationStat('outgoing', 'connection_failed');
|
||||
}
|
||||
|
||||
// Clean up the connection
|
||||
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${code}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a direct connection to the target
|
||||
*/
|
||||
private setupDirectConnection(
|
||||
socket: plugins.net.Socket,
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
serverName?: string,
|
||||
initialChunk?: Buffer,
|
||||
@ -1038,8 +1064,194 @@ export class RouteConnectionHandler {
|
||||
record.pendingDataSize = initialChunk.length;
|
||||
}
|
||||
|
||||
// Create the target socket
|
||||
const targetSocket = plugins.net.connect(connectionOptions);
|
||||
// Create the target socket with immediate error handling
|
||||
const targetSocket = createSocketWithErrorHandler({
|
||||
port: finalTargetPort,
|
||||
host: finalTargetHost,
|
||||
onError: (error) => {
|
||||
// Connection failed - clean up everything immediately
|
||||
// Check if connection record is still valid (client might have disconnected)
|
||||
if (record.connectionClosed) {
|
||||
logger.log('debug', `Backend connection failed but client already disconnected for ${connectionId}`, {
|
||||
connectionId,
|
||||
errorCode: (error as any).code,
|
||||
component: 'route-handler'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('error',
|
||||
`Connection setup error for ${connectionId} to ${finalTargetHost}:${finalTargetPort}: ${error.message} (${(error as any).code})`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
errorMessage: error.message,
|
||||
errorCode: (error as any).code,
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
|
||||
// Log specific error types for easier debugging
|
||||
if ((error as any).code === 'ECONNREFUSED') {
|
||||
logger.log('error',
|
||||
`Connection ${connectionId}: Target ${finalTargetHost}:${finalTargetPort} refused connection. Check if the target service is running and listening on that port.`,
|
||||
{
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
recommendation: 'Check if the target service is running and listening on that port.',
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Resume the incoming socket to prevent it from hanging
|
||||
if (socket && !socket.destroyed) {
|
||||
socket.resume();
|
||||
}
|
||||
|
||||
// Clean up the incoming socket
|
||||
if (socket && !socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
// Clean up the connection record - this is critical!
|
||||
this.connectionManager.cleanupConnection(record, `connection_failed_${(error as any).code || 'unknown'}`);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
|
||||
// Clear any error listeners added by createSocketWithErrorHandler
|
||||
targetSocket.removeAllListeners('error');
|
||||
|
||||
// Add the normal error handler for established connections
|
||||
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
||||
|
||||
// Flush any pending data to target
|
||||
if (record.pendingData.length > 0) {
|
||||
const combinedData = Buffer.concat(record.pendingData);
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
|
||||
);
|
||||
}
|
||||
|
||||
// Write pending data immediately
|
||||
targetSocket.write(combinedData, (err) => {
|
||||
if (err) {
|
||||
logger.log('error', `Error writing pending data to target for connection ${connectionId}: ${err.message}`, {
|
||||
connectionId,
|
||||
error: err.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
return this.connectionManager.cleanupConnection(record, 'write_error');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the buffer now that we've processed it
|
||||
record.pendingData = [];
|
||||
record.pendingDataSize = 0;
|
||||
}
|
||||
|
||||
// Use centralized bidirectional forwarding setup
|
||||
// Extract underlying sockets for socket-utils functions
|
||||
const incomingSocket = getUnderlyingSocket(socket);
|
||||
|
||||
setupBidirectionalForwarding(incomingSocket, targetSocket, {
|
||||
onClientData: (chunk) => {
|
||||
record.bytesReceived += chunk.length;
|
||||
this.timeoutManager.updateActivity(record);
|
||||
},
|
||||
onServerData: (chunk) => {
|
||||
record.bytesSent += chunk.length;
|
||||
this.timeoutManager.updateActivity(record);
|
||||
},
|
||||
onCleanup: (reason) => {
|
||||
this.connectionManager.cleanupConnection(record, reason);
|
||||
},
|
||||
enableHalfOpen: false // Default: close both when one closes (required for proxy chains)
|
||||
});
|
||||
|
||||
// Apply timeouts if keep-alive is enabled
|
||||
if (record.hasKeepAlive) {
|
||||
socket.setTimeout(this.settings.socketTimeout || 3600000);
|
||||
targetSocket.setTimeout(this.settings.socketTimeout || 3600000);
|
||||
}
|
||||
|
||||
// Log successful connection
|
||||
logger.log('info',
|
||||
`Connection established: ${record.remoteIP} -> ${finalTargetHost}:${finalTargetPort}` +
|
||||
`${serverName ? ` (SNI: ${serverName})` : record.lockedDomain ? ` (Domain: ${record.lockedDomain})` : ''}`,
|
||||
{
|
||||
remoteIP: record.remoteIP,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
sni: serverName || undefined,
|
||||
domain: !serverName && record.lockedDomain ? record.lockedDomain : undefined,
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
|
||||
// Add TLS renegotiation handler if needed
|
||||
if (serverName) {
|
||||
// Create connection info object for the existing connection
|
||||
const connInfo = {
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: record.incoming.remotePort || 0,
|
||||
destIp: record.incoming.localAddress || '',
|
||||
destPort: record.incoming.localPort || 0,
|
||||
};
|
||||
|
||||
// Create a renegotiation handler function
|
||||
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
|
||||
connectionId,
|
||||
serverName,
|
||||
connInfo,
|
||||
(_connectionId, reason) => this.connectionManager.cleanupConnection(record, reason)
|
||||
);
|
||||
|
||||
// Store the handler in the connection record so we can remove it during cleanup
|
||||
record.renegotiationHandler = renegotiationHandler;
|
||||
|
||||
// Add the handler to the socket
|
||||
socket.on('data', renegotiationHandler);
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `TLS renegotiation handler installed for connection ${connectionId} with SNI ${serverName}`, {
|
||||
connectionId,
|
||||
serverName,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set connection timeout
|
||||
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
|
||||
logger.log('warn', `Connection ${connectionId} from ${record.remoteIP} exceeded max lifetime, forcing cleanup`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
component: 'route-handler'
|
||||
});
|
||||
this.connectionManager.cleanupConnection(record, reason);
|
||||
});
|
||||
|
||||
// Mark TLS handshake as complete for TLS connections
|
||||
if (record.isTLS) {
|
||||
record.tlsHandshakeComplete = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set outgoing socket immediately so it can be cleaned up if client disconnects
|
||||
record.outgoing = targetSocket;
|
||||
record.outgoingStartTime = Date.now();
|
||||
|
||||
@ -1072,13 +1284,6 @@ export class RouteConnectionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Setup improved error handling for outgoing connection
|
||||
this.setupOutgoingErrorHandler(connectionId, targetSocket, record, socket, finalTargetHost, finalTargetPort);
|
||||
|
||||
// Setup close handlers
|
||||
targetSocket.on('close', this.connectionManager.handleClose('outgoing', record));
|
||||
socket.on('close', this.connectionManager.handleClose('incoming', record));
|
||||
|
||||
// Setup error handlers for incoming socket
|
||||
socket.on('error', this.connectionManager.handleError('incoming', record));
|
||||
|
||||
@ -1107,7 +1312,7 @@ export class RouteConnectionHandler {
|
||||
record.incomingTerminationReason = 'timeout';
|
||||
this.connectionManager.incrementTerminationStat('incoming', 'timeout');
|
||||
}
|
||||
this.connectionManager.initiateCleanupOnce(record, 'timeout_incoming');
|
||||
this.connectionManager.cleanupConnection(record, 'timeout_incoming');
|
||||
});
|
||||
|
||||
targetSocket.on('timeout', () => {
|
||||
@ -1134,133 +1339,10 @@ export class RouteConnectionHandler {
|
||||
record.outgoingTerminationReason = 'timeout';
|
||||
this.connectionManager.incrementTerminationStat('outgoing', 'timeout');
|
||||
}
|
||||
this.connectionManager.initiateCleanupOnce(record, 'timeout_outgoing');
|
||||
this.connectionManager.cleanupConnection(record, 'timeout_outgoing');
|
||||
});
|
||||
|
||||
// Apply socket timeouts
|
||||
this.timeoutManager.applySocketTimeouts(record);
|
||||
|
||||
// Track outgoing data for bytes counting
|
||||
targetSocket.on('data', (chunk: Buffer) => {
|
||||
record.bytesSent += chunk.length;
|
||||
this.timeoutManager.updateActivity(record);
|
||||
});
|
||||
|
||||
// Wait for the outgoing connection to be ready before setting up piping
|
||||
targetSocket.once('connect', () => {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
|
||||
connectionId,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the initial connection error handler
|
||||
targetSocket.removeAllListeners('error');
|
||||
|
||||
// Add the normal error handler for established connections
|
||||
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
|
||||
|
||||
// Flush any pending data to target
|
||||
if (record.pendingData.length > 0) {
|
||||
const combinedData = Buffer.concat(record.pendingData);
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
|
||||
);
|
||||
}
|
||||
|
||||
// Write pending data immediately
|
||||
targetSocket.write(combinedData, (err) => {
|
||||
if (err) {
|
||||
logger.log('error', `Error writing pending data to target for connection ${connectionId}: ${err.message}`, {
|
||||
connectionId,
|
||||
error: err.message,
|
||||
component: 'route-handler'
|
||||
});
|
||||
return this.connectionManager.initiateCleanupOnce(record, 'write_error');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the buffer now that we've processed it
|
||||
record.pendingData = [];
|
||||
record.pendingDataSize = 0;
|
||||
}
|
||||
|
||||
// Immediately setup bidirectional piping - much simpler than manual data management
|
||||
socket.pipe(targetSocket);
|
||||
targetSocket.pipe(socket);
|
||||
|
||||
// Track incoming data for bytes counting - do this after piping is set up
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
record.bytesReceived += chunk.length;
|
||||
this.timeoutManager.updateActivity(record);
|
||||
});
|
||||
|
||||
// Log successful connection
|
||||
logger.log('info',
|
||||
`Connection established: ${record.remoteIP} -> ${finalTargetHost}:${finalTargetPort}` +
|
||||
`${serverName ? ` (SNI: ${serverName})` : record.lockedDomain ? ` (Domain: ${record.lockedDomain})` : ''}`,
|
||||
{
|
||||
remoteIP: record.remoteIP,
|
||||
targetHost: finalTargetHost,
|
||||
targetPort: finalTargetPort,
|
||||
sni: serverName || undefined,
|
||||
domain: !serverName && record.lockedDomain ? record.lockedDomain : undefined,
|
||||
component: 'route-handler'
|
||||
}
|
||||
);
|
||||
|
||||
// Add TLS renegotiation handler if needed
|
||||
if (serverName) {
|
||||
// Create connection info object for the existing connection
|
||||
const connInfo = {
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: record.incoming.remotePort || 0,
|
||||
destIp: record.incoming.localAddress || '',
|
||||
destPort: record.incoming.localPort || 0,
|
||||
};
|
||||
|
||||
// Create a renegotiation handler function
|
||||
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
|
||||
connectionId,
|
||||
serverName,
|
||||
connInfo,
|
||||
(connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
||||
);
|
||||
|
||||
// Store the handler in the connection record so we can remove it during cleanup
|
||||
record.renegotiationHandler = renegotiationHandler;
|
||||
|
||||
// Add the handler to the socket
|
||||
socket.on('data', renegotiationHandler);
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
logger.log('info', `TLS renegotiation handler installed for connection ${connectionId} with SNI ${serverName}`, {
|
||||
connectionId,
|
||||
serverName,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set connection timeout
|
||||
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
|
||||
logger.log('warn', `Connection ${connectionId} from ${record.remoteIP} exceeded max lifetime, forcing cleanup`, {
|
||||
connectionId,
|
||||
remoteIP: record.remoteIP,
|
||||
component: 'route-handler'
|
||||
});
|
||||
this.connectionManager.initiateCleanupOnce(record, reason);
|
||||
});
|
||||
|
||||
// Mark TLS handshake as complete for TLS connections
|
||||
if (record.isTLS) {
|
||||
record.tlsHandshakeComplete = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,554 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
IRouteConfig,
|
||||
IRouteMatch,
|
||||
IRouteAction,
|
||||
TPortRange
|
||||
} from './models/route-types.js';
|
||||
import type {
|
||||
ISmartProxyOptions
|
||||
} from './models/interfaces.js';
|
||||
|
||||
/**
|
||||
* Result of route matching
|
||||
*/
|
||||
export interface IRouteMatchResult {
|
||||
route: IRouteConfig;
|
||||
// Additional match parameters (path, query, etc.)
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RouteManager handles all routing decisions based on connections and attributes
|
||||
*/
|
||||
export class RouteManager extends plugins.EventEmitter {
|
||||
private routes: IRouteConfig[] = [];
|
||||
private portMap: Map<number, IRouteConfig[]> = new Map();
|
||||
private options: ISmartProxyOptions;
|
||||
|
||||
constructor(options: ISmartProxyOptions) {
|
||||
super();
|
||||
|
||||
// Store options
|
||||
this.options = options;
|
||||
|
||||
// Initialize routes from either source
|
||||
this.updateRoutes(this.options.routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routes with new configuration
|
||||
*/
|
||||
public updateRoutes(routes: IRouteConfig[] = []): void {
|
||||
// Sort routes by priority (higher first)
|
||||
this.routes = [...(routes || [])].sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
|
||||
// Rebuild port mapping for fast lookups
|
||||
this.rebuildPortMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the port mapping for fast lookups
|
||||
* Also logs information about the ports being listened on
|
||||
*/
|
||||
private rebuildPortMap(): void {
|
||||
this.portMap.clear();
|
||||
this.portRangeCache.clear(); // Clear cache when rebuilding
|
||||
|
||||
// Track ports for logging
|
||||
const portToRoutesMap = new Map<number, string[]>();
|
||||
|
||||
for (const route of this.routes) {
|
||||
const ports = this.expandPortRange(route.match.ports);
|
||||
|
||||
// Skip if no ports were found
|
||||
if (ports.length === 0) {
|
||||
console.warn(`Route ${route.name || 'unnamed'} has no valid ports to listen on`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const port of ports) {
|
||||
// Add to portMap for routing
|
||||
if (!this.portMap.has(port)) {
|
||||
this.portMap.set(port, []);
|
||||
}
|
||||
this.portMap.get(port)!.push(route);
|
||||
|
||||
// Add to tracking for logging
|
||||
if (!portToRoutesMap.has(port)) {
|
||||
portToRoutesMap.set(port, []);
|
||||
}
|
||||
portToRoutesMap.get(port)!.push(route.name || 'unnamed');
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary of ports and routes
|
||||
const totalPorts = this.portMap.size;
|
||||
const totalRoutes = this.routes.length;
|
||||
console.log(`Route manager configured with ${totalRoutes} routes across ${totalPorts} ports`);
|
||||
|
||||
// Log port details if detailed logging is enabled
|
||||
const enableDetailedLogging = this.options.enableDetailedLogging;
|
||||
if (enableDetailedLogging) {
|
||||
for (const [port, routes] of this.portMap.entries()) {
|
||||
console.log(`Port ${port}: ${routes.length} routes (${portToRoutesMap.get(port)!.join(', ')})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a port range specification into an array of individual ports
|
||||
* Uses caching to improve performance for frequently used port ranges
|
||||
*
|
||||
* @public - Made public to allow external code to interpret port ranges
|
||||
*/
|
||||
public expandPortRange(portRange: TPortRange): number[] {
|
||||
// For simple number, return immediately
|
||||
if (typeof portRange === 'number') {
|
||||
return [portRange];
|
||||
}
|
||||
|
||||
// Create a cache key for this port range
|
||||
const cacheKey = JSON.stringify(portRange);
|
||||
|
||||
// Check if we have a cached result
|
||||
if (this.portRangeCache.has(cacheKey)) {
|
||||
return this.portRangeCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
// Process the port range
|
||||
let result: number[] = [];
|
||||
|
||||
if (Array.isArray(portRange)) {
|
||||
// Handle array of port objects or numbers
|
||||
result = portRange.flatMap(item => {
|
||||
if (typeof item === 'number') {
|
||||
return [item];
|
||||
} else if (typeof item === 'object' && 'from' in item && 'to' in item) {
|
||||
// Handle port range object - check valid range
|
||||
if (item.from > item.to) {
|
||||
console.warn(`Invalid port range: from (${item.from}) > to (${item.to})`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle port range object
|
||||
const ports: number[] = [];
|
||||
for (let p = item.from; p <= item.to; p++) {
|
||||
ports.push(p);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
this.portRangeCache.set(cacheKey, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoization cache for expanded port ranges
|
||||
*/
|
||||
private portRangeCache: Map<string, number[]> = new Map();
|
||||
|
||||
/**
|
||||
* Get all ports that should be listened on
|
||||
* This method automatically infers all required ports from route configurations
|
||||
*/
|
||||
public getListeningPorts(): number[] {
|
||||
// Return the unique set of ports from all routes
|
||||
return Array.from(this.portMap.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all routes for a given port
|
||||
*/
|
||||
public getRoutesForPort(port: number): IRouteConfig[] {
|
||||
return this.portMap.get(port) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all routes
|
||||
*/
|
||||
public getAllRoutes(): IRouteConfig[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a pattern matches a domain using glob matching
|
||||
*/
|
||||
private matchDomain(pattern: string, domain: string): boolean {
|
||||
// Convert glob pattern to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
return regex.test(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a domain against all patterns in a route
|
||||
*/
|
||||
private matchRouteDomain(route: IRouteConfig, domain: string): boolean {
|
||||
if (!route.match.domains) {
|
||||
// If no domains specified, match all domains
|
||||
return true;
|
||||
}
|
||||
|
||||
const patterns = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
return patterns.some(pattern => this.matchDomain(pattern, domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client IP is allowed by a route's security settings
|
||||
* @deprecated Security is now checked in route-connection-handler.ts after route matching
|
||||
*/
|
||||
private isClientIpAllowed(route: IRouteConfig, clientIp: string): boolean {
|
||||
const security = route.security;
|
||||
|
||||
if (!security) {
|
||||
return true; // No security settings means allowed
|
||||
}
|
||||
|
||||
// Check blocked IPs first
|
||||
if (security.ipBlockList && security.ipBlockList.length > 0) {
|
||||
for (const pattern of security.ipBlockList) {
|
||||
if (this.matchIpPattern(pattern, clientIp)) {
|
||||
return false; // IP is blocked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are allowed IPs, check them
|
||||
if (security.ipAllowList && security.ipAllowList.length > 0) {
|
||||
for (const pattern of security.ipAllowList) {
|
||||
if (this.matchIpPattern(pattern, clientIp)) {
|
||||
return true; // IP is allowed
|
||||
}
|
||||
}
|
||||
return false; // IP not in allowed list
|
||||
}
|
||||
|
||||
// No allowed IPs specified, so IP is allowed
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a pattern
|
||||
*/
|
||||
private matchIpPattern(pattern: string, ip: string): boolean {
|
||||
// Normalize IPv6-mapped IPv4 addresses
|
||||
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||
const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern;
|
||||
|
||||
// Handle exact match with normalized addresses
|
||||
if (pattern === ip || normalizedPattern === normalizedIp ||
|
||||
pattern === normalizedIp || normalizedPattern === ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle CIDR notation (e.g., 192.168.1.0/24)
|
||||
if (pattern.includes('/')) {
|
||||
return this.matchIpCidr(pattern, normalizedIp) ||
|
||||
(normalizedPattern !== pattern && this.matchIpCidr(normalizedPattern, normalizedIp));
|
||||
}
|
||||
|
||||
// Handle glob pattern (e.g., 192.168.1.*)
|
||||
if (pattern.includes('*')) {
|
||||
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (regex.test(ip) || regex.test(normalizedIp)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If pattern was normalized, also test with normalized pattern
|
||||
if (normalizedPattern !== pattern) {
|
||||
const normalizedRegexPattern = normalizedPattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
|
||||
const normalizedRegex = new RegExp(`^${normalizedRegexPattern}$`);
|
||||
return normalizedRegex.test(ip) || normalizedRegex.test(normalizedIp);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an IP against a CIDR pattern
|
||||
*/
|
||||
private matchIpCidr(cidr: string, ip: string): boolean {
|
||||
try {
|
||||
// In a real implementation, you'd use a proper IP library
|
||||
// This is a simplified implementation
|
||||
const [subnet, bits] = cidr.split('/');
|
||||
const mask = parseInt(bits, 10);
|
||||
|
||||
// Normalize IPv6-mapped IPv4 addresses
|
||||
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||
const normalizedSubnet = subnet.startsWith('::ffff:') ? subnet.substring(7) : subnet;
|
||||
|
||||
// Convert IP addresses to numeric values
|
||||
const ipNum = this.ipToNumber(normalizedIp);
|
||||
const subnetNum = this.ipToNumber(normalizedSubnet);
|
||||
|
||||
// Calculate subnet mask
|
||||
const maskNum = ~(2 ** (32 - mask) - 1);
|
||||
|
||||
// Check if IP is in subnet
|
||||
return (ipNum & maskNum) === (subnetNum & maskNum);
|
||||
} catch (e) {
|
||||
console.error(`Error matching IP ${ip} against CIDR ${cidr}:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IP address to a numeric value
|
||||
*/
|
||||
private ipToNumber(ip: string): number {
|
||||
// Normalize IPv6-mapped IPv4 addresses
|
||||
const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||
|
||||
const parts = normalizedIp.split('.').map(part => parseInt(part, 10));
|
||||
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the matching route for a connection
|
||||
*/
|
||||
public findMatchingRoute(options: {
|
||||
port: number;
|
||||
domain?: string;
|
||||
clientIp: string;
|
||||
path?: string;
|
||||
tlsVersion?: string;
|
||||
skipDomainCheck?: boolean;
|
||||
}): IRouteMatchResult | null {
|
||||
const { port, domain, clientIp, path, tlsVersion, skipDomainCheck } = options;
|
||||
|
||||
// Get all routes for this port
|
||||
const routesForPort = this.getRoutesForPort(port);
|
||||
|
||||
// Find the first matching route based on priority order
|
||||
for (const route of routesForPort) {
|
||||
// Check domain match
|
||||
// If the route has domain restrictions and we have a domain to check
|
||||
if (route.match.domains && !skipDomainCheck) {
|
||||
// If no domain was provided (non-TLS or no SNI), this route doesn't match
|
||||
if (!domain) {
|
||||
continue;
|
||||
}
|
||||
// If domain is provided but doesn't match the route's domains, skip
|
||||
if (!this.matchRouteDomain(route, domain)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// If route has no domain restrictions, it matches all domains
|
||||
// If skipDomainCheck is true, we skip domain validation for HTTP connections
|
||||
|
||||
// Check path match if specified in both route and request
|
||||
if (path && route.match.path) {
|
||||
if (!this.matchPath(route.match.path, path)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check client IP match
|
||||
if (route.match.clientIp && !route.match.clientIp.some(pattern =>
|
||||
this.matchIpPattern(pattern, clientIp))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check TLS version match
|
||||
if (tlsVersion && route.match.tlsVersion &&
|
||||
!route.match.tlsVersion.includes(tlsVersion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// All checks passed, this route matches
|
||||
// NOTE: Security is checked AFTER route matching in route-connection-handler.ts
|
||||
return { route };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path against a pattern
|
||||
*/
|
||||
private matchPath(pattern: string, path: string): boolean {
|
||||
// Convert the glob pattern to a regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*') // Convert * to .*
|
||||
.replace(/\//g, '\\/'); // Escape slashes
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain-based configuration methods have been removed
|
||||
* as part of the migration to pure route-based configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate the route configuration and return any warnings
|
||||
*/
|
||||
public validateConfiguration(): string[] {
|
||||
const warnings: string[] = [];
|
||||
const duplicatePorts = new Map<number, number>();
|
||||
|
||||
// Check for routes with the same exact match criteria
|
||||
for (let i = 0; i < this.routes.length; i++) {
|
||||
for (let j = i + 1; j < this.routes.length; j++) {
|
||||
const route1 = this.routes[i];
|
||||
const route2 = this.routes[j];
|
||||
|
||||
// Check if route match criteria are the same
|
||||
if (this.areMatchesSimilar(route1.match, route2.match)) {
|
||||
warnings.push(
|
||||
`Routes "${route1.name || i}" and "${route2.name || j}" have similar match criteria. ` +
|
||||
`The route with higher priority (${Math.max(route1.priority || 0, route2.priority || 0)}) will be used.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for routes that may never be matched due to priority
|
||||
for (let i = 0; i < this.routes.length; i++) {
|
||||
const route = this.routes[i];
|
||||
const higherPriorityRoutes = this.routes.filter(r =>
|
||||
(r.priority || 0) > (route.priority || 0));
|
||||
|
||||
for (const higherRoute of higherPriorityRoutes) {
|
||||
if (this.isRouteShadowed(route, higherRoute)) {
|
||||
warnings.push(
|
||||
`Route "${route.name || i}" may never be matched because it is shadowed by ` +
|
||||
`higher priority route "${higherRoute.name || 'unnamed'}"`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two route matches are similar (potential conflict)
|
||||
*/
|
||||
private areMatchesSimilar(match1: IRouteMatch, match2: IRouteMatch): boolean {
|
||||
// Check port overlap
|
||||
const ports1 = new Set(this.expandPortRange(match1.ports));
|
||||
const ports2 = new Set(this.expandPortRange(match2.ports));
|
||||
|
||||
let havePortOverlap = false;
|
||||
for (const port of ports1) {
|
||||
if (ports2.has(port)) {
|
||||
havePortOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!havePortOverlap) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check domain overlap
|
||||
if (match1.domains && match2.domains) {
|
||||
const domains1 = Array.isArray(match1.domains) ? match1.domains : [match1.domains];
|
||||
const domains2 = Array.isArray(match2.domains) ? match2.domains : [match2.domains];
|
||||
|
||||
// Check if any domain pattern from match1 could match any from match2
|
||||
let haveDomainOverlap = false;
|
||||
for (const domain1 of domains1) {
|
||||
for (const domain2 of domains2) {
|
||||
if (domain1 === domain2 ||
|
||||
(domain1.includes('*') || domain2.includes('*'))) {
|
||||
haveDomainOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (haveDomainOverlap) break;
|
||||
}
|
||||
|
||||
if (!haveDomainOverlap) {
|
||||
return false;
|
||||
}
|
||||
} else if (match1.domains || match2.domains) {
|
||||
// One has domains, the other doesn't - they could overlap
|
||||
// The one with domains is more specific, so it's not exactly a conflict
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check path overlap
|
||||
if (match1.path && match2.path) {
|
||||
// This is a simplified check - in a real implementation,
|
||||
// you'd need to check if the path patterns could match the same paths
|
||||
return match1.path === match2.path ||
|
||||
match1.path.includes('*') ||
|
||||
match2.path.includes('*');
|
||||
} else if (match1.path || match2.path) {
|
||||
// One has a path, the other doesn't
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we get here, the matches have significant overlap
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is completely shadowed by a higher priority route
|
||||
*/
|
||||
private isRouteShadowed(route: IRouteConfig, higherPriorityRoute: IRouteConfig): boolean {
|
||||
// If they don't have similar match criteria, no shadowing occurs
|
||||
if (!this.areMatchesSimilar(route.match, higherPriorityRoute.match)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If higher priority route has more specific criteria, no shadowing
|
||||
if (this.isRouteMoreSpecific(higherPriorityRoute.match, route.match)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If higher priority route is equally or less specific but has higher priority,
|
||||
// it shadows the lower priority route
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route1 is more specific than route2
|
||||
*/
|
||||
private isRouteMoreSpecific(match1: IRouteMatch, match2: IRouteMatch): boolean {
|
||||
// Check if match1 has more specific criteria
|
||||
let match1Points = 0;
|
||||
let match2Points = 0;
|
||||
|
||||
// Path is the most specific
|
||||
if (match1.path) match1Points += 3;
|
||||
if (match2.path) match2Points += 3;
|
||||
|
||||
// Domain is next most specific
|
||||
if (match1.domains) match1Points += 2;
|
||||
if (match2.domains) match2Points += 2;
|
||||
|
||||
// Client IP and TLS version are least specific
|
||||
if (match1.clientIp) match1Points += 1;
|
||||
if (match2.clientIp) match2Points += 1;
|
||||
|
||||
if (match1.tlsVersion) match1Points += 1;
|
||||
if (match2.tlsVersion) match2Points += 1;
|
||||
|
||||
return match1Points > match2Points;
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ import { TlsManager } from './tls-manager.js';
|
||||
import { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
import { TimeoutManager } from './timeout-manager.js';
|
||||
import { PortManager } from './port-manager.js';
|
||||
import { RouteManager } from './route-manager.js';
|
||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
import { NFTablesManager } from './nftables-manager.js';
|
||||
|
||||
@ -162,8 +162,20 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
this.timeoutManager
|
||||
);
|
||||
|
||||
// Create the route manager
|
||||
this.routeManager = new RouteManager(this.settings);
|
||||
// Create the route manager with SharedRouteManager API
|
||||
// Create a logger adapter to match ILogger interface
|
||||
const loggerAdapter = {
|
||||
debug: (message: string, data?: any) => logger.log('debug', message, data),
|
||||
info: (message: string, data?: any) => logger.log('info', message, data),
|
||||
warn: (message: string, data?: any) => logger.log('warn', message, data),
|
||||
error: (message: string, data?: any) => logger.log('error', message, data)
|
||||
};
|
||||
|
||||
this.routeManager = new RouteManager({
|
||||
logger: loggerAdapter,
|
||||
enableDetailedLogging: this.settings.enableDetailedLogging,
|
||||
routes: this.settings.routes
|
||||
});
|
||||
|
||||
|
||||
// Create other required components
|
||||
|
@ -92,6 +92,8 @@ export function mergeRouteConfigs(
|
||||
return mergedRoute;
|
||||
}
|
||||
|
||||
import { DomainMatcher, PathMatcher, HeaderMatcher } from '../../../core/routing/matchers/index.js';
|
||||
|
||||
/**
|
||||
* Check if a route matches a domain
|
||||
* @param route The route to check
|
||||
@ -107,14 +109,7 @@ export function routeMatchesDomain(route: IRouteConfig, domain: string): boolean
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
return domains.some(d => {
|
||||
// Handle wildcard domains
|
||||
if (d.startsWith('*.')) {
|
||||
const suffix = d.substring(2);
|
||||
return domain.endsWith(suffix) && domain.split('.').length > suffix.split('.').length;
|
||||
}
|
||||
return d.toLowerCase() === domain.toLowerCase();
|
||||
});
|
||||
return domains.some(d => DomainMatcher.match(d, domain));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,28 +155,7 @@ export function routeMatchesPath(route: IRouteConfig, path: string): boolean {
|
||||
return true; // No path specified means it matches any path
|
||||
}
|
||||
|
||||
// Handle exact path
|
||||
if (route.match.path === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle path prefix with trailing slash (e.g., /api/)
|
||||
if (route.match.path.endsWith('/') && path.startsWith(route.match.path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle exact path match without trailing slash
|
||||
if (!route.match.path.endsWith('/') && path === route.match.path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle wildcard paths (e.g., /api/*)
|
||||
if (route.match.path.endsWith('*')) {
|
||||
const prefix = route.match.path.slice(0, -1);
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
return PathMatcher.match(route.match.path, path).matches;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -198,25 +172,13 @@ export function routeMatchesHeaders(
|
||||
return true; // No headers specified means it matches any headers
|
||||
}
|
||||
|
||||
// Check each header in the route's match criteria
|
||||
return Object.entries(route.match.headers).every(([key, value]) => {
|
||||
// If the header isn't present in the request, it doesn't match
|
||||
if (!headers[key]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle exact match
|
||||
if (typeof value === 'string') {
|
||||
return headers[key] === value;
|
||||
}
|
||||
|
||||
// Handle regex match
|
||||
if (value instanceof RegExp) {
|
||||
return value.test(headers[key]);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
// Convert RegExp patterns to strings for HeaderMatcher
|
||||
const stringHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(route.match.headers)) {
|
||||
stringHeaders[key] = value instanceof RegExp ? value.source : value;
|
||||
}
|
||||
|
||||
return HeaderMatcher.matchAll(stringHeaders, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
|
266
ts/routing/router/http-router.ts
Normal file
266
ts/routing/router/http-router.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import { DomainMatcher, PathMatcher } from '../../core/routing/matchers/index.js';
|
||||
|
||||
/**
|
||||
* Interface for router result with additional metadata
|
||||
*/
|
||||
export interface RouterResult {
|
||||
route: IRouteConfig;
|
||||
pathMatch?: string;
|
||||
pathParams?: Record<string, string>;
|
||||
pathRemainder?: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Logger interface for HttpRouter
|
||||
*/
|
||||
export interface ILogger {
|
||||
debug?: (message: string, data?: any) => void;
|
||||
info: (message: string, data?: any) => void;
|
||||
warn: (message: string, data?: any) => void;
|
||||
error: (message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified HTTP Router for reverse proxy requests
|
||||
*
|
||||
* Domain matching patterns:
|
||||
* - Exact matches: "example.com"
|
||||
* - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com)
|
||||
* - TLD wildcards: "example.*" (matches example.com, example.org, etc.)
|
||||
* - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain)
|
||||
* - Default fallback: "*" (matches any unmatched domain)
|
||||
*
|
||||
* Path pattern matching:
|
||||
* - Exact path: "/api/users"
|
||||
* - Wildcard paths: "/api/*"
|
||||
* - Path parameters: "/users/:id/profile"
|
||||
*/
|
||||
export class HttpRouter {
|
||||
// Store routes sorted by priority
|
||||
private routes: IRouteConfig[] = [];
|
||||
// Default route to use when no match is found (optional)
|
||||
private defaultRoute?: IRouteConfig;
|
||||
// Logger interface
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
routes?: IRouteConfig[],
|
||||
logger?: ILogger
|
||||
) {
|
||||
this.logger = logger || {
|
||||
error: console.error.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
info: console.info.bind(console),
|
||||
debug: console.debug?.bind(console)
|
||||
};
|
||||
|
||||
if (routes) {
|
||||
this.setRoutes(routes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new set of routes
|
||||
* @param routes Array of route configurations
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = [...routes];
|
||||
|
||||
// Sort routes by priority (higher priority first)
|
||||
this.routes.sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
|
||||
// Find default route if any (route with "*" as domain)
|
||||
this.defaultRoute = this.routes.find(route => {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: route.match.domains ? [route.match.domains] : [];
|
||||
return domains.includes('*');
|
||||
});
|
||||
|
||||
const uniqueDomains = this.getHostnames();
|
||||
this.logger.info(`HttpRouter initialized with ${this.routes.length} routes (${uniqueDomains.length} unique hosts)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a request based on hostname and path
|
||||
* @param req The incoming HTTP request
|
||||
* @returns The matching route or undefined if no match found
|
||||
*/
|
||||
public routeReq(req: plugins.http.IncomingMessage): IRouteConfig | undefined {
|
||||
const result = this.routeReqWithDetails(req);
|
||||
return result ? result.route : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a request with detailed matching information
|
||||
* @param req The incoming HTTP request
|
||||
* @returns Detailed routing result including matched route and path information
|
||||
*/
|
||||
public routeReqWithDetails(req: plugins.http.IncomingMessage): RouterResult | undefined {
|
||||
// Extract and validate host header
|
||||
const originalHost = req.headers.host;
|
||||
if (!originalHost) {
|
||||
this.logger.error('No host header found in request');
|
||||
return this.defaultRoute ? { route: this.defaultRoute } : undefined;
|
||||
}
|
||||
|
||||
// Parse URL for path matching
|
||||
const parsedUrl = plugins.url.parse(req.url || '/');
|
||||
const urlPath = parsedUrl.pathname || '/';
|
||||
|
||||
// Extract hostname without port
|
||||
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
|
||||
|
||||
// Find matching route
|
||||
const matchingRoute = this.findMatchingRoute(hostWithoutPort, urlPath);
|
||||
|
||||
if (matchingRoute) {
|
||||
return matchingRoute;
|
||||
}
|
||||
|
||||
// Fall back to default route if available
|
||||
if (this.defaultRoute) {
|
||||
this.logger.warn(`No specific route found for host: ${hostWithoutPort}, using default`);
|
||||
return { route: this.defaultRoute };
|
||||
}
|
||||
|
||||
this.logger.error(`No route found for host: ${hostWithoutPort}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching route for a given hostname and path
|
||||
*/
|
||||
private findMatchingRoute(hostname: string, path: string): RouterResult | undefined {
|
||||
// Try each route in priority order
|
||||
for (const route of this.routes) {
|
||||
// Skip disabled routes
|
||||
if (route.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check domain match
|
||||
if (route.match.domains) {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
// Check if any domain pattern matches
|
||||
const domainMatches = domains.some(domain =>
|
||||
DomainMatcher.match(domain, hostname)
|
||||
);
|
||||
|
||||
if (!domainMatches) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check path match if specified
|
||||
if (route.match.path) {
|
||||
const pathResult = PathMatcher.match(route.match.path, path);
|
||||
if (pathResult.matches) {
|
||||
return {
|
||||
route,
|
||||
pathMatch: path,
|
||||
pathParams: pathResult.params,
|
||||
pathRemainder: pathResult.pathRemainder
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// No path specified, so domain match is sufficient
|
||||
return { route };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all currently active route configurations
|
||||
* @returns Array of all active routes
|
||||
*/
|
||||
public getRoutes(): IRouteConfig[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all hostnames that this router is configured to handle
|
||||
* @returns Array of unique hostnames
|
||||
*/
|
||||
public getHostnames(): string[] {
|
||||
const hostnames = new Set<string>();
|
||||
for (const route of this.routes) {
|
||||
if (!route.match.domains) continue;
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
for (const domain of domains) {
|
||||
if (domain !== '*') {
|
||||
hostnames.add(domain.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(hostnames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single new route configuration
|
||||
* @param route The route configuration to add
|
||||
*/
|
||||
public addRoute(route: IRouteConfig): void {
|
||||
this.routes.push(route);
|
||||
|
||||
// Re-sort routes by priority
|
||||
this.routes.sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes routes by domain pattern
|
||||
* @param domain The domain pattern to remove routes for
|
||||
* @returns Boolean indicating whether any routes were removed
|
||||
*/
|
||||
public removeRoutesByDomain(domain: string): boolean {
|
||||
const initialCount = this.routes.length;
|
||||
|
||||
// Filter out routes that match the domain
|
||||
this.routes = this.routes.filter(route => {
|
||||
if (!route.match.domains) return true;
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
return !domains.includes(domain);
|
||||
});
|
||||
|
||||
return this.routes.length !== initialCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific route by reference
|
||||
* @param route The route to remove
|
||||
* @returns Boolean indicating if the route was found and removed
|
||||
*/
|
||||
public removeRoute(route: IRouteConfig): boolean {
|
||||
const index = this.routes.indexOf(route);
|
||||
if (index !== -1) {
|
||||
this.routes.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
@ -2,11 +2,6 @@
|
||||
* HTTP routing
|
||||
*/
|
||||
|
||||
// Export selectively to avoid ambiguity between duplicate type names
|
||||
export { ProxyRouter } from './proxy-router.js';
|
||||
export type { IPathPatternConfig } from './proxy-router.js';
|
||||
// Re-export the RouterResult and PathPatternConfig from proxy-router.js (legacy names maintained for compatibility)
|
||||
export type { PathPatternConfig as ProxyPathPatternConfig, RouterResult as ProxyRouterResult } from './proxy-router.js';
|
||||
|
||||
export { RouteRouter } from './route-router.js';
|
||||
export type { PathPatternConfig as RoutePathPatternConfig, RouterResult as RouteRouterResult } from './route-router.js';
|
||||
// Export the unified HttpRouter
|
||||
export { HttpRouter } from './http-router.js';
|
||||
export type { RouterResult, ILogger } from './http-router.js';
|
||||
|
@ -1,437 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IReverseProxyConfig } from '../../proxies/http-proxy/models/types.js';
|
||||
|
||||
/**
|
||||
* Optional path pattern configuration that can be added to proxy configs
|
||||
*/
|
||||
export interface PathPatternConfig {
|
||||
pathPattern?: string;
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
export type IPathPatternConfig = PathPatternConfig;
|
||||
|
||||
/**
|
||||
* Interface for router result with additional metadata
|
||||
*/
|
||||
export interface RouterResult {
|
||||
config: IReverseProxyConfig;
|
||||
pathMatch?: string;
|
||||
pathParams?: Record<string, string>;
|
||||
pathRemainder?: string;
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
export type IRouterResult = RouterResult;
|
||||
|
||||
/**
|
||||
* Router for HTTP reverse proxy requests
|
||||
*
|
||||
* Supports the following domain matching patterns:
|
||||
* - Exact matches: "example.com"
|
||||
* - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com)
|
||||
* - TLD wildcards: "example.*" (matches example.com, example.org, etc.)
|
||||
* - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain)
|
||||
* - Default fallback: "*" (matches any unmatched domain)
|
||||
*
|
||||
* Also supports path pattern matching for each domain:
|
||||
* - Exact path: "/api/users"
|
||||
* - Wildcard paths: "/api/*"
|
||||
* - Path parameters: "/users/:id/profile"
|
||||
*/
|
||||
export class ProxyRouter {
|
||||
// Store original configs for reference
|
||||
private reverseProxyConfigs: IReverseProxyConfig[] = [];
|
||||
// Default config to use when no match is found (optional)
|
||||
private defaultConfig?: IReverseProxyConfig;
|
||||
// Store path patterns separately since they're not in the original interface
|
||||
private pathPatterns: Map<IReverseProxyConfig, string> = new Map();
|
||||
// Logger interface
|
||||
private logger: {
|
||||
error: (message: string, data?: any) => void;
|
||||
warn: (message: string, data?: any) => void;
|
||||
info: (message: string, data?: any) => void;
|
||||
debug: (message: string, data?: any) => void;
|
||||
};
|
||||
|
||||
constructor(
|
||||
configs?: IReverseProxyConfig[],
|
||||
logger?: {
|
||||
error: (message: string, data?: any) => void;
|
||||
warn: (message: string, data?: any) => void;
|
||||
info: (message: string, data?: any) => void;
|
||||
debug: (message: string, data?: any) => void;
|
||||
}
|
||||
) {
|
||||
this.logger = logger || console;
|
||||
if (configs) {
|
||||
this.setNewProxyConfigs(configs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new set of reverse configs to be routed to
|
||||
* @param reverseCandidatesArg Array of reverse proxy configurations
|
||||
*/
|
||||
public setNewProxyConfigs(reverseCandidatesArg: IReverseProxyConfig[]): void {
|
||||
this.reverseProxyConfigs = [...reverseCandidatesArg];
|
||||
|
||||
// Find default config if any (config with "*" as hostname)
|
||||
this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*');
|
||||
|
||||
this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.getHostnames().length} unique hosts)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a request based on hostname and path
|
||||
* @param req The incoming HTTP request
|
||||
* @returns The matching proxy config or undefined if no match found
|
||||
*/
|
||||
public routeReq(req: plugins.http.IncomingMessage): IReverseProxyConfig {
|
||||
const result = this.routeReqWithDetails(req);
|
||||
return result ? result.config : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a request with detailed matching information
|
||||
* @param req The incoming HTTP request
|
||||
* @returns Detailed routing result including matched config and path information
|
||||
*/
|
||||
public routeReqWithDetails(req: plugins.http.IncomingMessage): RouterResult | undefined {
|
||||
// Extract and validate host header
|
||||
const originalHost = req.headers.host;
|
||||
if (!originalHost) {
|
||||
this.logger.error('No host header found in request');
|
||||
return this.defaultConfig ? { config: this.defaultConfig } : undefined;
|
||||
}
|
||||
|
||||
// Parse URL for path matching
|
||||
const parsedUrl = plugins.url.parse(req.url || '/');
|
||||
const urlPath = parsedUrl.pathname || '/';
|
||||
|
||||
// Extract hostname without port
|
||||
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
|
||||
|
||||
// First try exact hostname match
|
||||
const exactConfig = this.findConfigForHost(hostWithoutPort, urlPath);
|
||||
if (exactConfig) {
|
||||
return exactConfig;
|
||||
}
|
||||
|
||||
// Try various wildcard patterns
|
||||
if (hostWithoutPort.includes('.')) {
|
||||
const domainParts = hostWithoutPort.split('.');
|
||||
|
||||
// Try wildcard subdomain (*.example.com)
|
||||
if (domainParts.length > 2) {
|
||||
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
|
||||
const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath);
|
||||
if (wildcardConfig) {
|
||||
return wildcardConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Try TLD wildcard (example.*)
|
||||
const baseDomain = domainParts.slice(0, -1).join('.');
|
||||
const tldWildcardDomain = `${baseDomain}.*`;
|
||||
const tldWildcardConfig = this.findConfigForHost(tldWildcardDomain, urlPath);
|
||||
if (tldWildcardConfig) {
|
||||
return tldWildcardConfig;
|
||||
}
|
||||
|
||||
// Try complex wildcard patterns
|
||||
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
|
||||
for (const pattern of wildcardPatterns) {
|
||||
const wildcardConfig = this.findConfigForHost(pattern, urlPath);
|
||||
if (wildcardConfig) {
|
||||
return wildcardConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default config if available
|
||||
if (this.defaultConfig) {
|
||||
this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`);
|
||||
return { config: this.defaultConfig };
|
||||
}
|
||||
|
||||
this.logger.error(`No config found for host: ${hostWithoutPort}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential wildcard patterns that could match a given hostname
|
||||
* Handles complex patterns like "*.lossless*" or other partial matches
|
||||
* @param hostname The hostname to find wildcard matches for
|
||||
* @returns Array of potential wildcard patterns that could match
|
||||
*/
|
||||
private findWildcardMatches(hostname: string): string[] {
|
||||
const patterns: string[] = [];
|
||||
const hostnameParts = hostname.split('.');
|
||||
|
||||
// Find all configured hostnames that contain wildcards
|
||||
const wildcardConfigs = this.reverseProxyConfigs.filter(
|
||||
config => config.hostName.includes('*')
|
||||
);
|
||||
|
||||
// Extract unique wildcard patterns
|
||||
const wildcardPatterns = [...new Set(
|
||||
wildcardConfigs.map(config => config.hostName.toLowerCase())
|
||||
)];
|
||||
|
||||
// For each wildcard pattern, check if it could match the hostname
|
||||
// using simplified regex pattern matching
|
||||
for (const pattern of wildcardPatterns) {
|
||||
// Skip the default wildcard '*'
|
||||
if (pattern === '*') continue;
|
||||
|
||||
// Skip already checked patterns (*.domain.com and domain.*)
|
||||
if (pattern.startsWith('*.') && pattern.indexOf('*', 2) === -1) continue;
|
||||
if (pattern.endsWith('.*') && pattern.indexOf('*') === pattern.length - 1) continue;
|
||||
|
||||
// Convert wildcard pattern to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*'); // Convert * to .* for regex
|
||||
|
||||
// Create regex object with case insensitive flag
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
|
||||
// If hostname matches this complex pattern, add it to the list
|
||||
if (regex.test(hostname)) {
|
||||
patterns.push(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a config for a specific host and path
|
||||
*/
|
||||
private findConfigForHost(hostname: string, path: string): RouterResult | undefined {
|
||||
// Find all configs for this hostname
|
||||
const configs = this.reverseProxyConfigs.filter(
|
||||
config => config.hostName.toLowerCase() === hostname.toLowerCase()
|
||||
);
|
||||
|
||||
if (configs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// First try configs with path patterns
|
||||
const configsWithPaths = configs.filter(config => this.pathPatterns.has(config));
|
||||
|
||||
// Sort by path pattern specificity - more specific first
|
||||
configsWithPaths.sort((a, b) => {
|
||||
const aPattern = this.pathPatterns.get(a) || '';
|
||||
const bPattern = this.pathPatterns.get(b) || '';
|
||||
|
||||
// Exact patterns come before wildcard patterns
|
||||
const aHasWildcard = aPattern.includes('*');
|
||||
const bHasWildcard = bPattern.includes('*');
|
||||
|
||||
if (aHasWildcard && !bHasWildcard) return 1;
|
||||
if (!aHasWildcard && bHasWildcard) return -1;
|
||||
|
||||
// Longer patterns are considered more specific
|
||||
return bPattern.length - aPattern.length;
|
||||
});
|
||||
|
||||
// Check each config with path pattern
|
||||
for (const config of configsWithPaths) {
|
||||
const pathPattern = this.pathPatterns.get(config);
|
||||
if (pathPattern) {
|
||||
const pathMatch = this.matchPath(path, pathPattern);
|
||||
if (pathMatch) {
|
||||
return {
|
||||
config,
|
||||
pathMatch: pathMatch.matched,
|
||||
pathParams: pathMatch.params,
|
||||
pathRemainder: pathMatch.remainder
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no path pattern matched, use the first config without a path pattern
|
||||
const configWithoutPath = configs.find(config => !this.pathPatterns.has(config));
|
||||
if (configWithoutPath) {
|
||||
return { config: configWithoutPath };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a URL path against a pattern
|
||||
* Supports:
|
||||
* - Exact matches: /users/profile
|
||||
* - Wildcards: /api/* (matches any path starting with /api/)
|
||||
* - Path parameters: /users/:id (captures id as a parameter)
|
||||
*
|
||||
* @param path The URL path to match
|
||||
* @param pattern The pattern to match against
|
||||
* @returns Match result with params and remainder, or null if no match
|
||||
*/
|
||||
private matchPath(path: string, pattern: string): {
|
||||
matched: string;
|
||||
params: Record<string, string>;
|
||||
remainder: string;
|
||||
} | null {
|
||||
// Handle exact match
|
||||
if (path === pattern) {
|
||||
return {
|
||||
matched: pattern,
|
||||
params: {},
|
||||
remainder: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Handle wildcard match
|
||||
if (pattern.endsWith('/*')) {
|
||||
const prefix = pattern.slice(0, -2);
|
||||
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
||||
return {
|
||||
matched: prefix,
|
||||
params: {},
|
||||
remainder: path.slice(prefix.length)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle path parameters
|
||||
const patternParts = pattern.split('/').filter(p => p);
|
||||
const pathParts = path.split('/').filter(p => p);
|
||||
|
||||
// Too few path parts to match
|
||||
if (pathParts.length < patternParts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// Compare each part
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
const pathPart = pathParts[i];
|
||||
|
||||
// Handle parameter
|
||||
if (patternPart.startsWith(':')) {
|
||||
const paramName = patternPart.slice(1);
|
||||
params[paramName] = pathPart;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle wildcard at the end
|
||||
if (patternPart === '*' && i === patternParts.length - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle exact match for this part
|
||||
if (patternPart !== pathPart) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the remainder - the unmatched path parts
|
||||
const remainderParts = pathParts.slice(patternParts.length);
|
||||
const remainder = remainderParts.length ? '/' + remainderParts.join('/') : '';
|
||||
|
||||
// Calculate the matched path
|
||||
const matchedParts = patternParts.map((part, i) => {
|
||||
return part.startsWith(':') ? pathParts[i] : part;
|
||||
});
|
||||
const matched = '/' + matchedParts.join('/');
|
||||
|
||||
return {
|
||||
matched,
|
||||
params,
|
||||
remainder
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all currently active proxy configurations
|
||||
* @returns Array of all active configurations
|
||||
*/
|
||||
public getProxyConfigs(): IReverseProxyConfig[] {
|
||||
return [...this.reverseProxyConfigs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all hostnames that this router is configured to handle
|
||||
* @returns Array of hostnames
|
||||
*/
|
||||
public getHostnames(): string[] {
|
||||
const hostnames = new Set<string>();
|
||||
for (const config of this.reverseProxyConfigs) {
|
||||
if (config.hostName !== '*') {
|
||||
hostnames.add(config.hostName.toLowerCase());
|
||||
}
|
||||
}
|
||||
return Array.from(hostnames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single new proxy configuration
|
||||
* @param config The configuration to add
|
||||
* @param pathPattern Optional path pattern for route matching
|
||||
*/
|
||||
public addProxyConfig(
|
||||
config: IReverseProxyConfig,
|
||||
pathPattern?: string
|
||||
): void {
|
||||
this.reverseProxyConfigs.push(config);
|
||||
|
||||
// Store path pattern if provided
|
||||
if (pathPattern) {
|
||||
this.pathPatterns.set(config, pathPattern);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a path pattern for an existing config
|
||||
* @param config The existing configuration
|
||||
* @param pathPattern The path pattern to set
|
||||
* @returns Boolean indicating if the config was found and updated
|
||||
*/
|
||||
public setPathPattern(
|
||||
config: IReverseProxyConfig,
|
||||
pathPattern: string
|
||||
): boolean {
|
||||
const exists = this.reverseProxyConfigs.includes(config);
|
||||
if (exists) {
|
||||
this.pathPatterns.set(config, pathPattern);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a proxy configuration by hostname
|
||||
* @param hostname The hostname to remove
|
||||
* @returns Boolean indicating whether any configs were removed
|
||||
*/
|
||||
public removeProxyConfig(hostname: string): boolean {
|
||||
const initialCount = this.reverseProxyConfigs.length;
|
||||
|
||||
// Find configs to remove
|
||||
const configsToRemove = this.reverseProxyConfigs.filter(
|
||||
config => config.hostName === hostname
|
||||
);
|
||||
|
||||
// Remove them from the patterns map
|
||||
for (const config of configsToRemove) {
|
||||
this.pathPatterns.delete(config);
|
||||
}
|
||||
|
||||
// Filter them out of the configs array
|
||||
this.reverseProxyConfigs = this.reverseProxyConfigs.filter(
|
||||
config => config.hostName !== hostname
|
||||
);
|
||||
|
||||
return this.reverseProxyConfigs.length !== initialCount;
|
||||
}
|
||||
}
|
@ -1,482 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
||||
import type { ILogger } from '../../proxies/http-proxy/models/types.js';
|
||||
|
||||
/**
|
||||
* Optional path pattern configuration that can be added to proxy configs
|
||||
*/
|
||||
export interface PathPatternConfig {
|
||||
pathPattern?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for router result with additional metadata
|
||||
*/
|
||||
export interface RouterResult {
|
||||
route: IRouteConfig;
|
||||
pathMatch?: string;
|
||||
pathParams?: Record<string, string>;
|
||||
pathRemainder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Router for HTTP reverse proxy requests based on route configurations
|
||||
*
|
||||
* Supports the following domain matching patterns:
|
||||
* - Exact matches: "example.com"
|
||||
* - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com)
|
||||
* - TLD wildcards: "example.*" (matches example.com, example.org, etc.)
|
||||
* - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain)
|
||||
* - Default fallback: "*" (matches any unmatched domain)
|
||||
*
|
||||
* Also supports path pattern matching for each domain:
|
||||
* - Exact path: "/api/users"
|
||||
* - Wildcard paths: "/api/*"
|
||||
* - Path parameters: "/users/:id/profile"
|
||||
*/
|
||||
export class RouteRouter {
|
||||
// Store original routes for reference
|
||||
private routes: IRouteConfig[] = [];
|
||||
// Default route to use when no match is found (optional)
|
||||
private defaultRoute?: IRouteConfig;
|
||||
// Store path patterns separately since they're not in the original interface
|
||||
private pathPatterns: Map<IRouteConfig, string> = new Map();
|
||||
// Logger interface
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
routes?: IRouteConfig[],
|
||||
logger?: ILogger
|
||||
) {
|
||||
this.logger = logger || {
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
debug: console.debug
|
||||
};
|
||||
|
||||
if (routes) {
|
||||
this.setRoutes(routes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new set of routes to be routed to
|
||||
* @param routes Array of route configurations
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = [...routes];
|
||||
|
||||
// Sort routes by priority
|
||||
this.routes.sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
|
||||
// Find default route if any (route with "*" as domain)
|
||||
this.defaultRoute = this.routes.find(route => {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
return domains.includes('*');
|
||||
});
|
||||
|
||||
// Extract path patterns from route match.path
|
||||
for (const route of this.routes) {
|
||||
if (route.match.path) {
|
||||
this.pathPatterns.set(route, route.match.path);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueDomains = this.getHostnames();
|
||||
this.logger.info(`Router initialized with ${this.routes.length} routes (${uniqueDomains.length} unique hosts)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a request based on hostname and path
|
||||
* @param req The incoming HTTP request
|
||||
* @returns The matching route or undefined if no match found
|
||||
*/
|
||||
public routeReq(req: plugins.http.IncomingMessage): IRouteConfig | undefined {
|
||||
const result = this.routeReqWithDetails(req);
|
||||
return result ? result.route : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a request with detailed matching information
|
||||
* @param req The incoming HTTP request
|
||||
* @returns Detailed routing result including matched route and path information
|
||||
*/
|
||||
public routeReqWithDetails(req: plugins.http.IncomingMessage): RouterResult | undefined {
|
||||
// Extract and validate host header
|
||||
const originalHost = req.headers.host;
|
||||
if (!originalHost) {
|
||||
this.logger.error('No host header found in request');
|
||||
return this.defaultRoute ? { route: this.defaultRoute } : undefined;
|
||||
}
|
||||
|
||||
// Parse URL for path matching
|
||||
const parsedUrl = plugins.url.parse(req.url || '/');
|
||||
const urlPath = parsedUrl.pathname || '/';
|
||||
|
||||
// Extract hostname without port
|
||||
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
|
||||
|
||||
// First try exact hostname match
|
||||
const exactRoute = this.findRouteForHost(hostWithoutPort, urlPath);
|
||||
if (exactRoute) {
|
||||
return exactRoute;
|
||||
}
|
||||
|
||||
// Try various wildcard patterns
|
||||
if (hostWithoutPort.includes('.')) {
|
||||
const domainParts = hostWithoutPort.split('.');
|
||||
|
||||
// Try wildcard subdomain (*.example.com)
|
||||
if (domainParts.length > 2) {
|
||||
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
|
||||
const wildcardRoute = this.findRouteForHost(wildcardDomain, urlPath);
|
||||
if (wildcardRoute) {
|
||||
return wildcardRoute;
|
||||
}
|
||||
}
|
||||
|
||||
// Try TLD wildcard (example.*)
|
||||
const baseDomain = domainParts.slice(0, -1).join('.');
|
||||
const tldWildcardDomain = `${baseDomain}.*`;
|
||||
const tldWildcardRoute = this.findRouteForHost(tldWildcardDomain, urlPath);
|
||||
if (tldWildcardRoute) {
|
||||
return tldWildcardRoute;
|
||||
}
|
||||
|
||||
// Try complex wildcard patterns
|
||||
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
|
||||
for (const pattern of wildcardPatterns) {
|
||||
const wildcardRoute = this.findRouteForHost(pattern, urlPath);
|
||||
if (wildcardRoute) {
|
||||
return wildcardRoute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default route if available
|
||||
if (this.defaultRoute) {
|
||||
this.logger.warn(`No specific route found for host: ${hostWithoutPort}, using default`);
|
||||
return { route: this.defaultRoute };
|
||||
}
|
||||
|
||||
this.logger.error(`No route found for host: ${hostWithoutPort}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential wildcard patterns that could match a given hostname
|
||||
* Handles complex patterns like "*.lossless*" or other partial matches
|
||||
* @param hostname The hostname to find wildcard matches for
|
||||
* @returns Array of potential wildcard patterns that could match
|
||||
*/
|
||||
private findWildcardMatches(hostname: string): string[] {
|
||||
const patterns: string[] = [];
|
||||
|
||||
// Find all routes with wildcard domains
|
||||
for (const route of this.routes) {
|
||||
if (!route.match.domains) continue;
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
// Filter to only wildcard domains
|
||||
const wildcardDomains = domains.filter(domain => domain.includes('*'));
|
||||
|
||||
// Convert each wildcard domain to a regex pattern and check if it matches
|
||||
for (const domain of wildcardDomains) {
|
||||
// Skip the default wildcard '*'
|
||||
if (domain === '*') continue;
|
||||
|
||||
// Skip already checked patterns (*.domain.com and domain.*)
|
||||
if (domain.startsWith('*.') && domain.indexOf('*', 2) === -1) continue;
|
||||
if (domain.endsWith('.*') && domain.indexOf('*') === domain.length - 1) continue;
|
||||
|
||||
// Convert wildcard pattern to regex
|
||||
const regexPattern = domain
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*'); // Convert * to .* for regex
|
||||
|
||||
// Create regex object with case insensitive flag
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
|
||||
// If hostname matches this complex pattern, add it to the list
|
||||
if (regex.test(hostname)) {
|
||||
patterns.push(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a route for a specific host and path
|
||||
*/
|
||||
private findRouteForHost(hostname: string, path: string): RouterResult | undefined {
|
||||
// Find all routes for this hostname
|
||||
const matchingRoutes = this.routes.filter(route => {
|
||||
if (!route.match.domains) return false;
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
return domains.some(domain => domain.toLowerCase() === hostname.toLowerCase());
|
||||
});
|
||||
|
||||
if (matchingRoutes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// First try routes with path patterns
|
||||
const routesWithPaths = matchingRoutes.filter(route => this.pathPatterns.has(route));
|
||||
|
||||
// Already sorted by priority during setRoutes
|
||||
|
||||
// Check each route with path pattern
|
||||
for (const route of routesWithPaths) {
|
||||
const pathPattern = this.pathPatterns.get(route);
|
||||
if (pathPattern) {
|
||||
const pathMatch = this.matchPath(path, pathPattern);
|
||||
if (pathMatch) {
|
||||
return {
|
||||
route,
|
||||
pathMatch: pathMatch.matched,
|
||||
pathParams: pathMatch.params,
|
||||
pathRemainder: pathMatch.remainder
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no path pattern matched, use the first route without a path pattern
|
||||
const routeWithoutPath = matchingRoutes.find(route => !this.pathPatterns.has(route));
|
||||
if (routeWithoutPath) {
|
||||
return { route: routeWithoutPath };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a URL path against a pattern
|
||||
* Supports:
|
||||
* - Exact matches: /users/profile
|
||||
* - Wildcards: /api/* (matches any path starting with /api/)
|
||||
* - Path parameters: /users/:id (captures id as a parameter)
|
||||
*
|
||||
* @param path The URL path to match
|
||||
* @param pattern The pattern to match against
|
||||
* @returns Match result with params and remainder, or null if no match
|
||||
*/
|
||||
private matchPath(path: string, pattern: string): {
|
||||
matched: string;
|
||||
params: Record<string, string>;
|
||||
remainder: string;
|
||||
} | null {
|
||||
// Handle exact match
|
||||
if (path === pattern) {
|
||||
return {
|
||||
matched: pattern,
|
||||
params: {},
|
||||
remainder: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Handle wildcard match
|
||||
if (pattern.endsWith('/*')) {
|
||||
const prefix = pattern.slice(0, -2);
|
||||
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
||||
return {
|
||||
matched: prefix,
|
||||
params: {},
|
||||
remainder: path.slice(prefix.length)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle path parameters
|
||||
const patternParts = pattern.split('/').filter(p => p);
|
||||
const pathParts = path.split('/').filter(p => p);
|
||||
|
||||
// Too few path parts to match
|
||||
if (pathParts.length < patternParts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// Compare each part
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
const pathPart = pathParts[i];
|
||||
|
||||
// Handle parameter
|
||||
if (patternPart.startsWith(':')) {
|
||||
const paramName = patternPart.slice(1);
|
||||
params[paramName] = pathPart;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle wildcard at the end
|
||||
if (patternPart === '*' && i === patternParts.length - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle exact match for this part
|
||||
if (patternPart !== pathPart) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the remainder - the unmatched path parts
|
||||
const remainderParts = pathParts.slice(patternParts.length);
|
||||
const remainder = remainderParts.length ? '/' + remainderParts.join('/') : '';
|
||||
|
||||
// Calculate the matched path
|
||||
const matchedParts = patternParts.map((part, i) => {
|
||||
return part.startsWith(':') ? pathParts[i] : part;
|
||||
});
|
||||
const matched = '/' + matchedParts.join('/');
|
||||
|
||||
return {
|
||||
matched,
|
||||
params,
|
||||
remainder
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all currently active route configurations
|
||||
* @returns Array of all active routes
|
||||
*/
|
||||
public getRoutes(): IRouteConfig[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all hostnames that this router is configured to handle
|
||||
* @returns Array of hostnames
|
||||
*/
|
||||
public getHostnames(): string[] {
|
||||
const hostnames = new Set<string>();
|
||||
for (const route of this.routes) {
|
||||
if (!route.match.domains) continue;
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
for (const domain of domains) {
|
||||
if (domain !== '*') {
|
||||
hostnames.add(domain.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(hostnames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single new route configuration
|
||||
* @param route The route configuration to add
|
||||
*/
|
||||
public addRoute(route: IRouteConfig): void {
|
||||
this.routes.push(route);
|
||||
|
||||
// Store path pattern if present
|
||||
if (route.match.path) {
|
||||
this.pathPatterns.set(route, route.match.path);
|
||||
}
|
||||
|
||||
// Re-sort routes by priority
|
||||
this.routes.sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes routes by domain pattern
|
||||
* @param domain The domain pattern to remove routes for
|
||||
* @returns Boolean indicating whether any routes were removed
|
||||
*/
|
||||
public removeRoutesByDomain(domain: string): boolean {
|
||||
const initialCount = this.routes.length;
|
||||
|
||||
// Find routes to remove
|
||||
const routesToRemove = this.routes.filter(route => {
|
||||
if (!route.match.domains) return false;
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
return domains.includes(domain);
|
||||
});
|
||||
|
||||
// Remove them from the patterns map
|
||||
for (const route of routesToRemove) {
|
||||
this.pathPatterns.delete(route);
|
||||
}
|
||||
|
||||
// Filter them out of the routes array
|
||||
this.routes = this.routes.filter(route => {
|
||||
if (!route.match.domains) return true;
|
||||
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
|
||||
return !domains.includes(domain);
|
||||
});
|
||||
|
||||
return this.routes.length !== initialCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for compatibility with ProxyRouter
|
||||
* Converts IReverseProxyConfig to IRouteConfig and calls setRoutes
|
||||
*
|
||||
* @param configs Array of legacy proxy configurations
|
||||
*/
|
||||
public setNewProxyConfigs(configs: any[]): void {
|
||||
// Convert legacy configs to routes and add them
|
||||
const routes: IRouteConfig[] = configs.map(config => {
|
||||
// Create a basic route configuration from the legacy config
|
||||
return {
|
||||
match: {
|
||||
ports: config.destinationPorts[0], // Just use the first port
|
||||
domains: config.hostName
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: config.destinationIps,
|
||||
port: config.destinationPorts[0]
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: {
|
||||
key: config.privateKey,
|
||||
cert: config.publicKey
|
||||
}
|
||||
}
|
||||
},
|
||||
name: `Legacy Config - ${config.hostName}`,
|
||||
enabled: true
|
||||
};
|
||||
});
|
||||
|
||||
this.setRoutes(routes);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user