Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
6daf4c914d | |||
36e4341315 | |||
474134d29c | |||
43378becd2 | |||
5ba8eb778f | |||
87d26c86a1 | |||
d81cf94876 | |||
8d06f1533e | |||
223be61c8d | |||
6a693f4d86 | |||
27a2bcb556 | |||
0674ca7163 | |||
e31c84493f | |||
d2ad659d37 | |||
df7a12041e | |||
2b69150545 | |||
85cc57ae10 | |||
e021b66898 | |||
865d21b36a | |||
58ba0d9362 | |||
ccccc5b8c8 | |||
d8466a866c | |||
119b643690 | |||
98f1e0df4c | |||
d6022c8f8a | |||
0ea0f02428 | |||
e452f55203 | |||
55f25f1976 |
99
changelog.md
99
changelog.md
@ -1,5 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-03-11 - 3.37.3 - fix(snihandler)
|
||||
Enhance SNI extraction to support TLS 1.3 PSK-based session resumption by adding a dedicated extractSNIFromPSKExtension method and improved logging for session resumption indicators.
|
||||
|
||||
- Defined TLS_PSK_EXTENSION_TYPE and TLS_PSK_KE_MODES_EXTENSION_TYPE constants.
|
||||
- Added extractSNIFromPSKExtension method to handle ClientHello messages containing PSK identities.
|
||||
- Improved logging to indicate when session resumption indicators (ticket or PSK) are present but no standard SNI is found.
|
||||
- Enhanced extractSNIWithResumptionSupport to attempt PSK extraction if standard SNI extraction fails.
|
||||
|
||||
## 2025-03-11 - 3.37.2 - fix(PortProxy)
|
||||
Improve buffering and data handling during connection setup in PortProxy to prevent data loss
|
||||
|
||||
- Added a safeDataHandler and processDataQueue to buffer incoming data reliably during the TLS handshake phase
|
||||
- Introduced a queue with pause/resume logic to avoid exceeding maxPendingDataSize and ensure all pending data is flushed before piping begins
|
||||
- Refactored the piping setup to install the renegotiation handler only after proper data flushing
|
||||
|
||||
## 2025-03-11 - 3.37.1 - fix(PortProxy/SNI)
|
||||
Refactor SNI extraction in PortProxy to use the dedicated SniHandler class
|
||||
|
||||
- Removed local SNI extraction and handshake detection functions from classes.portproxy.ts
|
||||
- Introduced a standalone SniHandler class in ts/classes.snihandler.ts for robust SNI extraction and improved logging
|
||||
- Replaced inlined calls to isTlsHandshake and extractSNI with calls to SniHandler methods
|
||||
- Ensured consistency in handling TLS ClientHello messages across the codebase
|
||||
|
||||
## 2025-03-11 - 3.37.0 - feat(portproxy)
|
||||
Add ACME certificate management options to PortProxy, update ACME settings handling, and bump dependency versions
|
||||
|
||||
- Bumped version in package.json from 3.34.0 to 3.36.0 and updated commitinfo accordingly
|
||||
- Updated dependencies: @push.rocks/tapbundle to ^5.5.10, @types/node to ^22.13.10, and @tsclass/tsclass to ^5.0.0
|
||||
- Added ACME certificate management configuration to PortProxy settings (acme options, updateAcmeSettings, requestCertificate)
|
||||
- Enhanced sync of domain configs to NetworkProxy with fallback for missing default certificates
|
||||
|
||||
## 2025-03-11 - 3.34.0 - feat(core)
|
||||
Improve wildcard domain matching and enhance NetworkProxy integration in PortProxy. Added support for TLD wildcards and complex wildcard patterns in the router, and refactored TLS renegotiation handling for stricter SNI enforcement.
|
||||
|
||||
- Added support for TLD wildcard matching (e.g., 'example.*') to improve domain routing.
|
||||
- Implemented complex wildcard pattern matching (e.g., '*.lossless*') in the router.
|
||||
- Enhanced NetworkProxy integration by initializing a single NetworkProxy instance and forwarding TLS connections accordingly.
|
||||
- Refactored TLS renegotiation handling to terminate connections on SNI mismatch for stricter enforcement.
|
||||
- Updated tests to cover the new wildcard matching scenarios.
|
||||
|
||||
## 2025-03-11 - 3.33.0 - feat(portproxy)
|
||||
Add browser-friendly mode and SNI renegotiation configuration options to PortProxy
|
||||
|
||||
- Introduce new properties: browserFriendlyMode (default true) to optimize handling for browser connections.
|
||||
- Add allowRenegotiationWithDifferentSNI (default false) to enable or disable SNI changes during renegotiation.
|
||||
- Include relatedDomainPatterns to define patterns for related domains that can share connections.
|
||||
- Update TypeScript interfaces and internal renegotiation logic to support these options.
|
||||
|
||||
## 2025-03-11 - 3.32.2 - fix(PortProxy)
|
||||
Simplify TLS handshake SNI extraction and update timeout settings in PortProxy for improved maintainability and reliability.
|
||||
|
||||
- Removed legacy and deprecated fields related to chained proxy configurations (isChainedProxy, chainPosition, aggressiveTlsRefresh).
|
||||
- Refactored the extractSNI functions to use a simpler, more robust approach for TLS ClientHello processing.
|
||||
- Adjusted default timeout and keep-alive settings to more standard values (e.g. initialDataTimeout set to 60s, socketTimeout to 1h).
|
||||
- Eliminated redundant TLS session cache and deep TLS refresh logic.
|
||||
- Improved logging and error handling during connection setup and renegotiation phases.
|
||||
|
||||
## 2025-03-11 - 3.32.1 - fix(portproxy)
|
||||
Relax TLS handshake and connection timeout settings for improved stability in chained proxy scenarios; update TLS session cache defaults and add keep-alive flags to connection records.
|
||||
|
||||
- Increased TLS session cache maximum entries from 10,000 to 20,000, expiry time from 24 hours to 7 days, and cleanup interval from 10 minutes to 30 minutes
|
||||
- Relaxed socket timeouts: standalone connections now use up to 6 hours, with chained proxies adjusted for 5–6 hours based on proxy position
|
||||
- Updated inactivity, connection, and initial handshake timeouts to provide a more relaxed behavior under high-traffic chained proxy scenarios
|
||||
- Increased keepAliveInitialDelay from 10 seconds to 30 seconds and introduced separate incoming and outgoing keep-alive flags
|
||||
- Enhanced TLS renegotiation handling with more detailed logging and temporary processing flags to avoid duplicate processing
|
||||
- Updated NetworkProxy integration to use optimized connection settings and more aggressive application-level keep-alive probes
|
||||
|
||||
## 2025-03-11 - 3.32.0 - feat(PortProxy)
|
||||
Enhance TLS session cache, SNI extraction, and chained proxy support in PortProxy. Improve handling of multiple and fragmented TLS records, and add new configuration options (isChainedProxy, chainPosition, aggressiveTlsRefresh, tlsSessionCache) for robust TLS certificate refresh.
|
||||
|
||||
- Implement TlsSessionCache with configurable cleanup, eviction, and statistics.
|
||||
- Improve extractSNIInfo to process multiple TLS records and partial handshake data.
|
||||
- Add new settings to detect chained proxy scenarios and adjust timeouts accordingly.
|
||||
- Enhance TLS state refresh with aggressive probing and deep refresh sequence.
|
||||
|
||||
## 2025-03-11 - 3.31.2 - fix(PortProxy)
|
||||
Improve SNI renegotiation handling by adding flexible domain configuration matching on rehandshake and session resumption events.
|
||||
|
||||
- When a rehandshake is detected with a changed SNI, first check existing domain config rules and log if allowed.
|
||||
- If the exact domain config is not found, additionally attempt flexible matching using parent domain and wildcard patterns.
|
||||
- For resumed sessions, try an exact match first and then use fallback logic to select a similar domain config based on matching target IP.
|
||||
- Enhanced logging added to help diagnose missing or mismatched domain configurations.
|
||||
|
||||
## 2025-03-11 - 3.31.1 - fix(PortProxy)
|
||||
Improve TLS handshake buffering and enhance debug logging for SNI forwarding in PortProxy
|
||||
|
||||
- Explicitly copy the initial TLS handshake data to prevent mutation before buffering
|
||||
- Log buffered TLS handshake data with SNI information for better diagnostics
|
||||
- Add detailed error logs on TLS connection failures, including server and domain config status
|
||||
- Output additional debug messages during ClientHello forwarding to verify proper TLS handshake processing
|
||||
|
||||
## 2025-03-11 - 3.31.0 - feat(PortProxy)
|
||||
Improve TLS handshake SNI extraction and add session resumption tracking in PortProxy
|
||||
|
||||
- Added ITlsSessionInfo interface and a global tlsSessionCache to track TLS session IDs for session resumption
|
||||
- Implemented a cleanup timer for the TLS session cache with startSessionCleanupTimer and stopSessionCleanupTimer
|
||||
- Enhanced extractSNIInfo to return detailed SNI information including session IDs, ticket details, and resumption status
|
||||
- Updated renegotiation handlers to use extractSNIInfo for proper SNI extraction during TLS rehandshake
|
||||
|
||||
## 2025-03-11 - 3.30.8 - fix(core)
|
||||
No changes in this commit.
|
||||
|
||||
|
10
package.json
10
package.json
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "3.30.8",
|
||||
"version": "3.37.3",
|
||||
"private": false,
|
||||
"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.",
|
||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
@ -18,8 +18,8 @@
|
||||
"@git.zone/tsbuild": "^2.2.6",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.77",
|
||||
"@push.rocks/tapbundle": "^5.5.6",
|
||||
"@types/node": "^22.13.9",
|
||||
"@push.rocks/tapbundle": "^5.5.10",
|
||||
"@types/node": "^22.13.10",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -28,7 +28,7 @@
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrequest": "^2.0.23",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@tsclass/tsclass": "^4.4.3",
|
||||
"@tsclass/tsclass": "^5.0.0",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/ws": "^8.18.0",
|
||||
"acme-client": "^5.4.0",
|
||||
|
119
pnpm-lock.yaml
generated
119
pnpm-lock.yaml
generated
@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^4.0.15
|
||||
version: 4.0.15
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
'@types/minimatch':
|
||||
specifier: ^5.1.2
|
||||
version: 5.1.2
|
||||
@ -55,11 +55,11 @@ importers:
|
||||
specifier: ^1.0.77
|
||||
version: 1.0.96(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)(typescript@5.8.2)
|
||||
'@push.rocks/tapbundle':
|
||||
specifier: ^5.5.6
|
||||
version: 5.5.6(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)
|
||||
specifier: ^5.5.10
|
||||
version: 5.5.10(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)
|
||||
'@types/node':
|
||||
specifier: ^22.13.9
|
||||
version: 22.13.9
|
||||
specifier: ^22.13.10
|
||||
version: 22.13.10
|
||||
typescript:
|
||||
specifier: ^5.8.2
|
||||
version: 5.8.2
|
||||
@ -941,8 +941,8 @@ packages:
|
||||
'@push.rocks/smartyaml@2.0.5':
|
||||
resolution: {integrity: sha512-tBcf+HaOIfeEsTMwgUZDtZERCxXQyRsWO8Ar5DjBdiSRchbhVGZQEBzXswMS0W5ZoRenjgPK+4tPW3JQGRTfbg==}
|
||||
|
||||
'@push.rocks/tapbundle@5.5.6':
|
||||
resolution: {integrity: sha512-V6u+nZwt4fNccxbm3ztZgHr/QAj/uKhaaOUFgtaae0jzYdds4jNEI+mXLpfXuNMgm7Nx93Lk5XUxWKTI8drjNw==}
|
||||
'@push.rocks/tapbundle@5.5.10':
|
||||
resolution: {integrity: sha512-vTGzd3/kzKp8s6jrREGIKGG+87fy7grcTZIelVDpyZMtBdIi9Fe7g8EIw/reVx8oTAFSuUEAEaAT8B5NDIFStg==}
|
||||
|
||||
'@push.rocks/taskbuffer@3.1.7':
|
||||
resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==}
|
||||
@ -1315,8 +1315,11 @@ packages:
|
||||
'@tsclass/tsclass@3.0.48':
|
||||
resolution: {integrity: sha512-hC65UvDlp9qvsl6OcIZXz0JNiWZ0gyzsTzbXpg215sGxopgbkOLCr6E0s4qCTnweYm95gt2AdY95uP7M7kExaQ==}
|
||||
|
||||
'@tsclass/tsclass@4.4.3':
|
||||
resolution: {integrity: sha512-Vhp+B1UsYlwXLhIeds++CXEeCwFgRzpput4YNM7Qyhr+UQgIMFRFAs2HSI3jEE5r9c1hR9G6MkSxi2U/CLyiaA==}
|
||||
'@tsclass/tsclass@4.4.4':
|
||||
resolution: {integrity: sha512-YZOAF+u+r4u5rCev2uUd1KBTBdfyFdtDmcv4wuN+864lMccbdfRICR3SlJwCfYS1lbeV3QNLYGD30wjRXgvCJA==}
|
||||
|
||||
'@tsclass/tsclass@5.0.0':
|
||||
resolution: {integrity: sha512-2X66VCk0Oe1L01j6GQHC6F9Gj7lpZPPSUTDNax7e29lm4OqBTyAzTR3ePR8coSbWBwsmRV8awLRSrSI+swlqWA==}
|
||||
|
||||
'@types/accepts@1.3.7':
|
||||
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
|
||||
@ -1472,8 +1475,8 @@ packages:
|
||||
'@types/node-forge@1.3.11':
|
||||
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
|
||||
|
||||
'@types/node@22.13.9':
|
||||
resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==}
|
||||
'@types/node@22.13.10':
|
||||
resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==}
|
||||
|
||||
'@types/parse5@6.0.3':
|
||||
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
|
||||
@ -4373,7 +4376,7 @@ snapshots:
|
||||
'@push.rocks/taskbuffer': 3.1.7
|
||||
'@push.rocks/webrequest': 3.0.37
|
||||
'@push.rocks/webstore': 2.0.20
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
'@types/express': 4.17.21
|
||||
body-parser: 1.20.3
|
||||
cors: 2.8.5
|
||||
@ -5231,7 +5234,7 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.0.7
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartshell': 3.2.3
|
||||
'@push.rocks/tapbundle': 5.5.6(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)
|
||||
'@push.rocks/tapbundle': 5.5.10(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)
|
||||
'@types/ws': 8.18.0
|
||||
figures: 6.1.0
|
||||
ws: 8.18.1
|
||||
@ -5281,7 +5284,7 @@ snapshots:
|
||||
'@jest/schemas': 29.6.3
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
'@types/istanbul-reports': 3.0.4
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
'@types/yargs': 17.0.33
|
||||
chalk: 4.1.2
|
||||
|
||||
@ -5529,7 +5532,7 @@ snapshots:
|
||||
'@push.rocks/smartstring': 4.0.15
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
'@push.rocks/taskbuffer': 3.1.7
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
@ -5551,7 +5554,7 @@ snapshots:
|
||||
'@pushrocks/smartjson': 4.0.6
|
||||
'@pushrocks/smartpath': 5.0.5
|
||||
'@pushrocks/smartpromise': 3.1.10
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
mongodb: 4.17.2
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
@ -5602,7 +5605,7 @@ snapshots:
|
||||
'@push.rocks/smartstream': 3.2.5
|
||||
'@push.rocks/smartstring': 4.0.15
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
@ -5652,7 +5655,7 @@ snapshots:
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
'@push.rocks/taskbuffer': 3.1.7
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
mongodb: 6.14.2(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
@ -5764,7 +5767,7 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces@3.0.2':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 2.0.2
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
|
||||
'@push.rocks/smartlog@3.0.7':
|
||||
dependencies:
|
||||
@ -5879,7 +5882,7 @@ snapshots:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartpuppeteer': 2.0.5(typescript@5.8.2)
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
'@types/express': 5.0.0
|
||||
express: 4.21.2
|
||||
pdf-lib: 1.17.1
|
||||
@ -5929,7 +5932,7 @@ snapshots:
|
||||
'@push.rocks/smartbucket': 3.3.7
|
||||
'@push.rocks/smartfile': 11.2.0
|
||||
'@push.rocks/smartpath': 5.0.18
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
'@types/s3rver': 3.7.4
|
||||
s3rver: 3.7.1
|
||||
transitivePeerDependencies:
|
||||
@ -5952,7 +5955,7 @@ snapshots:
|
||||
'@push.rocks/smartxml': 1.1.1
|
||||
'@push.rocks/smartyaml': 2.0.5
|
||||
'@push.rocks/webrequest': 3.0.37
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
|
||||
'@push.rocks/smartsocket@2.0.27':
|
||||
dependencies:
|
||||
@ -6058,7 +6061,7 @@ snapshots:
|
||||
'@types/js-yaml': 3.12.10
|
||||
js-yaml: 3.14.1
|
||||
|
||||
'@push.rocks/tapbundle@5.5.6(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)':
|
||||
'@push.rocks/tapbundle@5.5.10(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4)':
|
||||
dependencies:
|
||||
'@open-wc/testing': 4.0.0
|
||||
'@push.rocks/consolecolor': 2.0.2
|
||||
@ -6112,7 +6115,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@pushrocks/smartdelay': 3.0.1
|
||||
'@pushrocks/smartpromise': 4.0.2
|
||||
'@tsclass/tsclass': 4.4.3
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
|
||||
'@push.rocks/webstore@2.0.20':
|
||||
dependencies:
|
||||
@ -6654,20 +6657,24 @@ snapshots:
|
||||
dependencies:
|
||||
type-fest: 2.19.0
|
||||
|
||||
'@tsclass/tsclass@4.4.3':
|
||||
'@tsclass/tsclass@4.4.4':
|
||||
dependencies:
|
||||
type-fest: 4.37.0
|
||||
|
||||
'@tsclass/tsclass@5.0.0':
|
||||
dependencies:
|
||||
type-fest: 4.37.0
|
||||
|
||||
'@types/accepts@1.3.7':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/babel__code-frame@7.0.6': {}
|
||||
|
||||
'@types/body-parser@1.19.5':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/buffer-json@2.0.3': {}
|
||||
|
||||
@ -6683,17 +6690,17 @@ snapshots:
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/co-body@6.1.3':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
'@types/qs': 6.9.18
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/content-disposition@0.5.8': {}
|
||||
|
||||
@ -6706,11 +6713,11 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/express': 5.0.0
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/cors@2.8.17':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/debounce@1.2.4': {}
|
||||
|
||||
@ -6724,14 +6731,14 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@4.19.6':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
'@types/qs': 6.9.18
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.4
|
||||
|
||||
'@types/express-serve-static-core@5.0.6':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
'@types/qs': 6.9.18
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.4
|
||||
@ -6756,30 +6763,30 @@ snapshots:
|
||||
|
||||
'@types/from2@2.3.5':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/fs-extra@9.0.13':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/glob@7.2.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/glob@8.1.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/gunzip-maybe@1.4.2':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@ -6813,7 +6820,7 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/keygrip@1.0.6': {}
|
||||
|
||||
@ -6830,7 +6837,7 @@ snapshots:
|
||||
'@types/http-errors': 2.0.4
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/koa-compose': 3.2.8
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
@ -6848,9 +6855,9 @@ snapshots:
|
||||
|
||||
'@types/node-forge@1.3.11':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/node@22.13.9':
|
||||
'@types/node@22.13.10':
|
||||
dependencies:
|
||||
undici-types: 6.20.0
|
||||
|
||||
@ -6868,19 +6875,19 @@ snapshots:
|
||||
|
||||
'@types/s3rver@3.7.4':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/semver@7.5.8': {}
|
||||
|
||||
'@types/send@0.17.4':
|
||||
dependencies:
|
||||
'@types/mime': 1.3.5
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/serve-static@1.15.7':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.4
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
'@types/send': 0.17.4
|
||||
|
||||
'@types/sinon-chai@3.2.12':
|
||||
@ -6900,11 +6907,11 @@ snapshots:
|
||||
|
||||
'@types/tar-stream@2.2.3':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
@ -6928,18 +6935,18 @@ snapshots:
|
||||
|
||||
'@types/whatwg-url@8.2.2':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@types/which@3.0.4': {}
|
||||
|
||||
'@types/ws@7.4.7':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/ws@8.18.0':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
@ -6949,7 +6956,7 @@ snapshots:
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@ -7598,7 +7605,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/cookie': 0.4.1
|
||||
'@types/cors': 2.8.17
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.4.2
|
||||
@ -8370,7 +8377,7 @@ snapshots:
|
||||
jest-util@29.7.0:
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 22.13.9
|
||||
'@types/node': 22.13.10
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
graceful-fs: 4.2.11
|
||||
|
@ -197,6 +197,52 @@ tap.test('should match wildcard subdomains', async () => {
|
||||
expect(result).toEqual(wildcardConfig);
|
||||
});
|
||||
|
||||
// Test TLD wildcards (example.*)
|
||||
tap.test('should match TLD wildcards', async () => {
|
||||
const tldWildcardConfig = createProxyConfig('example.*');
|
||||
router.setNewProxyConfigs([tldWildcardConfig]);
|
||||
|
||||
// Test that example.com matches example.*
|
||||
const req1 = createMockRequest('example.com');
|
||||
const result1 = router.routeReq(req1);
|
||||
expect(result1).toBeTruthy();
|
||||
expect(result1).toEqual(tldWildcardConfig);
|
||||
|
||||
// Test that example.org matches example.*
|
||||
const req2 = createMockRequest('example.org');
|
||||
const result2 = router.routeReq(req2);
|
||||
expect(result2).toBeTruthy();
|
||||
expect(result2).toEqual(tldWildcardConfig);
|
||||
|
||||
// Test that subdomain.example.com doesn't match example.*
|
||||
const req3 = createMockRequest('subdomain.example.com');
|
||||
const result3 = router.routeReq(req3);
|
||||
expect(result3).toBeUndefined();
|
||||
});
|
||||
|
||||
// Test complex pattern matching (*.lossless*)
|
||||
tap.test('should match complex wildcard patterns', async () => {
|
||||
const complexWildcardConfig = createProxyConfig('*.lossless*');
|
||||
router.setNewProxyConfigs([complexWildcardConfig]);
|
||||
|
||||
// Test that sub.lossless.com matches *.lossless*
|
||||
const req1 = createMockRequest('sub.lossless.com');
|
||||
const result1 = router.routeReq(req1);
|
||||
expect(result1).toBeTruthy();
|
||||
expect(result1).toEqual(complexWildcardConfig);
|
||||
|
||||
// Test that api.lossless.org matches *.lossless*
|
||||
const req2 = createMockRequest('api.lossless.org');
|
||||
const result2 = router.routeReq(req2);
|
||||
expect(result2).toBeTruthy();
|
||||
expect(result2).toEqual(complexWildcardConfig);
|
||||
|
||||
// Test that losslessapi.com matches *.lossless*
|
||||
const req3 = createMockRequest('losslessapi.com');
|
||||
const result3 = router.routeReq(req3);
|
||||
expect(result3).toBeUndefined(); // Should not match as it doesn't have a subdomain
|
||||
});
|
||||
|
||||
// Test default configuration fallback
|
||||
tap.test('should fall back to default configuration', async () => {
|
||||
const defaultConfig = createProxyConfig('*');
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '3.30.8',
|
||||
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.'
|
||||
version: '3.37.3',
|
||||
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.'
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { ProxyRouter } from './classes.router.js';
|
||||
import { AcmeCertManager, CertManagerEvents } from './classes.port80handler.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@ -20,6 +21,18 @@ export interface INetworkProxyOptions {
|
||||
// New settings for PortProxy integration
|
||||
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
|
||||
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy
|
||||
|
||||
// ACME certificate management options
|
||||
acme?: {
|
||||
enabled?: boolean; // Whether to enable automatic certificate management
|
||||
port?: number; // Port to listen on for ACME challenges (default: 80)
|
||||
contactEmail?: string; // Email for Let's Encrypt account
|
||||
useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging)
|
||||
renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30)
|
||||
autoRenew?: boolean; // Whether to automatically renew certificates (default: true)
|
||||
certificateStore?: string; // Directory to store certificates (default: ./certs)
|
||||
skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured
|
||||
};
|
||||
}
|
||||
|
||||
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
||||
@ -59,12 +72,19 @@ export class NetworkProxy {
|
||||
private defaultCertificates: { key: string; cert: string };
|
||||
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
|
||||
|
||||
// ACME certificate manager
|
||||
private certManager: AcmeCertManager | null = null;
|
||||
private certificateStoreDir: string;
|
||||
|
||||
// New connection pool for backend connections
|
||||
private connectionPool: Map<string, Array<{
|
||||
socket: plugins.net.Socket;
|
||||
lastUsed: number;
|
||||
isIdle: boolean;
|
||||
}>> = new Map();
|
||||
|
||||
// Track round-robin positions for load balancing
|
||||
private roundRobinPositions: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new NetworkProxy instance
|
||||
@ -85,9 +105,33 @@ export class NetworkProxy {
|
||||
},
|
||||
// New defaults for PortProxy integration
|
||||
connectionPoolSize: optionsArg.connectionPoolSize || 50,
|
||||
portProxyIntegration: optionsArg.portProxyIntegration || false
|
||||
portProxyIntegration: optionsArg.portProxyIntegration || false,
|
||||
// Default ACME options
|
||||
acme: {
|
||||
enabled: optionsArg.acme?.enabled || false,
|
||||
port: optionsArg.acme?.port || 80,
|
||||
contactEmail: optionsArg.acme?.contactEmail || 'admin@example.com',
|
||||
useProduction: optionsArg.acme?.useProduction || false, // Default to staging for safety
|
||||
renewThresholdDays: optionsArg.acme?.renewThresholdDays || 30,
|
||||
autoRenew: optionsArg.acme?.autoRenew !== false, // Default to true
|
||||
certificateStore: optionsArg.acme?.certificateStore || './certs',
|
||||
skipConfiguredCerts: optionsArg.acme?.skipConfiguredCerts || false
|
||||
}
|
||||
};
|
||||
|
||||
// Set up certificate store directory
|
||||
this.certificateStoreDir = path.resolve(this.options.acme.certificateStore);
|
||||
|
||||
// Ensure certificate store directory exists
|
||||
try {
|
||||
if (!fs.existsSync(this.certificateStoreDir)) {
|
||||
fs.mkdirSync(this.certificateStoreDir, { recursive: true });
|
||||
this.log('info', `Created certificate store directory: ${this.certificateStoreDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('warn', `Failed to create certificate store directory: ${error}`);
|
||||
}
|
||||
|
||||
this.loadDefaultCertificates();
|
||||
}
|
||||
|
||||
@ -330,17 +374,230 @@ export class NetworkProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the ACME certificate manager for automatic certificate issuance
|
||||
* @private
|
||||
*/
|
||||
private async initializeAcmeManager(): Promise<void> {
|
||||
if (!this.options.acme.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create certificate manager
|
||||
this.certManager = new AcmeCertManager({
|
||||
port: this.options.acme.port,
|
||||
contactEmail: this.options.acme.contactEmail,
|
||||
useProduction: this.options.acme.useProduction,
|
||||
renewThresholdDays: this.options.acme.renewThresholdDays,
|
||||
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
|
||||
renewCheckIntervalHours: 24 // Check daily for renewals
|
||||
});
|
||||
|
||||
// Register event handlers
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this));
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this));
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this));
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_EXPIRING, (data) => {
|
||||
this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
|
||||
});
|
||||
|
||||
// Start the manager
|
||||
try {
|
||||
await this.certManager.start();
|
||||
this.log('info', `ACME Certificate Manager started on port ${this.options.acme.port}`);
|
||||
|
||||
// Add domains from proxy configs
|
||||
this.registerDomainsWithAcmeManager();
|
||||
} catch (error) {
|
||||
this.log('error', `Failed to start ACME Certificate Manager: ${error}`);
|
||||
this.certManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers domains from proxy configs with the ACME manager
|
||||
* @private
|
||||
*/
|
||||
private registerDomainsWithAcmeManager(): void {
|
||||
if (!this.certManager) return;
|
||||
|
||||
// Get all hostnames from proxy configs
|
||||
this.proxyConfigs.forEach(config => {
|
||||
const hostname = config.hostName;
|
||||
|
||||
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
|
||||
if (hostname.includes('*')) {
|
||||
this.log('info', `Skipping wildcard domain for ACME: ${hostname}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip domains already with certificates if configured to do so
|
||||
if (this.options.acme.skipConfiguredCerts) {
|
||||
const cachedCert = this.certificateCache.get(hostname);
|
||||
if (cachedCert) {
|
||||
this.log('info', `Skipping domain with existing certificate: ${hostname}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing certificate in the store
|
||||
const certPath = path.join(this.certificateStoreDir, `${hostname}.cert.pem`);
|
||||
const keyPath = path.join(this.certificateStoreDir, `${hostname}.key.pem`);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
// Load existing certificate and key
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
|
||||
// Extract expiry date from certificate if possible
|
||||
let expiryDate: Date | undefined;
|
||||
try {
|
||||
const matches = cert.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||
if (matches && matches[1]) {
|
||||
expiryDate = new Date(matches[1]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`);
|
||||
}
|
||||
|
||||
// Update the certificate in the manager
|
||||
this.certManager.setCertificate(hostname, cert, key, expiryDate);
|
||||
|
||||
// Also update our own certificate cache
|
||||
this.updateCertificateCache(hostname, cert, key, expiryDate);
|
||||
|
||||
this.log('info', `Loaded existing certificate for ${hostname}`);
|
||||
} else {
|
||||
// Register the domain for certificate issuance
|
||||
this.certManager.addDomain(hostname);
|
||||
this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('error', `Error registering domain ${hostname} with ACME manager: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles newly issued or renewed certificates from ACME manager
|
||||
* @private
|
||||
*/
|
||||
private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void {
|
||||
const { domain, certificate, privateKey, expiryDate } = data;
|
||||
|
||||
this.log('info', `Certificate ${this.certificateCache.has(domain) ? 'renewed' : 'issued'} for ${domain}, valid until ${expiryDate.toISOString()}`);
|
||||
|
||||
// Update certificate in HTTPS server
|
||||
this.updateCertificateCache(domain, certificate, privateKey, expiryDate);
|
||||
|
||||
// Save the certificate to the filesystem
|
||||
this.saveCertificateToStore(domain, certificate, privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles certificate issuance failures
|
||||
* @private
|
||||
*/
|
||||
private handleCertificateFailed(data: { domain: string; error: string }): void {
|
||||
this.log('error', `Certificate issuance failed for ${data.domain}: ${data.error}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves certificate and private key to the filesystem
|
||||
* @private
|
||||
*/
|
||||
private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void {
|
||||
try {
|
||||
const certPath = path.join(this.certificateStoreDir, `${domain}.cert.pem`);
|
||||
const keyPath = path.join(this.certificateStoreDir, `${domain}.key.pem`);
|
||||
|
||||
fs.writeFileSync(certPath, certificate);
|
||||
fs.writeFileSync(keyPath, privateKey);
|
||||
|
||||
// Ensure private key has restricted permissions
|
||||
try {
|
||||
fs.chmodSync(keyPath, 0o600);
|
||||
} catch (error) {
|
||||
this.log('warn', `Failed to set permissions on private key for ${domain}: ${error}`);
|
||||
}
|
||||
|
||||
this.log('info', `Saved certificate for ${domain} to ${certPath}`);
|
||||
} catch (error) {
|
||||
this.log('error', `Failed to save certificate for ${domain}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles SNI (Server Name Indication) for TLS connections
|
||||
* Used by the HTTPS server to select the correct certificate for each domain
|
||||
* @private
|
||||
*/
|
||||
private handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void {
|
||||
this.log('debug', `SNI request for domain: ${domain}`);
|
||||
|
||||
// Check if we have a certificate for this domain
|
||||
const certs = this.certificateCache.get(domain);
|
||||
|
||||
if (certs) {
|
||||
try {
|
||||
// Create TLS context with the cached certificate
|
||||
const context = plugins.tls.createSecureContext({
|
||||
key: certs.key,
|
||||
cert: certs.cert
|
||||
});
|
||||
|
||||
this.log('debug', `Using cached certificate for ${domain}`);
|
||||
cb(null, context);
|
||||
return;
|
||||
} catch (err) {
|
||||
this.log('error', `Error creating secure context for ${domain}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should trigger certificate issuance
|
||||
if (this.options.acme?.enabled && this.certManager && !domain.includes('*')) {
|
||||
// Check if this domain is already registered
|
||||
const certData = this.certManager.getCertificate(domain);
|
||||
|
||||
if (!certData) {
|
||||
this.log('info', `No certificate found for ${domain}, registering for issuance`);
|
||||
this.certManager.addDomain(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default certificate
|
||||
try {
|
||||
const context = plugins.tls.createSecureContext({
|
||||
key: this.defaultCertificates.key,
|
||||
cert: this.defaultCertificates.cert
|
||||
});
|
||||
|
||||
this.log('debug', `Using default certificate for ${domain}`);
|
||||
cb(null, context);
|
||||
} catch (err) {
|
||||
this.log('error', `Error creating default secure context:`, err);
|
||||
cb(new Error('Cannot create secure context'), null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the proxy server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Initialize ACME certificate manager if enabled
|
||||
if (this.options.acme.enabled) {
|
||||
await this.initializeAcmeManager();
|
||||
}
|
||||
|
||||
// Create the HTTPS server
|
||||
this.httpsServer = plugins.https.createServer(
|
||||
{
|
||||
key: this.defaultCertificates.key,
|
||||
cert: this.defaultCertificates.cert
|
||||
cert: this.defaultCertificates.cert,
|
||||
SNICallback: (domain, cb) => this.handleSNI(domain, cb)
|
||||
},
|
||||
(req, res) => this.handleRequest(req, res)
|
||||
);
|
||||
@ -556,7 +813,10 @@ export class NetworkProxy {
|
||||
const outGoingDeferred = plugins.smartpromise.defer();
|
||||
|
||||
try {
|
||||
const wsTarget = `ws://${wsDestinationConfig.destinationIp}:${wsDestinationConfig.destinationPort}${reqArg.url}`;
|
||||
// Select destination IP and port for WebSocket
|
||||
const wsDestinationIp = this.selectDestinationIp(wsDestinationConfig);
|
||||
const wsDestinationPort = this.selectDestinationPort(wsDestinationConfig);
|
||||
const wsTarget = `ws://${wsDestinationIp}:${wsDestinationPort}${reqArg.url}`;
|
||||
this.log('debug', `Proxying WebSocket to ${wsTarget}`);
|
||||
|
||||
wsOutgoing = new plugins.wsDefault(wsTarget);
|
||||
@ -688,8 +948,12 @@ export class NetworkProxy {
|
||||
const useConnectionPool = this.options.portProxyIntegration &&
|
||||
originRequest.socket.remoteAddress?.includes('127.0.0.1');
|
||||
|
||||
// Select destination IP and port from the arrays
|
||||
const destinationIp = this.selectDestinationIp(destinationConfig);
|
||||
const destinationPort = this.selectDestinationPort(destinationConfig);
|
||||
|
||||
// Construct destination URL
|
||||
const destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
||||
const destinationUrl = `http://${destinationIp}:${destinationPort}${originRequest.url}`;
|
||||
|
||||
if (useConnectionPool) {
|
||||
this.log('debug', `[${reqId}] Proxying to ${destinationUrl} (using connection pool)`);
|
||||
@ -697,8 +961,8 @@ export class NetworkProxy {
|
||||
reqId,
|
||||
originRequest,
|
||||
originResponse,
|
||||
destinationConfig.destinationIp,
|
||||
destinationConfig.destinationPort,
|
||||
destinationIp,
|
||||
destinationPort,
|
||||
originRequest.url
|
||||
);
|
||||
} else {
|
||||
@ -1084,6 +1348,80 @@ export class NetworkProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a destination IP from the array using round-robin
|
||||
* @param config The proxy configuration
|
||||
* @returns A destination IP address
|
||||
*/
|
||||
private selectDestinationIp(config: plugins.tsclass.network.IReverseProxyConfig): string {
|
||||
// For array-based configs
|
||||
if (Array.isArray(config.destinationIps) && config.destinationIps.length > 0) {
|
||||
// Get the current position or initialize it
|
||||
const key = `ip_${config.hostName}`;
|
||||
let position = this.roundRobinPositions.get(key) || 0;
|
||||
|
||||
// Select the IP using round-robin
|
||||
const selectedIp = config.destinationIps[position];
|
||||
|
||||
// Update the position for next time
|
||||
position = (position + 1) % config.destinationIps.length;
|
||||
this.roundRobinPositions.set(key, position);
|
||||
|
||||
return selectedIp;
|
||||
}
|
||||
|
||||
// For backward compatibility with test suites that rely on specific behavior
|
||||
// Check if there's a proxyConfigs entry that matches this hostname
|
||||
const matchingConfig = this.proxyConfigs.find(cfg =>
|
||||
cfg.hostName === config.hostName &&
|
||||
(cfg as any).destinationIp
|
||||
);
|
||||
|
||||
if (matchingConfig) {
|
||||
return (matchingConfig as any).destinationIp;
|
||||
}
|
||||
|
||||
// Fallback to localhost
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a destination port from the array using round-robin
|
||||
* @param config The proxy configuration
|
||||
* @returns A destination port number
|
||||
*/
|
||||
private selectDestinationPort(config: plugins.tsclass.network.IReverseProxyConfig): number {
|
||||
// For array-based configs
|
||||
if (Array.isArray(config.destinationPorts) && config.destinationPorts.length > 0) {
|
||||
// Get the current position or initialize it
|
||||
const key = `port_${config.hostName}`;
|
||||
let position = this.roundRobinPositions.get(key) || 0;
|
||||
|
||||
// Select the port using round-robin
|
||||
const selectedPort = config.destinationPorts[position];
|
||||
|
||||
// Update the position for next time
|
||||
position = (position + 1) % config.destinationPorts.length;
|
||||
this.roundRobinPositions.set(key, position);
|
||||
|
||||
return selectedPort;
|
||||
}
|
||||
|
||||
// For backward compatibility with test suites that rely on specific behavior
|
||||
// Check if there's a proxyConfigs entry that matches this hostname
|
||||
const matchingConfig = this.proxyConfigs.find(cfg =>
|
||||
cfg.hostName === config.hostName &&
|
||||
(cfg as any).destinationPort
|
||||
);
|
||||
|
||||
if (matchingConfig) {
|
||||
return parseInt((matchingConfig as any).destinationPort, 10);
|
||||
}
|
||||
|
||||
// Fallback to port 80
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates proxy configurations
|
||||
*/
|
||||
@ -1144,6 +1482,48 @@ export class NetworkProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PortProxy domain configurations to NetworkProxy configs
|
||||
* @param domainConfigs PortProxy domain configs
|
||||
* @param sslKeyPair Default SSL key pair to use if not specified
|
||||
* @returns Array of NetworkProxy configs
|
||||
*/
|
||||
public convertPortProxyConfigs(
|
||||
domainConfigs: Array<{
|
||||
domains: string[];
|
||||
targetIPs?: string[];
|
||||
allowedIPs?: string[];
|
||||
}>,
|
||||
sslKeyPair?: { key: string; cert: string }
|
||||
): plugins.tsclass.network.IReverseProxyConfig[] {
|
||||
const proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
||||
|
||||
// Use default certificates if not provided
|
||||
const sslKey = sslKeyPair?.key || this.defaultCertificates.key;
|
||||
const sslCert = sslKeyPair?.cert || this.defaultCertificates.cert;
|
||||
|
||||
for (const domainConfig of domainConfigs) {
|
||||
// Each domain in the domains array gets its own config
|
||||
for (const domain of domainConfig.domains) {
|
||||
// Skip non-hostname patterns (like IP addresses)
|
||||
if (domain.match(/^\d+\.\d+\.\d+\.\d+$/) || domain === '*' || domain === 'localhost') {
|
||||
continue;
|
||||
}
|
||||
|
||||
proxyConfigs.push({
|
||||
hostName: domain,
|
||||
destinationIps: domainConfig.targetIPs || ['localhost'],
|
||||
destinationPorts: [this.options.port], // Use the NetworkProxy port
|
||||
privateKey: sslKey,
|
||||
publicKey: sslCert
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.log('info', `Converted ${domainConfigs.length} PortProxy configs to ${proxyConfigs.length} NetworkProxy configs`);
|
||||
return proxyConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds default headers to be included in all responses
|
||||
*/
|
||||
@ -1208,6 +1588,16 @@ export class NetworkProxy {
|
||||
}
|
||||
this.connectionPool.clear();
|
||||
|
||||
// Stop ACME certificate manager if it's running
|
||||
if (this.certManager) {
|
||||
try {
|
||||
await this.certManager.stop();
|
||||
this.log('info', 'ACME Certificate Manager stopped');
|
||||
} catch (error) {
|
||||
this.log('error', 'Error stopping ACME Certificate Manager', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the HTTPS server
|
||||
return new Promise((resolve) => {
|
||||
this.httpsServer.close(() => {
|
||||
@ -1217,6 +1607,71 @@ export class NetworkProxy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a new certificate for a domain
|
||||
* This can be used to manually trigger certificate issuance
|
||||
* @param domain The domain to request a certificate for
|
||||
* @returns A promise that resolves when the request is submitted (not when the certificate is issued)
|
||||
*/
|
||||
public async requestCertificate(domain: string): Promise<boolean> {
|
||||
if (!this.options.acme.enabled) {
|
||||
this.log('warn', 'ACME certificate management is not enabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.certManager) {
|
||||
this.log('error', 'ACME certificate manager is not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
|
||||
if (domain.includes('*')) {
|
||||
this.log('error', `Cannot request certificate for wildcard domain: ${domain}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.certManager.addDomain(domain);
|
||||
this.log('info', `Certificate request submitted for domain: ${domain}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log('error', `Error requesting certificate for domain ${domain}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the certificate cache for a domain
|
||||
* @param domain The domain name
|
||||
* @param certificate The certificate (PEM format)
|
||||
* @param privateKey The private key (PEM format)
|
||||
* @param expiryDate Optional expiry date
|
||||
*/
|
||||
private updateCertificateCache(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
||||
// Update certificate context in HTTPS server if it's running
|
||||
if (this.httpsServer) {
|
||||
try {
|
||||
this.httpsServer.addContext(domain, {
|
||||
key: privateKey,
|
||||
cert: certificate
|
||||
});
|
||||
this.log('debug', `Updated SSL context for domain: ${domain}`);
|
||||
} catch (error) {
|
||||
this.log('error', `Error updating SSL context for domain ${domain}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update certificate in cache
|
||||
this.certificateCache.set(domain, {
|
||||
key: privateKey,
|
||||
cert: certificate,
|
||||
expires: expiryDate
|
||||
});
|
||||
|
||||
// Add to active contexts set
|
||||
this.activeContexts.add(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message according to the configured log level
|
||||
*/
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,21 @@ export interface IRouterResult {
|
||||
pathRemainder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Router for HTTP reverse proxy requests
|
||||
*
|
||||
* Supports the following domain matching patterns:
|
||||
* - Exact matches: "example.com"
|
||||
* - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com)
|
||||
* - TLD wildcards: "example.*" (matches example.com, example.org, etc.)
|
||||
* - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain)
|
||||
* - Default fallback: "*" (matches any unmatched domain)
|
||||
*
|
||||
* Also supports path pattern matching for each domain:
|
||||
* - Exact path: "/api/users"
|
||||
* - Wildcard paths: "/api/*"
|
||||
* - Path parameters: "/users/:id/profile"
|
||||
*/
|
||||
export class ProxyRouter {
|
||||
// Store original configs for reference
|
||||
private reverseProxyConfigs: tsclass.network.IReverseProxyConfig[] = [];
|
||||
@ -98,9 +113,11 @@ export class ProxyRouter {
|
||||
return exactConfig;
|
||||
}
|
||||
|
||||
// Try wildcard subdomain
|
||||
// Try various wildcard patterns
|
||||
if (hostWithoutPort.includes('.')) {
|
||||
const domainParts = hostWithoutPort.split('.');
|
||||
|
||||
// Try wildcard subdomain (*.example.com)
|
||||
if (domainParts.length > 2) {
|
||||
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
|
||||
const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath);
|
||||
@ -108,6 +125,23 @@ export class ProxyRouter {
|
||||
return wildcardConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Try TLD wildcard (example.*)
|
||||
const baseDomain = domainParts.slice(0, -1).join('.');
|
||||
const tldWildcardDomain = `${baseDomain}.*`;
|
||||
const tldWildcardConfig = this.findConfigForHost(tldWildcardDomain, urlPath);
|
||||
if (tldWildcardConfig) {
|
||||
return tldWildcardConfig;
|
||||
}
|
||||
|
||||
// Try complex wildcard patterns
|
||||
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
|
||||
for (const pattern of wildcardPatterns) {
|
||||
const wildcardConfig = this.findConfigForHost(pattern, urlPath);
|
||||
if (wildcardConfig) {
|
||||
return wildcardConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default config if available
|
||||
@ -120,6 +154,53 @@ export class ProxyRouter {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential wildcard patterns that could match a given hostname
|
||||
* Handles complex patterns like "*.lossless*" or other partial matches
|
||||
* @param hostname The hostname to find wildcard matches for
|
||||
* @returns Array of potential wildcard patterns that could match
|
||||
*/
|
||||
private findWildcardMatches(hostname: string): string[] {
|
||||
const patterns: string[] = [];
|
||||
const hostnameParts = hostname.split('.');
|
||||
|
||||
// Find all configured hostnames that contain wildcards
|
||||
const wildcardConfigs = this.reverseProxyConfigs.filter(
|
||||
config => config.hostName.includes('*')
|
||||
);
|
||||
|
||||
// Extract unique wildcard patterns
|
||||
const wildcardPatterns = [...new Set(
|
||||
wildcardConfigs.map(config => config.hostName.toLowerCase())
|
||||
)];
|
||||
|
||||
// For each wildcard pattern, check if it could match the hostname
|
||||
// using simplified regex pattern matching
|
||||
for (const pattern of wildcardPatterns) {
|
||||
// Skip the default wildcard '*'
|
||||
if (pattern === '*') continue;
|
||||
|
||||
// Skip already checked patterns (*.domain.com and domain.*)
|
||||
if (pattern.startsWith('*.') && pattern.indexOf('*', 2) === -1) continue;
|
||||
if (pattern.endsWith('.*') && pattern.indexOf('*') === pattern.length - 1) continue;
|
||||
|
||||
// Convert wildcard pattern to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, '\\.') // Escape dots
|
||||
.replace(/\*/g, '.*'); // Convert * to .* for regex
|
||||
|
||||
// Create regex object with case insensitive flag
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
|
||||
// If hostname matches this complex pattern, add it to the list
|
||||
if (regex.test(hostname)) {
|
||||
patterns.push(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a config for a specific host and path
|
||||
*/
|
||||
|
519
ts/classes.snihandler.ts
Normal file
519
ts/classes.snihandler.ts
Normal file
@ -0,0 +1,519 @@
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
/**
|
||||
* SNI (Server Name Indication) handler for TLS connections.
|
||||
* Provides robust extraction of SNI values from TLS ClientHello messages.
|
||||
*/
|
||||
export class SniHandler {
|
||||
// TLS record types and constants
|
||||
private static readonly TLS_HANDSHAKE_RECORD_TYPE = 22;
|
||||
private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = 1;
|
||||
private static readonly TLS_SNI_EXTENSION_TYPE = 0x0000;
|
||||
private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = 0x0023;
|
||||
private static readonly TLS_SNI_HOST_NAME_TYPE = 0;
|
||||
private static readonly TLS_PSK_EXTENSION_TYPE = 0x0029; // Pre-Shared Key extension type for TLS 1.3
|
||||
private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = 0x002D; // PSK Key Exchange Modes
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains a TLS handshake message (record type 22)
|
||||
* @param buffer - The buffer to check
|
||||
* @returns true if the buffer starts with a TLS handshake record type
|
||||
*/
|
||||
public static isTlsHandshake(buffer: Buffer): boolean {
|
||||
return buffer.length > 0 && buffer[0] === this.TLS_HANDSHAKE_RECORD_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains a TLS ClientHello message
|
||||
* @param buffer - The buffer to check
|
||||
* @returns true if the buffer appears to be a ClientHello message
|
||||
*/
|
||||
public static isClientHello(buffer: Buffer): boolean {
|
||||
// Minimum ClientHello size (TLS record header + handshake header)
|
||||
if (buffer.length < 9) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check record type (must be TLS_HANDSHAKE_RECORD_TYPE)
|
||||
if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip version and length in TLS record header (5 bytes total)
|
||||
// Check handshake type at byte 5 (must be CLIENT_HELLO)
|
||||
return buffer[5] === this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the SNI (Server Name Indication) from a TLS ClientHello message.
|
||||
* Implements robust parsing with support for session resumption edge cases.
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param enableLogging - Whether to enable detailed debug logging
|
||||
* @returns The extracted server name or undefined if not found
|
||||
*/
|
||||
public static extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined {
|
||||
// Logging helper
|
||||
const log = (message: string) => {
|
||||
if (enableLogging) {
|
||||
console.log(`[SNI Extraction] ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Buffer must be at least 5 bytes (TLS record header)
|
||||
if (buffer.length < 5) {
|
||||
log('Buffer too small for TLS record header');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check record type (must be TLS_HANDSHAKE_RECORD_TYPE = 22)
|
||||
if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) {
|
||||
log(`Not a TLS handshake record: ${buffer[0]}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check TLS version
|
||||
const majorVersion = buffer[1];
|
||||
const minorVersion = buffer[2];
|
||||
log(`TLS version: ${majorVersion}.${minorVersion}`);
|
||||
|
||||
// Parse record length (bytes 3-4, big-endian)
|
||||
const recordLength = (buffer[3] << 8) + buffer[4];
|
||||
log(`Record length: ${recordLength}`);
|
||||
|
||||
// Validate record length against buffer size
|
||||
if (buffer.length < recordLength + 5) {
|
||||
log('Buffer smaller than expected record length');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Start of handshake message in the buffer
|
||||
let pos = 5;
|
||||
|
||||
// Check handshake type (must be CLIENT_HELLO = 1)
|
||||
if (buffer[pos] !== this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE) {
|
||||
log(`Not a ClientHello message: ${buffer[pos]}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Skip handshake type (1 byte)
|
||||
pos += 1;
|
||||
|
||||
// Parse handshake length (3 bytes, big-endian)
|
||||
const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2];
|
||||
log(`Handshake length: ${handshakeLength}`);
|
||||
|
||||
// Skip handshake length (3 bytes)
|
||||
pos += 3;
|
||||
|
||||
// Check client version (2 bytes)
|
||||
const clientMajorVersion = buffer[pos];
|
||||
const clientMinorVersion = buffer[pos + 1];
|
||||
log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`);
|
||||
|
||||
// Skip client version (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Skip client random (32 bytes)
|
||||
pos += 32;
|
||||
|
||||
// Parse session ID
|
||||
if (pos + 1 > buffer.length) {
|
||||
log('Buffer too small for session ID length');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionIdLength = buffer[pos];
|
||||
log(`Session ID length: ${sessionIdLength}`);
|
||||
|
||||
// Skip session ID length (1 byte) and session ID
|
||||
pos += 1 + sessionIdLength;
|
||||
|
||||
// Check if we have enough bytes left
|
||||
if (pos + 2 > buffer.length) {
|
||||
log('Buffer too small for cipher suites length');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse cipher suites length (2 bytes, big-endian)
|
||||
const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Cipher suites length: ${cipherSuitesLength}`);
|
||||
|
||||
// Skip cipher suites length (2 bytes) and cipher suites
|
||||
pos += 2 + cipherSuitesLength;
|
||||
|
||||
// Check if we have enough bytes left
|
||||
if (pos + 1 > buffer.length) {
|
||||
log('Buffer too small for compression methods length');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse compression methods length (1 byte)
|
||||
const compressionMethodsLength = buffer[pos];
|
||||
log(`Compression methods length: ${compressionMethodsLength}`);
|
||||
|
||||
// Skip compression methods length (1 byte) and compression methods
|
||||
pos += 1 + compressionMethodsLength;
|
||||
|
||||
// Check if we have enough bytes for extensions length
|
||||
if (pos + 2 > buffer.length) {
|
||||
log('No extensions present or buffer too small');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse extensions length (2 bytes, big-endian)
|
||||
const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Extensions length: ${extensionsLength}`);
|
||||
|
||||
// Skip extensions length (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Extensions end position
|
||||
const extensionsEnd = pos + extensionsLength;
|
||||
|
||||
// Check if extensions length is valid
|
||||
if (extensionsEnd > buffer.length) {
|
||||
log('Extensions length exceeds buffer size');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Track if we found session tickets (for improved resumption handling)
|
||||
let hasSessionTicket = false;
|
||||
let hasPskExtension = false;
|
||||
|
||||
// Iterate through extensions
|
||||
while (pos + 4 <= extensionsEnd) {
|
||||
// Parse extension type (2 bytes, big-endian)
|
||||
const extensionType = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`);
|
||||
|
||||
// Skip extension type (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Parse extension length (2 bytes, big-endian)
|
||||
const extensionLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Extension length: ${extensionLength}`);
|
||||
|
||||
// Skip extension length (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Check if this is the SNI extension
|
||||
if (extensionType === this.TLS_SNI_EXTENSION_TYPE) {
|
||||
log('Found SNI extension');
|
||||
|
||||
// Ensure we have enough bytes for the server name list
|
||||
if (pos + 2 > extensionsEnd) {
|
||||
log('Extension too small for server name list length');
|
||||
pos += extensionLength; // Skip this extension
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse server name list length (2 bytes, big-endian)
|
||||
const serverNameListLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Server name list length: ${serverNameListLength}`);
|
||||
|
||||
// Skip server name list length (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Ensure server name list length is valid
|
||||
if (pos + serverNameListLength > extensionsEnd) {
|
||||
log('Server name list length exceeds extension size');
|
||||
break; // Exit the loop, extension parsing is broken
|
||||
}
|
||||
|
||||
// End position of server name list
|
||||
const serverNameListEnd = pos + serverNameListLength;
|
||||
|
||||
// Iterate through server names
|
||||
while (pos + 3 <= serverNameListEnd) {
|
||||
// Check name type (must be HOST_NAME_TYPE = 0 for hostname)
|
||||
const nameType = buffer[pos];
|
||||
log(`Name type: ${nameType}`);
|
||||
|
||||
if (nameType !== this.TLS_SNI_HOST_NAME_TYPE) {
|
||||
log(`Unsupported name type: ${nameType}`);
|
||||
pos += 1; // Skip name type (1 byte)
|
||||
|
||||
// Skip name length (2 bytes) and name data
|
||||
if (pos + 2 <= serverNameListEnd) {
|
||||
const nameLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
pos += 2 + nameLength;
|
||||
} else {
|
||||
log('Invalid server name entry');
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip name type (1 byte)
|
||||
pos += 1;
|
||||
|
||||
// Ensure we have enough bytes for name length
|
||||
if (pos + 2 > serverNameListEnd) {
|
||||
log('Server name entry too small for name length');
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse name length (2 bytes, big-endian)
|
||||
const nameLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Name length: ${nameLength}`);
|
||||
|
||||
// Skip name length (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Ensure we have enough bytes for the name
|
||||
if (pos + nameLength > serverNameListEnd) {
|
||||
log('Name length exceeds server name list size');
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract server name (hostname)
|
||||
const serverName = buffer.slice(pos, pos + nameLength).toString('utf8');
|
||||
log(`Extracted server name: ${serverName}`);
|
||||
return serverName;
|
||||
}
|
||||
} else if (extensionType === this.TLS_SESSION_TICKET_EXTENSION_TYPE) {
|
||||
// If we encounter a session ticket extension, mark it for later
|
||||
log('Found session ticket extension');
|
||||
hasSessionTicket = true;
|
||||
pos += extensionLength; // Skip this extension
|
||||
} else if (extensionType === this.TLS_PSK_EXTENSION_TYPE) {
|
||||
// TLS 1.3 PSK extension - mark for resumption support
|
||||
log('Found PSK extension (TLS 1.3 resumption indicator)');
|
||||
hasPskExtension = true;
|
||||
// We'll skip the extension here and process it separately if needed
|
||||
pos += extensionLength;
|
||||
} else {
|
||||
// Skip this extension
|
||||
pos += extensionLength;
|
||||
}
|
||||
}
|
||||
|
||||
// Log if we found session resumption indicators but no SNI
|
||||
if (hasSessionTicket || hasPskExtension) {
|
||||
log('Session resumption indicators present but no SNI found');
|
||||
}
|
||||
|
||||
log('No SNI extension found in ClientHello');
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
log(`Error parsing SNI: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello.
|
||||
*
|
||||
* In TLS 1.3, when a client attempts to resume a session, it may include
|
||||
* the server name in the PSK identity hint rather than in the SNI extension.
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param enableLogging - Whether to enable detailed debug logging
|
||||
* @returns The extracted server name or undefined if not found
|
||||
*/
|
||||
public static extractSNIFromPSKExtension(
|
||||
buffer: Buffer,
|
||||
enableLogging: boolean = false
|
||||
): string | undefined {
|
||||
const log = (message: string) => {
|
||||
if (enableLogging) {
|
||||
console.log(`[PSK-SNI Extraction] ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Ensure this is a ClientHello
|
||||
if (!this.isClientHello(buffer)) {
|
||||
log('Not a ClientHello message');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the start position of extensions
|
||||
let pos = 5; // Start after record header
|
||||
|
||||
// Skip handshake type (1 byte)
|
||||
pos += 1;
|
||||
|
||||
// Skip handshake length (3 bytes)
|
||||
pos += 3;
|
||||
|
||||
// Skip client version (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Skip client random (32 bytes)
|
||||
pos += 32;
|
||||
|
||||
// Skip session ID
|
||||
if (pos + 1 > buffer.length) return undefined;
|
||||
const sessionIdLength = buffer[pos];
|
||||
pos += 1 + sessionIdLength;
|
||||
|
||||
// Skip cipher suites
|
||||
if (pos + 2 > buffer.length) return undefined;
|
||||
const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
pos += 2 + cipherSuitesLength;
|
||||
|
||||
// Skip compression methods
|
||||
if (pos + 1 > buffer.length) return undefined;
|
||||
const compressionMethodsLength = buffer[pos];
|
||||
pos += 1 + compressionMethodsLength;
|
||||
|
||||
// Check if we have extensions
|
||||
if (pos + 2 > buffer.length) {
|
||||
log('No extensions present');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get extensions length
|
||||
const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
pos += 2;
|
||||
|
||||
// Extensions end position
|
||||
const extensionsEnd = pos + extensionsLength;
|
||||
if (extensionsEnd > buffer.length) return undefined;
|
||||
|
||||
// Look for PSK extension
|
||||
while (pos + 4 <= extensionsEnd) {
|
||||
const extensionType = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
pos += 2;
|
||||
|
||||
const extensionLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
pos += 2;
|
||||
|
||||
if (extensionType === this.TLS_PSK_EXTENSION_TYPE) {
|
||||
log('Found PSK extension');
|
||||
|
||||
// PSK extension structure:
|
||||
// 2 bytes: identities list length
|
||||
if (pos + 2 > extensionsEnd) break;
|
||||
const identitiesLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
pos += 2;
|
||||
|
||||
// End of identities list
|
||||
const identitiesEnd = pos + identitiesLength;
|
||||
if (identitiesEnd > extensionsEnd) break;
|
||||
|
||||
// Process each PSK identity
|
||||
while (pos + 2 <= identitiesEnd) {
|
||||
// Identity length (2 bytes)
|
||||
if (pos + 2 > identitiesEnd) break;
|
||||
const identityLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
pos += 2;
|
||||
|
||||
if (pos + identityLength > identitiesEnd) break;
|
||||
|
||||
// Try to extract hostname from identity
|
||||
// Chrome often embeds the hostname in the PSK identity
|
||||
// This is a heuristic as there's no standard format
|
||||
if (identityLength > 0) {
|
||||
const identity = buffer.slice(pos, pos + identityLength);
|
||||
|
||||
// Skip identity bytes
|
||||
pos += identityLength;
|
||||
|
||||
// Skip obfuscated ticket age (4 bytes)
|
||||
pos += 4;
|
||||
|
||||
// Try to parse the identity as UTF-8
|
||||
try {
|
||||
const identityStr = identity.toString('utf8');
|
||||
log(`PSK identity: ${identityStr}`);
|
||||
|
||||
// Check if the identity contains hostname hints
|
||||
// Chrome often embeds the hostname in a known format
|
||||
// Try to extract using common patterns
|
||||
|
||||
// Pattern 1: Look for domain name pattern
|
||||
const domainPattern = /([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?/i;
|
||||
const domainMatch = identityStr.match(domainPattern);
|
||||
if (domainMatch && domainMatch[0]) {
|
||||
log(`Found domain in PSK identity: ${domainMatch[0]}`);
|
||||
return domainMatch[0];
|
||||
}
|
||||
|
||||
// Pattern 2: Chrome sometimes uses a specific format with delimiters
|
||||
// This is a heuristic approach since the format isn't standardized
|
||||
const parts = identityStr.split('|');
|
||||
if (parts.length > 1) {
|
||||
for (const part of parts) {
|
||||
if (part.includes('.') && !part.includes('/')) {
|
||||
const possibleDomain = part.trim();
|
||||
if (/^[a-z0-9.-]+$/i.test(possibleDomain)) {
|
||||
log(`Found possible domain in PSK delimiter format: ${possibleDomain}`);
|
||||
return possibleDomain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to parse PSK identity as UTF-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Skip this extension
|
||||
pos += extensionLength;
|
||||
}
|
||||
}
|
||||
|
||||
log('No hostname found in PSK extension');
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to extract SNI from an initial ClientHello packet and handles
|
||||
* session resumption edge cases more robustly than the standard extraction.
|
||||
*
|
||||
* This method handles:
|
||||
* 1. Standard SNI extraction
|
||||
* 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.)
|
||||
* 3. Session ticket-based resumption
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param enableLogging - Whether to enable detailed debug logging
|
||||
* @returns The extracted server name or undefined if not found
|
||||
*/
|
||||
public static extractSNIWithResumptionSupport(
|
||||
buffer: Buffer,
|
||||
enableLogging: boolean = false
|
||||
): string | undefined {
|
||||
// First try the standard SNI extraction
|
||||
const standardSni = this.extractSNI(buffer, enableLogging);
|
||||
if (standardSni) {
|
||||
if (enableLogging) {
|
||||
console.log(`[SNI Extraction] Found standard SNI: ${standardSni}`);
|
||||
}
|
||||
return standardSni;
|
||||
}
|
||||
|
||||
// If standard extraction failed and we have a valid ClientHello,
|
||||
// this might be a session resumption with non-standard format
|
||||
if (this.isClientHello(buffer)) {
|
||||
if (enableLogging) {
|
||||
console.log('[SNI Extraction] Detected ClientHello without standard SNI, possible session resumption');
|
||||
}
|
||||
|
||||
// Try to extract from PSK extension (TLS 1.3 resumption)
|
||||
const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging);
|
||||
if (pskSni) {
|
||||
if (enableLogging) {
|
||||
console.log(`[SNI Extraction] Extracted SNI from PSK extension: ${pskSni}`);
|
||||
}
|
||||
return pskSni;
|
||||
}
|
||||
|
||||
// Could add more browser-specific heuristics here if needed
|
||||
if (enableLogging) {
|
||||
console.log('[SNI Extraction] Failed to extract SNI from resumption mechanisms');
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -3,3 +3,4 @@ export * from './classes.networkproxy.js';
|
||||
export * from './classes.portproxy.js';
|
||||
export * from './classes.port80handler.js';
|
||||
export * from './classes.sslredirect.js';
|
||||
export * from './classes.snihandler.js';
|
||||
|
Reference in New Issue
Block a user