Compare commits

...

82 Commits

Author SHA1 Message Date
48c5ea3b1d 3.23.1 2025-03-05 14:33:10 +00:00
bd9292bf47 fix(PortProxy): Enhanced connection setup to handle pending data buffering before establishing outgoing connection 2025-03-05 14:33:09 +00:00
6532e6f0e0 3.23.0 2025-03-03 03:18:49 +00:00
8791da83b4 feat(documentation): Updated documentation with architecture flow diagrams. 2025-03-03 03:18:49 +00:00
9ad08edf79 3.22.5 2025-03-03 03:05:50 +00:00
c0de8c59a2 fix(documentation): Refactored readme for clarity and consistency, fixed documentation typos 2025-03-03 03:05:49 +00:00
3748689c16 3.22.4 2025-03-03 02:16:48 +00:00
d0b3139fda fix(core): Addressed minor issues in the core modules to improve stability and performance. 2025-03-03 02:16:48 +00:00
fd4f731ada 3.22.3 2025-03-03 02:14:22 +00:00
ced9b5b27b fix(core): Improve connection management and error handling in PortProxy 2025-03-03 02:14:21 +00:00
eb70a86304 3.22.2 2025-03-03 02:03:24 +00:00
131d9d326e fix(portproxy): Refactored connection cleanup logic in PortProxy 2025-03-03 02:03:24 +00:00
12de96a7d5 3.22.1 2025-03-03 01:57:52 +00:00
296e1fcdc7 fix(PortProxy): Fix connection timeout and IP validation handling for PortProxy 2025-03-03 01:57:52 +00:00
8459e4013c 3.22.0 2025-03-03 01:50:30 +00:00
191c8ac0e6 feat(classes.portproxy): Enhanced PortProxy to support initial data timeout and improved IP handling 2025-03-03 01:50:30 +00:00
3ab483d164 3.21.0 2025-03-03 01:42:16 +00:00
fcd80dc56b feat(PortProxy): Enhancements to connection management in PortProxy 2025-03-03 01:42:16 +00:00
8ddffcd6e5 3.20.2 2025-03-01 20:31:50 +00:00
a5a7781c17 fix(PortProxy): Enhance connection cleanup handling in PortProxy 2025-03-01 20:31:50 +00:00
d647e77cdf 3.20.1 2025-03-01 17:32:31 +00:00
9161336197 fix(PortProxy): Improve IP allowance check for forced domains 2025-03-01 17:32:31 +00:00
2e63d13dd4 3.20.0 2025-03-01 17:19:27 +00:00
af6ed735d5 feat(PortProxy): Enhance PortProxy with advanced connection cleanup and logging 2025-03-01 17:19:27 +00:00
7d38f29ef3 3.19.0 2025-03-01 13:17:05 +00:00
0df26d4367 feat(PortProxy): Enhance PortProxy with default blocked IPs 2025-03-01 13:17:05 +00:00
f9a6e2d748 3.18.2 2025-02-27 21:25:03 +00:00
1cb6302750 fix(portproxy): Fixed typographical errors in comments within PortProxy class. 2025-02-27 21:25:03 +00:00
f336f25535 3.18.1 2025-02-27 21:19:34 +00:00
5d6b707440 fix(PortProxy): Refactor and enhance PortProxy test cases and handling 2025-02-27 21:19:34 +00:00
622ad2ff20 3.18.0 2025-02-27 20:59:29 +00:00
dd23efd28d feat(PortProxy): Add SNI-based renegotiation handling in PortProxy 2025-02-27 20:59:29 +00:00
0ddf68a919 3.17.1 2025-02-27 20:10:26 +00:00
ec08ca51f5 fix(PortProxy): Fix handling of SNI re-negotiation in PortProxy 2025-02-27 20:10:26 +00:00
29688d1379 3.17.0 2025-02-27 19:57:28 +00:00
c83f6fa278 feat(smartproxy): Enhance description clarity and improve SNI handling with domain locking. 2025-02-27 19:57:27 +00:00
60333b0a59 3.16.9 2025-02-27 15:46:14 +00:00
1aa409907b fix(portproxy): Extend domain input validation to support string arrays in port proxy configurations. 2025-02-27 15:46:14 +00:00
adee6afc76 3.16.8 2025-02-27 15:41:03 +00:00
4a0792142f fix(PortProxy): Fix IP filtering for domain and global default allowed lists and improve port-based routing logic. 2025-02-27 15:41:03 +00:00
f1b810a4fa 3.16.7 2025-02-27 15:32:06 +00:00
96b5877c5f fix(PortProxy): Improved IP validation logic in PortProxy to ensure correct domain matching and fallback 2025-02-27 15:32:06 +00:00
6d627f67f7 3.16.6 2025-02-27 15:30:20 +00:00
9af968b8e7 fix(PortProxy): Optimize connection cleanup logic in PortProxy by removing unnecessary delays. 2025-02-27 15:30:20 +00:00
b3ba0c21e8 3.16.5 2025-02-27 15:05:38 +00:00
ef707a5870 fix(PortProxy): Improved connection cleanup process with added asynchronous delays 2025-02-27 15:05:38 +00:00
6ca14edb38 3.16.4 2025-02-27 14:23:44 +00:00
5a5686b6b9 fix(PortProxy): Fix and enhance port proxy handling 2025-02-27 14:23:44 +00:00
2080f419cb 3.16.3 2025-02-27 13:04:01 +00:00
659aae297b fix(PortProxy): Refactored PortProxy to support multiple listening ports and improved modularity. 2025-02-27 13:04:01 +00:00
fcd0f61b5c 3.16.2 2025-02-27 12:54:15 +00:00
7ee35a98e3 fix(PortProxy): Fix port-based routing logic in PortProxy 2025-02-27 12:54:14 +00:00
ea0f6d2270 3.16.1 2025-02-27 12:42:50 +00:00
621ad9e681 fix(core): Updated minor version numbers in dependencies for patch release. 2025-02-27 12:42:50 +00:00
7cea5773ee 3.16.0 2025-02-27 12:41:20 +00:00
a2cb56ba65 feat(PortProxy): Enhancements made to PortProxy settings and capabilities 2025-02-27 12:41:20 +00:00
408b793149 3.15.0 2025-02-27 12:25:48 +00:00
f6c3d2d3d0 feat(classes.portproxy): Add support for port range-based routing with enhanced IP and port validation. 2025-02-27 12:25:48 +00:00
422eb5ec40 3.14.2 2025-02-26 19:00:09 +00:00
45390c4389 fix(PortProxy): Fix cleanup timer reset for PortProxy 2025-02-26 19:00:09 +00:00
0f2e6d688c 3.14.1 2025-02-26 12:56:00 +00:00
3bd7b70c19 fix(PortProxy): Increased default maxConnectionLifetime for PortProxy to 600000 ms 2025-02-26 12:56:00 +00:00
07a82a09be 3.14.0 2025-02-26 10:29:21 +00:00
23253a2731 feat(PortProxy): Introduce max connection lifetime feature 2025-02-26 10:29:21 +00:00
be31a9b553 3.13.0 2025-02-25 00:56:02 +00:00
a1051f78e8 feat(core): Add support for tagging iptables rules with comments and cleaning them up on process exit 2025-02-25 00:56:01 +00:00
aa756bd698 3.12.0 2025-02-24 23:27:48 +00:00
ff4f44d6fc feat(IPTablesProxy): Introduce IPTablesProxy class for managing iptables NAT rules 2025-02-24 23:27:48 +00:00
63ebad06ea 3.11.0 2025-02-24 10:00:57 +00:00
31e15b65ec feat(Port80Handler): Add automatic certificate issuance with ACME client 2025-02-24 10:00:57 +00:00
266895ccc5 3.10.5 2025-02-24 09:53:39 +00:00
dc3d56771b fix(portproxy): Fix incorrect import path in test file 2025-02-24 09:53:39 +00:00
38601a41bb 3.10.4 2025-02-23 17:38:23 +00:00
a53e6f1019 fix(PortProxy): Refactor connection tracking to utilize unified records in PortProxy 2025-02-23 17:38:22 +00:00
3de35f3b2c 3.10.3 2025-02-23 17:30:41 +00:00
b9210d891e fix(PortProxy): Refactor and optimize PortProxy for improved readability and maintainability 2025-02-23 17:30:41 +00:00
133d5a47e0 3.10.2 2025-02-23 11:43:21 +00:00
f2f4e47893 fix(PortProxy): Fix connection handling to include timeouts for SNI-enabled connections. 2025-02-23 11:43:21 +00:00
e47436608f 3.10.1 2025-02-22 13:22:26 +00:00
128f8203ac fix(PortProxy): Improve socket cleanup logic to prevent potential resource leaks 2025-02-22 13:22:26 +00:00
c7697eca84 3.10.0 2025-02-22 05:46:30 +00:00
71b5237cd4 feat(smartproxy.portproxy): Enhance PortProxy with detailed connection statistics and termination tracking 2025-02-22 05:46:30 +00:00
16 changed files with 2081 additions and 557 deletions

View File

