Compare commits

...

61 Commits

Author SHA1 Message Date
424407d879 fix(readme): update 2025-06-13 17:22:31 +00:00
7e1b7b190c fix(readme): update 2025-06-12 16:59:25 +00:00
8347e0fec7 19.6.2
Some checks failed
Default (tags) / security (push) Successful in 45s
Default (tags) / test (push) Failing after 34m50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-09 22:13:56 +00:00
fc09af9afd 19.6.1
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 31m49s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-09 16:37:46 +00:00
4c847fd3d7 19.6.0
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 33m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-09 15:28:53 +00:00
2e11f9358c docs(readme): add metrics and monitoring documentation
Document the new metrics collection system including available metrics methods, usage examples, and export formats for external monitoring systems.
2025-06-09 15:14:13 +00:00
9bf15ff756 feat(metrics): add comprehensive metrics collection system
Implement real-time stats tracking including connection counts, request metrics, bandwidth usage, and route-specific monitoring. Adds MetricsCollector with observable streams for reactive monitoring integration.
2025-06-09 15:08:37 +00:00
6726de277e 19.5.26
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 27m56s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-08 12:26:32 +00:00
dc3eda5e29 fix accumulation 2025-06-08 12:25:31 +00:00
82a350bf51 19.5.25
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 24m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-07 20:37:52 +00:00
890e907664 fix(connection): filter zombie connections part 2 2025-06-07 20:37:49 +00:00
19590ef107 19.5.24
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 24m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-07 10:56:08 +00:00
47735adbf2 Implement zombie connection detection and cleanup in ConnectionManager; enhance tests for edge cases 2025-06-07 10:55:59 +00:00
9094b76b1b 19.5.23
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 24m25s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-06 23:36:19 +00:00
9aebcd488d Implement connection timeout handling and improve connection cleanup in SmartProxy 2025-06-06 23:34:50 +00:00
311691c2cc 19.5.22
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 19m29s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-06 15:54:40 +00:00
578d1ba2f7 update 2025-06-06 15:00:46 +00:00
233c98e5ff 19.5.21
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 19m32s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-06 14:30:39 +00:00
b3714d583d Implement PROXY protocol v1 support in SmartProxy
- Added ProxyProtocolParser class for parsing and generating PROXY protocol v1 headers.
- Integrated PROXY protocol parsing into RouteConnectionHandler for handling incoming connections from trusted proxies.
- Implemented WrappedSocket class to encapsulate real client information.
- Configured SmartProxy to accept and send PROXY protocol headers in routing actions.
- Developed comprehensive unit tests for PROXY protocol parsing and generation.
- Documented usage patterns, configuration, and best practices for proxy chaining scenarios.
- Added security and performance considerations for PROXY protocol implementation.
2025-06-06 13:45:44 +00:00
527cacb1a8 19.5.20
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 19m26s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-06 08:25:22 +00:00
5f175b4ca8 fix tests 2025-06-06 08:23:37 +00:00
b9be6533ae start fixing tests 2025-06-06 07:40:59 +00:00
18d79ac7e1 feat(proxy): Implement WrappedSocket class for PROXY protocol support and update connection handling 2025-06-05 17:57:24 +00:00
2a75e7c490 Refactor routing and proxy components for improved structure and compatibility
- Removed deprecated route utility functions in favor of direct matcher usage.
- Updated imports to reflect new module structure for routing utilities.
- Consolidated route manager functionality into SharedRouteManager for better consistency.
- Eliminated legacy routing methods and interfaces, streamlining the HttpProxy and associated components.
- Enhanced WebSocket and HTTP request handling to utilize the new unified HttpRouter.
- Updated route matching logic to leverage matcher classes for domain, path, and header checks.
- Cleaned up legacy compatibility code across various modules, ensuring a more maintainable codebase.
2025-06-03 16:21:09 +00:00
cf70b6ace5 feat(routing): Add SharedRouteManager and route matching utilities for enhanced routing capabilities 2025-06-03 16:19:52 +00:00
54ffbadb86 feat(routing): Implement unified routing and matching system
- Introduced a centralized routing module with comprehensive matchers for domains, headers, IPs, and paths.
- Added DomainMatcher for domain pattern matching with support for wildcards and specificity calculation.
- Implemented HeaderMatcher for HTTP header matching, including exact matches and pattern support.
- Developed IpMatcher for IP address matching, supporting CIDR notation, ranges, and wildcards.
- Created PathMatcher for path matching with parameter extraction and wildcard support.
- Established RouteSpecificity class to calculate and compare route specificity scores.
- Enhanced HttpRouter to utilize the new matching system, supporting both modern and legacy route configurations.
- Added detailed logging and error handling for routing operations.
2025-06-02 03:57:52 +00:00
01e1153fb8 feat(proxy): Start implementing PROXY protocol support and WrappedSocket class for enhanced client IP handling 2025-06-01 21:30:37 +00:00
fa9166be4b 19.5.19
Some checks failed
Default (tags) / security (push) Failing after 14m47s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 15:45:03 +00:00
c5efee3bfe fix(smartproxy): Fix connection handling and improve route matching edge cases 2025-06-01 15:45:03 +00:00
47508eb1eb Refactor socket handling in forwarding handlers to use centralized utilities and remove deprecated functions 2025-06-01 15:35:45 +00:00
fb147148ef 19.5.18
Some checks failed
Default (tags) / security (push) Failing after 14m49s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 15:10:42 +00:00
07f5ceddc4 Implement proxy chain connection accumulation fix and add comprehensive tests
- Updated socket handling to prevent connection accumulation in chained proxies.
- Introduced centralized bidirectional forwarding for consistent socket management.
- Enhanced cleanup logic to ensure immediate closure of sockets when one closes.
- Added tests to verify connection behavior under various scenarios, including backend failures and rapid reconnections.
2025-06-01 15:10:36 +00:00
3ac3345be8 19.5.17
Some checks failed
Default (tags) / security (push) Failing after 14m51s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 14:41:23 +00:00
5b40e82c41 Add tests for connect-disconnect and error handling in SmartProxy 2025-06-01 14:41:19 +00:00
2a75a86d73 19.5.16
Some checks failed
Default (tags) / security (push) Failing after 14m53s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 14:22:23 +00:00
250eafd36c Enhance connection cleanup and error handling in RouteConnectionHandler
- Implement immediate cleanup for connection failures to prevent leaks
- Add NFTables cleanup on socket close to manage memory usage
- Fix connection limit bypass by checking record after creation
- Introduce tests for rapid connection retries and routing failures
2025-06-01 14:22:06 +00:00
facb68a9d0 19.5.15
Some checks failed
Default (tags) / security (push) Failing after 14m55s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 14:00:05 +00:00
23898c1577 19.5.14
Some checks failed
Default (tags) / security (push) Failing after 14m57s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 13:58:30 +00:00
2d240671ab Improve error handling and logging for outgoing connections in RouteConnectionHandler 2025-06-01 13:58:20 +00:00
705a59413d 19.5.13
Some checks failed
Default (tags) / security (push) Failing after 16m13s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 13:43:46 +00:00
e9723a8af9 19.5.12
Some checks failed
Default (tags) / security (push) Failing after 16m15s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 13:43:05 +00:00
300ab1a077 Fix connection leak in route-connection-handler by using safe socket creation
The previous fix only addressed ForwardingHandler classes but missed the critical setupDirectConnection() method in route-connection-handler.ts where SmartProxy actually handles connections. This caused active connections to rise indefinitely on ECONNREFUSED errors.

Changes:
- Import createSocketWithErrorHandler in route-connection-handler.ts
- Replace net.connect() with createSocketWithErrorHandler() in setupDirectConnection()
- Properly clean up connection records when server connection fails
- Add connectionFailed flag to prevent setup of failed connections

This ensures connection records are cleaned up immediately when backend connections fail, preventing memory leaks.
2025-06-01 13:42:46 +00:00
900942a263 19.5.11
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 32m5s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-06-01 13:32:16 +00:00
d45485985a Fix socket error handling to prevent server crashes on ECONNREFUSED
This commit addresses critical issues where unhandled socket connection errors (ECONNREFUSED) would crash the server and cause memory leaks with rising connection counts.

Changes:
- Add createSocketWithErrorHandler() utility that attaches error handlers immediately upon socket creation
- Update https-passthrough-handler to use safe socket creation and clean up client sockets on server connection failure
- Update https-terminate-to-http-handler to use safe socket creation
- Ensure proper connection cleanup when server connections fail
- Document the fix in readme.hints.md and create implementation plan in readme.plan.md