@ -1,5 +1,280 @@
# Changelog
## 2025-03-05 - 3.23.1 - fix(PortProxy)
Enhanced connection setup to handle pending data buffering before establishing outgoing connection
- Introduced pending data buffering to address issues with data reception before outgoing connection is fully established.
- Removed immediate data piping in favor of buffering to ensure complete initial data transfer.
- Added temporary data handler to collect incoming data during connection setup for precise activity tracking.
## 2025-03-03 - 3.23.0 - feat(documentation)
Updated documentation with architecture flow diagrams.
- Added detailed architecture and flow diagrams for SmartProxy components.
- Included HTTPS Reverse Proxy Flow diagram.
- Integrated Port Proxy with SNI-based Routing diagram.
- Added Let's Encrypt Certificate Acquisition flow.
## 2025-03-03 - 3.22.5 - fix(documentation)
Refactored readme for clarity and consistency, fixed documentation typos
- Updated readme to improve clarity and remove redundant information.
- Fixed minor documentation issues in the code comments.
- Reorganized readme structure for better readability.
- Improved sample code snippets for easier understanding.
## 2025-03-03 - 3.22.4 - fix(core)
Addressed minor issues in the core modules to improve stability and performance.
## 2025-03-03 - 3.22.3 - fix(core)
Improve connection management and error handling in PortProxy
- Refactored connection cleanup to handle errors more gracefully.
- Introduced comprehensive comments for better code understanding.
- Revised SNI data timeout logic for connection handling.
- Enhanced logging and error reporting during connection management.
- Improved inactivity checks and parity checks for existing connections.
## 2025-03-03 - 3.22.2 - fix(portproxy)
Refactored connection cleanup logic in PortProxy
- Simplified the connection cleanup logic by removing redundant methods.
- Consolidated the cleanup initiation and execution into a single cleanup method.
- Improved error handling by ensuring connections are closed appropriately.
## 2025-03-03 - 3.22.1 - fix(PortProxy)
Fix connection timeout and IP validation handling for PortProxy
- Adjusted initial data timeout setting for SNI-enabled connections in PortProxy.
- Restored IP validation logic to original behavior, ensuring compatibility with domain configurations.
## 2025-03-03 - 3.22.0 - feat(classes.portproxy)
Enhanced PortProxy to support initial data timeout and improved IP handling
- Added `initialDataTimeout` to PortProxy settings for handling data flow in chained proxies.
- Improved IP validation by allowing relaxed checks in chained proxy setups.
- Introduced dynamic logging for connection lifecycle and proxy configurations.
- Enhanced timeout handling for better proxy resilience.
## 2025-03-03 - 3.21.0 - feat(PortProxy)
Enhancements to connection management in PortProxy
- Introduced a unique ID for each connection record for improved tracking.
- Enhanced cleanup mechanism for connections with dual states: initiated and executed.
- Implemented shutdown process handling to ensure graceful connection closure.
- Added logging for better tracing of connection activities and states.
- Improved connection setup with explicit timeouts and data flow management.
- Integrated inactivity and parity checks to monitor connection health.
## 2025-03-01 - 3.20.2 - fix(PortProxy)
Enhance connection cleanup handling in PortProxy
- Add checks to ensure timers are reset only if outgoing socket is active
- Prevent setting outgoingActive if the connection is already closed
## 2025-03-01 - 3.20.1 - fix(PortProxy)
Improve IP allowance check for forced domains
- Enhanced IP allowance check logic by incorporating blocked IPs and default allowed IPs for forced domains within port proxy configurations.
## 2025-03-01 - 3.20.0 - feat(PortProxy)
Enhance PortProxy with advanced connection cleanup and logging
- Introduced `cleanupConnection` method for improved connection management.
- Added logging for connection cleanup including special conditions.
- Implemented parity check to clean up connections when outgoing side closes but incoming remains active.
- Improved logging during interval checks for active connections and their durations.
## 2025-03-01 - 3.19.0 - feat(PortProxy)
Enhance PortProxy with default blocked IPs
- Introduced defaultBlockedIPs in IPortProxySettings to handle globally blocked IPs.
- Added logic for merging domain-specific and default allowed and blocked IPs for effective IP filtering.
- Refactored helper functions for IP and port range checks to improve modularity in PortProxy.
## 2025-02-27 - 3.18.2 - fix(portproxy)
Fixed typographical errors in comments within PortProxy class.
- Corrected typographical errors in comments within the PortProxy class.
## 2025-02-27 - 3.18.1 - fix(PortProxy)
Refactor and enhance PortProxy test cases and handling
- Refactored test cases in test/test.portproxy.ts for clarity and added coverage.
- Improved TCP server helper functions for better flexibility.
- Fixed issues with domain handling in PortProxy configuration.
- Introduced round-robin logic for multi-IP domains in PortProxy.
- Ensured proper cleanup and stopping of test servers in the test suite.
## 2025-02-27 - 3.18.0 - feat(PortProxy)
Add SNI-based renegotiation handling in PortProxy
- Introduced a new field 'lockedDomain' in IConnectionRecord to store initial SNI.
- Enhanced connection management by enforcing termination if rehandshake is detected with different SNI.
## 2025-02-27 - 3.17.1 - fix(PortProxy)
Fix handling of SNI re-negotiation in PortProxy
- Removed connection locking to the initially negotiated SNI
- Improved handling of SNI during renegotiation in PortProxy
## 2025-02-27 - 3.17.0 - feat(smartproxy)
Enhance description clarity and improve SNI handling with domain locking.
- Improved package description in package.json, readme.md, and npmextra.json for better clarity and keyword optimization.
- Enhanced SNI handling in PortProxy by adding domain locking and extra checks to terminate connections if a different SNI is detected post-handshake.
- Refactored readme.md to better explain the usage and functionalities of the proxy features including SSL redirection, WebSocket handling, and dynamic routing.
## 2025-02-27 - 3.16.9 - fix(portproxy)
Extend domain input validation to support string arrays in port proxy configurations.
- Modify IDomainConfig interface to allow domain specification as string array.
- Update connection setup logic to handle multiple domain patterns.
- Enhance domain rejection logging to include all domain patterns.
## 2025-02-27 - 3.16.8 - fix(PortProxy)
Fix IP filtering for domain and global default allowed lists and improve port-based routing logic.
- Improved logic to prioritize domain-specific allowed IPs over global defaults.
- Fixed port-based rules application to handle global port ranges more effectively.
- Enhanced rejection handling for unauthorized IP addresses in both domain-specific and default global lists.
## 2025-02-27 - 3.16.7 - fix(PortProxy)
Improved IP validation logic in PortProxy to ensure correct domain matching and fallback
- Refactored the setupConnection function inside PortProxy to enhance IP address validation.
- Domain-specific allowed IP preference is applied before default list lookup.
- Removed redundant condition checks to streamline connection rejection paths.
## 2025-02-27 - 3.16.6 - fix(PortProxy)
Optimize connection cleanup logic in PortProxy by removing unnecessary delays.
- Removed multiple await plugins.smartdelay.delayFor(0) calls.
- Improved performance by ensuring timely resource release during connection termination.
## 2025-02-27 - 3.16.5 - fix(PortProxy)
Improved connection cleanup process with added asynchronous delays
- Connection cleanup now includes asynchronous delays for reliable order of operations.
## 2025-02-27 - 3.16.4 - fix(PortProxy)
Fix and enhance port proxy handling
- Ensure that all created proxy servers are correctly checked for listening state.
- Corrected the handling of ports and domain configurations within port proxy setups.
- Expanded test coverage for handling multiple concurrent and chained proxy connections.
## 2025-02-27 - 3.16.3 - fix(PortProxy)
Refactored PortProxy to support multiple listening ports and improved modularity.
- Updated PortProxy to allow multiple listening ports with flexible configuration.
- Moved helper functions for IP and port range checks outside the class for cleaner code structure.
## 2025-02-27 - 3.16.2 - fix(PortProxy)
Fix port-based routing logic in PortProxy
- Optimized the handling and checking of local ports in the global port range.
- Fixed the logic for rejecting or accepting connections based on predefined port ranges.
- Improved handling of the default and specific domain configurations during port-based connections.
## 2025-02-27 - 3.16.1 - fix(core)
Updated minor version numbers in dependencies for patch release.
- No specific file changes detected.
- Dependencies versioning adjusted for stability.
## 2025-02-27 - 3.16.0 - feat(PortProxy)
Enhancements made to PortProxy settings and capabilities
- Added 'forwardAllGlobalRanges' and 'targetIP' to IPortProxySettings.
- Improved PortProxy to forward connections based on domain-specific configurations.
- Added comprehensive handling for global port-range based connection forwarding.
- Enabled forwarding of all connections on global port ranges directly to global target IP.
## 2025-02-27 - 3.15.0 - feat(classes.portproxy)
Add support for port range-based routing with enhanced IP and port validation.
- Introduced globalPortRanges in IPortProxySettings for routing based on port ranges.
- Improved connection handling with port range and domain configuration validations.
- Updated connection logging to include the local port information.
## 2025-02-26 - 3.14.2 - fix(PortProxy)
Fix cleanup timer reset for PortProxy
- Resolved an issue where the cleanup timer in the PortProxy class did not reset correctly if both incoming and outgoing data events were triggered without clearing flags.
## 2025-02-26 - 3.14.1 - fix(PortProxy)
Increased default maxConnectionLifetime for PortProxy to 600000 ms
- Updated PortProxy settings to extend default maxConnectionLifetime to 10 minutes.
## 2025-02-26 - 3.14.0 - feat(PortProxy)
Introduce max connection lifetime feature
- Added an optional maxConnectionLifetime setting for PortProxy.
- Forces cleanup of long-lived connections based on inactivity or lifetime limit.
## 2025-02-25 - 3.13.0 - feat(core)
Add support for tagging iptables rules with comments and cleaning them up on process exit
- Extended IPTablesProxy class to include tagging rules with unique comments.
- Added feature to clean up iptables rules via comments during process exit.
## 2025-02-24 - 3.12.0 - feat(IPTablesProxy)
Introduce IPTablesProxy class for managing iptables NAT rules
- Added IPTablesProxy class to facilitate basic port forwarding using iptables.
- Introduced IIpTableProxySettings interface for configuring IPTablesProxy.
- Implemented start and stop methods for managing iptables rules dynamically.
## 2025-02-24 - 3.11.0 - feat(Port80Handler)
Add automatic certificate issuance with ACME client
- Implemented automatic certificate issuance using 'acme-client' for Port80Handler.
- Converts account key and CSR from Buffers to strings for processing.
- Implemented HTTP-01 challenge handling for certificate acquisition.
- New certificates are fetched and added dynamically.
## 2025-02-24 - 3.10.5 - fix(portproxy)
Fix incorrect import path in test file
- Change import path from '../ts/smartproxy.portproxy.js' to '../ts/classes.portproxy.js' in test/test.portproxy.ts
## 2025-02-23 - 3.10.4 - fix(PortProxy)
Refactor connection tracking to utilize unified records in PortProxy
- Implemented a unified record system for tracking incoming and outgoing connections.
- Replaced individual connection tracking sets with a Set of IConnectionRecord.
- Improved logging of connection activities and statistics.
## 2025-02-23 - 3.10.3 - fix(PortProxy)
Refactor and optimize PortProxy for improved readability and maintainability
- Simplified and clarified inline comments.
- Optimized the extractSNI function for better readability.
- Streamlined the cleanup process for connections in PortProxy.
- Improved handling and logging of incoming and outgoing connections.
## 2025-02-23 - 3.10.2 - fix(PortProxy)
Fix connection handling to include timeouts for SNI-enabled connections.
- Added initial data timeout for SNI-enabled connections to improve connection handling.
- Cleared timeout once data is received to prevent premature socket closure.
## 2025-02-22 - 3.10.1 - fix(PortProxy)
Improve socket cleanup logic to prevent potential resource leaks
- Updated socket cleanup in PortProxy to ensure sockets are forcefully destroyed if not already destroyed.
## 2025-02-22 - 3.10.0 - feat(smartproxy.portproxy)
Enhance PortProxy with detailed connection statistics and termination tracking
- Added tracking of termination statistics for incoming and outgoing connections
- Enhanced logging to include detailed termination statistics
- Introduced helpers to update and log termination stats
- Retained detailed connection duration and active connection logging
## 2025-02-22 - 3.9.4 - fix(PortProxy)
Ensure proper cleanup on connection rejection in PortProxy

View File

@ -5,22 +5,26 @@
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartproxy",
"description": "a proxy for handling high workloads of proxying",
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
"npmPackagename": "@push.rocks/smartproxy",
"license": "MIT",
"projectDomain": "push.rocks",
"keywords": [
"proxy",
"network traffic",
"network",
"traffic management",
"SSL",
"TLS",
"WebSocket",
"port proxying",
"dynamic routing",
"authentication",
"real-time applications",
"high workload",
"http",
"https",
"websocket",
"network routing",
"ssl redirect",
"port mapping",
"HTTPS",
"reverse proxy",
"authentication"
"server",
"network security"
]
}
},

View File

@ -1,8 +1,8 @@
{
"name": "@push.rocks/smartproxy",
"version": "3.9.4",
"version": "3.23.1",
"private": false,
"description": "a proxy for handling high workloads of proxying",
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
@ -31,6 +31,7 @@
"@tsclass/tsclass": "^4.4.0",
"@types/minimatch": "^5.1.2",
"@types/ws": "^8.5.14",
"acme-client": "^5.4.0",
"minimatch": "^9.0.3",
"pretty-ms": "^9.2.0",
"ws": "^8.18.0"
@ -52,16 +53,20 @@
],
"keywords": [
"proxy",
"network traffic",
"network",
"traffic management",
"SSL",
"TLS",
"WebSocket",
"port proxying",
"dynamic routing",
"authentication",
"real-time applications",
"high workload",
"http",
"https",
"websocket",
"network routing",
"ssl redirect",
"port mapping",
"HTTPS",
"reverse proxy",
"authentication"
"server",
"network security"
],
"homepage": "https://code.foss.global/push.rocks/smartproxy#readme",
"repository": {

187
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
'@types/ws':
specifier: ^8.5.14
version: 8.5.14
acme-client:
specifier: ^5.4.0
version: 5.4.0
minimatch:
specifier: ^9.0.3
version: 9.0.5
@ -683,6 +686,39 @@ packages:
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@peculiar/asn1-cms@2.3.15':
resolution: {integrity: sha512-B+DoudF+TCrxoJSTjjcY8Mmu+lbv8e7pXGWrhNp2/EGJp9EEcpzjBCar7puU57sGifyzaRVM03oD5L7t7PghQg==}
'@peculiar/asn1-csr@2.3.15':
resolution: {integrity: sha512-caxAOrvw2hUZpxzhz8Kp8iBYKsHbGXZPl2KYRMIPvAfFateRebS3136+orUpcVwHRmpXWX2kzpb6COlIrqCumA==}
'@peculiar/asn1-ecc@2.3.15':
resolution: {integrity: sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==}
'@peculiar/asn1-pfx@2.3.15':
resolution: {integrity: sha512-E3kzQe3J2xV9DP6SJS4X6/N1e4cYa2xOAK46VtvpaRk8jlheNri8v0rBezKFVPB1rz/jW8npO+u1xOvpATFMWg==}
'@peculiar/asn1-pkcs8@2.3.15':
resolution: {integrity: sha512-/PuQj2BIAw1/v76DV1LUOA6YOqh/UvptKLJHtec/DQwruXOCFlUo7k6llegn8N5BTeZTWMwz5EXruBw0Q10TMg==}
'@peculiar/asn1-pkcs9@2.3.15':
resolution: {integrity: sha512-yiZo/1EGvU1KiQUrbcnaPGWc0C7ElMMskWn7+kHsCFm+/9fU0+V1D/3a5oG0Jpy96iaXggQpA9tzdhnYDgjyFg==}
'@peculiar/asn1-rsa@2.3.15':
resolution: {integrity: sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==}
'@peculiar/asn1-schema@2.3.15':
resolution: {integrity: sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==}
'@peculiar/asn1-x509-attr@2.3.15':
resolution: {integrity: sha512-TWJVJhqc+IS4MTEML3l6W1b0sMowVqdsnI4dnojg96LvTuP8dga9f76fjP07MUuss60uSyT2ckoti/2qHXA10A==}
'@peculiar/asn1-x509@2.3.15':
resolution: {integrity: sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==}
'@peculiar/x509@1.12.3':
resolution: {integrity: sha512-+Mzq+W7cNEKfkNZzyLl6A6ffqc3r21HGZUezgfKxpZrkORfOqgRXnS80Zu0IV6a9Ue9QBJeKD7kN0iWfc3bhRQ==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -1563,6 +1599,10 @@ packages:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
acme-client@5.4.0:
resolution: {integrity: sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==}
engines: {node: '>= 16'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@ -1626,6 +1666,10 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
asn1js@3.0.5:
resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
engines: {node: '>=12.0.0'}
astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
@ -1643,6 +1687,9 @@ packages:
resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==}
engines: {node: '>=4'}
axios@1.7.9:
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
b4a@1.6.7:
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
@ -3463,6 +3510,13 @@ packages:
resolution: {integrity: sha512-+vZPU8iBSdCx1Kn5hHas80fyo0TiVyMeqLGv/1dygX2HKhAZjO9YThadbRTCoTYq0yWw+w/CysldPsEekDtjDQ==}
engines: {node: '>=14.1.0'}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
@ -3508,6 +3562,9 @@ packages:
resolution: {integrity: sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==}
engines: {node: '>= 14.18.0'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
@ -3897,6 +3954,10 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsyringe@4.8.0:
resolution: {integrity: sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==}
engines: {node: '>= 6.0.0'}
turndown-plugin-gfm@1.0.2:
resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==}
@ -5212,6 +5273,96 @@ snapshots:
dependencies:
pako: 1.0.11
'@peculiar/asn1-cms@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
'@peculiar/asn1-x509-attr': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-csr@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-ecc@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-pfx@2.3.15':
dependencies:
'@peculiar/asn1-cms': 2.3.15
'@peculiar/asn1-pkcs8': 2.3.15
'@peculiar/asn1-rsa': 2.3.15
'@peculiar/asn1-schema': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.3.15':
dependencies:
'@peculiar/asn1-cms': 2.3.15
'@peculiar/asn1-pfx': 2.3.15
'@peculiar/asn1-pkcs8': 2.3.15
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
'@peculiar/asn1-x509-attr': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-rsa@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-schema@2.3.15':
dependencies:
asn1js: 3.0.5
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
asn1js: 3.0.5
tslib: 2.8.1
'@peculiar/asn1-x509@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
asn1js: 3.0.5
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@1.12.3':
dependencies:
'@peculiar/asn1-cms': 2.3.15
'@peculiar/asn1-csr': 2.3.15
'@peculiar/asn1-ecc': 2.3.15
'@peculiar/asn1-pkcs9': 2.3.15
'@peculiar/asn1-rsa': 2.3.15
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.8.0
'@pkgjs/parseargs@0.11.0':
optional: true
@ -6783,6 +6934,16 @@ snapshots:
mime-types: 2.1.35
negotiator: 0.6.3
acme-client@5.4.0:
dependencies:
'@peculiar/x509': 1.12.3
asn1js: 3.0.5
axios: 1.7.9(debug@4.4.0)
debug: 4.4.0
node-forge: 1.3.1
transitivePeerDependencies:
- supports-color
agent-base@6.0.2:
dependencies:
debug: 4.3.4
@ -6834,6 +6995,12 @@ snapshots:
array-union@2.1.0: {}
asn1js@3.0.5:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.3
tslib: 2.8.1
astral-regex@2.0.0: {}
async-mutex@0.3.2:
@ -6846,6 +7013,14 @@ snapshots:
axe-core@4.10.2: {}
axios@1.7.9(debug@4.4.0):
dependencies:
follow-redirects: 1.15.9(debug@4.4.0)
form-data: 4.0.1
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
b4a@1.6.7: {}
bail@2.0.2: {}
@ -8954,6 +9129,12 @@ snapshots:
- supports-color
- utf-8-validate
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.3: {}
qs@6.13.0:
dependencies:
side-channel: 1.1.0
@ -9008,6 +9189,8 @@ snapshots:
readdirp@4.1.1: {}
reflect-metadata@0.2.2: {}
regenerator-runtime@0.14.1: {}
registry-auth-token@5.0.3:
@ -9488,6 +9671,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsyringe@4.8.0:
dependencies:
tslib: 1.14.1
turndown-plugin-gfm@1.0.2: {}
turndown@7.2.0:

421
readme.md
View File

@ -1,108 +1,393 @@
# @push.rocks/smartproxy
A proxy for handling high workloads of proxying.
A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.
## Install
## Architecture & Flow Diagrams
To install `@push.rocks/smartproxy`, run the following command in your project's root directory:
### Component Architecture
The diagram below illustrates the main components of SmartProxy and how they interact:
```bash
npm install @push.rocks/smartproxy --save
```mermaid
flowchart TB
Client([Client])
subgraph "SmartProxy Components"
direction TB
HTTP80[HTTP Port 80\nSslRedirect]
HTTPS443[HTTPS Port 443\nNetworkProxy]
PortProxy[TCP Port Proxy\nwith SNI routing]
IPTables[IPTablesProxy]
Router[ProxyRouter]
ACME[Port80Handler\nACME/Let's Encrypt]
Certs[(SSL Certificates)]
end
subgraph "Backend Services"
Service1[Service 1]
Service2[Service 2]
Service3[Service 3]
end
Client -->|HTTP Request| HTTP80
HTTP80 -->|Redirect| Client
Client -->|HTTPS Request| HTTPS443
Client -->|TLS/TCP| PortProxy
HTTPS443 -->|Route Request| Router
Router -->|Proxy Request| Service1
Router -->|Proxy Request| Service2
PortProxy -->|Direct TCP| Service2
PortProxy -->|Direct TCP| Service3
IPTables -.->|Low-level forwarding| PortProxy
HTTP80 -.->|Challenge Response| ACME
ACME -.->|Generate/Manage| Certs
Certs -.->|Provide TLS Certs| HTTPS443
classDef component fill:#f9f,stroke:#333,stroke-width:2px;
classDef backend fill:#bbf,stroke:#333,stroke-width:1px;
classDef client fill:#dfd,stroke:#333,stroke-width:2px;
class Client client;
class HTTP80,HTTPS443,PortProxy,IPTables,Router,ACME component;
class Service1,Service2,Service3 backend;
```
This will add `@push.rocks/smartproxy` to your project's dependencies.
### HTTPS Reverse Proxy Flow
This diagram shows how HTTPS requests are handled and proxied to backend services:
```mermaid
sequenceDiagram
participant Client
participant NetworkProxy
participant ProxyRouter
participant Backend
Client->>NetworkProxy: HTTPS Request
Note over NetworkProxy: TLS Termination
NetworkProxy->>ProxyRouter: Route Request
ProxyRouter->>ProxyRouter: Match hostname to config
alt Authentication Required
NetworkProxy->>Client: Request Authentication
Client->>NetworkProxy: Send Credentials
NetworkProxy->>NetworkProxy: Validate Credentials
end
NetworkProxy->>Backend: Forward Request
Backend->>NetworkProxy: Response
Note over NetworkProxy: Add Default Headers
NetworkProxy->>Client: Forward Response
alt WebSocket Request
Client->>NetworkProxy: Upgrade to WebSocket
NetworkProxy->>Backend: Upgrade to WebSocket
loop WebSocket Active
Client->>NetworkProxy: WebSocket Message
NetworkProxy->>Backend: Forward Message
Backend->>NetworkProxy: WebSocket Message
NetworkProxy->>Client: Forward Message
NetworkProxy-->>NetworkProxy: Heartbeat Check
end
end
```
### Port Proxy with SNI-based Routing
This diagram illustrates how TCP connections with SNI (Server Name Indication) are processed and forwarded:
```mermaid
sequenceDiagram
participant Client
participant PortProxy
participant Backend
Client->>PortProxy: TLS Connection
alt SNI Enabled
PortProxy->>Client: Accept Connection
Client->>PortProxy: TLS ClientHello with SNI
PortProxy->>PortProxy: Extract SNI Hostname
PortProxy->>PortProxy: Match Domain Config
PortProxy->>PortProxy: Validate Client IP
alt IP Allowed
PortProxy->>Backend: Forward Connection
Note over PortProxy,Backend: Bidirectional Data Flow
else IP Rejected
PortProxy->>Client: Close Connection
end
else Port-based Routing
PortProxy->>PortProxy: Match Port Range
PortProxy->>PortProxy: Find Domain Config
PortProxy->>PortProxy: Validate Client IP
alt IP Allowed
PortProxy->>Backend: Forward Connection
Note over PortProxy,Backend: Bidirectional Data Flow
else IP Rejected
PortProxy->>Client: Close Connection
end
end
loop Connection Active
PortProxy-->>PortProxy: Monitor Activity
PortProxy-->>PortProxy: Check Max Lifetime
alt Inactivity or Max Lifetime Exceeded
PortProxy->>Client: Close Connection
PortProxy->>Backend: Close Connection
end
end
```
### Let's Encrypt Certificate Acquisition
This diagram shows how certificates are automatically acquired through the ACME protocol:
```mermaid
sequenceDiagram
participant Client
participant Port80Handler
participant ACME as Let's Encrypt ACME
participant NetworkProxy
Client->>Port80Handler: HTTP Request for domain
alt Certificate Exists
Port80Handler->>Client: Redirect to HTTPS
else No Certificate
Port80Handler->>Port80Handler: Mark domain as obtaining cert
Port80Handler->>ACME: Create account & new order
ACME->>Port80Handler: Challenge information
Port80Handler->>Port80Handler: Store challenge token & key authorization
ACME->>Port80Handler: HTTP-01 Challenge Request
Port80Handler->>ACME: Challenge Response
ACME->>ACME: Validate domain ownership
ACME->>Port80Handler: Challenge validated
Port80Handler->>Port80Handler: Generate CSR
Port80Handler->>ACME: Submit CSR
ACME->>Port80Handler: Issue Certificate
Port80Handler->>Port80Handler: Store certificate & private key
Port80Handler->>Port80Handler: Mark certificate as obtained
Note over Port80Handler,NetworkProxy: Certificate available for use
Client->>Port80Handler: Another HTTP Request
Port80Handler->>Client: Redirect to HTTPS
Client->>NetworkProxy: HTTPS Request
Note over NetworkProxy: Uses new certificate
end
```
## Features
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
- **TCP Port Forwarding** - Advanced port forwarding with SNI inspection and domain-based routing
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
- **IPTables Integration** - Direct manipulation of iptables for low-level port forwarding
- **Basic Authentication** - Support for basic auth on proxied routes
- **Connection Management** - Intelligent connection tracking and cleanup
## Installation
```bash
npm install @push.rocks/smartproxy
```
## Usage
`@push.rocks/smartproxy` is a versatile package for setting up and handling proxies with various capabilities such as SSL redirection, port proxying, and creating network proxies with complex routing rules. Below is a comprehensive guide on using its features.
### Setting Up a Network Proxy
Create a network proxy to route incoming HTTPS requests to different local servers based on the hostname.
### Basic Reverse Proxy Setup
```typescript
import { NetworkProxy } from '@push.rocks/smartproxy';
// Instantiate the NetworkProxy with desired options
const myNetworkProxy = new NetworkProxy({ port: 443 });
// Create a reverse proxy listening on port 443
const proxy = new NetworkProxy({
port: 443
});
// Define your reverse proxy configurations
// Define reverse proxy configurations
const proxyConfigs = [
{
destinationIp: '127.0.0.1',
destinationPort: '3000',
hostName: 'example.com',
privateKey: `-----BEGIN PRIVATE KEY-----
PRIVATE_KEY_CONTENT
-----END PRIVATE KEY-----`,
publicKey: `-----BEGIN CERTIFICATE-----
CERTIFICATE_CONTENT
-----END CERTIFICATE-----`,
destinationIp: '127.0.0.1',
destinationPort: 3000,
publicKey: 'your-cert-content',
privateKey: 'your-key-content'
},
// Add more reverse proxy configurations here
{
hostName: 'api.example.com',
destinationIp: '127.0.0.1',
destinationPort: 4000,
publicKey: 'your-cert-content',
privateKey: 'your-key-content',
// Optional basic auth
authentication: {
type: 'Basic',
user: 'admin',
pass: 'secret'
}
}
];
// Start the network proxy
await myNetworkProxy.start();
// Update proxy configurations dynamically
await myNetworkProxy.updateProxyConfigs(proxyConfigs);
// Optionally, add default headers to all responses
await myNetworkProxy.addDefaultHeaders({
'X-Powered-By': 'smartproxy',
});
// Start the proxy and update configurations
(async () => {
await proxy.start();
await proxy.updateProxyConfigs(proxyConfigs);
// Add default headers to all responses
await proxy.addDefaultHeaders({
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'
});
})();
```
### Port Proxying
You can also set up a port proxy to forward traffic from one port to another, which is useful for dynamic port forwarding scenarios.
```typescript
import { PortProxy } from '@push.rocks/smartproxy';
// Create a PortProxy to forward traffic from port 5000 to port 3000
const myPortProxy = new PortProxy(5000, 3000);
// Start the port proxy
await myPortProxy.start();
// To stop the port proxy, simply call
await myPortProxy.stop();
```
### Enabling SSL Redirection
Easily redirect HTTP traffic to HTTPS using the `SslRedirect` class. This is particularly useful when ensuring all traffic uses encryption.
### HTTP to HTTPS Redirection
```typescript
import { SslRedirect } from '@push.rocks/smartproxy';
// Instantiate the SslRedirect on port 80 (HTTP)
const mySslRedirect = new SslRedirect(80);
// Start listening and redirecting to HTTPS
await mySslRedirect.start();
// To stop the redirection, use
await mySslRedirect.stop();
// Create and start HTTP to HTTPS redirect service on port 80
const redirector = new SslRedirect(80);
redirector.start();
```
### Advanced Usage
### TCP Port Forwarding with Domain-based Routing
The package integrates seamlessly with TypeScript, allowing for advanced use cases, such as implementing custom routing logic, authentication mechanisms, and handling WebSocket connections through the network proxy.
```typescript
import { PortProxy } from '@push.rocks/smartproxy';
For a more advanced setup involving WebSocket proxying and dynamic configuration reloading, refer to the network proxy example provided above. The WebSocket support demonstrates how seamless it is to work with real-time applications.
// Configure port proxy with domain-based routing
const portProxy = new PortProxy({
fromPort: 443,
toPort: 8443,
targetIP: 'localhost', // Default target host
sniEnabled: true, // Enable SNI inspection
globalPortRanges: [{ from: 443, to: 443 }],
defaultAllowedIPs: ['*'], // Allow all IPs by default
domainConfigs: [
{
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
allowedIPs: ['192.168.1.*'], // Restrict access by IP
blockedIPs: ['192.168.1.100'], // Block specific IPs
targetIPs: ['10.0.0.1', '10.0.0.2'], // Round-robin between multiple targets
portRanges: [{ from: 443, to: 443 }]
}
],
maxConnectionLifetime: 3600000, // 1 hour in milliseconds
preserveSourceIP: true
});
Remember, when dealing with certificates and private keys for HTTPS configurations, always secure your keys and store them appropriately.
portProxy.start();
```
`@push.rocks/smartproxy` provides a solid foundation for handling high workloads and complex proxying requirements with ease, whether you're implementing SSL redirections, port forwarding, or extensive routing and WebSocket support in your network.
### IPTables Port Forwarding
For more information on how to use the features, refer to the in-depth documentation available in the package's repository or the npm package description.
```typescript
import { IPTablesProxy } from '@push.rocks/smartproxy';
// Configure IPTables to forward from port 80 to 8080
const iptables = new IPTablesProxy({
fromPort: 80,
toPort: 8080,
toHost: 'localhost',
preserveSourceIP: true,
deleteOnExit: true // Automatically clean up rules on process exit
});
iptables.start();
```
### Automatic HTTPS Certificate Management
```typescript
import { Port80Handler } from '@push.rocks/smartproxy';
// Create an ACME handler for Let's Encrypt
const acmeHandler = new Port80Handler();
// Add domains to manage certificates for
acmeHandler.addDomain('example.com');
acmeHandler.addDomain('api.example.com');
```
## Configuration Options
### NetworkProxy Options
| Option | Description | Default |
|----------------|---------------------------------------------------|---------|
| `port` | Port to listen on for HTTPS connections | - |
### PortProxy Settings
| Option | Description | Default |
|--------------------------|--------------------------------------------------------|-------------|
| `fromPort` | Port to listen on | - |
| `toPort` | Destination port to forward to | - |
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
| `defaultAllowedIPs` | IP patterns allowed by default | - |
| `defaultBlockedIPs` | IP patterns blocked by default | - |
| `preserveSourceIP` | Preserve the original client IP | false |
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 600000 |
| `globalPortRanges` | Array of port ranges to listen on | - |
| `forwardAllGlobalRanges` | Forward all global range connections to targetIP | false |
| `gracefulShutdownTimeout`| Time in ms to wait during shutdown | 30000 |
### IPTablesProxy Settings
| Option | Description | Default |
|-------------------|---------------------------------------------|-------------|
| `fromPort` | Source port to forward from | - |
| `toPort` | Destination port to forward to | - |
| `toHost` | Destination host to forward to | 'localhost' |
| `preserveSourceIP`| Preserve the original client IP | false |
| `deleteOnExit` | Remove iptables rules when process exits | false |
## Advanced Features
### Connection Management and Monitoring
The `PortProxy` class includes built-in connection tracking and monitoring:
- Automatic cleanup of idle connections
- Timeouts for connections that exceed maximum lifetime
- Detailed logging of connection states
- Termination statistics
### WebSocket Support
The `NetworkProxy` class provides WebSocket support with:
- WebSocket connection proxying
- Automatic heartbeat monitoring
- Connection cleanup for inactive WebSockets
### SNI-based Routing
The `PortProxy` class can inspect the SNI (Server Name Indication) field in TLS handshakes to route connections based on the requested domain:
- Multiple backend targets per domain
- Round-robin load balancing
- Domain-specific allowed IP ranges
- Protection against SNI renegotiation attacks
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

@ -1,6 +1,6 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as net from 'net';
import { PortProxy } from '../ts/smartproxy.portproxy.js';
import { PortProxy } from '../ts/classes.portproxy.js';
let testServer: net.Server;
let portProxy: PortProxy;
@ -8,85 +8,79 @@ const TEST_SERVER_PORT = 4000;
const PROXY_PORT = 4001;
const TEST_DATA = 'Hello through port proxy!';
// Helper function to create a test TCP server
function createTestServer(port: number): Promise<net.Server> {
// Helper: Creates a test TCP server that listens on a given port and host.
function createTestServer(port: number, host: string = 'localhost'): Promise<net.Server> {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
socket.on('data', (data) => {
// Echo the received data back
// Echo the received data back with a prefix.
socket.write(`Echo: ${data.toString()}`);
});
socket.on('error', (error) => {
console.error('[Test Server] Socket error:', error);
console.error(`[Test Server] Socket error on ${host}:${port}:`, error);
});
});
server.listen(port, () => {
console.log(`[Test Server] Listening on port ${port}`);
server.listen(port, host, () => {
console.log(`[Test Server] Listening on ${host}:${port}`);
resolve(server);
});
});
}
// Helper function to create a test client connection
// Helper: Creates a test client connection.
function createTestClient(port: number, data: string): Promise<string> {
return new Promise((resolve, reject) => {
const client = new net.Socket();
let response = '';
client.connect(port, 'localhost', () => {
console.log('[Test Client] Connected to server');
client.write(data);
});
client.on('data', (chunk) => {
response += chunk.toString();
client.end();
});
client.on('end', () => {
resolve(response);
});
client.on('error', (error) => {
reject(error);
});
client.on('end', () => resolve(response));
client.on('error', (error) => reject(error));
});
}
// Setup test environment
// SETUP: Create a test server and a PortProxy instance.
tap.test('setup port proxy test environment', async () => {
testServer = await createTestServer(TEST_SERVER_PORT);
portProxy = new PortProxy({
fromPort: PROXY_PORT,
toPort: TEST_SERVER_PORT,
toHost: 'localhost',
domains: [],
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1']
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
});
// Test that the proxy starts and its servers are listening.
tap.test('should start port proxy', async () => {
await portProxy.start();
expect(portProxy.netServer.listening).toBeTrue();
expect((portProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue();
});
// Test basic TCP forwarding.
tap.test('should forward TCP connections and data to localhost', async () => {
const response = await createTestClient(PROXY_PORT, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
});
// Test proxy with a custom target host.
tap.test('should forward TCP connections to custom host', async () => {
// Create a new proxy instance with a custom host
const customHostProxy = new PortProxy({
fromPort: PROXY_PORT + 1,
toPort: TEST_SERVER_PORT,
toHost: '127.0.0.1',
domains: [],
targetIP: '127.0.0.1',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1']
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
await customHostProxy.start();
@ -95,148 +89,151 @@ tap.test('should forward TCP connections to custom host', async () => {
await customHostProxy.stop();
});
tap.test('should forward connections based on domain-specific target IP', async () => {
// Create a second test server on a different port
const TEST_SERVER_PORT_2 = TEST_SERVER_PORT + 100;
const testServer2 = await createTestServer(TEST_SERVER_PORT_2);
// Test forced domain routing via port-range configuration.
// In this test, we want to forward to a different IP (using '127.0.0.2')
// while keeping the same port. We create a test server on '127.0.0.2'.
tap.test('should forward connections based on domain-specific target IP (forced domain via port-range)', async () => {
const forcedProxyPort = PROXY_PORT + 2;
// Create a test server listening on '127.0.0.2' at forcedProxyPort.
const testServer2 = await createTestServer(forcedProxyPort, '127.0.0.2');
// Create a proxy with domain-specific target IPs
const domainProxy = new PortProxy({
fromPort: PROXY_PORT + 2,
toPort: TEST_SERVER_PORT, // default port
toHost: 'localhost', // default host
domains: [{
domain: 'domain1.test',
fromPort: forcedProxyPort,
toPort: TEST_SERVER_PORT, // default target port (unused for forced domain)
targetIP: 'localhost',
domainConfigs: [{
domains: ['forced.test'],
allowedIPs: ['127.0.0.1'],
targetIP: '127.0.0.1'
}, {
domain: 'domain2.test',
allowedIPs: ['127.0.0.1'],
targetIP: 'localhost'
targetIPs: ['127.0.0.2'], // Use a different IP than the default.
portRanges: [{ from: forcedProxyPort, to: forcedProxyPort }]
}],
sniEnabled: false, // We'll test without SNI first since this is a TCP proxy test
defaultAllowedIPs: ['127.0.0.1']
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: [{ from: forcedProxyPort, to: forcedProxyPort }]
});
await domainProxy.start();
// Test default connection (should use default host)
const response1 = await createTestClient(PROXY_PORT + 2, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
// Create another proxy with different default host
const domainProxy2 = new PortProxy({
fromPort: PROXY_PORT + 3,
toPort: TEST_SERVER_PORT,
toHost: '127.0.0.1',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1']
});
await domainProxy2.start();
const response2 = await createTestClient(PROXY_PORT + 3, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`);
// When connecting to forcedProxyPort, forced domain handling triggers,
// so the proxy will connect to '127.0.0.2' on the same port.
const response = await createTestClient(forcedProxyPort, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
await domainProxy.stop();
await domainProxy2.stop();
await new Promise<void>((resolve) => testServer2.close(() => resolve()));
});
// Test handling of multiple concurrent connections.
tap.test('should handle multiple concurrent connections', async () => {
const concurrentRequests = 5;
const requests = Array(concurrentRequests).fill(null).map((_, i) =>
const requests = Array(concurrentRequests).fill(null).map((_, i) =>
createTestClient(PROXY_PORT, `${TEST_DATA} ${i + 1}`)
);
const responses = await Promise.all(requests);
responses.forEach((response, i) => {
expect(response).toEqual(`Echo: ${TEST_DATA} ${i + 1}`);
});
});
// Test connection timeout handling.
tap.test('should handle connection timeouts', async () => {
const client = new net.Socket();
await new Promise<void>((resolve) => {
client.connect(PROXY_PORT, 'localhost', () => {
// Don't send any data, just wait for timeout
client.on('close', () => {
resolve();
});
// Do not send any data to trigger a timeout.
client.on('close', () => resolve());
});
});
});
// Test stopping the port proxy.
tap.test('should stop port proxy', async () => {
await portProxy.stop();
expect(portProxy.netServer.listening).toBeFalse();
expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
});
// Cleanup
// Test chained proxies with and without source IP preservation.
tap.test('should support optional source IP preservation in chained proxies', async () => {
// Test 1: Without IP preservation (default behavior)
// Chained proxies without IP preservation.
const firstProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 4,
toPort: PROXY_PORT + 5,
toHost: 'localhost',
domains: [],
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
const secondProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 5,
toPort: TEST_SERVER_PORT,
toHost: 'localhost',
domains: [],
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
await secondProxyDefault.start();
await firstProxyDefault.start();
// This should work because we explicitly allow both IPv4 and IPv6 formats
const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyDefault.stop();
await secondProxyDefault.stop();
// Test 2: With IP preservation
// Chained proxies with IP preservation.
const firstProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 6,
toPort: PROXY_PORT + 7,
toHost: 'localhost',
domains: [],
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true
preserveSourceIP: true,
globalPortRanges: []
});
const secondProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 7,
toPort: TEST_SERVER_PORT,
toHost: 'localhost',
domains: [],
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true
preserveSourceIP: true,
globalPortRanges: []
});
await secondProxyPreserved.start();
await firstProxyPreserved.start();
// This should work with just IPv4 because source IP is preserved
const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyPreserved.stop();
await secondProxyPreserved.stop();
});
// Test round-robin behavior for multiple target IPs in a domain config.
tap.test('should use round robin for multiple target IPs in domain config', async () => {
const domainConfig = {
domains: ['rr.test'],
allowedIPs: ['127.0.0.1'],
targetIPs: ['hostA', 'hostB']
} as any;
const proxyInstance = new PortProxy({
fromPort: 0,
toPort: 0,
targetIP: 'localhost',
domainConfigs: [domainConfig],
sniEnabled: false,
defaultAllowedIPs: [],
globalPortRanges: []
});
const firstTarget = (proxyInstance as any).getTargetIP(domainConfig);
const secondTarget = (proxyInstance as any).getTargetIP(domainConfig);
expect(firstTarget).toEqual('hostA');
expect(secondTarget).toEqual('hostB');
});
// CLEANUP: Tear down the test server.
tap.test('cleanup port proxy test environment', async () => {
await new Promise<void>((resolve) => testServer.close(() => resolve()));
});
@ -245,9 +242,9 @@ process.on('exit', () => {
if (testServer) {
testServer.close();
}
if (portProxy && portProxy.netServer) {
if (portProxy && (portProxy as any).netServers) {
portProxy.stop();
}
});
export default tap.start();
export default tap.start();

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.9.4',
description: 'a proxy for handling high workloads of proxying'
version: '3.23.1',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
}