The fix prevents race conditions where sockets could emit errors before handlers were attached, and ensures failed connections are properly cleaned up to prevent memory leaks.
2025-06-01 13:30:06 +00:00
9fdc2d5069 Refactor socket handling plan to address server crashes, memory leaks, and race conditions 2025-06-01 13:01:24 +00:00
37c87e8450 19.5.10
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 20m32s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-01 12:33:48 +00:00
92b2f230ef 19.5.9
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 20m42s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-01 12:27:59 +00:00
e7ebf57ce1 19.5.8
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 20m46s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-01 12:27:25 +00:00
ad80798210 Enhance socket cleanup and management for improved connection handling
- Refactor cleanupSocket function to support options for immediate destruction, allowing drain, and grace periods.
- Introduce createIndependentSocketHandlers for better management of half-open connections between client and server sockets.
- Update various handlers (HTTP, HTTPS passthrough, HTTPS terminate) to utilize new cleanup and socket management functions.
- Implement custom timeout handling in socket setup to prevent immediate closure during keep-alive connections.
- Add tests for long-lived connections and half-open connection scenarios to ensure stability and reliability.
- Adjust connection manager to handle socket cleanup based on activity status, improving resource management.
2025-06-01 12:27:15 +00:00
265b80ee04 19.5.7
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 14m26s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-01 08:09:39 +00:00
726d40b9a5 feat(lifecycle-component): enhance lifecycle management with unref support for timers and event listeners
fix(lifecycle-component): store actual event handler for proper cleanup
chore(meta): update certificate dates in meta.json
2025-06-01 08:09:29 +00:00
cacc88797a 19.5.6
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 17m22s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-01 08:03:39 +00:00
bed1a76537 refactor(socket-utils): replace direct socket cleanup with centralized cleanupSocket utility across connection management 2025-06-01 08:02:32 +00:00
eb2e67fecc feat(socket-utils): implement socket cleanup utilities and enhance socket handling in forwarding handlers 2025-06-01 07:51:20 +00:00
c7c325a7d8 fix(tests): update AcmeStateManager tests to use socket-handler for challenge routes
fix(tests): enhance non-TLS connection detection with range support in HttpProxy tests
2025-06-01 07:06:11 +00:00
a2affcd93e 19.5.5
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 11m45s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-31 22:18:55 +00:00
e0f3e8a0ec fix(lifecycle-component): support 'once' option for event listeners 2025-05-31 22:18:34 +00:00
96c4de0f8a fix(connection-manager): set default maxConnections to 10000 if not specified 2025-05-31 18:12:19 +00:00
829ae0d6a3 fix(refactor): remove deprecated Port80Handler and related utilities
- Deleted event-utils.ts which contained deprecated Port80Handler and its subscribers.
- Updated index.ts to remove the export of event-utils.
- Refactored ConnectionManager to extend LifecycleComponent for better resource management.
- Added BinaryHeap implementation for efficient priority queue operations.
- Introduced EnhancedConnectionPool for managing pooled connections with lifecycle management.
- Implemented LifecycleComponent to manage timers and event listeners automatically.
- Added comprehensive tests for BinaryHeap and LifecycleComponent to ensure functionality.
2025-05-31 18:01:09 +00:00
7b81186bb3 feat(performance): Add async utility functions and filesystem utilities
- Implemented async utilities including delay, retryWithBackoff, withTimeout, parallelLimit, debounceAsync, AsyncMutex, and CircuitBreaker.
- Created tests for async utilities to ensure functionality and reliability.
- Developed AsyncFileSystem class with methods for file and directory operations, including ensureDir, readFile, writeFile, remove, and more.
- Added tests for filesystem utilities to validate file operations and error handling.
2025-05-31 17:45:40 +00:00
02603c3b07 fix(performance): start with planning performance optimizations 2025-05-31 17:14:15 +00:00
108 changed files with 11252 additions and 6227 deletions

View File

@ -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"
}

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "19.5.4",
"version": "19.6.2",
"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": {
@ -31,6 +31,7 @@
"@push.rocks/smartnetwork": "^4.0.2",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.0.15",
"@push.rocks/taskbuffer": "^3.1.7",
"@tsclass/tsclass": "^9.2.0",

82
pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ importers:
'@push.rocks/smartrequest':
specifier: ^2.1.0
version: 2.1.0
'@push.rocks/smartrx':
specifier: ^3.0.10
version: 3.0.10
'@push.rocks/smartstring':
specifier: ^4.0.15
version: 4.0.15
@ -70,8 +73,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
@ -977,9 +980,6 @@ packages:
'@push.rocks/smartrx@3.0.10':
resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==}
'@push.rocks/smartrx@3.0.7':
resolution: {integrity: sha512-qCWy0s3RLAgGSnaw/Gu0BNaJ59CsI6RK5OJDCCqxc7P2X/S755vuLtnAR5/0dEjdhCHXHX9ytPZx+o9g/CNiyA==}
'@push.rocks/smarts3@2.2.5':
resolution: {integrity: sha512-OZjD0jBCUTJCLnwraxBcyZ3he5buXf2OEM1zipiTBChA2EcKUZWKk/a6KR5WT+NlFCIIuB23UG+U+cxsIWM91Q==}
@ -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
@ -6130,11 +6131,6 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3
rxjs: 7.8.2
'@push.rocks/smartrx@3.0.7':
dependencies:
'@push.rocks/smartpromise': 4.2.3
rxjs: 7.8.2
'@push.rocks/smarts3@2.2.5':
dependencies:
'@push.rocks/smartbucket': 3.3.7
@ -6300,7 +6296,7 @@ snapshots:
'@push.rocks/smartenv': 5.0.12
'@push.rocks/smartjson': 5.0.20
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.7
'@push.rocks/smartrx': 3.0.10
'@tempfix/idb': 8.0.3
fake-indexeddb: 5.0.2
@ -7097,27 +7093,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 +7125,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 +7133,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 +7150,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 +7195,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 +7213,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 +7240,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 +7287,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 +7578,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 +7831,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

View File