183
ts/classes.iptablesproxy.ts Normal file
View File

@ -0,0 +1,183 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Settings for IPTablesProxy.
*/
export interface IIpTableProxySettings {
fromPort: number;
toPort: number;
toHost?: string; // Target host for proxying; defaults to 'localhost'
preserveSourceIP?: boolean; // If true, the original source IP is preserved.
deleteOnExit?: boolean; // If true, clean up marked iptables rules before process exit.
}
/**
* IPTablesProxy sets up iptables NAT rules to forward TCP traffic.
* It only supports basic port forwarding and uses iptables comments to tag rules.
*/
export class IPTablesProxy {
public settings: IIpTableProxySettings;
private rulesInstalled: boolean = false;
private ruleTag: string;
constructor(settings: IIpTableProxySettings) {
this.settings = {
...settings,
toHost: settings.toHost || 'localhost',
};
// Generate a unique identifier for the rules added by this instance.
this.ruleTag = `IPTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`;
// If deleteOnExit is true, register cleanup handlers.
if (this.settings.deleteOnExit) {
const cleanup = () => {
try {
IPTablesProxy.cleanSlateSync();
} catch (err) {
console.error('Error cleaning iptables rules on exit:', err);
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit();
});
process.on('SIGTERM', () => {
cleanup();
process.exit();
});
}
}
/**
* Sets up iptables rules for port forwarding.
* The rules are tagged with a unique comment so that they can be identified later.
*/
public async start(): Promise<void> {
const dnatCmd = `iptables -t nat -A PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
`-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
`-m comment --comment "${this.ruleTag}:DNAT"`;
try {
await execAsync(dnatCmd);
console.log(`Added iptables rule: ${dnatCmd}`);
this.rulesInstalled = true;
} catch (err) {
console.error(`Failed to add iptables DNAT rule: ${err}`);
throw err;
}
// If preserveSourceIP is false, add a MASQUERADE rule.
if (!this.settings.preserveSourceIP) {
const masqueradeCmd = `iptables -t nat -A POSTROUTING -p tcp -d ${this.settings.toHost} ` +
`--dport ${this.settings.toPort} -j MASQUERADE ` +
`-m comment --comment "${this.ruleTag}:MASQ"`;
try {
await execAsync(masqueradeCmd);
console.log(`Added iptables rule: ${masqueradeCmd}`);
} catch (err) {
console.error(`Failed to add iptables MASQUERADE rule: ${err}`);
// Roll back the DNAT rule if MASQUERADE fails.
try {
const rollbackCmd = `iptables -t nat -D PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
`-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
`-m comment --comment "${this.ruleTag}:DNAT"`;
await execAsync(rollbackCmd);
this.rulesInstalled = false;
} catch (rollbackErr) {
console.error(`Rollback failed: ${rollbackErr}`);
}
throw err;
}
}
}
/**
* Removes the iptables rules that were added in start(), by matching the unique comment.
*/
public async stop(): Promise<void> {
if (!this.rulesInstalled) return;
const dnatDelCmd = `iptables -t nat -D PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
`-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
`-m comment --comment "${this.ruleTag}:DNAT"`;
try {
await execAsync(dnatDelCmd);
console.log(`Removed iptables rule: ${dnatDelCmd}`);
} catch (err) {
console.error(`Failed to remove iptables DNAT rule: ${err}`);
}
if (!this.settings.preserveSourceIP) {
const masqueradeDelCmd = `iptables -t nat -D POSTROUTING -p tcp -d ${this.settings.toHost} ` +
`--dport ${this.settings.toPort} -j MASQUERADE ` +
`-m comment --comment "${this.ruleTag}:MASQ"`;
try {
await execAsync(masqueradeDelCmd);
console.log(`Removed iptables rule: ${masqueradeDelCmd}`);
} catch (err) {
console.error(`Failed to remove iptables MASQUERADE rule: ${err}`);
}
}
this.rulesInstalled = false;
}
/**
* Asynchronously cleans up any iptables rules in the nat table that were added by this module.
* It looks for rules with comments containing "IPTablesProxy:".
*/
public static async cleanSlate(): Promise<void> {
try {
const { stdout } = await execAsync('iptables-save -t nat');
const lines = stdout.split('\n');
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
for (const line of proxyLines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('-A')) {
// Replace the "-A" with "-D" to form a deletion command.
const deleteRule = trimmedLine.replace('-A', '-D');
const cmd = `iptables -t nat ${deleteRule}`;
try {
await execAsync(cmd);
console.log(`Cleaned up iptables rule: ${cmd}`);
} catch (err) {
console.error(`Failed to remove iptables rule: ${cmd}`, err);
}
}
}
} catch (err) {
console.error(`Failed to run iptables-save: ${err}`);
}
}
/**
* Synchronously cleans up any iptables rules in the nat table that were added by this module.
* It looks for rules with comments containing "IPTablesProxy:".
* This method is intended for use in process exit handlers.
*/
public static cleanSlateSync(): void {
try {
const stdout = execSync('iptables-save -t nat').toString();
const lines = stdout.split('\n');
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
for (const line of proxyLines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('-A')) {
const deleteRule = trimmedLine.replace('-A', '-D');
const cmd = `iptables -t nat ${deleteRule}`;
try {
execSync(cmd);
console.log(`Cleaned up iptables rule: ${cmd}`);
} catch (err) {
console.error(`Failed to remove iptables rule: ${cmd}`, err);
}
}
}
} catch (err) {
console.error(`Failed to run iptables-save: ${err}`);
}
}
}

View File

@ -1,5 +1,5 @@
import * as plugins from './plugins.js';
import { ProxyRouter } from './smartproxy.classes.router.js';
import { ProxyRouter } from './classes.router.js';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';

214
ts/classes.port80handler.ts Normal file
View File

@ -0,0 +1,214 @@
import * as http from 'http';
import * as acme from 'acme-client';
interface IDomainCertificate {
certObtained: boolean;
obtainingInProgress: boolean;
certificate?: string;
privateKey?: string;
challengeToken?: string;
challengeKeyAuthorization?: string;
}
export class Port80Handler {
private domainCertificates: Map<string, IDomainCertificate>;
private server: http.Server;
private acmeClient: acme.Client | null = null;
private accountKey: string | null = null;
constructor() {
this.domainCertificates = new Map<string, IDomainCertificate>();
// Create and start an HTTP server on port 80.
this.server = http.createServer((req, res) => this.handleRequest(req, res));
this.server.listen(80, () => {
console.log('Port80Handler is listening on port 80');
});
}
/**
* Adds a domain to be managed.
* @param domain The domain to add.
*/
public addDomain(domain: string): void {
if (!this.domainCertificates.has(domain)) {
this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false });
console.log(`Domain added: ${domain}`);
}
}
/**
* Removes a domain from management.
* @param domain The domain to remove.
*/
public removeDomain(domain: string): void {
if (this.domainCertificates.delete(domain)) {
console.log(`Domain removed: ${domain}`);
}
}
/**
* Lazy initialization of the ACME client.
* Uses Lets Encrypts production directory (for testing you might switch to staging).
*/
private async getAcmeClient(): Promise<acme.Client> {
if (this.acmeClient) {
return this.acmeClient;
}
// Generate a new account key and convert Buffer to string.
this.accountKey = (await acme.forge.createPrivateKey()).toString();
this.acmeClient = new acme.Client({
directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate
// For testing, you could use:
// directoryUrl: acme.directory.letsencrypt.staging,
accountKey: this.accountKey,
});
// Create a new account. Make sure to update the contact email.
await this.acmeClient.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:admin@example.com'],
});
return this.acmeClient;
}
/**
* Handles incoming HTTP requests on port 80.
* If the request is for an ACME challenge, it responds with the key authorization.
* If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance.
*/
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const hostHeader = req.headers.host;
if (!hostHeader) {
res.statusCode = 400;
res.end('Bad Request: Host header is missing');
return;
}
// Extract domain (ignoring any port in the Host header)
const domain = hostHeader.split(':')[0];
// If the request is for an ACME HTTP-01 challenge, handle it.
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
this.handleAcmeChallenge(req, res, domain);
return;
}
if (!this.domainCertificates.has(domain)) {
res.statusCode = 404;
res.end('Domain not configured');
return;
}
const domainInfo = this.domainCertificates.get(domain)!;
// If certificate exists, redirect to HTTPS on port 443.
if (domainInfo.certObtained) {
const redirectUrl = `https://${domain}:443${req.url}`;
res.statusCode = 301;
res.setHeader('Location', redirectUrl);
res.end(`Redirecting to ${redirectUrl}`);
} else {
// Trigger certificate issuance if not already running.
if (!domainInfo.obtainingInProgress) {
domainInfo.obtainingInProgress = true;
this.obtainCertificate(domain).catch(err => {
console.error(`Error obtaining certificate for ${domain}:`, err);
});
}
res.statusCode = 503;
res.end('Certificate issuance in progress, please try again later.');
}
}
/**
* Serves the ACME HTTP-01 challenge response.
*/
private handleAcmeChallenge(req: http.IncomingMessage, res: http.ServerResponse, domain: string): void {
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
res.statusCode = 404;
res.end('Domain not configured');
return;
}
// The token is the last part of the URL.
const urlParts = req.url?.split('/');
const token = urlParts ? urlParts[urlParts.length - 1] : '';
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(domainInfo.challengeKeyAuthorization);
console.log(`Served ACME challenge response for ${domain}`);
} else {
res.statusCode = 404;
res.end('Challenge token not found');
}
}
/**
* Uses acme-client to perform a full ACME HTTP-01 challenge to obtain a certificate.
* On success, it stores the certificate and key in memory and clears challenge data.
*/
private async obtainCertificate(domain: string): Promise<void> {
try {
const client = await this.getAcmeClient();
// Create a new order for the domain.
const order = await client.createOrder({
identifiers: [{ type: 'dns', value: domain }],
});
// Get the authorizations for the order.
const authorizations = await client.getAuthorizations(order);
for (const authz of authorizations) {
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
if (!challenge) {
throw new Error('HTTP-01 challenge not found');
}
// Get the key authorization for the challenge.
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
const domainInfo = this.domainCertificates.get(domain)!;
domainInfo.challengeToken = challenge.token;
domainInfo.challengeKeyAuthorization = keyAuthorization;
// Notify the ACME server that the challenge is ready.
// The acme-client examples show that verifyChallenge takes three arguments:
// (authorization, challenge, keyAuthorization). However, the official TypeScript
// types appear to be out-of-sync. As a workaround, we cast client to 'any'.
await (client as any).verifyChallenge(authz, challenge, keyAuthorization);
await client.completeChallenge(challenge);
// Wait until the challenge is validated.
await client.waitForValidStatus(challenge);
console.log(`HTTP-01 challenge completed for ${domain}`);
}
// Generate a CSR and a new private key for the domain.
// Convert the resulting Buffers to strings.
const [csrBuffer, privateKeyBuffer] = await acme.forge.createCsr({
commonName: domain,
});
const csr = csrBuffer.toString();
const privateKey = privateKeyBuffer.toString();
// Finalize the order and obtain the certificate.
await client.finalizeOrder(order, csr);
const certificate = await client.getCertificate(order);
const domainInfo = this.domainCertificates.get(domain)!;
domainInfo.certificate = certificate;
domainInfo.privateKey = privateKey;
domainInfo.certObtained = true;
domainInfo.obtainingInProgress = false;
delete domainInfo.challengeToken;
delete domainInfo.challengeKeyAuthorization;
console.log(`Certificate obtained for ${domain}`);
// In a production system, persist the certificate and key and reload your TLS server.
} catch (error) {
console.error(`Error during certificate issuance for ${domain}:`, error);
const domainInfo = this.domainCertificates.get(domain);
if (domainInfo) {
domainInfo.obtainingInProgress = false;
}
}
}
}