@ -1,321 +0,0 @@
# SmartProxy Project Hints
## Project Overview
- Package: `@push.rocks/smartproxy` high-performance proxy supporting HTTP(S), TCP, WebSocket, and ACME integration.
- Written in TypeScript, compiled output in `dist_ts/`, uses ESM with NodeNext resolution.
## Important: ACME Configuration in v19.0.0
- **Breaking Change**: ACME configuration must be placed within individual route TLS settings, not at the top level
- Route-level ACME config is the ONLY way to enable SmartAcme initialization
- SmartCertManager requires email in route config for certificate acquisition
- Top-level ACME configuration is ignored in v19.0.0
## Repository Structure
- `ts/` TypeScript source files:
- `index.ts` exports main modules.
- `plugins.ts` centralizes native and third-party imports.
- Subdirectories: `networkproxy/`, `nftablesproxy/`, `port80handler/`, `redirect/`, `smartproxy/`.
- Key classes: `ProxyRouter` (`classes.router.ts`), `SmartProxy` (`classes.smartproxy.ts`), plus handlers/managers.
- `dist_ts/` transpiled `.js` and `.d.ts` files mirroring `ts/` structure.
- `test/` test suites in TypeScript:
- `test.router.ts` routing logic (hostname matching, wildcards, path parameters, config management).
- `test.smartproxy.ts` proxy behavior tests (TCP forwarding, SNI handling, concurrency, chaining, timeouts).
- `test/helpers/` utilities (e.g., certificates).
- `assets/certs/` placeholder certificates for ACME and TLS.
## Development Setup
- Requires `pnpm` (v10+).
- Install dependencies: `pnpm install`.
- Build: `pnpm build` (runs `tsbuild --web --allowimplicitany`).
- Test: `pnpm test` (runs `tstest test/`).
- Format: `pnpm format` (runs `gitzone format`).
## How to Test
### Test Structure
Tests use tapbundle from `@git.zone/tstest`. The correct pattern is:
```typescript
import { tap, expect } from '@git.zone/tstest/tapbundle';
tap.test('test description', async () => {
// Test logic here
expect(someValue).toEqual(expectedValue);
});
// IMPORTANT: Must end with tap.start()
tap.start();
```
### Expect Syntax (from @push.rocks/smartexpect)
```typescript
// Type assertions
expect('hello').toBeTypeofString();
expect(42).toBeTypeofNumber();
// Equality
expect('hithere').toEqual('hithere');
// Negated assertions
expect(1).not.toBeTypeofString();
// Regular expressions
expect('hithere').toMatch(/hi/);
// Numeric comparisons
expect(5).toBeGreaterThan(3);
expect(0.1 + 0.2).toBeCloseTo(0.3, 10);
// Arrays
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
// Async assertions
await expect(asyncFunction()).resolves.toEqual('expected');
await expect(asyncFunction()).resolves.withTimeout(5000).toBeTypeofString();
// Complex object navigation
expect(complexObject)
.property('users')
.arrayItem(0)
.property('name')
.toEqual('Alice');
```
### Test Modifiers
- `tap.only.test()` - Run only this test
- `tap.skip.test()` - Skip a test
- `tap.timeout()` - Set test-specific timeout
### Running Tests
- All tests: `pnpm test`
- Specific test: `tsx test/test.router.ts`
- With options: `tstest test/**/*.ts --verbose --timeout 60`
### Test File Requirements
- Must start with `test.` prefix
- Must use `.ts` extension
- Must call `tap.start()` at the end
## Coding Conventions
- Import modules via `plugins.ts`:
```ts
import * as plugins from './plugins.ts';
const server = new plugins.http.Server();
```
- Reference plugins with full path: `plugins.acme`, `plugins.smartdelay`, `plugins.minimatch`, etc.
- Path patterns support globs (`*`) and parameters (`:param`) in `ProxyRouter`.
- Wildcard hostname matching leverages `minimatch` patterns.
## Key Components
- **ProxyRouter**
- Methods: `routeReq`, `routeReqWithDetails`.
- Hostname matching: case-insensitive, strips port, supports exact, wildcard, TLD, complex patterns.
- Path routing: exact, wildcard, parameter extraction (`pathParams`), returns `pathMatch` and `pathRemainder`.
- Config API: `setNewProxyConfigs`, `addProxyConfig`, `removeProxyConfig`, `getHostnames`, `getProxyConfigs`.
- **SmartProxy**
- Manages one or more `net.Server` instances to forward TCP streams.
- Options: `preserveSourceIP`, `defaultAllowedIPs`, `globalPortRanges`, `sniEnabled`.
- DomainConfigManager: round-robin selection for multiple target IPs.
- Graceful shutdown in `stop()`, ensures no lingering servers or sockets.
## Notable Points
- **TSConfig**: `module: NodeNext`, `verbatimModuleSyntax`, allows `.js` extension imports in TS.
- Mermaid diagrams and architecture flows in `readme.md` illustrate component interactions and protocol flows.
- CLI entrypoint (`cli.js`) supports command-line usage (ACME, proxy controls).
- ACME and certificate handling via `Port80Handler` and `helpers.certificates.ts`.
## ACME/Certificate Configuration Example (v19.0.0)
```typescript
const proxy = new SmartProxy({
routes: [{
name: 'example.com',
match: { domains: 'example.com', ports: 443 },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
tls: {
mode: 'terminate',
certificate: 'auto',
acme: { // ACME config MUST be here, not at top level
email: 'ssl@example.com',
useProduction: false,
challengePort: 80
}
}
}
}]
});
```
## TODOs / Considerations
- Ensure import extensions in source match build outputs (`.ts` vs `.js`).
- Update `plugins.ts` when adding new dependencies.
- Maintain test coverage for new routing or proxy features.
- Keep `ts/` and `dist_ts/` in sync after refactors.
- Consider implementing top-level ACME config support for backward compatibility
## HTTP-01 ACME Challenge Fix (v19.3.8)
### Issue
Non-TLS connections on ports configured in `useHttpProxy` were not being forwarded to HttpProxy. This caused ACME HTTP-01 challenges to fail when the ACME port (usually 80) was included in `useHttpProxy`.
### Root Cause
In the `RouteConnectionHandler.handleForwardAction` method, only connections with TLS settings (mode: 'terminate' or 'terminate-and-reencrypt') were being forwarded to HttpProxy. Non-TLS connections were always handled as direct connections, even when the port was configured for HttpProxy.
### Solution
Added a check for non-TLS connections on ports listed in `useHttpProxy`:
```typescript
// No TLS settings - check if this port should use HttpProxy
const isHttpProxyPort = this.settings.useHttpProxy?.includes(record.localPort);
if (isHttpProxyPort && this.httpProxyBridge.getHttpProxy()) {
// Forward non-TLS connections to HttpProxy if configured
this.httpProxyBridge.forwardToHttpProxy(/*...*/);
return;
}
```
### Test Coverage
- `test/test.http-fix-unit.ts` - Unit tests verifying the fix
- Tests confirm that non-TLS connections on HttpProxy ports are properly forwarded
- Tests verify that non-HttpProxy ports still use direct connections
### Configuration Example
```typescript
const proxy = new SmartProxy({
useHttpProxy: [80], // Enable HttpProxy for port 80
httpProxyPort: 8443,
acme: {
email: 'ssl@example.com',
port: 80
},
routes: [
// Your routes here
]
});
```
## ACME Certificate Provisioning Timing Fix (v19.3.9)
### Issue
Certificate provisioning would start before ports were listening, causing ACME HTTP-01 challenges to fail with connection refused errors.
### Root Cause
SmartProxy initialization sequence:
1. Certificate manager initialized → immediately starts provisioning
2. Ports start listening (too late for ACME challenges)
### Solution
Deferred certificate provisioning until after ports are ready:
```typescript
// SmartCertManager.initialize() now skips automatic provisioning
// SmartProxy.start() calls provisionAllCertificates() directly after ports are listening
```
### Test Coverage
- `test/test.acme-timing-simple.ts` - Verifies proper timing sequence
### Migration
Update to v19.3.9+, no configuration changes needed.
## Socket Handler Race Condition Fix (v19.5.0)
### Issue
Initial data chunks were being emitted before async socket handlers had completed setup, causing data loss when handlers performed async operations before setting up data listeners.
### Root Cause
The `handleSocketHandlerAction` method was using `process.nextTick` to emit initial chunks regardless of whether the handler was sync or async. This created a race condition where async handlers might not have their listeners ready when the initial data was emitted.
### Solution
Differentiated between sync and async handlers:
```typescript
const result = route.action.socketHandler(socket);
if (result instanceof Promise) {
// Async handler - wait for completion before emitting initial data
result.then(() => {
if (initialChunk && initialChunk.length > 0) {
socket.emit('data', initialChunk);
}
}).catch(/*...*/);
} else {
// Sync handler - use process.nextTick as before
if (initialChunk && initialChunk.length > 0) {
process.nextTick(() => {
socket.emit('data', initialChunk);
});
}
}
```
### Test Coverage
- `test/test.socket-handler-race.ts` - Specifically tests async handlers with delayed listener setup
- Verifies that initial data is received even when handler sets up listeners after async work
### Usage Note
Socket handlers require initial data from the client to trigger routing (not just a TLS handshake). Clients must send at least one byte of data for the handler to be invoked.
## Route-Specific Security Implementation (v19.5.3)
### Issue
Route-specific security configurations (ipAllowList, ipBlockList, authentication) were defined in the route types but not enforced at runtime.
### Root Cause
The RouteConnectionHandler only checked global IP validation but didn't enforce route-specific security rules after matching a route.
### Solution
Added security checks after route matching:
```typescript
// Apply route-specific security checks
const routeSecurity = route.action.security || route.security;
if (routeSecurity) {
// Check IP allow/block lists
if (routeSecurity.ipAllowList || routeSecurity.ipBlockList) {
const isIPAllowed = this.securityManager.isIPAuthorized(
remoteIP,
routeSecurity.ipAllowList || [],
routeSecurity.ipBlockList || []
);
if (!isIPAllowed) {
socket.end();
this.connectionManager.cleanupConnection(record, 'route_ip_blocked');
return;
}
}
}
```
### Test Coverage
- `test/test.route-security-unit.ts` - Unit tests verifying SecurityManager.isIPAuthorized logic
- Tests confirm IP allow/block lists work correctly with glob patterns
### Configuration Example
```typescript
const routes: IRouteConfig[] = [{
name: 'secure-api',
match: { ports: 8443, domains: 'api.example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 },
security: {
ipAllowList: ['192.168.1.*', '10.0.0.0/8'], // Allow internal IPs
ipBlockList: ['192.168.1.100'], // But block specific IP
maxConnections: 100, // Per-route limit (TODO)
authentication: { // HTTP-only, requires TLS termination
type: 'basic',
credentials: [{ username: 'api', password: 'secret' }]
}
}
}
}];
```
### Notes
- IP lists support glob patterns (via minimatch): `192.168.*`, `10.?.?.1`
- Block lists take precedence over allow lists
- 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

858
readme.md
View File