736
ts/classes.portproxy.ts Normal file
View File

@ -0,0 +1,736 @@
import * as plugins from './plugins.js';
/** Domain configuration with per-domain allowed port ranges */
export interface IDomainConfig {
domains: string[]; // Glob patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs
blockedIPs?: string[]; // Glob patterns for blocked IPs
targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
}
/** Port proxy settings including global allowed port ranges */
export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number;
toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domainConfigs: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
}
/**
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
* @param buffer - Buffer containing the TLS ClientHello.
* @returns The server name if found, otherwise undefined.
*/
function extractSNI(buffer: Buffer): string | undefined {
let offset = 0;
if (buffer.length < 5) return undefined;
const recordType = buffer.readUInt8(0);
if (recordType !== 22) return undefined; // 22 = handshake
const recordLength = buffer.readUInt16BE(3);
if (buffer.length < 5 + recordLength) return undefined;
offset = 5;
const handshakeType = buffer.readUInt8(offset);
if (handshakeType !== 1) return undefined; // 1 = ClientHello
offset += 4; // Skip handshake header (type + length)
offset += 2 + 32; // Skip client version and random
const sessionIDLength = buffer.readUInt8(offset);
offset += 1 + sessionIDLength; // Skip session ID
const cipherSuitesLength = buffer.readUInt16BE(offset);
offset += 2 + cipherSuitesLength; // Skip cipher suites
const compressionMethodsLength = buffer.readUInt8(offset);
offset += 1 + compressionMethodsLength; // Skip compression methods
if (offset + 2 > buffer.length) return undefined;
const extensionsLength = buffer.readUInt16BE(offset);
offset += 2;
const extensionsEnd = offset + extensionsLength;
while (offset + 4 <= extensionsEnd) {
const extensionType = buffer.readUInt16BE(offset);
const extensionLength = buffer.readUInt16BE(offset + 2);
offset += 4;
if (extensionType === 0x0000) { // SNI extension
if (offset + 2 > buffer.length) return undefined;
const sniListLength = buffer.readUInt16BE(offset);
offset += 2;
const sniListEnd = offset + sniListLength;
while (offset + 3 < sniListEnd) {
const nameType = buffer.readUInt8(offset++);
const nameLen = buffer.readUInt16BE(offset);
offset += 2;
if (nameType === 0) { // host_name
if (offset + nameLen > buffer.length) return undefined;
return buffer.toString('utf8', offset, offset + nameLen);
}
offset += nameLen;
}
break;
} else {
offset += extensionLength;
}
}
return undefined;
}
interface IConnectionRecord {
id: string; // Unique connection identifier
incoming: plugins.net.Socket;
outgoing: plugins.net.Socket | null;
incomingStartTime: number;
outgoingStartTime?: number;
outgoingClosedTime?: number;
lockedDomain?: string; // Used to lock this connection to the initial SNI
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
lastActivity: number; // Last activity timestamp for inactivity detection
pendingData: Buffer[]; // Buffer to hold data during connection setup
}
// Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some(range => port >= range.from && port <= range.to);
};
// Helper: Check if a given IP matches any of the glob patterns
const isAllowed = (ip: string, patterns: string[]): boolean => {
const normalizeIP = (ip: string): string[] => {
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const normalizedIPVariants = normalizeIP(ip);
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some(ipVariant =>
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
);
};
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
return isAllowed(ip, allowed);
};
// Helper: Generate a unique connection ID
const generateConnectionId = (): string => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
export class PortProxy {
private netServers: plugins.net.Server[] = [];
settings: IPortProxySettings;
private connectionRecords: Map<string, IConnectionRecord> = new Map();
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
// Map to track round robin indices for each domain config
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
private terminationStats: {
incoming: Record<string, number>;
outgoing: Record<string, number>;
} = {
incoming: {},
outgoing: {},
};
constructor(settingsArg: IPortProxySettings) {
this.settings = {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
};
}
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* Cleans up a connection record.
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
* @param record - The connection record to clean up
* @param reason - Optional reason for cleanup (for logging)
*/
private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
if (!record.connectionClosed) {
record.connectionClosed = true;
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
try {
if (!record.incoming.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
record.incoming.end();
setTimeout(() => {
if (record && !record.incoming.destroyed) {
record.incoming.destroy();
}
}, 1000);
}
} catch (err) {
console.log(`Error closing incoming socket: ${err}`);
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
}
try {
if (record.outgoing && !record.outgoing.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
record.outgoing.end();
setTimeout(() => {
if (record && record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy();
}
}, 1000);
}
} catch (err) {
console.log(`Error closing outgoing socket: ${err}`);
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy();
}
}
// Remove the record from the tracking map
this.connectionRecords.delete(record.id);
const remoteIP = record.incoming.remoteAddress || 'unknown';
console.log(`Connection from ${remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
}
}
private updateActivity(record: IConnectionRecord): void {
record.lastActivity = Date.now();
}
private getTargetIP(domainConfig: IDomainConfig): string {
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP!;
}
public async start() {
// Define a unified connection handler for all listening ports.
const connectionHandler = (socket: plugins.net.Socket) => {
if (this.isShuttingDown) {
socket.end();
socket.destroy();
return;
}
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort; // The port on which this connection was accepted.
const connectionId = generateConnectionId();
const connectionRecord: IConnectionRecord = {
id: connectionId,
incoming: socket,
outgoing: null,
incomingStartTime: Date.now(),
lastActivity: Date.now(),
connectionClosed: false,
pendingData: [] // Initialize buffer for pending data
};
this.connectionRecords.set(connectionId, connectionRecord);
console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
let initialDataReceived = false;
let incomingTerminationReason: string | null = null;
let outgoingTerminationReason: string | null = null;
// Local function for cleanupOnce
const cleanupOnce = () => {
this.cleanupConnection(connectionRecord);
};
// Define initiateCleanupOnce for compatibility with potential future improvements
const initiateCleanupOnce = (reason: string = 'normal') => {
console.log(`Connection cleanup initiated for ${remoteIP} (${reason})`);
cleanupOnce();
};
// Helper to reject an incoming connection
const rejectIncomingConnection = (reason: string, logMessage: string) => {
console.log(logMessage);
socket.end();
if (incomingTerminationReason === null) {
incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
cleanupOnce();
};
// Set an initial timeout for SNI data if needed
let initialTimeout: NodeJS.Timeout | null = null;
if (this.settings.sniEnabled) {
initialTimeout = setTimeout(() => {
if (!initialDataReceived) {
console.log(`Initial data timeout for ${remoteIP}`);
socket.end();
cleanupOnce();
}
}, 5000);
} else {
initialDataReceived = true;
}
socket.on('error', (err: Error) => {
console.log(`Incoming socket error from ${remoteIP}: ${err.message}`);
});
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
const code = (err as any).code;
let reason = 'error';
if (code === 'ECONNRESET') {
reason = 'econnreset';
console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`);
} else {
console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
}
if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason);
}
initiateCleanupOnce(reason);
};
const handleClose = (side: 'incoming' | 'outgoing') => () => {
console.log(`Connection closed on ${side} side from ${remoteIP}`);
if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = 'normal';
this.incrementTerminationStat('incoming', 'normal');
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
outgoingTerminationReason = 'normal';
this.incrementTerminationStat('outgoing', 'normal');
// Record the time when outgoing socket closed.
connectionRecord.outgoingClosedTime = Date.now();
}
initiateCleanupOnce('closed_' + side);
};
/**
* Sets up the connection to the target host.
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
* @param initialChunk - Optional initial data chunk.
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
* @param overridePort - If provided, use this port for the outgoing connection.
*/
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
// Clear the initial timeout since we've received data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
? forcedDomain
: (serverName ? this.settings.domainConfigs.find(config =>
config.domains.some(d => plugins.minimatch(serverName, d))
) : undefined);
// IP validation is skipped if allowedIPs is empty
if (domainConfig) {
const effectiveAllowedIPs: string[] = [
...domainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || [])
];
const effectiveBlockedIPs: string[] = [
...(domainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || [])
];
// Skip IP validation if allowedIPs is empty
if (domainConfig.allowedIPs.length > 0 && !isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
}
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
}
}
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: overridePort !== undefined ? overridePort : this.settings.toPort,
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
}
// Temporary handler to collect data during connection setup
const tempDataHandler = (chunk: Buffer) => {
connectionRecord.pendingData.push(Buffer.from(chunk));
this.updateActivity(connectionRecord);
};
// Add the temp handler to capture all incoming data during connection setup
socket.on('data', tempDataHandler);
// Add initial chunk to pending data if present
if (initialChunk) {
connectionRecord.pendingData.push(Buffer.from(initialChunk));
}
// Create the target socket but don't set up piping immediately
const targetSocket = plugins.net.connect(connectionOptions);
connectionRecord.outgoing = targetSocket;
connectionRecord.outgoingStartTime = Date.now();
// Setup error handlers immediately
socket.on('error', handleError('incoming'));
targetSocket.on('error', handleError('outgoing'));
socket.on('close', handleClose('incoming'));
targetSocket.on('close', handleClose('outgoing'));
// Handle timeouts
socket.on('timeout', () => {
console.log(`Timeout on incoming side from ${remoteIP}`);
if (incomingTerminationReason === null) {
incomingTerminationReason = 'timeout';
this.incrementTerminationStat('incoming', 'timeout');
}
initiateCleanupOnce('timeout_incoming');
});
targetSocket.on('timeout', () => {
console.log(`Timeout on outgoing side from ${remoteIP}`);
if (outgoingTerminationReason === null) {
outgoingTerminationReason = 'timeout';
this.incrementTerminationStat('outgoing', 'timeout');
}
initiateCleanupOnce('timeout_outgoing');
});
// Set appropriate timeouts
socket.setTimeout(120000);
targetSocket.setTimeout(120000);
// Wait for the outgoing connection to be ready before setting up piping
targetSocket.once('connect', () => {
// Remove temporary data handler
socket.removeListener('data', tempDataHandler);
// Flush all pending data to target
if (connectionRecord.pendingData.length > 0) {
const combinedData = Buffer.concat(connectionRecord.pendingData);
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(`Error writing pending data to target: ${err.message}`);
return initiateCleanupOnce('write_error');
}
// Now set up piping for future data
socket.pipe(targetSocket);
targetSocket.pipe(socket);
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
);
});
} else {
// No pending data, so just set up piping
socket.pipe(targetSocket);
targetSocket.pipe(socket);
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
);
}
// Clear the buffer now that we've processed it
connectionRecord.pendingData = [];
// Set up activity tracking
socket.on('data', () => {
connectionRecord.lastActivity = Date.now();
});
targetSocket.on('data', () => {
connectionRecord.lastActivity = Date.now();
});
// Add the renegotiation listener (we don't need setImmediate here anymore
// since we're already in the connect callback)
if (serverName) {
socket.on('data', (renegChunk: Buffer) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
try {
// Try to extract SNI from potential renegotiation
const newSNI = extractSNI(renegChunk);
if (newSNI && newSNI !== connectionRecord.lockedDomain) {
console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
initiateCleanupOnce('sni_mismatch');
} else if (newSNI) {
console.log(`Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
}
} catch (err) {
console.log(`Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
}
}
});
}
});
// Initialize a cleanup timer for max connection lifetime
if (this.settings.maxConnectionLifetime) {
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`Connection from ${remoteIP} exceeded max lifetime (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
initiateCleanupOnce('max_lifetime');
}, this.settings.maxConnectionLifetime);
}
};
// --- PORT RANGE-BASED HANDLING ---
// Only apply port-based rules if the incoming port is within one of the global port ranges.
if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) {
if (this.settings.forwardAllGlobalRanges) {
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
socket.end();
return;
}
console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
setupConnection('', undefined, {
domains: ['global'],
allowedIPs: this.settings.defaultAllowedIPs || [],
blockedIPs: this.settings.defaultBlockedIPs || [],
targetIPs: [this.settings.targetIP!],
portRanges: []
}, localPort);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.settings.domainConfigs.find(
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
);
if (forcedDomain) {
const effectiveAllowedIPs: string[] = [
...forcedDomain.allowedIPs,
...(this.settings.defaultAllowedIPs || [])
];
const effectiveBlockedIPs: string[] = [
...(forcedDomain.blockedIPs || []),
...(this.settings.defaultBlockedIPs || [])
];
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
socket.end();
return;
}
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
setupConnection('', undefined, forcedDomain, localPort);
return;
}
// Fall through to SNI/default handling if no forced domain config is found.
}
}
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) {
initialDataReceived = false;
socket.once('data', (chunk: Buffer) => {
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
const serverName = extractSNI(chunk) || '';
// Lock the connection to the negotiated SNI.
connectionRecord.lockedDomain = serverName;
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
setupConnection(serverName, chunk);
});
} else {
initialDataReceived = true;
if (!this.settings.defaultAllowedIPs || this.settings.defaultAllowedIPs.length === 0 || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
}
setupConnection('');
}
};
// --- SETUP LISTENERS ---
// Determine which ports to listen on.
const listeningPorts = new Set<number>();
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
// Listen on every port defined by the global ranges.
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
// Also ensure the default fromPort is listened to if it isn't already in the ranges.
listeningPorts.add(this.settings.fromPort);
} else {
listeningPorts.add(this.settings.fromPort);
}
// Create a server for each port.
for (const port of listeningPorts) {
const server = plugins.net
.createServer(connectionHandler)
.on('error', (err: Error) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
console.log(`PortProxy -> OK: Now listening on port ${port}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`);
});
this.netServers.push(server);
}
// Log active connection count, longest running durations, and run parity checks every 10 seconds.
this.connectionLogger = setInterval(() => {
if (this.isShuttingDown) return;
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (!record) continue;
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
// Parity check: if outgoing socket closed and incoming remains active
if (record.outgoingClosedTime &&
!record.incoming.destroyed &&
!record.connectionClosed &&
(now - record.outgoingClosedTime > 30000)) {
const remoteIP = record.incoming.remoteAddress || 'unknown';
console.log(`Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing closed.`);
this.cleanupConnection(record, 'parity_check');
}
// Inactivity check
const inactivityTime = now - record.lastActivity;
if (inactivityTime > 180000 && // 3 minutes
!record.connectionClosed) {
const remoteIP = record.incoming.remoteAddress || 'unknown';
console.log(`Inactivity check: No activity on connection from ${remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
this.cleanupConnection(record, 'inactivity');
}
}
console.log(
`(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
`Termination stats (incoming): ${JSON.stringify(this.terminationStats.incoming)}, ` +
`(outgoing): ${JSON.stringify(this.terminationStats.outgoing)}`
);
}, 10000);
}
public async stop() {
console.log("PortProxy shutting down...");
this.isShuttingDown = true;
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
server =>
new Promise<void>((resolve) => {
server.close(() => resolve());
})
);
// Stop the connection logger
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
this.connectionLogger = null;
}
// Wait for servers to close
await Promise.all(closeServerPromises);
console.log("All servers closed. Cleaning up active connections...");
// Clean up active connections
const connectionIds = [...this.connectionRecords.keys()];
console.log(`Cleaning up ${connectionIds.length} active connections...`);
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record && !record.connectionClosed) {
this.cleanupConnection(record, 'shutdown');
}
}
// Wait for graceful shutdown or timeout
const shutdownTimeout = this.settings.gracefulShutdownTimeout || 30000;
await new Promise<void>((resolve) => {
const checkInterval = setInterval(() => {
if (this.connectionRecords.size === 0) {
clearInterval(checkInterval);
resolve(); // lets resolve here as early as we reach 0 remaining connections
}
}, 1000);
// Force resolve after timeout
setTimeout(() => {
clearInterval(checkInterval);
if (this.connectionRecords.size > 0) {
console.log(`Forcing shutdown with ${this.connectionRecords.size} connections still active`);
// Force destroy any remaining connections
for (const record of this.connectionRecords.values()) {
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy();
}
}
this.connectionRecords.clear();
}
resolve();
}, shutdownTimeout);
});
console.log("PortProxy shutdown complete.");
}
}

View File

@ -1,3 +1,5 @@
export * from './smartproxy.classes.networkproxy.js';
export * from './smartproxy.portproxy.js';
export * from './smartproxy.classes.sslredirect.js';
export * from './classes.iptablesproxy.js';
export * from './classes.networkproxy.js';
export * from './classes.portproxy.js';
export * from './classes.port80handler.js';
export * from './classes.sslredirect.js';

View File

@ -1,364 +0,0 @@
import * as plugins from './plugins.js';
export interface IDomainConfig {
domain: string; // glob pattern for domain
allowedIPs: string[]; // glob patterns for IPs allowed to access this domain
targetIP?: string; // Optional target IP for this domain
}
export interface IProxySettings extends plugins.tls.TlsOptions {
// Port configuration
fromPort: number;
toPort: number;
toHost?: string; // Target host to proxy to, defaults to 'localhost'
// Domain and security settings
domains: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[]; // Optional default IP patterns if no matching domain found
preserveSourceIP?: boolean; // Whether to preserve the client's source IP when proxying
}
/**
* Extract SNI (Server Name Indication) from a TLS ClientHello packet.
* Returns the server name if found, or undefined.
*/
function extractSNI(buffer: Buffer): string | undefined {
let offset = 0;
// We need at least 5 bytes for the record header.
if (buffer.length < 5) {
return undefined;
}
// TLS record header
const recordType = buffer.readUInt8(0);
if (recordType !== 22) { // 22 = handshake
return undefined;
}
// Read record length
const recordLength = buffer.readUInt16BE(3);
if (buffer.length < 5 + recordLength) {
// Not all data arrived yet; in production you might need to accumulate more data.
return undefined;
}
offset = 5;
// Handshake message type should be 1 for ClientHello.
const handshakeType = buffer.readUInt8(offset);
if (handshakeType !== 1) {
return undefined;
}
// Skip handshake header (1 byte type + 3 bytes length)
offset += 4;
// Skip client version (2 bytes) and random (32 bytes)
offset += 2 + 32;
// Session ID
const sessionIDLength = buffer.readUInt8(offset);
offset += 1 + sessionIDLength;
// Cipher suites
const cipherSuitesLength = buffer.readUInt16BE(offset);
offset += 2 + cipherSuitesLength;
// Compression methods
const compressionMethodsLength = buffer.readUInt8(offset);
offset += 1 + compressionMethodsLength;
// Extensions length
if (offset + 2 > buffer.length) {
return undefined;
}
const extensionsLength = buffer.readUInt16BE(offset);
offset += 2;
const extensionsEnd = offset + extensionsLength;
// Iterate over extensions
while (offset + 4 <= extensionsEnd) {
const extensionType = buffer.readUInt16BE(offset);
const extensionLength = buffer.readUInt16BE(offset + 2);
offset += 4;
// Check for SNI extension (type 0)
if (extensionType === 0x0000) {
// SNI extension: first 2 bytes are the SNI list length.
if (offset + 2 > buffer.length) {
return undefined;
}
const sniListLength = buffer.readUInt16BE(offset);
offset += 2;
const sniListEnd = offset + sniListLength;
// Loop through the list; typically there is one entry.
while (offset + 3 < sniListEnd) {
const nameType = buffer.readUInt8(offset);
offset++;
const nameLen = buffer.readUInt16BE(offset);
offset += 2;
if (nameType === 0) { // host_name
if (offset + nameLen > buffer.length) {
return undefined;
}
const serverName = buffer.toString('utf8', offset, offset + nameLen);
return serverName;
}
offset += nameLen;
}
break;
} else {
offset += extensionLength;
}
}
return undefined;
}
export class PortProxy {
netServer: plugins.net.Server;
settings: IProxySettings;
// Track active incoming connections
private activeConnections: Set<plugins.net.Socket> = new Set();
// Record start times for incoming connections
private incomingConnectionTimes: Map<plugins.net.Socket, number> = new Map();
// Record start times for outgoing connections
private outgoingConnectionTimes: Map<plugins.net.Socket, number> = new Map();
private connectionLogger: NodeJS.Timeout | null = null;
constructor(settings: IProxySettings) {
this.settings = {
...settings,
toHost: settings.toHost || 'localhost'
};
}
public async start() {
// Adjusted cleanUpSockets to allow an optional outgoing socket.
const cleanUpSockets = (from: plugins.net.Socket, to?: plugins.net.Socket) => {
from.end();
from.removeAllListeners();
from.unpipe();
from.destroy();
if (to) {
to.end();
to.removeAllListeners();
to.unpipe();
to.destroy();
}
};
const normalizeIP = (ip: string): string[] => {
// Handle IPv4-mapped IPv6 addresses
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7); // Remove '::ffff:' prefix
return [ip, ipv4];
}
// Handle IPv4 addresses by adding IPv4-mapped IPv6 variant
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const isAllowed = (value: string, patterns: string[]): boolean => {
// Expand patterns to include both IPv4 and IPv6 variants
const expandedPatterns = patterns.flatMap(normalizeIP);
// Check if any variant of the IP matches any expanded pattern
return normalizeIP(value).some(ip =>
expandedPatterns.some(pattern => plugins.minimatch(ip, pattern))
);
};
const findMatchingDomain = (serverName: string): IDomainConfig | undefined => {
return this.settings.domains.find(config => plugins.minimatch(serverName, config.domain));
};
// Create a plain net server for TLS passthrough.
this.netServer = plugins.net.createServer((socket: plugins.net.Socket) => {
const remoteIP = socket.remoteAddress || '';
// Record start time for the incoming connection.
this.activeConnections.add(socket);
this.incomingConnectionTimes.set(socket, Date.now());
console.log(`New connection from ${remoteIP}. Active connections: ${this.activeConnections.size}`);
// Flag to detect if we've received the first data chunk.
let initialDataReceived = false;
// Immediately attach an error handler to catch early errors.
socket.on('error', (err: Error) => {
if (!initialDataReceived) {
console.log(`(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`);
} else {
console.log(`(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`);
}
});
// Flag to ensure cleanup happens only once.
let connectionClosed = false;
const cleanupOnce = () => {
if (!connectionClosed) {
connectionClosed = true;
cleanUpSockets(socket, to || undefined);
this.incomingConnectionTimes.delete(socket);
if (to) {
this.outgoingConnectionTimes.delete(to);
}
if (this.activeConnections.has(socket)) {
this.activeConnections.delete(socket);
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.activeConnections.size}`);
}
}
};
// Declare the outgoing connection as possibly null.
let to: plugins.net.Socket | null = null;
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
const code = (err as any).code;
if (code === 'ECONNRESET') {
console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`);
} else {
console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
}
cleanupOnce();
};
const handleClose = (side: 'incoming' | 'outgoing') => () => {
console.log(`Connection closed on ${side} side from ${remoteIP}`);
cleanupOnce();
};
// Setup connection, optionally accepting the initial data chunk.
const setupConnection = (serverName: string, initialChunk?: Buffer) => {
// Check if the IP is allowed by default.
const isDefaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
if (!isDefaultAllowed && serverName) {
const domainConfig = findMatchingDomain(serverName);
if (!domainConfig) {
console.log(`Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`);
socket.end();
cleanupOnce();
return;
}
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
console.log(`Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`);
socket.end();
cleanupOnce();
return;
}
} else if (!isDefaultAllowed && !serverName) {
console.log(`Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`);
socket.end();
cleanupOnce();
return;
} else {
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`);
}
// Determine target host.
const domainConfig = serverName ? findMatchingDomain(serverName) : undefined;
const targetHost = domainConfig?.targetIP || this.settings.toHost!;
// Create connection options.
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: this.settings.toPort,
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
}
// Establish outgoing connection.
to = plugins.net.connect(connectionOptions);
// Record start time for the outgoing connection.
if (to) {
this.outgoingConnectionTimes.set(to, Date.now());
}
console.log(`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}${serverName ? ` (SNI: ${serverName})` : ''}`);
// Push back the initial chunk if provided.
if (initialChunk) {
socket.unshift(initialChunk);
}
socket.setTimeout(120000);
// Since 'to' is not null here, we can use the non-null assertion.
socket.pipe(to!);
to!.pipe(socket);
// Attach error and close handlers for both sockets.
socket.on('error', handleError('incoming'));
to!.on('error', handleError('outgoing'));
socket.on('close', handleClose('incoming'));
to!.on('close', handleClose('outgoing'));
socket.on('timeout', () => {
console.log(`Timeout on incoming side from ${remoteIP}`);
cleanupOnce();
});
to!.on('timeout', () => {
console.log(`Timeout on outgoing side from ${remoteIP}`);
cleanupOnce();
});
socket.on('end', handleClose('incoming'));
to!.on('end', handleClose('outgoing'));
};
// For SNI-enabled connections, peek at the first chunk.
if (this.settings.sniEnabled) {
socket.once('data', (chunk: Buffer) => {
initialDataReceived = true;
// Try to extract the server name from the ClientHello.
const serverName = extractSNI(chunk) || '';
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
setupConnection(serverName, chunk);
});
} else {
// For non-SNI connections, simply check defaultAllowedIPs.
initialDataReceived = true;
if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
console.log(`Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
socket.end();
cleanupOnce();
return;
}
setupConnection('');
}
})
.on('error', (err: Error) => {
console.log(`Server Error: ${err.message}`);
})
.listen(this.settings.fromPort, () => {
console.log(`PortProxy -> OK: Now listening on port ${this.settings.fromPort}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`);
});
// Log active connection count and longest running connections every 10 seconds.
this.connectionLogger = setInterval(() => {
const now = Date.now();
let maxIncoming = 0;
for (const startTime of this.incomingConnectionTimes.values()) {
const duration = now - startTime;
if (duration > maxIncoming) {
maxIncoming = duration;
}
}
let maxOutgoing = 0;
for (const startTime of this.outgoingConnectionTimes.values()) {
const duration = now - startTime;
if (duration > maxOutgoing) {
maxOutgoing = duration;
}
}
console.log(`(Interval Log) Active connections: ${this.activeConnections.size}. Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}`);
}, 10000);
}
public async stop() {
const done = plugins.smartpromise.defer();
this.netServer.close(() => {
done.resolve();
});
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
this.connectionLogger = null;
}
await done.promise;
}
}