@ -665,6 +665,661 @@ redirect: {
}
```
## Forwarding Modes Guide
This section provides a comprehensive reference for all forwarding modes available in SmartProxy, helping you choose the right configuration for your use case.
### Visual Overview
```mermaid
graph TD
A[Incoming Traffic] --> B{Action Type?}
B -->|forward| C{TLS Mode?}
B -->|socket-handler| D[Custom Handler]
C -->|terminate| E[Decrypt TLS]
C -->|passthrough| F[Forward Encrypted]
C -->|terminate-and-reencrypt| G[Decrypt & Re-encrypt]
C -->|none/HTTP| H[Forward HTTP]
E --> I{Engine?}
F --> I
G --> I
H --> I
I -->|node| J[Node.js Processing]
I -->|nftables| K[Kernel NAT]
J --> L[Backend]
K --> L
D --> M[Custom Logic]
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#bbf,stroke:#333,stroke-width:2px
style I fill:#bfb,stroke:#333,stroke-width:2px
```
### Overview
SmartProxy offers flexible traffic forwarding through combinations of:
- **Action Types**: How to handle matched traffic
- **TLS Modes**: How to handle HTTPS/TLS connections
- **Forwarding Engines**: Where packet processing occurs
### Quick Reference
#### Modern Route-Based Configuration
| Use Case | Action Type | TLS Mode | Engine | Performance | Security |
|----------|------------|----------|---------|-------------|----------|
| HTTP web server | `forward` | N/A | `node` | Good | Basic |
| HTTPS web server (inspect traffic) | `forward` | `terminate` | `node` | Good | Full inspection |
| HTTPS passthrough (no inspection) | `forward` | `passthrough` | `node` | Better | End-to-end encryption |
| HTTPS gateway (re-encrypt to backend) | `forward` | `terminate-and-reencrypt` | `node` | Moderate | Full control |
| High-performance TCP forwarding | `forward` | `passthrough` | `nftables` | Excellent | Basic |
| Custom protocol handling | `socket-handler` | N/A | `node` | Varies | Custom |
#### Legacy Forwarding Types (Deprecated)
| Legacy Type | Modern Equivalent |
|------------|------------------|
| `http-only` | `action.type: 'forward'` with port 80 |
| `https-passthrough` | `action.type: 'forward'` + `tls.mode: 'passthrough'` |
| `https-terminate-to-http` | `action.type: 'forward'` + `tls.mode: 'terminate'` |
| `https-terminate-to-https` | `action.type: 'forward'` + `tls.mode: 'terminate-and-reencrypt'` |
### Forwarding Mode Categories
#### 1. Action Types
##### Forward Action
Routes traffic to a backend server. This is the most common action type.
```typescript
{
action: {
type: 'forward',
target: {
host: 'backend-server',
port: 8080
}
}
}
```
##### Socket Handler Action
Provides custom handling for any TCP protocol. Used for specialized protocols or custom logic.
```typescript
{
action: {
type: 'socket-handler',
socketHandler: async (socket, context) => {
// Custom protocol implementation
}
}
}
```
#### 2. TLS Modes (for Forward Action)
##### Passthrough Mode
- **What**: Forwards encrypted TLS traffic without decryption
- **When**: Backend handles its own TLS termination
- **Pros**: Maximum performance, true end-to-end encryption
- **Cons**: Cannot inspect or modify HTTPS traffic
```mermaid
graph LR
Client -->|TLS| SmartProxy
SmartProxy -->|TLS| Backend
style SmartProxy fill:#f9f,stroke:#333,stroke-width:2px
```
##### Terminate Mode
- **What**: Decrypts TLS, forwards as plain HTTP
- **When**: Backend doesn't support HTTPS or you need to inspect traffic
- **Pros**: Can modify headers, inspect content, add security headers
- **Cons**: Backend connection is unencrypted
```mermaid
graph LR
Client -->|TLS| SmartProxy
SmartProxy -->|HTTP| Backend
style SmartProxy fill:#f9f,stroke:#333,stroke-width:2px
```
##### Terminate-and-Reencrypt Mode
- **What**: Decrypts TLS, then creates new TLS connection to backend
- **When**: Need traffic inspection but backend requires HTTPS
- **Pros**: Full control while maintaining backend security
- **Cons**: Higher CPU usage, increased latency
```mermaid
graph LR
Client -->|TLS| SmartProxy
SmartProxy -->|New TLS| Backend
style SmartProxy fill:#f9f,stroke:#333,stroke-width:2px
```
#### 3. Forwarding Engines
##### Node.js Engine (Default)
- **Processing**: Application-level in Node.js event loop
- **Features**: Full protocol support, header manipulation, WebSockets
- **Performance**: Good for most use cases
- **Use when**: You need application-layer features
##### NFTables Engine
- **Processing**: Kernel-level packet forwarding
- **Features**: Basic NAT, minimal overhead
- **Performance**: Excellent, near wire-speed
- **Use when**: Maximum performance is critical
- **Requirements**: Linux, root permissions, NFTables installed
### Detailed Mode Explanations
#### HTTP Forwarding (Port 80)
Simple HTTP forwarding without encryption:
```typescript
{
match: { ports: 80, domains: 'example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 }
}
}
```
**Data Flow**: Client → SmartProxy (HTTP) → Backend (HTTP)
#### HTTPS with TLS Termination
Decrypt HTTPS and forward as HTTP:
```typescript
{
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
tls: {
mode: 'terminate',
certificate: 'auto' // Use Let's Encrypt
}
}
}
```
**Data Flow**: Client → SmartProxy (HTTPS decrypt) → Backend (HTTP)
#### HTTPS Passthrough
Forward encrypted traffic without decryption:
```typescript
{
match: { ports: 443, domains: 'legacy.example.com' },
action: {
type: 'forward',
target: { host: '192.168.1.10', port: 443 },
tls: {
mode: 'passthrough'
}
}
}
```
**Data Flow**: Client → SmartProxy (TLS forwarding) → Backend (Original TLS)
#### HTTPS Gateway (Terminate and Re-encrypt)
Decrypt, inspect, then re-encrypt to backend:
```typescript
{
match: { ports: 443, domains: 'api.example.com' },
action: {
type: 'forward',
target: { host: 'api-backend', port: 443 },
tls: {
mode: 'terminate-and-reencrypt',
certificate: 'auto'
},
advanced: {
headers: {
'X-Forwarded-Proto': 'https',
'X-Real-IP': '{clientIp}'
}
}
}
}
```
**Data Flow**: Client → SmartProxy (HTTPS decrypt) → SmartProxy (New HTTPS) → Backend
#### High-Performance NFTables Forwarding
Kernel-level forwarding for maximum performance:
```typescript
{
match: { ports: 443, domains: 'fast.example.com' },
action: {
type: 'forward',
target: { host: 'backend', port: 443 },
tls: { mode: 'passthrough' },
forwardingEngine: 'nftables',
nftables: {
preserveSourceIP: true,
maxRate: '10gbps'
}
}
}
```
**Data Flow**: Client → Kernel (NFTables NAT) → Backend
#### Custom Socket Handler
Handle custom protocols or implement specialized logic:
```typescript
{
match: { ports: 9000, domains: 'custom.example.com' },
action: {
type: 'socket-handler',
socketHandler: async (socket, context) => {
console.log(`Connection from ${context.clientIp}`);
socket.write('Welcome to custom protocol server\n');
socket.on('data', (data) => {
// Handle custom protocol
const response = processCustomProtocol(data);
socket.write(response);
});
}
}
}
```
### Decision Guide
#### Choose HTTP Forwarding When:
- Backend only supports HTTP
- Internal services not exposed to internet
- Development/testing environments
#### Choose HTTPS Termination When:
- Need to inspect/modify HTTP traffic
- Backend doesn't support HTTPS
- Want to add security headers
- Need to cache responses
#### Choose HTTPS Passthrough When:
- Backend manages its own certificates
- Need true end-to-end encryption
- Compliance requires no MITM
- WebSocket connections to backend
#### Choose HTTPS Terminate-and-Reencrypt When:
- Need traffic inspection AND backend requires HTTPS
- API gateway scenarios
- Adding authentication layers
- Different certificates for client/backend
#### Choose NFTables Engine When:
- Handling 1Gbps+ traffic
- Thousands of concurrent connections
- Minimal latency is critical
- Don't need application-layer features
#### Choose Socket Handler When:
- Implementing custom protocols
- Need fine-grained connection control
- Building protocol adapters
- Special authentication flows
### Complete Examples
#### Example 1: Complete Web Application
```typescript
const proxy = new SmartProxy({
routes: [
// HTTP to HTTPS redirect
{
match: { ports: 80, domains: ['example.com', 'www.example.com'] },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}{path}')
}
},
// Main website with TLS termination
{
match: { ports: 443, domains: ['example.com', 'www.example.com'] },
action: {
type: 'forward',
target: { host: 'web-backend', port: 3000 },
tls: {
mode: 'terminate',
certificate: 'auto'
},
websocket: { enabled: true }
}
},
// API with re-encryption
{
match: { ports: 443, domains: 'api.example.com' },
action: {
type: 'forward',
target: { host: 'api-backend', port: 443 },
tls: {
mode: 'terminate-and-reencrypt',
certificate: 'auto'
}
},
security: {
ipAllowList: ['10.0.0.0/8'],
rateLimit: {
enabled: true,
maxRequests: 100,
window: 60
}
}
}
]
});
```
#### Example 2: Multi-Mode Proxy Setup
```typescript
const proxy = new SmartProxy({
routes: [
// Legacy app with passthrough
{
match: { ports: 443, domains: 'legacy.example.com' },
action: {
type: 'forward',
target: { host: 'legacy-server', port: 443 },
tls: { mode: 'passthrough' }
}
},
// High-performance streaming with NFTables
{
match: { ports: 8080, domains: 'stream.example.com' },
action: {
type: 'forward',
target: { host: 'stream-backend', port: 8080 },
forwardingEngine: 'nftables',
nftables: {
protocol: 'tcp',
preserveSourceIP: true
}
}
},
// Custom protocol handler
{
match: { ports: 9999 },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.proxy('custom-backend', 9999)
}
}
]
});
```
### Performance Considerations
#### Node.js Engine Performance
| Metric | Typical Performance |
|--------|-------------------|
| Throughput | 1-10 Gbps |
| Connections | 10,000-50,000 concurrent |
| Latency | 1-5ms added |
| CPU Usage | Moderate |
**Best for**: Most web applications, APIs, sites needing inspection
#### NFTables Engine Performance
| Metric | Typical Performance |
|--------|-------------------|
| Throughput | 10-100 Gbps |
| Connections | 100,000+ concurrent |
| Latency | <0.1ms added |
| CPU Usage | Minimal |
**Best for**: High-traffic services, streaming, gaming, TCP forwarding
#### Performance Tips
1. **Use passthrough mode** when you don't need inspection
2. **Enable NFTables** for high-traffic services
3. **Terminate TLS only when necessary** - it adds CPU overhead
4. **Use connection pooling** for terminate-and-reencrypt mode
5. **Enable HTTP/2** for better multiplexing
### Security Implications
#### TLS Termination Security
**Pros:**
- Inspect traffic for threats
- Add security headers
- Implement WAF rules
- Log requests for audit
**Cons:**
- Proxy has access to decrypted data
- Requires secure certificate storage
- Potential compliance issues
**Best Practices:**
- Use auto-renewal with Let's Encrypt
- Store certificates securely
- Implement proper access controls
- Use strong TLS configurations
#### Passthrough Security
**Pros:**
- True end-to-end encryption
- No MITM concerns
- Backend controls security
**Cons:**
- Cannot inspect traffic
- Cannot add security headers
- Limited DDoS protection
#### Socket Handler Security
**Risks:**
- Custom code may have vulnerabilities
- Resource exhaustion possible
- Authentication bypass risks
**Mitigations:**
```typescript
{
action: {
type: 'socket-handler',
socketHandler: async (socket, context) => {
// Always validate and sanitize input
socket.on('data', (data) => {
if (data.length > MAX_SIZE) {
socket.destroy();
return;
}
// Process safely...
});
// Set timeouts
socket.setTimeout(30000);
// Rate limit connections
if (connectionsFromIP(context.clientIp) > 10) {
socket.destroy();
}
}
}
}
```
### Migration from Legacy Types
#### From `http-only`
**Old:**
```typescript
{
type: 'http-only',
target: { host: 'localhost', port: 8080 }
}
```
**New:**
```typescript
{
match: { ports: 80, domains: 'example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 }
}
}
```
#### From `https-passthrough`
**Old:**
```typescript
{
type: 'https-passthrough',
target: { host: 'backend', port: 443 }
}
```
**New:**
```typescript
{
match: { ports: 443, domains: 'example.com' },
action: {
type: 'forward',
target: { host: 'backend', port: 443 },
tls: { mode: 'passthrough' }
}
}
```
#### From `https-terminate-to-http`
**Old:**
```typescript
{
type: 'https-terminate-to-http',
target: { host: 'localhost', port: 8080 },
ssl: { /* certs */ }
}
```
**New:**
```typescript
{
match: { ports: 443, domains: 'example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
tls: {
mode: 'terminate',
certificate: 'auto' // or provide cert/key
}
}
}
```
#### From `https-terminate-to-https`
**Old:**
```typescript
{
type: 'https-terminate-to-https',
target: { host: 'backend', port: 443 },
ssl: { /* certs */ }
}
```
**New:**
```typescript
{
match: { ports: 443, domains: 'example.com' },
action: {
type: 'forward',
target: { host: 'backend', port: 443 },
tls: {
mode: 'terminate-and-reencrypt',
certificate: 'auto'
}
}
}
```
### Helper Functions Quick Reference
SmartProxy provides helper functions for common configurations:
```typescript
// HTTP forwarding
createHttpRoute('example.com', { host: 'localhost', port: 8080 })
// HTTPS with termination
createHttpsTerminateRoute('secure.com', { host: 'localhost', port: 8080 }, {
certificate: 'auto'
})
// HTTPS passthrough
createHttpsPassthroughRoute('legacy.com', { host: 'backend', port: 443 })
// Complete HTTPS setup (includes HTTP redirect)
...createCompleteHttpsServer('example.com', { host: 'localhost', port: 8080 }, {
certificate: 'auto'
})
// NFTables high-performance
createNfTablesRoute('fast.com', { host: 'backend', port: 8080 }, {
ports: 80,
preserveSourceIP: true
})
// Custom socket handler
createSocketHandlerRoute('custom.com', 9000, async (socket, context) => {
// Handler implementation
})
```
### Summary
SmartProxy's forwarding modes provide flexibility for any proxy scenario:
- **Simple HTTP/HTTPS forwarding** for most web applications
- **TLS passthrough** for end-to-end encryption
- **TLS termination** for traffic inspection and modification
- **NFTables** for extreme performance requirements
- **Socket handlers** for custom protocols
Choose based on your security requirements, performance needs, and whether you need to inspect or modify traffic. The modern route-based configuration provides a consistent interface regardless of the forwarding mode you choose.
### Route Metadata and Prioritization
You can add metadata to routes to help with organization and control matching priority:
@ -919,6 +1574,152 @@ Available helper functions:
})
```
## Metrics and Monitoring
SmartProxy includes a comprehensive metrics collection system that provides real-time insights into proxy performance, connection statistics, and throughput data.
### Getting Metrics
```typescript
const proxy = new SmartProxy({ /* config */ });
await proxy.start();
// Access metrics through the getStats() method
const stats = proxy.getStats();
// Get current active connections
console.log(`Active connections: ${stats.getActiveConnections()}`);
// Get total connections since start
console.log(`Total connections: ${stats.getTotalConnections()}`);
// Get requests per second (RPS)
console.log(`Current RPS: ${stats.getRequestsPerSecond()}`);
// Get throughput data
const throughput = stats.getThroughput();
console.log(`Bytes received: ${throughput.bytesIn}`);
console.log(`Bytes sent: ${throughput.bytesOut}`);
// Get connections by route
const routeConnections = stats.getConnectionsByRoute();
for (const [route, count] of routeConnections) {
console.log(`Route ${route}: ${count} connections`);
}
// Get connections by IP address
const ipConnections = stats.getConnectionsByIP();
for (const [ip, count] of ipConnections) {
console.log(`IP ${ip}: ${count} connections`);
}
```
### Available Metrics
The `IProxyStats` interface provides the following methods:
- `getActiveConnections()`: Current number of active connections
- `getTotalConnections()`: Total connections handled since proxy start
- `getRequestsPerSecond()`: Current requests per second (1-minute average)
- `getThroughput()`: Total bytes transferred (in/out)
- `getConnectionsByRoute()`: Connection count per route
- `getConnectionsByIP()`: Connection count per client IP
Additional extended methods available:
- `getThroughputRate()`: Bytes per second rate for the last minute
- `getTopIPs(limit?: number)`: Get top IPs by connection count
- `isIPBlocked(ip: string, maxConnectionsPerIP: number)`: Check if an IP has reached the connection limit
### Extended Metrics Example
```typescript
const stats = proxy.getStats() as any; // Extended methods are available
// Get throughput rate
const rate = stats.getThroughputRate();
console.log(`Incoming: ${rate.bytesInPerSec} bytes/sec`);
console.log(`Outgoing: ${rate.bytesOutPerSec} bytes/sec`);
// Get top 10 IPs by connection count
const topIPs = stats.getTopIPs(10);
topIPs.forEach(({ ip, connections }) => {
console.log(`${ip}: ${connections} connections`);
});
// Check if an IP should be rate limited
if (stats.isIPBlocked('192.168.1.100', 100)) {
console.log('IP has too many connections');
}
```
### Monitoring Example
```typescript
// Create a monitoring loop
setInterval(() => {
const stats = proxy.getStats();
// Log key metrics
console.log({
timestamp: new Date().toISOString(),
activeConnections: stats.getActiveConnections(),
rps: stats.getRequestsPerSecond(),
throughput: stats.getThroughput()
});
// Check for high connection counts from specific IPs
const ipConnections = stats.getConnectionsByIP();
for (const [ip, count] of ipConnections) {
if (count > 100) {
console.warn(`High connection count from ${ip}: ${count}`);
}
}
}, 10000); // Every 10 seconds
```
### Exporting Metrics
You can export metrics in various formats for external monitoring systems:
```typescript
// Export as JSON
app.get('/metrics.json', (req, res) => {
const stats = proxy.getStats();
res.json({
activeConnections: stats.getActiveConnections(),
totalConnections: stats.getTotalConnections(),
requestsPerSecond: stats.getRequestsPerSecond(),
throughput: stats.getThroughput(),
connectionsByRoute: Object.fromEntries(stats.getConnectionsByRoute()),
connectionsByIP: Object.fromEntries(stats.getConnectionsByIP())
});
});
// Export as Prometheus format
app.get('/metrics', (req, res) => {
const stats = proxy.getStats();
res.set('Content-Type', 'text/plain');
res.send(`
# HELP smartproxy_active_connections Current active connections
# TYPE smartproxy_active_connections gauge
smartproxy_active_connections ${stats.getActiveConnections()}
# HELP smartproxy_requests_per_second Current requests per second
# TYPE smartproxy_requests_per_second gauge
smartproxy_requests_per_second ${stats.getRequestsPerSecond()}
# HELP smartproxy_bytes_in Total bytes received
# TYPE smartproxy_bytes_in counter
smartproxy_bytes_in ${stats.getThroughput().bytesIn}
# HELP smartproxy_bytes_out Total bytes sent
# TYPE smartproxy_bytes_out counter
smartproxy_bytes_out ${stats.getThroughput().bytesOut}
`);
});
```
## Other Components
While SmartProxy provides a unified API for most needs, you can also use individual components:
@ -1618,6 +2419,62 @@ createHttpToHttpsRedirect('old.example.com', 443)
}
```
## WebSocket Keep-Alive Configuration
If your WebSocket connections are disconnecting every 30 seconds in SNI passthrough mode, here's how to configure keep-alive settings:
### Extended Keep-Alive Treatment (Recommended)
```typescript
const proxy = new SmartProxy({
// Extend timeout for keep-alive connections
keepAliveTreatment: 'extended',
keepAliveInactivityMultiplier: 10, // 10x the base timeout
inactivityTimeout: 14400000, // 4 hours base (40 hours with multiplier)
routes: [
{
name: 'websocket-passthrough',
match: {
ports: 443,
domains: ['ws.example.com', 'wss.example.com']
},
action: {
type: 'forward',
target: { host: 'backend', port: 443 },
tls: { mode: 'passthrough' }
}
}
]
});
```
### Immortal Connections (Never Timeout)
```typescript
const proxy = new SmartProxy({
// Never timeout keep-alive connections
keepAliveTreatment: 'immortal',
routes: [
// ... same as above
]
});
```
### Understanding the Issue
In SNI passthrough mode:
1. **WebSocket Heartbeat**: The HTTP proxy's WebSocket handler sends ping frames every 30 seconds
2. **SNI Passthrough**: In passthrough mode, traffic is encrypted end-to-end
3. **Can't Inject Pings**: The proxy can't inject ping frames into encrypted traffic
4. **Connection Terminated**: After 30 seconds, connection is marked inactive and closed
The solution involves:
- Longer grace periods for encrypted connections (5 minutes vs 30 seconds)
- Relying on OS-level TCP keep-alive instead of application-level heartbeat
- Different timeout strategies per route type
## Configuration Options
### SmartProxy (IRoutedSmartProxyOptions)
@ -1628,6 +2485,7 @@ createHttpToHttpsRedirect('old.example.com', 443)
- `httpProxyPort` (number, default 8443) - Port where HttpProxy listens for forwarded connections
- Connection timeouts: `initialDataTimeout`, `socketTimeout`, `inactivityTimeout`, etc.
- Socket opts: `noDelay`, `keepAlive`, `enableKeepAliveProbes`
- Keep-alive configuration: `keepAliveTreatment` ('standard'|'extended'|'immortal'), `keepAliveInactivityMultiplier`
- `certProvisionFunction` (callback) - Custom certificate provisioning
#### SmartProxy Dynamic Port Management Methods

View File

@ -1,316 +0,0 @@
# SmartProxy Development Plan
## Implementation Plan: Socket Handler Function Support (Simplified) ✅ COMPLETED
### Overview
Add support for custom socket handler functions with the simplest possible API - just pass a function that receives the socket.
### User Experience Goal
```typescript
const proxy = new SmartProxy({
routes: [{
name: 'my-custom-protocol',
match: { ports: 9000, domains: 'custom.example.com' },
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}`);
});
}
}
}]
});
```
That's it. Simple and powerful.
---
## Phase 1: Minimal Type Changes
### 1.1 Add Socket Handler Action Type
**File:** `ts/proxies/smart-proxy/models/route-types.ts`
```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
// Socket handler function (when type is 'socket-handler')
socketHandler?: TSocketHandler;
}
```
---
## 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:
```typescript
// After route matching...
if (matchedRoute) {
const action = matchedRoute.action;
if (action.type === 'socket-handler') {
if (!action.socketHandler) {
logger.error('socket-handler action missing socketHandler function');
socket.destroy();
return;
}
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');
});
}
}
}
```
### 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;
}
// Proceed with WebSocket upgrade
const ws = new WebSocket(socket, headers);
// ... handle WebSocket
}
}
}
```
---
## Benefits of This Approach
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
---
## Implementation Steps
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)
**Total implementation time: ~4 hours** (vs 6 weeks for the complex version)
---
## What We're NOT Doing
- ❌ Creating new ForwardingHandler classes
- ❌ Complex context objects with utils
- ❌ HTTP request handling for socket handlers
- ❌ Complex protocol detection mechanisms
- ❌ Middleware patterns
- ❌ Lifecycle hooks
Keep it simple. The user just wants to handle a socket.
---
## Success Criteria
- ✅ 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
---
## 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**

View File

@ -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

View File

@ -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).

View 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();

View 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();

View 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();

View 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();

View 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();

View File

@ -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();

View 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();

View 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();

View File

@ -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();

View File

@ -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();

View File

@ -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'
}
};

View File

@ -0,0 +1,93 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
tap.test('cleanup queue bug - verify queue processing handles more than batch size', async (tools) => {
console.log('\n=== Cleanup Queue Bug Test ===');
console.log('Purpose: Verify that the cleanup queue correctly processes all connections');
console.log('even when there are more than the batch size (100)');
// Create proxy
const proxy = new SmartProxy({
routes: [{
name: 'test-route',
match: { ports: 8588 },
action: {
type: 'forward',
target: { host: 'localhost', port: 9996 }
}
}],
enableDetailedLogging: false,
});
await proxy.start();
console.log('✓ Proxy started on port 8588');
// Access connection manager
const cm = (proxy as any).connectionManager;
// Create mock connection records
console.log('\n--- Creating 150 mock connections ---');
const mockConnections: any[] = [];
for (let i = 0; i < 150; i++) {
const mockRecord = {
id: `mock-${i}`,
incoming: { destroyed: true, remoteAddress: '127.0.0.1' },
outgoing: { destroyed: true },
connectionClosed: false,
incomingStartTime: Date.now(),
lastActivity: Date.now(),
remoteIP: '127.0.0.1',
remotePort: 10000 + i,
localPort: 8588,
bytesReceived: 100,
bytesSent: 100,
incomingTerminationReason: null,
cleanupTimer: null
};
// Add to connection records
cm.connectionRecords.set(mockRecord.id, mockRecord);
mockConnections.push(mockRecord);
}
console.log(`Created ${cm.getConnectionCount()} mock connections`);
expect(cm.getConnectionCount()).toEqual(150);
// Queue all connections for cleanup
console.log('\n--- Queueing all connections for cleanup ---');
for (const conn of mockConnections) {
cm.initiateCleanupOnce(conn, 'test_cleanup');
}
console.log(`Cleanup queue size: ${cm.cleanupQueue.size}`);
expect(cm.cleanupQueue.size).toEqual(150);
// Wait for cleanup to complete
console.log('\n--- Waiting for cleanup batches to process ---');
// The first batch should process immediately (100 connections)
// Then additional batches should be scheduled
await new Promise(resolve => setTimeout(resolve, 500));
// Check final state
const finalCount = cm.getConnectionCount();
console.log(`\nFinal connection count: ${finalCount}`);
console.log(`Cleanup queue size: ${cm.cleanupQueue.size}`);
// All connections should be cleaned up
expect(finalCount).toEqual(0);
expect(cm.cleanupQueue.size).toEqual(0);
// Verify termination stats
const stats = cm.getTerminationStats();
console.log('Termination stats:', stats);
expect(stats.incoming.test_cleanup).toEqual(150);
// Cleanup
await proxy.stop();
console.log('\n✓ Test complete: Cleanup queue now correctly processes all connections');
});
tap.start();

View 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();

View 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();

View File

@ -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;

View File

@ -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

View File

@ -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();

View File

@ -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',

View File

@ -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();

View File

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

View File

@ -0,0 +1,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();

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();

View 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();

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

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

View File

@ -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();

View 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();

View File

@ -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',
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();

View File

@ -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();

View File

@ -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();

View File

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

View File

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

366
test/test.wrapped-socket.ts Normal file
View 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();

View File

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

View File

@ -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.'
}

View File

@ -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);
}
}

View File

@ -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
}

View File

@ -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';

View 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;

View 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
View 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';

View 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);
}
}

View 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);
}
}

View 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;

View 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);
}
}

View 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);
}
}

View File

@ -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);
}
}

View 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;
}

View 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
View 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>;
}

View 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();
}
}

View 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);
}
}
}

View 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();
}
}

View File

@ -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);
});
}
}

View File

@ -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
View 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;
}
}

View File

@ -2,14 +2,17 @@
* 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';
export * from './proxy-protocol.js';

View 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;
}
}

View File

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

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,322 @@
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();
// Track if connected
let connected = false;
let connectionTimeout: NodeJS.Timeout | null = null;
// Attach error handler BEFORE connecting to catch immediate errors
socket.on('error', (error) => {
console.error(`Socket connection error to ${host}:${port}: ${error.message}`);
// Clear the connection timeout if it exists
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
if (onError) {
onError(error);
}
});
// Attach connect handler
const handleConnect = () => {
connected = true;
// Clear the connection timeout
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
// Set inactivity timeout if provided (after connection is established)
if (timeout) {
socket.setTimeout(timeout);
}
if (onConnect) {
onConnect();
}
};
socket.on('connect', handleConnect);
// Implement connection establishment timeout
if (timeout) {
connectionTimeout = setTimeout(() => {
if (!connected && !socket.destroyed) {
// Connection timed out - destroy the socket
const error = new Error(`Connection timeout after ${timeout}ms to ${host}:${port}`);
(error as any).code = 'ETIMEDOUT';
console.error(`Socket connection timeout to ${host}:${port} after ${timeout}ms`);
// Destroy the socket
socket.destroy();
// Call error handler
if (onError) {
onError(error);
}
}
}, timeout);
}
// Now attempt to connect - any immediate errors will be caught
socket.connect(port, host);
return socket;
}

View File

@ -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, {

View File

@ -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;
// 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}`
});
// Check if server socket is writable
if (serverSocket.writable) {
const flushed = serverSocket.write(data);
// 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
});
});
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
});
}
);
// Forward data from server to client
serverSocket.on('data', (data) => {
bytesReceived += data.length;
cleanupClient = handlers.cleanupClient;
cleanupServer = handlers.cleanupServer;
// Check if client socket is writable
if (clientSocket.writable) {
const flushed = clientSocket.write(data);
// Setup handlers with custom timeout handling that doesn't close connections
const timeout = this.getTimeout();
// Handle backpressure
if (!flushed) {
serverSocket.pause();
clientSocket.once('drain', () => {
serverSocket.resume();
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');

View File

@ -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');
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
// Handle TLS data
tlsSocket.on('data', (data) => {
// If backend connection already established, just forward the data
if (connectionEstablished && backendSocket && !backendSocket.destroyed) {
backendSocket.write(data);
return;
}
// Append to buffer
dataBuffer = Buffer.concat([dataBuffer, data]);
// Very basic HTTP parsing - in a real implementation, use http-parser
if (dataBuffer.includes(Buffer.from('\r\n\r\n')) && !connectionEstablished) {
const target = this.getTargetFromConfig();
// Create backend connection with immediate error handling
backendSocket = createSocketWithErrorHandler({
port: target.port,
host: target.host,
onError: (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
error: error.message,
code: (error as any).code || 'UNKNOWN',
remoteAddress,
target: `${target.host}:${target.port}`
});
// Clean up the TLS socket since we can't forward
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason: `backend_connection_failed: ${error.message}`
});
},
onConnect: () => {
connectionEstablished = true;
// Send buffered data
if (dataBuffer.length > 0) {
backendSocket!.write(dataBuffer);
dataBuffer = Buffer.alloc(0);
}
// Now set up bidirectional forwarding with proper cleanup
forwardingSetup = true;
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
forwardingSetup = false;
},
enableHalfOpen: false // Close both when one closes
});
}
});
// Additional error logging for backend socket
backendSocket.on('error', (error) => {
if (!connectionEstablished) {
// Connection failed during setup
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Target connection error: ${error.message}`
});
}
// If connected, setupBidirectionalForwarding handles cleanup
});
}
});
}

View File

@ -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}`
});
// Variable to track backend socket
let backendSocket: plugins.tls.TLSSocket | null = null;
let isConnectedToBackend = false;
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
// 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');
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
// Get the target from configuration
const target = this.getTargetFromConfig();
// Set up the connection to the HTTPS backend
const connectToBackend = () => {
backendSocket = plugins.tls.connect({
host: target.host,
port: target.port,
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
}, () => {
isConnectedToBackend = true;
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
target: `${target.host}:${target.port}`,
tls: true
});
// Set up bidirectional forwarding with proper cleanup
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
},
enableHalfOpen: false // Close both when one closes
});
// Set timeout for backend socket
backendSocket!.setTimeout(timeout);
backendSocket!.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Backend connection timeout'
});
// Let setupBidirectionalForwarding handle the cleanup
});
});
// Handle backend connection errors
backendSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Backend connection error: ${error.message}`
});
if (!isConnectedToBackend) {
// Connection failed, clean up TLS socket
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason: `backend_connection_failed: ${error.message}`
});
}
// If connected, let setupBidirectionalForwarding handle cleanup
});
};
// Wait for the TLS handshake to complete before connecting to backend
tlsSocket.on('secure', () => {
connectToBackend();
});
}

View File

@ -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';

View File

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

View File

@ -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)) {
@ -37,8 +48,27 @@ 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();

View File

@ -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(() => {});
}
}

View File

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

View File

@ -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.
@ -518,15 +464,17 @@ export class HttpProxy implements IMetricsTracker {
// Stop WebSocket handler
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);
}
// Destroy request handler (cleans up intervals and caches)
if (this.requestHandler && typeof this.requestHandler.destroy === 'function') {
this.requestHandler.destroy();
}
// Close all tracked sockets
const socketCleanupPromises = this.socketMap.getArray().map(socket =>
cleanupSocket(socket, 'http-proxy-stop', { immediate: true })
);
await Promise.all(socketCleanupPromises);
// Close all connection pool connections
this.connectionPool.closeAllConnections();

View File

@ -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

View File

@ -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';
@ -45,21 +43,28 @@ export class RequestHandler {
// Security manager for IP filtering, rate limiting, etc.
public securityManager: SecurityManager;
// Rate limit cleanup interval
private rateLimitCleanupInterval: NodeJS.Timeout | null = null;
constructor(
private options: IHttpProxyOptions,
private connectionPool: ConnectionPool,
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);
// Schedule rate limit cleanup every minute
setInterval(() => {
this.rateLimitCleanupInterval = setInterval(() => {
this.securityManager.cleanupExpiredRateLimits();
}, 60000);
// Make sure the interval doesn't keep the process alive
if (this.rateLimitCleanupInterval.unref) {
this.rateLimitCleanupInterval.unref();
}
}
/**
@ -373,7 +378,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 +587,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 +619,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 +744,32 @@ 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);
}
}
// No route was found
stream.respond({ ':status': 404 });
stream.end('Not Found: No route configuration for this request');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
}
// 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
);
/**
* Cleanup resources and stop intervals
*/
public destroy(): void {
if (this.rateLimitCleanupInterval) {
clearInterval(this.rateLimitCleanupInterval);
this.rateLimitCleanupInterval = null;
}
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();
// Close all HTTP/2 sessions
for (const [key, session] of this.h2Sessions) {
session.close();
}
this.h2Sessions.clear();
// Clear function cache if it has a destroy method
if (this.functionCache && typeof this.functionCache.destroy === 'function') {
this.functionCache.destroy();
}
this.logger.debug('RequestHandler destroyed');
}
}

View File

@ -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

View File

@ -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';

View File

@ -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
);
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');
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);
// 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();

View File

@ -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);
}
}

View File

@ -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,24 +54,39 @@ 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 remotePort = socket.remotePort || 0;
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,
bytesReceived: 0,
bytesSent: 0,
remoteIP,
remotePort,
localPort,
isTLS: false,
tlsHandshakeComplete: false,
@ -70,6 +109,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 more frequently (every 10 seconds) to catch zombies and stuck connections faster
this.setInterval(() => {
this.performOptimizedInactivityCheck();
}, 10000);
// Note: LifecycleComponent's setInterval already calls unref()
}
/**
@ -98,18 +173,73 @@ 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 {
// Check if connection is already being processed
const record = this.connectionRecords.get(connectionId);
if (!record || record.connectionClosed) {
// Already cleaned up or doesn't exist, skip
return;
}
this.cleanupQueue.add(connectionId);
// Process immediately if queue is getting large
if (this.cleanupQueue.size >= this.cleanupBatchSize) {
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);
// Remove only the items we're processing, not the entire queue!
for (const connectionId of toCleanup) {
this.cleanupQueue.delete(connectionId);
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 +249,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,31 +260,72 @@ 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 = [];
record.pendingDataSize = 0;
@ -162,28 +336,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 +355,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
@ -243,44 +368,39 @@ export class ConnectionManager {
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 +423,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,21 +451,102 @@ 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;
// Find connections that need checking
for (const [connectionId, checkTime] of this.nextInactivityCheck) {
if (checkTime <= now) {
connectionsToCheck.push(connectionId);
}
}
// Skip inactivity check if disabled or for immortal keep-alive connections
if (
this.settings.disableInactivityCheck ||
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
) {
// Also check ALL connections for zombie state (destroyed sockets but not cleaned up)
// This is critical for proxy chains where sockets can be destroyed without events
for (const [connectionId, record] of this.connectionRecords) {
if (!record.connectionClosed) {
const incomingDestroyed = record.incoming?.destroyed || false;
const outgoingDestroyed = record.outgoing?.destroyed || false;
// Check for zombie connections: both sockets destroyed but connection not cleaned up
if (incomingDestroyed && outgoingDestroyed) {
logger.log('warn', `Zombie connection detected: ${connectionId} - both sockets destroyed but not cleaned up`, {
connectionId,
remoteIP: record.remoteIP,
age: plugins.prettyMs(now - record.incomingStartTime),
component: 'connection-manager'
});
// Clean up immediately
this.cleanupConnection(record, 'zombie_cleanup');
continue;
}
// Check for half-zombie: one socket destroyed
if (incomingDestroyed || outgoingDestroyed) {
const age = now - record.incomingStartTime;
// Use longer grace period for encrypted connections (5 minutes vs 30 seconds)
const gracePeriod = record.isTLS ? 300000 : 30000;
// Also ensure connection is old enough to avoid premature cleanup
if (age > gracePeriod && age > 10000) {
logger.log('warn', `Half-zombie connection detected: ${connectionId} - ${incomingDestroyed ? 'incoming' : 'outgoing'} destroyed`, {
connectionId,
remoteIP: record.remoteIP,
age: plugins.prettyMs(age),
incomingDestroyed,
outgoingDestroyed,
isTLS: record.isTLS,
gracePeriod: plugins.prettyMs(gracePeriod),
component: 'connection-manager'
});
// Clean up
this.cleanupConnection(record, 'half_zombie_cleanup');
}
}
// Check for stuck connections: no data sent back to client
if (!record.connectionClosed && record.outgoing && record.bytesReceived > 0 && record.bytesSent === 0) {
const age = now - record.incomingStartTime;
// Use longer grace period for encrypted connections (5 minutes vs 60 seconds)
const stuckThreshold = record.isTLS ? 300000 : 60000;
// If connection is older than threshold and no data sent back, likely stuck
if (age > stuckThreshold) {
logger.log('warn', `Stuck connection detected: ${connectionId} - received ${record.bytesReceived} bytes but sent 0 bytes`, {
connectionId,
remoteIP: record.remoteIP,
age: plugins.prettyMs(age),
bytesReceived: record.bytesReceived,
targetHost: record.targetHost,
targetPort: record.targetPort,
isTLS: record.isTLS,
threshold: plugins.prettyMs(stuckThreshold),
component: 'connection-manager'
});
// Set termination reason and increment stats
if (record.incomingTerminationReason == null) {
record.incomingTerminationReason = 'stuck_no_response';
this.incrementTerminationStat('incoming', 'stuck_no_response');
}
// Clean up
this.cleanupConnection(record, 'stuck_no_response');
}
}
}
}
// Process only connections that need checking
for (const connectionId of connectionsToCheck) {
const record = this.connectionRecords.get(connectionId);
if (!record || record.connectionClosed) {
this.nextInactivityCheck.delete(connectionId);
continue;
}
@ -359,37 +559,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 +597,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();
}
// First pass: End all connections gracefully
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
/**
* Override LifecycleComponent's onCleanup method
*/
protected async onCleanup(): Promise<void> {
// 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);
}
}

View File

@ -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)
});
}
/**

View File

@ -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';

View File

@ -0,0 +1,289 @@
import * as plugins from '../../plugins.js';
import type { SmartProxy } from './smart-proxy.js';
import type { IProxyStats, IProxyStatsExtended } from './models/metrics-types.js';
import { logger } from '../../core/utils/logger.js';
/**
* Collects and computes metrics for SmartProxy on-demand
*/
export class MetricsCollector implements IProxyStatsExtended {
// RPS tracking (the only state we need to maintain)
private requestTimestamps: number[] = [];
private readonly RPS_WINDOW_SIZE = 60000; // 1 minute window
private readonly MAX_TIMESTAMPS = 5000; // Maximum timestamps to keep
// Optional caching for performance
private cachedMetrics: {
timestamp: number;
connectionsByRoute?: Map<string, number>;
connectionsByIP?: Map<string, number>;
} = { timestamp: 0 };
private readonly CACHE_TTL = 1000; // 1 second cache
// RxJS subscription for connection events
private connectionSubscription?: plugins.smartrx.rxjs.Subscription;
constructor(
private smartProxy: SmartProxy
) {
// Subscription will be set up in start() method
}
/**
* Get the current number of active connections
*/
public getActiveConnections(): number {
return this.smartProxy.connectionManager.getConnectionCount();
}
/**
* Get connection counts grouped by route name
*/
public getConnectionsByRoute(): Map<string, number> {
const now = Date.now();
// Return cached value if fresh
if (this.cachedMetrics.connectionsByRoute &&
now - this.cachedMetrics.timestamp < this.CACHE_TTL) {
return new Map(this.cachedMetrics.connectionsByRoute);
}
// Compute fresh value
const routeCounts = new Map<string, number>();
const connections = this.smartProxy.connectionManager.getConnections();
if (this.smartProxy.settings?.enableDetailedLogging) {
logger.log('debug', `MetricsCollector: Computing route connections`, {
totalConnections: connections.size,
component: 'metrics'
});
}
for (const [_, record] of connections) {
// Try different ways to get the route name
const routeName = (record as any).routeName ||
record.routeConfig?.name ||
(record.routeConfig as any)?.routeName ||
'unknown';
if (this.smartProxy.settings?.enableDetailedLogging) {
logger.log('debug', `MetricsCollector: Connection route info`, {
connectionId: record.id,
routeName,
hasRouteConfig: !!record.routeConfig,
routeConfigName: record.routeConfig?.name,
routeConfigKeys: record.routeConfig ? Object.keys(record.routeConfig) : [],
component: 'metrics'
});
}
const current = routeCounts.get(routeName) || 0;
routeCounts.set(routeName, current + 1);
}
// Cache and return
this.cachedMetrics.connectionsByRoute = routeCounts;
this.cachedMetrics.timestamp = now;
return new Map(routeCounts);
}
/**
* Get connection counts grouped by IP address
*/
public getConnectionsByIP(): Map<string, number> {
const now = Date.now();
// Return cached value if fresh
if (this.cachedMetrics.connectionsByIP &&
now - this.cachedMetrics.timestamp < this.CACHE_TTL) {
return new Map(this.cachedMetrics.connectionsByIP);
}
// Compute fresh value
const ipCounts = new Map<string, number>();
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
const ip = record.remoteIP;
const current = ipCounts.get(ip) || 0;
ipCounts.set(ip, current + 1);
}
// Cache and return
this.cachedMetrics.connectionsByIP = ipCounts;
this.cachedMetrics.timestamp = now;
return new Map(ipCounts);
}
/**
* Get the total number of connections since proxy start
*/
public getTotalConnections(): number {
// Get from termination stats
const stats = this.smartProxy.connectionManager.getTerminationStats();
let total = this.smartProxy.connectionManager.getConnectionCount(); // Add active connections
// Add all terminated connections
for (const reason in stats.incoming) {
total += stats.incoming[reason];
}
return total;
}
/**
* Get the current requests per second rate
*/
public getRequestsPerSecond(): number {
const now = Date.now();
const windowStart = now - this.RPS_WINDOW_SIZE;
// Clean old timestamps
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > windowStart);
// Calculate RPS based on window
const requestsInWindow = this.requestTimestamps.length;
return requestsInWindow / (this.RPS_WINDOW_SIZE / 1000);
}
/**
* Record a new request for RPS tracking
*/
public recordRequest(): void {
const now = Date.now();
this.requestTimestamps.push(now);
// Prevent unbounded growth - clean up more aggressively
if (this.requestTimestamps.length > this.MAX_TIMESTAMPS) {
// Keep only timestamps within the window
const cutoff = now - this.RPS_WINDOW_SIZE;
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > cutoff);
}
}
/**
* Get total throughput (bytes transferred)
*/
public getThroughput(): { bytesIn: number; bytesOut: number } {
let bytesIn = 0;
let bytesOut = 0;
// Sum bytes from all active connections
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
bytesIn += record.bytesReceived;
bytesOut += record.bytesSent;
}
return { bytesIn, bytesOut };
}
/**
* Get throughput rate (bytes per second) for last minute
*/
public getThroughputRate(): { bytesInPerSec: number; bytesOutPerSec: number } {
const now = Date.now();
let recentBytesIn = 0;
let recentBytesOut = 0;
// Calculate bytes transferred in last minute from active connections
for (const [_, record] of this.smartProxy.connectionManager.getConnections()) {
const connectionAge = now - record.incomingStartTime;
if (connectionAge < 60000) { // Connection started within last minute
recentBytesIn += record.bytesReceived;
recentBytesOut += record.bytesSent;
} else {
// For older connections, estimate rate based on average
const rate = connectionAge / 60000;
recentBytesIn += record.bytesReceived / rate;
recentBytesOut += record.bytesSent / rate;
}
}
return {
bytesInPerSec: Math.round(recentBytesIn / 60),
bytesOutPerSec: Math.round(recentBytesOut / 60)
};
}
/**
* Get top IPs by connection count
*/
public getTopIPs(limit: number = 10): Array<{ ip: string; connections: number }> {
const ipCounts = this.getConnectionsByIP();
const sorted = Array.from(ipCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, connections]) => ({ ip, connections }));
return sorted;
}
/**
* Check if an IP has reached the connection limit
*/
public isIPBlocked(ip: string, maxConnectionsPerIP: number): boolean {
const ipCounts = this.getConnectionsByIP();
const currentConnections = ipCounts.get(ip) || 0;
return currentConnections >= maxConnectionsPerIP;
}
/**
* Clean up old request timestamps
*/
private cleanupOldRequests(): void {
const cutoff = Date.now() - this.RPS_WINDOW_SIZE;
this.requestTimestamps = this.requestTimestamps.filter(ts => ts > cutoff);
}
/**
* Start the metrics collector and set up subscriptions
*/
public start(): void {
if (!this.smartProxy.routeConnectionHandler) {
throw new Error('MetricsCollector: RouteConnectionHandler not available');
}
// Subscribe to the newConnectionSubject from RouteConnectionHandler
this.connectionSubscription = this.smartProxy.routeConnectionHandler.newConnectionSubject.subscribe({
next: (record) => {
this.recordRequest();
// Optional: Log connection details
if (this.smartProxy.settings?.enableDetailedLogging) {
logger.log('debug', `MetricsCollector: New connection recorded`, {
connectionId: record.id,
remoteIP: record.remoteIP,
routeName: record.routeConfig?.name || 'unknown',
component: 'metrics'
});
}
},
error: (err) => {
logger.log('error', `MetricsCollector: Error in connection subscription`, {
error: err.message,
component: 'metrics'
});
}
});
logger.log('debug', 'MetricsCollector started', { component: 'metrics' });
}
/**
* Stop the metrics collector and clean up resources
*/
public stop(): void {
if (this.connectionSubscription) {
this.connectionSubscription.unsubscribe();
this.connectionSubscription = undefined;
}
logger.log('debug', 'MetricsCollector stopped', { component: 'metrics' });
}
/**
* Alias for stop() for backward compatibility
*/
public destroy(): void {
this.stop();
}
}

View File

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

View File

@ -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?: {
@ -63,6 +69,7 @@ export interface ISmartProxyOptions {
maxVersion?: string;
// Timeout settings
connectionTimeout?: number; // Timeout for establishing connection to backend (ms), default: 30000 (30s)
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
@ -128,8 +135,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;
@ -145,6 +152,7 @@ export interface IConnectionRecord {
bytesReceived: number; // Total bytes received
bytesSent: number; // Total bytes sent
remoteIP: string; // Remote IP (cached for logging after socket close)
remotePort: number; // Remote port (cached for logging after socket close)
localPort: number; // Local port (cached for logging)
isTLS: boolean; // Whether this connection is a TLS connection
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete

View File

@ -0,0 +1,54 @@
/**
* Interface for proxy statistics and metrics
*/
export interface IProxyStats {
/**
* Get the current number of active connections
*/
getActiveConnections(): number;
/**
* Get connection counts grouped by route name
*/
getConnectionsByRoute(): Map<string, number>;
/**
* Get connection counts grouped by IP address
*/
getConnectionsByIP(): Map<string, number>;
/**
* Get the total number of connections since proxy start
*/
getTotalConnections(): number;
/**
* Get the current requests per second rate
*/
getRequestsPerSecond(): number;
/**
* Get total throughput (bytes transferred)
*/
getThroughput(): { bytesIn: number; bytesOut: number };
}
/**
* Extended interface for additional metrics helpers
*/
export interface IProxyStatsExtended extends IProxyStats {
/**
* Get throughput rate (bytes per second) for last minute
*/
getThroughputRate(): { bytesInPerSec: number; bytesOutPerSec: number };
/**
* Get top IPs by connection count
*/
getTopIPs(limit?: number): Array<{ ip: string; connections: number }>;
/**
* Check if an IP has reached the connection limit
*/
isIPBlocked(ip: string, maxConnectionsPerIP: number): boolean;
}

View File

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

View File

@ -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;
}

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