Compare commits
54 Commits
Author | SHA1 | Date | |
---|---|---|---|
e47436608f | |||
128f8203ac | |||
c7697eca84 | |||
71b5237cd4 | |||
2df2f0ceaf | |||
2b266ca779 | |||
c2547036fd | |||
a8131ece26 | |||
ad8c667dec | |||
942e0649c8 | |||
59625167b4 | |||
385d984727 | |||
a959c2ad0e | |||
88f5436c9a | |||
06101cd1b1 | |||
438d65107d | |||
233b26c308 | |||
ba787729e8 | |||
4854d7c38d | |||
e841bda003 | |||
477b930a37 | |||
935bd95723 | |||
0e33ea4eb5 | |||
6181065963 | |||
1a586dcbd7 | |||
ee03224561 | |||
483cbb3634 | |||
c77b31b72c | |||
8cb8fa1a52 | |||
8e5bb12edb | |||
9be9a426ad | |||
32d875aed9 | |||
4747462cff | |||
70f69ef1ea | |||
2be1c57dd7 | |||
58bd6b4a85 | |||
63e1cd48e8 | |||
5150ddc18e | |||
4bee483954 | |||
4328d4365f | |||
21e9d0fd0d | |||
6c0c65bb1a | |||
23f61eb60b | |||
a4ad6c59c1 | |||
e67eff0fcc | |||
e5db2e171c | |||
7389072841 | |||
9dd56a9362 | |||
1e7c45918e | |||
49b65508a5 | |||
3e66debb01 | |||
f1bb1702c1 | |||
5abc0d8a14 | |||
9150e8c5fc |
@ -6,8 +6,8 @@ on:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
@ -26,7 +26,7 @@ jobs:
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
|
||||
- name: Run npm prepare
|
||||
run: npmci npm prepare
|
||||
|
@ -6,8 +6,8 @@ on:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
@ -26,7 +26,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
@ -54,7 +54,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
@ -82,7 +82,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Release
|
||||
@ -104,7 +104,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Code quality
|
||||
@ -119,6 +119,6 @@ jobs:
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
pnpm install -g @gitzone/tsdoc
|
||||
pnpm install -g @git.zone/tsdoc
|
||||
npmci command tsdoc
|
||||
continue-on-error: true
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,7 +3,6 @@
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
@ -17,4 +16,4 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# custom
|
||||
#------# custom
|
19
assets/certs/cert.pem
Normal file
19
assets/certs/cert.pem
Normal file
@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDCzCCAfOgAwIBAgIUPU4tviz3ZvsMDjCz1NZRT16b0Y4wDQYJKoZIhvcNAQEL
|
||||
BQAwFTETMBEGA1UEAwwKcHVzaC5yb2NrczAeFw0yNTAyMDMyMzA5MzRaFw0yNjAy
|
||||
MDMyMzA5MzRaMBUxEzARBgNVBAMMCnB1c2gucm9ja3MwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQCZMkBYD/pYLBv9MiyHTLRT24kQyPeJBtZqryibi1jk
|
||||
BT1ZgNl3yo5U6kjj/nYBU/oy7M4OFC0xyaJQ4wpvLHu7xzREqwT9N9WcDcxaahUi
|
||||
P8+PsjGyznPrtXa1ASzGAYMNvXyWWp3351UWZHMEs6eY/Y7i8m4+0NwP5h8RNBCF
|
||||
KSFS41Ee9rNAMCnQSHZv1vIzKeVYPmYnCVmL7X2kQb+gS6Rvq5sEGLLKMC5QtTwI
|
||||
rdkPGpx4xZirIyf8KANbt0sShwUDpiCSuOCtpze08jMzoHLG9Nv97cJQjb/BhiES
|
||||
hLL+YjfAUFjq0rQ38zFKLJ87QB9Jym05mY6IadGQLXVXAgMBAAGjUzBRMB0GA1Ud
|
||||
DgQWBBQjpowWjrql/Eo2EVjl29xcjuCgkTAfBgNVHSMEGDAWgBQjpowWjrql/Eo2
|
||||
EVjl29xcjuCgkTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAY
|
||||
44vqbaf6ewFrZC0f3Kk4A10lC6qjWkcDFfw+JE8nzt+4+xPqp1eWgZKF2rONyAv2
|
||||
nG41Xygt19ByancXLU44KB24LX8F1GV5Oo7CGBA+xtoSPc0JulXw9fGclZDC6XiR
|
||||
P/+vhGgCHicbfP2O+N00pOifrTtf2tmOT4iPXRRo4TxmPzuCd+ZJTlBhPlKCmICq
|
||||
yGdAiEo6HsSiP+M5qVlNx8s57MhQYk5TpgmI6FU4mO7zfDfSatFonlg+aDbrnaqJ
|
||||
v/+km02M+oB460GmKwsSTnThHZgLNCLiKqD8bdziiCQjx5u0GjLI6468o+Aehb8l
|
||||
l/x9vWTTk/QKq41X5hFk
|
||||
-----END CERTIFICATE-----
|
28
assets/certs/key.pem
Normal file
28
assets/certs/key.pem
Normal file
@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCZMkBYD/pYLBv9
|
||||
MiyHTLRT24kQyPeJBtZqryibi1jkBT1ZgNl3yo5U6kjj/nYBU/oy7M4OFC0xyaJQ
|
||||
4wpvLHu7xzREqwT9N9WcDcxaahUiP8+PsjGyznPrtXa1ASzGAYMNvXyWWp3351UW
|
||||
ZHMEs6eY/Y7i8m4+0NwP5h8RNBCFKSFS41Ee9rNAMCnQSHZv1vIzKeVYPmYnCVmL
|
||||
7X2kQb+gS6Rvq5sEGLLKMC5QtTwIrdkPGpx4xZirIyf8KANbt0sShwUDpiCSuOCt
|
||||
pze08jMzoHLG9Nv97cJQjb/BhiEShLL+YjfAUFjq0rQ38zFKLJ87QB9Jym05mY6I
|
||||
adGQLXVXAgMBAAECggEARGCBBq1PBHbfoUH5TQSIAlvdEEBa9+602lZG7jIioVfT
|
||||
W7Uem5Ctuan+kcDcY9hbNsqqZ+9KgsvoJmlIGXoF2jjeE/4vUmRO9AHWoc5yk2Be
|
||||
4NjcxN3QMLdEfiLBnLlFCOd4CdX1ZxZ6TG3WRpV3a1pVIeeqHGB1sKT6Xd/atcwG
|
||||
RvpiXzu0SutGxVb6WE9r6hovZ4fVERCyCRczUGrUH5ICbxf6E7L4u8xjEYR4uEKK
|
||||
/8ZkDqrWdRASDAdPPMNqnHUEAho/WnxpNeb6B4lvvv2QWxIS9H1OikF/NzWPgVNS
|
||||
oPpvtJgjyo5xdgLm3zE4lcSPNVSrh1TBXuAn9TG4WQKBgQDScPFkUNBqjC5iPMof
|
||||
bqDHlhlptrHmiv9LC0lgjEDPgIEQfjLfdCugwDk32QyAcb5B60upDYeqCFDkfV/C
|
||||
T536qxevYPjPAjahLPHqMxkWpjvtY6NOTgbbcpVtblU2Fj8R8qbyPNADG31LicU9
|
||||
GVPtQ4YcVaMWCYbg5107+9dFWQKBgQC6XK+foKK+81RFdrqaNNgebTWTsANnBcZe
|
||||
xl0bj6oL5yY0IzroxHvgcNS7UMriWCu+K2xfkUBdMmxU773VN5JQ5k15ezjgtrvc
|
||||
8oAaEsxYP4su12JSTC/zsBANUgrNbFj8++qqKYWt2aQc2O/kbZ4MNfekIVFc8AjM
|
||||
2X9PxvxKLwKBgHXL7QO3TQLnVyt8VbQEjBFMzwriznB7i+4o8jkOKVU93IEr8zQr
|
||||
5iQElcLSR3I6uUJTALYvsaoXH5jXKVwujwL69LYiNQRDe+r6qqvrUHbiNJdsd8Rk
|
||||
XuhGGqj34tD04Pcd+h+MtO+YWqmHBBZwcA9XBeIkebbjPFH2kLT8AwN5AoGAYQy9
|
||||
hMJxnkE3hIkk+gNE/OtgeE20J+Vw/ZANkrnJEzPHyGUEW41e+W2oyvdzAFZsSTdx
|
||||
037f5ujIU58Z27x53NliRT4vS4693H0Iyws5EUfeIoGVuUflvODWKymraHjhCrXh
|
||||
6cV/0R5DAabTnsCbCr7b/MRBC8YQvyUQ0KnOXo8CgYBQYGpvJnSWyvsCjtb6apTP
|
||||
drjcBhVd0aSBpLGtDdtUCV4oLl9HPy+cLzcGaqckBqCwEq5DKruhMEf7on56bUMd
|
||||
m/3ItFk1TnhysAeJHb3zLqmJ9CKBitpqLlsOE7MEXVNmbTYeXU10Uo9yOfyt1i7T
|
||||
su+nT5VtyPkmF/l4wZl5+g==
|
||||
-----END PRIVATE KEY-----
|
175
changelog.md
175
changelog.md
@ -1,5 +1,180 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Added cleanup calls after socket end in connection rejection scenarios within PortProxy
|
||||
|
||||
## 2025-02-21 - 3.9.3 - fix(PortProxy)
|
||||
Fix handling of optional outgoing socket in PortProxy
|
||||
|
||||
- Refactored the cleanUpSockets function to correctly handle cases where the outgoing socket may be undefined.
|
||||
- Ensured correct handling of socket events with non-null assertions where applicable.
|
||||
- Improved robustness in connection establishment and cleanup processes.
|
||||
|
||||
## 2025-02-21 - 3.9.2 - fix(PortProxy)
|
||||
Improve timeout handling for port proxy connections
|
||||
|
||||
- Added console logging for both incoming and outgoing side timeouts in the PortProxy class.
|
||||
- Updated the timeout event handlers to ensure proper cleanup of connections.
|
||||
|
||||
## 2025-02-21 - 3.9.1 - fix(dependencies)
|
||||
Ensure correct ordering of dependencies and improve logging format.
|
||||
|
||||
- Reorder dependencies in package.json for better readability.
|
||||
- Use pretty-ms for displaying time durations in logs.
|
||||
|
||||
## 2025-02-21 - 3.9.0 - feat(smartproxy.portproxy)
|
||||
Add logging of connection durations to PortProxy
|
||||
|
||||
- Track start times for incoming and outgoing connections.
|
||||
- Log duration of longest running incoming and outgoing connections every 10 seconds.
|
||||
|
||||
## 2025-02-21 - 3.8.1 - fix(plugins)
|
||||
Simplified plugin import structure across codebase
|
||||
|
||||
- Consolidated plugin imports under a single 'plugins.ts' file.
|
||||
- Replaced individual plugin imports in smartproxy files with the consolidated plugin imports.
|
||||
- Fixed error handling for early socket errors in PortProxy setup.
|
||||
|
||||
## 2025-02-21 - 3.8.0 - feat(PortProxy)
|
||||
Add active connection tracking and logging in PortProxy
|
||||
|
||||
- Implemented a feature to track active incoming connections in PortProxy.
|
||||
- Active connections are now logged every 10 seconds for monitoring purposes.
|
||||
- Refactored connection handling to ensure proper cleanup and logging.
|
||||
|
||||
## 2025-02-21 - 3.7.3 - fix(portproxy)
|
||||
Fix handling of connections in PortProxy to improve stability and performance.
|
||||
|
||||
- Improved IP normalization and matching
|
||||
- Better SNI extraction and handling for TLS
|
||||
- Streamlined connection handling with robust error management
|
||||
|
||||
## 2025-02-21 - 3.7.2 - fix(PortProxy)
|
||||
Improve SNICallback and connection handling in PortProxy
|
||||
|
||||
- Fixed SNICallback to create minimal TLS context for SNI.
|
||||
- Changed connection setup to use net.connect for raw passthrough.
|
||||
|
||||
## 2025-02-21 - 3.7.1 - fix(smartproxy.portproxy)
|
||||
Optimize SNI handling by simplifying context creation
|
||||
|
||||
- Removed unnecessary SecureContext creation for SNI requests in PortProxy
|
||||
- Improved handling of SNI passthrough by acknowledging requests without context creation
|
||||
|
||||
## 2025-02-21 - 3.7.0 - feat(PortProxy)
|
||||
Add optional source IP preservation support in PortProxy
|
||||
|
||||
- Added a feature to optionally preserve the client's source IP when proxying connections.
|
||||
- Enhanced test cases to include scenarios for source IP preservation.
|
||||
|
||||
## 2025-02-21 - 3.6.0 - feat(PortProxy)
|
||||
Add feature to preserve original client IP through chained proxies
|
||||
|
||||
- Added support to bind local address in PortProxy to preserve original client IP.
|
||||
- Implemented test for chained proxies to ensure client IP is preserved.
|
||||
|
||||
## 2025-02-21 - 3.5.0 - feat(PortProxy)
|
||||
Enhance PortProxy to support domain-specific target IPs
|
||||
|
||||
- Introduced support for domain-specific target IP configurations in PortProxy.
|
||||
- Updated connection handling to prioritize domain-specific target IPs if provided.
|
||||
- Added tests to verify forwarding based on domain-specific target IPs.
|
||||
|
||||
## 2025-02-21 - 3.4.4 - fix(PortProxy)
|
||||
Fixed handling of SNI domain connections and IP allowance checks
|
||||
|
||||
- Improved logic for handling SNI domain checks, ensuring IPs are correctly verified.
|
||||
- Fixed issue where default allowed IPs were not being checked correctly for non-SNI connections.
|
||||
- Revised the SNICallback behavior to handle connections more gracefully when domain configurations are unavailable.
|
||||
|
||||
## 2025-02-21 - 3.4.3 - fix(PortProxy)
|
||||
Fixed indentation issue and ensured proper cleanup of sockets in PortProxy
|
||||
|
||||
- Fixed inconsistent indentation in IP allowance check.
|
||||
- Ensured proper cleanup of sockets on connection end in PortProxy.
|
||||
|
||||
## 2025-02-21 - 3.4.2 - fix(smartproxy)
|
||||
Enhance SSL/TLS handling with SNI and error logging
|
||||
|
||||
- Improved handling for SNI-enabled and non-SNI connections
|
||||
- Added detailed logging for connection establishment and rejections
|
||||
- Introduced error logging for TLS client errors and server errors
|
||||
|
||||
## 2025-02-21 - 3.4.1 - fix(PortProxy)
|
||||
Normalize IP addresses for port proxy to handle IPv4-mapped IPv6 addresses.
|
||||
|
||||
- Improved IP normalization logic in PortProxy to support IPv4-mapped IPv6 addresses.
|
||||
- Updated isAllowed function to expand patterns for better matching accuracy.
|
||||
|
||||
## 2025-02-21 - 3.4.0 - feat(PortProxy)
|
||||
Enhanced PortProxy with custom target host and improved testing
|
||||
|
||||
- PortProxy constructor now accepts 'fromPort', 'toPort', and optional 'toHost' directly from settings
|
||||
- Refactored test cases to cover forwarding to the custom host
|
||||
- Added support to handle multiple concurrent connections
|
||||
- Refactored internal connection handling logic to utilize default configurations
|
||||
|
||||
## 2025-02-21 - 3.3.1 - fix(PortProxy)
|
||||
fixed import usage of net and tls libraries for PortProxy
|
||||
|
||||
- Corrected the use of plugins for importing 'tls' and 'net' libraries in the PortProxy module.
|
||||
- Updated the constructor of PortProxy to accept combined tls options with ProxySettings.
|
||||
|
||||
## 2025-02-21 - 3.3.0 - feat(PortProxy)
|
||||
Enhanced PortProxy with domain and IP filtering, SNI support, and minimatch integration
|
||||
|
||||
- Added new ProxySettings interface to configure domain patterns, SNI, and default allowed IPs.
|
||||
- Integrated minimatch to filter allowed IPs and domains.
|
||||
- Enabled SNI support for PortProxy connections.
|
||||
- Updated port proxy test to accommodate new settings.
|
||||
|
||||
## 2025-02-04 - 3.2.0 - feat(testing)
|
||||
Added a comprehensive test suite for the PortProxy class
|
||||
|
||||
- Set up a test environment for PortProxy using net.Server.
|
||||
- Test coverage includes starting and stopping the proxy, handling TCP connections, concurrent connections, and timeouts.
|
||||
- Ensures proper resource cleanup after tests.
|
||||
|
||||
## 2025-02-04 - 3.1.4 - fix(core)
|
||||
No uncommitted changes. Preparing for potential minor improvements or bug fixes.
|
||||
|
||||
|
||||
## 2025-02-04 - 3.1.3 - fix(networkproxy)
|
||||
Refactor and improve WebSocket handling and request processing
|
||||
|
||||
- Improved error handling in WebSocket connection and request processing.
|
||||
- Refactored the WebSocket handling in NetworkProxy to use a unified error logging mechanism.
|
||||
|
||||
## 2025-02-04 - 3.1.2 - fix(core)
|
||||
Refactor certificate handling across the project
|
||||
|
||||
- Moved certificate keys and certs to the assets/certs directory.
|
||||
- Updated test utilities to load certificates from the central location.
|
||||
- Cleaned up redundant code and improved error logging regarding certificates.
|
||||
- Ensured correct handling of host header in ProxyRouter class.
|
||||
|
||||
## 2025-02-03 - 3.1.1 - fix(workflow)
|
||||
Update Gitea workflow paths and dependencies
|
||||
|
||||
- Updated registry paths for npmci image and repositories in Gitea workflow files.
|
||||
- Fixed dependency paths in package.json.
|
||||
- Completed adding typescript to the list of devDependencies.
|
||||
|
||||
## 2024-10-07 - 3.1.0 - feat(NetworkProxy)
|
||||
Introduce WebSocket heartbeat to maintain active connections in NetworkProxy
|
||||
|
||||
|
38
package.json
38
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "3.1.0",
|
||||
"version": "3.10.1",
|
||||
"private": false,
|
||||
"description": "a proxy for handling high workloads of proxying",
|
||||
"main": "dist_ts/index.js",
|
||||
@ -15,21 +15,25 @@
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gitzone/tsbuild": "^2.1.66",
|
||||
"@gitzone/tsrun": "^1.2.44",
|
||||
"@gitzone/tstest": "^1.0.77",
|
||||
"@push.rocks/tapbundle": "^5.0.12",
|
||||
"@types/node": "^20.4.5"
|
||||
"@git.zone/tsbuild": "^2.1.66",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.77",
|
||||
"@push.rocks/tapbundle": "^5.5.6",
|
||||
"@types/node": "^22.13.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.0.3",
|
||||
"@push.rocks/lik": "^6.1.0",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartrequest": "^2.0.18",
|
||||
"@push.rocks/smartstring": "^4.0.7",
|
||||
"@tsclass/tsclass": "^4.0.42",
|
||||
"@types/ws": "^8.5.5",
|
||||
"ws": "^8.13.0"
|
||||
"@push.rocks/smartpromise": "^4.2.2",
|
||||
"@push.rocks/smartrequest": "^2.0.23",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@tsclass/tsclass": "^4.4.0",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/ws": "^8.5.14",
|
||||
"minimatch": "^9.0.3",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@ -59,9 +63,15 @@
|
||||
"reverse proxy",
|
||||
"authentication"
|
||||
],
|
||||
"homepage": "https://code.foss.global/push.rocks/smartproxy",
|
||||
"homepage": "https://code.foss.global/push.rocks/smartproxy#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://code.foss.global/push.rocks/smartproxy.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://code.foss.global/push.rocks/smartproxy/issues"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {}
|
||||
}
|
||||
}
|
||||
|
11640
pnpm-lock.yaml
generated
11640
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,9 @@
|
||||
# @push.rocks/smartproxy
|
||||
|
||||
A proxy for handling high workloads of proxying.
|
||||
|
||||
## Install
|
||||
|
||||
To install `@push.rocks/smartproxy`, run the following command in your project's root directory:
|
||||
|
||||
```bash
|
||||
@ -100,7 +102,7 @@ For more information on how to use the features, refer to the in-depth documenta
|
||||
|
||||
## 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.
|
||||
|
||||
|
@ -1,27 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEljCCAn4CCQDY+ZbC9FASVjANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJE
|
||||
RTAeFw0xOTA5MjAxNjAxNDRaFw0yMDA5MTkxNjAxNDRaMA0xCzAJBgNVBAYTAkRF
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4thf9JEK/epoXt8hFr8t
|
||||
pkRzmaEkgbSKoOga3uGXDLvdNf3BzSIxZ8pzRhZfUnutcmW1thdz3wre/pEJR7oN
|
||||
QsfixbLL8/oS5QeXKiUGX0Ssfdg4W0TsoLcRva+1AZsf38MfiUPhzh1/UW/rMywW
|
||||
asazQwRZdkkXb4nKJ2IFZx22qnAD4/5Sug+sfeKoFBF/rzI2yK7rognt7kW2LHv6
|
||||
rswHnZ1Z2P/gbhlZ/EhG9hFVRZwRLDscWKcuWcxkePDt2J1pDNqD6SYa6ZjGC3AE
|
||||
TJw5iEA1bLQ9YvjDNpVYcf6ZvcSilIFjSQu5cs9sUbHGeKTrS5HzfeJXh1PfJyL8
|
||||
X0Hu7UBSjfSudso3baE9FGiBFBW2cnXZKDZGtV8eq/qxPetOOgS09pVbNP6508WV
|
||||
BR+rz98/VDZLZqcbZ2UpOuz4+kAKmbYE9GplxKQZZO7wWEox7Mid/uUdcqEo4QKn
|
||||
no6ujOuzQzn5a2oOS0k5Hk3uHapNJWlW9YI3LHtfADpYH+6cOR+/c3JWBzQJ6AD7
|
||||
muvNzA9mWXeHqLxMMP4pkmb7otzZYrEkodUqJgAQxcYhGh6XsCPfJ/D9RN734OJc
|
||||
gleVXFI8Kz455HxCW19XNfz16k7T6kqhZ/6SOBbkxEuqg7oEthAP109ZZzgx4oDo
|
||||
hQsw24TjLkI4SPIc7nr60UUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAu0+zrg0C
|
||||
mlSv4Yi24OwB7TBvx+WHesl1IilCUdTiiUMo3NumvsU9Dr3Jkd0jGqYI0eyH4gIt
|
||||
KrhAveXfEw7tAOEHiYicmAdIFtyzh++ZWb8mgbBeqij1MP/76Jv+cc0lUqpfRo/A
|
||||
qytAsPAILuyL1o1jh28JHcq+v+WYn/FEhjUlH6emhGKGlsAjhUPjzK8MEshNolhj
|
||||
t2UXw9WB5B2xWvrqlNMy0F3NAZBkZ/+k21HZo6FmVi+q6OEGcOo7wJt6wrH/lko9
|
||||
LxX96GC1JoN1Pfr2FoTKy1WHzrSfyGmDIUCrbaYQ58UuMOR+5eIPPdkf/030u5eX
|
||||
xXhF2fBujD57E2zQGh/l2MrOjamcSo0+wYhOqlX3WNdaKNAzPqloBnF6w7eqLYde
|
||||
h9He39ySmxjENwv3miOjEP1sBeMBSRfL/ckEonfK5uJgYA5nVMQ3ojUeDMZzLfFE
|
||||
Ue2WHt+uPyYk7mMZfOrK2uHzI2/Coqj7lbfRodFwj+fCArYBck2NZannDPKA6X8V
|
||||
TzJTbTCteOUUJTrcfZ0gGhGkF4nYLmX5OI+TPqrDJf0fZ+mzAEHzDDVXcBYpYRDr
|
||||
r8d9QwrK+WaqVi2ofbMfMByVF72jgeJNa4nxwT9bVbu/Q1T2Lt+YPb4pQ7yCoUgS
|
||||
JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
-----END CERTIFICATE-----
|
37
test/helpers/certificates.ts
Normal file
37
test/helpers/certificates.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as tls from 'tls';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export interface TestCertificates {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export function loadTestCertificates(): TestCertificates {
|
||||
const certPath = path.join(__dirname, '..', '..', 'assets', 'certs', 'cert.pem');
|
||||
const keyPath = path.join(__dirname, '..', '..', 'assets', 'certs', 'key.pem');
|
||||
|
||||
// Read certificates
|
||||
const publicKey = fs.readFileSync(certPath, 'utf8');
|
||||
const privateKey = fs.readFileSync(keyPath, 'utf8');
|
||||
|
||||
// Validate certificates
|
||||
try {
|
||||
// Try to create a secure context with the certificates
|
||||
tls.createSecureContext({
|
||||
cert: publicKey,
|
||||
key: privateKey
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid certificates: ${error.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey
|
||||
};
|
||||
}
|
52
test/key.pem
52
test/key.pem
@ -1,52 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
2TkK01XDDJURK5Lhuvc7WrK2rSJ/fK+0GA==
|
||||
-----END PRIVATE KEY-----
|
253
test/test.portproxy.ts
Normal file
253
test/test.portproxy.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { PortProxy } from '../ts/smartproxy.portproxy.js';
|
||||
|
||||
let testServer: net.Server;
|
||||
let portProxy: PortProxy;
|
||||
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> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
// Echo the received data back
|
||||
socket.write(`Echo: ${data.toString()}`);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('[Test Server] Socket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[Test Server] Listening on port ${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to create 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Setup test environment
|
||||
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: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1']
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should start port proxy', async () => {
|
||||
await portProxy.start();
|
||||
expect(portProxy.netServer.listening).toBeTrue();
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
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: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1']
|
||||
});
|
||||
|
||||
await customHostProxy.start();
|
||||
const response = await createTestClient(PROXY_PORT + 1, TEST_DATA);
|
||||
expect(response).toEqual(`Echo: ${TEST_DATA}`);
|
||||
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);
|
||||
|
||||
// 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',
|
||||
allowedIPs: ['127.0.0.1'],
|
||||
targetIP: '127.0.0.1'
|
||||
}, {
|
||||
domain: 'domain2.test',
|
||||
allowedIPs: ['127.0.0.1'],
|
||||
targetIP: 'localhost'
|
||||
}],
|
||||
sniEnabled: false, // We'll test without SNI first since this is a TCP proxy test
|
||||
defaultAllowedIPs: ['127.0.0.1']
|
||||
});
|
||||
|
||||
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}`);
|
||||
|
||||
await domainProxy.stop();
|
||||
await domainProxy2.stop();
|
||||
await new Promise<void>((resolve) => testServer2.close(() => resolve()));
|
||||
});
|
||||
|
||||
tap.test('should handle multiple concurrent connections', async () => {
|
||||
const concurrentRequests = 5;
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should stop port proxy', async () => {
|
||||
await portProxy.stop();
|
||||
expect(portProxy.netServer.listening).toBeFalse();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
tap.test('should support optional source IP preservation in chained proxies', async () => {
|
||||
// Test 1: Without IP preservation (default behavior)
|
||||
const firstProxyDefault = new PortProxy({
|
||||
fromPort: PROXY_PORT + 4,
|
||||
toPort: PROXY_PORT + 5,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
});
|
||||
|
||||
const secondProxyDefault = new PortProxy({
|
||||
fromPort: PROXY_PORT + 5,
|
||||
toPort: TEST_SERVER_PORT,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
});
|
||||
|
||||
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
|
||||
const firstProxyPreserved = new PortProxy({
|
||||
fromPort: PROXY_PORT + 6,
|
||||
toPort: PROXY_PORT + 7,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1'],
|
||||
preserveSourceIP: true
|
||||
});
|
||||
|
||||
const secondProxyPreserved = new PortProxy({
|
||||
fromPort: PROXY_PORT + 7,
|
||||
toPort: TEST_SERVER_PORT,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1'],
|
||||
preserveSourceIP: true
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
tap.test('cleanup port proxy test environment', async () => {
|
||||
await new Promise<void>((resolve) => testServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
process.on('exit', () => {
|
||||
if (testServer) {
|
||||
testServer.close();
|
||||
}
|
||||
if (portProxy && portProxy.netServer) {
|
||||
portProxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
488
test/test.ts
488
test/test.ts
@ -1,116 +1,422 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as smartproxy from '../ts/index.js';
|
||||
import { loadTestCertificates } from './helpers/certificates.js';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
let testProxy: smartproxy.NetworkProxy;
|
||||
let testServer: http.Server;
|
||||
let wsServer: WebSocketServer;
|
||||
let testCertificates: { privateKey: string; publicKey: string };
|
||||
|
||||
tap.test('first test', async () => {
|
||||
// Helper function to make HTTPS requests
|
||||
async function makeHttpsRequest(
|
||||
options: https.RequestOptions,
|
||||
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||
console.log('[TEST] Making HTTPS request:', {
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
path: options.path,
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
console.log('[TEST] Received HTTPS response:', {
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
});
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
console.log('[TEST] Response completed:', { data });
|
||||
resolve({
|
||||
statusCode: res.statusCode!,
|
||||
headers: res.headers,
|
||||
body: data,
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
console.error('[TEST] Request error:', error);
|
||||
reject(error);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup test environment
|
||||
tap.test('setup test environment', async () => {
|
||||
// Load and validate certificates
|
||||
console.log('[TEST] Loading and validating certificates');
|
||||
testCertificates = loadTestCertificates();
|
||||
console.log('[TEST] Certificates loaded and validated');
|
||||
|
||||
// Create a test HTTP server
|
||||
testServer = http.createServer((req, res) => {
|
||||
console.log('[TEST SERVER] Received HTTP request:', {
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('Hello from test server!');
|
||||
});
|
||||
|
||||
// Handle WebSocket upgrade requests
|
||||
testServer.on('upgrade', (request, socket, head) => {
|
||||
console.log('[TEST SERVER] Received WebSocket upgrade request:', {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: {
|
||||
host: request.headers.host,
|
||||
upgrade: request.headers.upgrade,
|
||||
connection: request.headers.connection,
|
||||
'sec-websocket-key': request.headers['sec-websocket-key'],
|
||||
'sec-websocket-version': request.headers['sec-websocket-version'],
|
||||
'sec-websocket-protocol': request.headers['sec-websocket-protocol'],
|
||||
},
|
||||
});
|
||||
|
||||
if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
|
||||
console.log('[TEST SERVER] Not a WebSocket upgrade request');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TEST SERVER] Handling WebSocket upgrade');
|
||||
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
||||
console.log('[TEST SERVER] WebSocket connection upgraded');
|
||||
wsServer.emit('connection', ws, request);
|
||||
});
|
||||
});
|
||||
|
||||
// Create a WebSocket server (for the test HTTP server)
|
||||
console.log('[TEST SERVER] Creating WebSocket server');
|
||||
wsServer = new WebSocketServer({
|
||||
noServer: true,
|
||||
perMessageDeflate: false,
|
||||
clientTracking: true,
|
||||
handleProtocols: () => 'echo-protocol',
|
||||
});
|
||||
|
||||
wsServer.on('connection', (ws, request) => {
|
||||
console.log('[TEST SERVER] WebSocket connection established:', {
|
||||
url: request.url,
|
||||
headers: {
|
||||
host: request.headers.host,
|
||||
upgrade: request.headers.upgrade,
|
||||
connection: request.headers.connection,
|
||||
'sec-websocket-key': request.headers['sec-websocket-key'],
|
||||
'sec-websocket-version': request.headers['sec-websocket-version'],
|
||||
'sec-websocket-protocol': request.headers['sec-websocket-protocol'],
|
||||
},
|
||||
});
|
||||
|
||||
// Set up connection timeout
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
console.error('[TEST SERVER] WebSocket connection timed out');
|
||||
ws.terminate();
|
||||
}, 5000);
|
||||
|
||||
// Clear timeout when connection is properly closed
|
||||
const clearConnectionTimeout = () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
};
|
||||
|
||||
ws.on('message', (message) => {
|
||||
const msg = message.toString();
|
||||
console.log('[TEST SERVER] Received message:', msg);
|
||||
try {
|
||||
const response = `Echo: ${msg}`;
|
||||
console.log('[TEST SERVER] Sending response:', response);
|
||||
ws.send(response);
|
||||
// Clear timeout on successful message exchange
|
||||
clearConnectionTimeout();
|
||||
} catch (error) {
|
||||
console.error('[TEST SERVER] Error sending message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[TEST SERVER] WebSocket error:', error);
|
||||
clearConnectionTimeout();
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log('[TEST SERVER] WebSocket connection closed:', {
|
||||
code,
|
||||
reason: reason.toString(),
|
||||
wasClean: code === 1000 || code === 1001,
|
||||
});
|
||||
clearConnectionTimeout();
|
||||
});
|
||||
|
||||
ws.on('ping', (data) => {
|
||||
try {
|
||||
console.log('[TEST SERVER] Received ping, sending pong');
|
||||
ws.pong(data);
|
||||
} catch (error) {
|
||||
console.error('[TEST SERVER] Error sending pong:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('pong', (data) => {
|
||||
console.log('[TEST SERVER] Received pong');
|
||||
});
|
||||
});
|
||||
|
||||
wsServer.on('error', (error) => {
|
||||
console.error('Test server: WebSocket server error:', error);
|
||||
});
|
||||
|
||||
wsServer.on('headers', (headers) => {
|
||||
console.log('Test server: WebSocket headers:', headers);
|
||||
});
|
||||
|
||||
wsServer.on('close', () => {
|
||||
console.log('Test server: WebSocket server closed');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => testServer.listen(3000, resolve));
|
||||
console.log('Test server listening on port 3000');
|
||||
});
|
||||
|
||||
tap.test('should create proxy instance', async () => {
|
||||
testProxy = new smartproxy.NetworkProxy({
|
||||
port: 3001,
|
||||
});
|
||||
expect(testProxy).toBeInstanceOf(smartproxy.NetworkProxy);
|
||||
expect(testProxy).toEqual(testProxy); // Instance equality check
|
||||
});
|
||||
|
||||
tap.test('should start the testproxy', async () => {
|
||||
tap.test('should start the proxy server', async () => {
|
||||
// Ensure any previous server is closed
|
||||
if (testProxy && testProxy.httpsServer) {
|
||||
await new Promise<void>((resolve) =>
|
||||
testProxy.httpsServer.close(() => resolve())
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[TEST] Starting the proxy server');
|
||||
await testProxy.start();
|
||||
});
|
||||
console.log('[TEST] Proxy server started');
|
||||
|
||||
tap.test('should supply reverse proxy config', async () => {
|
||||
testProxy.updateProxyConfigs([
|
||||
// Configure proxy with test certificates
|
||||
// Awaiting the update ensures that the SNI context is added before any requests come in.
|
||||
await testProxy.updateProxyConfigs([
|
||||
{
|
||||
destinationIp: '127.0.0.1',
|
||||
destinationPort: '3000',
|
||||
hostName: 'push.rocks',
|
||||
privateKey: `-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
2TkK01XDDJURK5Lhuvc7WrK2rSJ/fK+0GA==
|
||||
-----END PRIVATE KEY-----
|
||||
`,
|
||||
publicKey: `-----BEGIN CERTIFICATE-----
|
||||
MIIEljCCAn4CCQDY+ZbC9FASVjANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJE
|
||||
RTAeFw0xOTA5MjAxNjAxNDRaFw0yMDA5MTkxNjAxNDRaMA0xCzAJBgNVBAYTAkRF
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4thf9JEK/epoXt8hFr8t
|
||||
pkRzmaEkgbSKoOga3uGXDLvdNf3BzSIxZ8pzRhZfUnutcmW1thdz3wre/pEJR7oN
|
||||
QsfixbLL8/oS5QeXKiUGX0Ssfdg4W0TsoLcRva+1AZsf38MfiUPhzh1/UW/rMywW
|
||||
asazQwRZdkkXb4nKJ2IFZx22qnAD4/5Sug+sfeKoFBF/rzI2yK7rognt7kW2LHv6
|
||||
rswHnZ1Z2P/gbhlZ/EhG9hFVRZwRLDscWKcuWcxkePDt2J1pDNqD6SYa6ZjGC3AE
|
||||
TJw5iEA1bLQ9YvjDNpVYcf6ZvcSilIFjSQu5cs9sUbHGeKTrS5HzfeJXh1PfJyL8
|
||||
X0Hu7UBSjfSudso3baE9FGiBFBW2cnXZKDZGtV8eq/qxPetOOgS09pVbNP6508WV
|
||||
BR+rz98/VDZLZqcbZ2UpOuz4+kAKmbYE9GplxKQZZO7wWEox7Mid/uUdcqEo4QKn
|
||||
no6ujOuzQzn5a2oOS0k5Hk3uHapNJWlW9YI3LHtfADpYH+6cOR+/c3JWBzQJ6AD7
|
||||
muvNzA9mWXeHqLxMMP4pkmb7otzZYrEkodUqJgAQxcYhGh6XsCPfJ/D9RN734OJc
|
||||
gleVXFI8Kz455HxCW19XNfz16k7T6kqhZ/6SOBbkxEuqg7oEthAP109ZZzgx4oDo
|
||||
hQsw24TjLkI4SPIc7nr60UUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAu0+zrg0C
|
||||
mlSv4Yi24OwB7TBvx+WHesl1IilCUdTiiUMo3NumvsU9Dr3Jkd0jGqYI0eyH4gIt
|
||||
KrhAveXfEw7tAOEHiYicmAdIFtyzh++ZWb8mgbBeqij1MP/76Jv+cc0lUqpfRo/A
|
||||
qytAsPAILuyL1o1jh28JHcq+v+WYn/FEhjUlH6emhGKGlsAjhUPjzK8MEshNolhj
|
||||
t2UXw9WB5B2xWvrqlNMy0F3NAZBkZ/+k21HZo6FmVi+q6OEGcOo7wJt6wrH/lko9
|
||||
LxX96GC1JoN1Pfr2FoTKy1WHzrSfyGmDIUCrbaYQ58UuMOR+5eIPPdkf/030u5eX
|
||||
xXhF2fBujD57E2zQGh/l2MrOjamcSo0+wYhOqlX3WNdaKNAzPqloBnF6w7eqLYde
|
||||
h9He39ySmxjENwv3miOjEP1sBeMBSRfL/ckEonfK5uJgYA5nVMQ3ojUeDMZzLfFE
|
||||
Ue2WHt+uPyYk7mMZfOrK2uHzI2/Coqj7lbfRodFwj+fCArYBck2NZannDPKA6X8V
|
||||
TzJTbTCteOUUJTrcfZ0gGhGkF4nYLmX5OI+TPqrDJf0fZ+mzAEHzDDVXcBYpYRDr
|
||||
r8d9QwrK+WaqVi2ofbMfMByVF72jgeJNa4nxwT9bVbu/Q1T2Lt+YPb4pQ7yCoUgS
|
||||
JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
publicKey: testCertificates.publicKey,
|
||||
privateKey: testCertificates.privateKey,
|
||||
},
|
||||
]);
|
||||
|
||||
console.log('[TEST] Proxy configuration updated');
|
||||
});
|
||||
|
||||
tap.test('should wait for 60 seconds', async (tools) => {
|
||||
await tools.delayFor(10000);
|
||||
tap.test('should route HTTPS requests based on host header', async () => {
|
||||
// IMPORTANT: Connect to localhost (where the proxy is listening) but use the Host header "push.rocks"
|
||||
const response = await makeHttpsRequest({
|
||||
hostname: 'localhost', // changed from 'push.rocks' to 'localhost'
|
||||
port: 3001,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'push.rocks', // virtual host for routing
|
||||
},
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body).toEqual('Hello from test server!');
|
||||
});
|
||||
|
||||
tap.test('should close the testproxy', async () => {
|
||||
tap.test('should handle unknown host headers', async () => {
|
||||
// Connect to localhost but use an unknown host header.
|
||||
const response = await makeHttpsRequest({
|
||||
hostname: 'localhost', // connecting to localhost
|
||||
port: 3001,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'unknown.host', // this should not match any proxy config
|
||||
},
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
// Expect a 404 response with the appropriate error message.
|
||||
expect(response.statusCode).toEqual(404);
|
||||
expect(response.body).toEqual('This route is not available on this server.');
|
||||
});
|
||||
|
||||
tap.test('should support WebSocket connections', async () => {
|
||||
console.log('\n[TEST] ====== WebSocket Test Started ======');
|
||||
console.log('[TEST] Test server port:', 3000);
|
||||
console.log('[TEST] Proxy server port:', 3001);
|
||||
console.log('\n[TEST] Starting WebSocket test');
|
||||
|
||||
// Reconfigure proxy with test certificates if necessary
|
||||
await testProxy.updateProxyConfigs([
|
||||
{
|
||||
destinationIp: '127.0.0.1',
|
||||
destinationPort: '3000',
|
||||
hostName: 'push.rocks',
|
||||
publicKey: testCertificates.publicKey,
|
||||
privateKey: testCertificates.privateKey,
|
||||
},
|
||||
]);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
console.log('[TEST] Creating WebSocket client');
|
||||
|
||||
// IMPORTANT: Connect to localhost but specify the SNI servername and Host header as "push.rocks"
|
||||
const wsUrl = 'wss://localhost:3001'; // changed from 'wss://push.rocks:3001'
|
||||
console.log('[TEST] Creating WebSocket connection to:', wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
rejectUnauthorized: false, // Accept self-signed certificates
|
||||
handshakeTimeout: 5000,
|
||||
perMessageDeflate: false,
|
||||
headers: {
|
||||
Host: 'push.rocks', // required for SNI and routing on the proxy
|
||||
Connection: 'Upgrade',
|
||||
Upgrade: 'websocket',
|
||||
'Sec-WebSocket-Version': '13',
|
||||
},
|
||||
protocol: 'echo-protocol',
|
||||
agent: new https.Agent({
|
||||
rejectUnauthorized: false, // Also needed for the underlying HTTPS connection
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('[TEST] WebSocket client created');
|
||||
|
||||
let resolved = false;
|
||||
const cleanup = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
try {
|
||||
console.log('[TEST] Cleaning up WebSocket connection');
|
||||
ws.close();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('[TEST] Error during cleanup:', error);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('[TEST] WebSocket test timed out');
|
||||
cleanup();
|
||||
reject(new Error('WebSocket test timed out after 5 seconds'));
|
||||
}, 5000);
|
||||
|
||||
// Connection establishment events
|
||||
ws.on('upgrade', (response) => {
|
||||
console.log('[TEST] WebSocket upgrade response received:', {
|
||||
headers: response.headers,
|
||||
statusCode: response.statusCode,
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('[TEST] WebSocket connection opened');
|
||||
try {
|
||||
console.log('[TEST] Sending test message');
|
||||
ws.send('Hello WebSocket');
|
||||
} catch (error) {
|
||||
console.error('[TEST] Error sending message:', error);
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (message) => {
|
||||
console.log('[TEST] Received message:', message.toString());
|
||||
if (
|
||||
message.toString() === 'Hello WebSocket' ||
|
||||
message.toString() === 'Echo: Hello WebSocket'
|
||||
) {
|
||||
console.log('[TEST] Message received correctly');
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[TEST] WebSocket error:', error);
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log('[TEST] WebSocket connection closed:', {
|
||||
code,
|
||||
reason: reason.toString(),
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should handle custom headers', async () => {
|
||||
await testProxy.addDefaultHeaders({
|
||||
'X-Proxy-Header': 'test-value',
|
||||
});
|
||||
|
||||
const response = await makeHttpsRequest({
|
||||
hostname: 'localhost', // changed to 'localhost'
|
||||
port: 3001,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'push.rocks', // still routing to push.rocks
|
||||
},
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
expect(response.headers['x-proxy-header']).toEqual('test-value');
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
console.log('[TEST] Starting cleanup');
|
||||
|
||||
// Clean up all servers
|
||||
console.log('[TEST] Terminating WebSocket clients');
|
||||
wsServer.clients.forEach((client) => {
|
||||
client.terminate();
|
||||
});
|
||||
|
||||
console.log('[TEST] Closing WebSocket server');
|
||||
await new Promise<void>((resolve) =>
|
||||
wsServer.close(() => {
|
||||
console.log('[TEST] WebSocket server closed');
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
|
||||
console.log('[TEST] Closing test server');
|
||||
await new Promise<void>((resolve) =>
|
||||
testServer.close(() => {
|
||||
console.log('[TEST] Test server closed');
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
|
||||
console.log('[TEST] Stopping proxy');
|
||||
await testProxy.stop();
|
||||
console.log('[TEST] Cleanup complete');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
process.on('exit', () => {
|
||||
console.log('[TEST] Shutting down test server');
|
||||
testServer.close(() => console.log('[TEST] Test server shut down'));
|
||||
wsServer.close(() => console.log('[TEST] WebSocket server shut down'));
|
||||
testProxy.stop().then(() => console.log('[TEST] Proxy server stopped'));
|
||||
});
|
||||
|
||||
tap.start();
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '3.1.0',
|
||||
version: '3.10.1',
|
||||
description: 'a proxy for handling high workloads of proxying'
|
||||
}
|
||||
|
@ -2,9 +2,10 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
import * as url from 'url';
|
||||
|
||||
export { http, https, net, url };
|
||||
export { http, https, net, tls, url };
|
||||
|
||||
// tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
@ -21,7 +22,9 @@ import * as smartstring from '@push.rocks/smartstring';
|
||||
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
||||
|
||||
// third party scope
|
||||
import prettyMs from 'pretty-ms';
|
||||
import * as ws from 'ws';
|
||||
import wsDefault from 'ws';
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
export { wsDefault, ws };
|
||||
export { prettyMs, ws, wsDefault, minimatch };
|
@ -1,16 +1,18 @@
|
||||
import * as plugins from './smartproxy.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { ProxyRouter } from './smartproxy.classes.router.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export interface INetworkProxyOptions {
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface WebSocketWithHeartbeat extends plugins.wsDefault {
|
||||
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
export class NetworkProxy {
|
||||
// INSTANCE
|
||||
public options: INetworkProxyOptions;
|
||||
public proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
||||
public httpsServer: plugins.https.Server;
|
||||
@ -18,6 +20,7 @@ export class NetworkProxy {
|
||||
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
||||
public defaultHeaders: { [key: string]: string } = {};
|
||||
public heartbeatInterval: NodeJS.Timeout;
|
||||
private defaultCertificates: { key: string; cert: string };
|
||||
|
||||
public alreadyAddedReverseConfigs: {
|
||||
[hostName: string]: plugins.tsclass.network.IReverseProxyConfig;
|
||||
@ -25,236 +28,38 @@ export class NetworkProxy {
|
||||
|
||||
constructor(optionsArg: INetworkProxyOptions) {
|
||||
this.options = optionsArg;
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const certPath = path.join(__dirname, '..', 'assets', 'certs');
|
||||
|
||||
try {
|
||||
this.defaultCertificates = {
|
||||
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
||||
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading certificates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* starts the proxyInstance
|
||||
*/
|
||||
public async start() {
|
||||
// Instead of marking the callback async (which Node won't await),
|
||||
// we call our async handler and catch errors.
|
||||
this.httpsServer = plugins.https.createServer(
|
||||
// ================
|
||||
// Spotted this keypair in the code?
|
||||
// Don't get exited:
|
||||
// It is an invalid default keypair.
|
||||
// For proper requests custom domain level keypairs are used that are provided in the reverse config
|
||||
// ================
|
||||
{
|
||||
key: `-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
2TkK01XDDJURK5Lhuvc7WrK2rSJ/fK+0GA==
|
||||
-----END PRIVATE KEY-----
|
||||
`,
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIEljCCAn4CCQDY+ZbC9FASVjANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJE
|
||||
RTAeFw0xOTA5MjAxNjAxNDRaFw0yMDA5MTkxNjAxNDRaMA0xCzAJBgNVBAYTAkRF
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4thf9JEK/epoXt8hFr8t
|
||||
pkRzmaEkgbSKoOga3uGXDLvdNf3BzSIxZ8pzRhZfUnutcmW1thdz3wre/pEJR7oN
|
||||
QsfixbLL8/oS5QeXKiUGX0Ssfdg4W0TsoLcRva+1AZsf38MfiUPhzh1/UW/rMywW
|
||||
asazQwRZdkkXb4nKJ2IFZx22qnAD4/5Sug+sfeKoFBF/rzI2yK7rognt7kW2LHv6
|
||||
rswHnZ1Z2P/gbhlZ/EhG9hFVRZwRLDscWKcuWcxkePDt2J1pDNqD6SYa6ZjGC3AE
|
||||
TJw5iEA1bLQ9YvjDNpVYcf6ZvcSilIFjSQu5cs9sUbHGeKTrS5HzfeJXh1PfJyL8
|
||||
X0Hu7UBSjfSudso3baE9FGiBFBW2cnXZKDZGtV8eq/qxPetOOgS09pVbNP6508WV
|
||||
BR+rz98/VDZLZqcbZ2UpOuz4+kAKmbYE9GplxKQZZO7wWEox7Mid/uUdcqEo4QKn
|
||||
no6ujOuzQzn5a2oOS0k5Hk3uHapNJWlW9YI3LHtfADpYH+6cOR+/c3JWBzQJ6AD7
|
||||
muvNzA9mWXeHqLxMMP4pkmb7otzZYrEkodUqJgAQxcYhGh6XsCPfJ/D9RN734OJc
|
||||
gleVXFI8Kz455HxCW19XNfz16k7T6kqhZ/6SOBbkxEuqg7oEthAP109ZZzgx4oDo
|
||||
hQsw24TjLkI4SPIc7nr60UUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAu0+zrg0C
|
||||
mlSv4Yi24OwB7TBvx+WHesl1IilCUdTiiUMo3NumvsU9Dr3Jkd0jGqYI0eyH4gIt
|
||||
KrhAveXfEw7tAOEHiYicmAdIFtyzh++ZWb8mgbBeqij1MP/76Jv+cc0lUqpfRo/A
|
||||
qytAsPAILuyL1o1jh28JHcq+v+WYn/FEhjUlH6emhGKGlsAjhUPjzK8MEshNolhj
|
||||
t2UXw9WB5B2xWvrqlNMy0F3NAZBkZ/+k21HZo6FmVi+q6OEGcOo7wJt6wrH/lko9
|
||||
LxX96GC1JoN1Pfr2FoTKy1WHzrSfyGmDIUCrbaYQ58UuMOR+5eIPPdkf/030u5eX
|
||||
xXhF2fBujD57E2zQGh/l2MrOjamcSo0+wYhOqlX3WNdaKNAzPqloBnF6w7eqLYde
|
||||
h9He39ySmxjENwv3miOjEP1sBeMBSRfL/ckEonfK5uJgYA5nVMQ3ojUeDMZzLfFE
|
||||
Ue2WHt+uPyYk7mMZfOrK2uHzI2/Coqj7lbfRodFwj+fCArYBck2NZannDPKA6X8V
|
||||
TzJTbTCteOUUJTrcfZ0gGhGkF4nYLmX5OI+TPqrDJf0fZ+mzAEHzDDVXcBYpYRDr
|
||||
r8d9QwrK+WaqVi2ofbMfMByVF72jgeJNa4nxwT9bVbu/Q1T2Lt+YPb4pQ7yCoUgS
|
||||
JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
key: this.defaultCertificates.key,
|
||||
cert: this.defaultCertificates.cert
|
||||
},
|
||||
async (originRequest, originResponse) => {
|
||||
/**
|
||||
* endRequest function
|
||||
* can be used to prematurely end a request
|
||||
*/
|
||||
const endOriginReqRes = (
|
||||
statusArg: number = 404,
|
||||
messageArg: string = 'This route is not available on this server.',
|
||||
headers: plugins.http.OutgoingHttpHeaders = {}
|
||||
) => {
|
||||
originResponse.writeHead(statusArg, messageArg);
|
||||
originResponse.end(messageArg);
|
||||
if (originRequest.socket !== originResponse.socket) {
|
||||
console.log('hey, something is strange.');
|
||||
}
|
||||
originResponse.destroy();
|
||||
};
|
||||
|
||||
console.log(
|
||||
`got request: ${originRequest.headers.host}${plugins.url.parse(originRequest.url).path}`
|
||||
);
|
||||
const destinationConfig = this.router.routeReq(originRequest);
|
||||
|
||||
if (!destinationConfig) {
|
||||
console.log(
|
||||
`${originRequest.headers.host} can't be routed properly. Terminating request.`
|
||||
);
|
||||
endOriginReqRes();
|
||||
return;
|
||||
}
|
||||
|
||||
// authentication
|
||||
if (destinationConfig.authentication) {
|
||||
const authInfo = destinationConfig.authentication;
|
||||
switch (authInfo.type) {
|
||||
case 'Basic':
|
||||
const authHeader = originRequest.headers.authorization;
|
||||
if (authHeader) {
|
||||
if (!authHeader.includes('Basic ')) {
|
||||
return endOriginReqRes(401, 'Authentication required', {
|
||||
'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
|
||||
});
|
||||
}
|
||||
const authStringBase64 = originRequest.headers.authorization.replace('Basic ', '');
|
||||
const authString: string = plugins.smartstring.base64.decode(authStringBase64);
|
||||
const userPassArray = authString.split(':');
|
||||
const user = userPassArray[0];
|
||||
const pass = userPassArray[1];
|
||||
if (user === authInfo.user && pass === authInfo.pass) {
|
||||
console.log('request successfully authenticated');
|
||||
} else {
|
||||
return endOriginReqRes(403, 'Forbidden: Wrong credentials');
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return endOriginReqRes(
|
||||
403,
|
||||
'Forbidden: unsupported authentication method configured. Please report to the admin.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let destinationUrl: string;
|
||||
if (destinationConfig) {
|
||||
destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
||||
} else {
|
||||
return endOriginReqRes();
|
||||
}
|
||||
console.log(destinationUrl);
|
||||
try {
|
||||
const proxyResponse = await plugins.smartrequest.request(
|
||||
destinationUrl,
|
||||
{
|
||||
method: originRequest.method,
|
||||
headers: {
|
||||
...originRequest.headers,
|
||||
'X-Forwarded-Host': originRequest.headers.host,
|
||||
'X-Forwarded-Proto': 'https',
|
||||
},
|
||||
keepAlive: true,
|
||||
},
|
||||
true, // lets make this streaming (keepAlive)
|
||||
(proxyRequest) => {
|
||||
originRequest.on('data', (data) => {
|
||||
proxyRequest.write(data);
|
||||
});
|
||||
originRequest.on('end', () => {
|
||||
proxyRequest.end();
|
||||
});
|
||||
originRequest.on('error', () => {
|
||||
proxyRequest.end();
|
||||
});
|
||||
originRequest.on('close', () => {
|
||||
proxyRequest.end();
|
||||
});
|
||||
originRequest.on('timeout', () => {
|
||||
proxyRequest.end();
|
||||
originRequest.destroy();
|
||||
});
|
||||
proxyRequest.on('error', () => {
|
||||
endOriginReqRes();
|
||||
});
|
||||
}
|
||||
);
|
||||
originResponse.statusCode = proxyResponse.statusCode;
|
||||
console.log(proxyResponse.statusCode);
|
||||
for (const defaultHeader of Object.keys(this.defaultHeaders)) {
|
||||
originResponse.setHeader(defaultHeader, this.defaultHeaders[defaultHeader]);
|
||||
}
|
||||
for (const header of Object.keys(proxyResponse.headers)) {
|
||||
originResponse.setHeader(header, proxyResponse.headers[header]);
|
||||
}
|
||||
proxyResponse.on('data', (data) => {
|
||||
originResponse.write(data);
|
||||
});
|
||||
proxyResponse.on('end', () => {
|
||||
(originRequest, originResponse) => {
|
||||
this.handleRequest(originRequest, originResponse).catch((error) => {
|
||||
console.error('Unhandled error in request handler:', error);
|
||||
try {
|
||||
originResponse.end();
|
||||
});
|
||||
proxyResponse.on('error', () => {
|
||||
originResponse.destroy();
|
||||
});
|
||||
proxyResponse.on('close', () => {
|
||||
originResponse.end();
|
||||
});
|
||||
proxyResponse.on('timeout', () => {
|
||||
originResponse.end();
|
||||
originResponse.destroy();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error while processing request:', error);
|
||||
endOriginReqRes(502, 'Bad Gateway: Error processing the request');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore errors during cleanup
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Enable websockets
|
||||
@ -263,7 +68,7 @@ JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
// Set up the heartbeat interval
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
wsServer.clients.forEach((ws: plugins.wsDefault) => {
|
||||
const wsIncoming = ws as WebSocketWithHeartbeat;
|
||||
const wsIncoming = ws as IWebSocketWithHeartbeat;
|
||||
if (!wsIncoming.lastPong) {
|
||||
wsIncoming.lastPong = Date.now();
|
||||
}
|
||||
@ -278,9 +83,9 @@ JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
|
||||
wsServer.on(
|
||||
'connection',
|
||||
async (wsIncoming: WebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
|
||||
(wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
|
||||
console.log(
|
||||
`wss proxy: got connection for wsc for https://${reqArg.headers.host}${reqArg.url}`
|
||||
`wss proxy: got connection for wsc for https://${reqArg.headers.host}${reqArg.url}`,
|
||||
);
|
||||
|
||||
wsIncoming.lastPong = Date.now();
|
||||
@ -289,21 +94,24 @@ JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
});
|
||||
|
||||
let wsOutgoing: plugins.wsDefault;
|
||||
|
||||
const outGoingDeferred = plugins.smartpromise.defer();
|
||||
|
||||
// --- Improvement 2: Only call routeReq once ---
|
||||
const wsDestinationConfig = this.router.routeReq(reqArg);
|
||||
if (!wsDestinationConfig) {
|
||||
wsIncoming.terminate();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wsOutgoing = new plugins.wsDefault(
|
||||
`ws://${this.router.routeReq(reqArg).destinationIp}:${
|
||||
this.router.routeReq(reqArg).destinationPort
|
||||
}${reqArg.url}`
|
||||
`ws://${wsDestinationConfig.destinationIp}:${wsDestinationConfig.destinationPort}${reqArg.url}`,
|
||||
);
|
||||
console.log('wss proxy: initiated outgoing proxy');
|
||||
wsOutgoing.on('open', async () => {
|
||||
outGoingDeferred.resolve();
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
console.error('Error initiating outgoing WebSocket:', err);
|
||||
wsIncoming.terminate();
|
||||
return;
|
||||
}
|
||||
@ -328,21 +136,21 @@ JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
const terminateWsOutgoing = () => {
|
||||
if (wsOutgoing) {
|
||||
wsOutgoing.terminate();
|
||||
console.log('terminated outgoing ws.');
|
||||
console.log('Terminated outgoing ws.');
|
||||
}
|
||||
};
|
||||
wsIncoming.on('error', () => terminateWsOutgoing());
|
||||
wsIncoming.on('close', () => terminateWsOutgoing());
|
||||
wsIncoming.on('error', terminateWsOutgoing);
|
||||
wsIncoming.on('close', terminateWsOutgoing);
|
||||
|
||||
const terminateWsIncoming = () => {
|
||||
if (wsIncoming) {
|
||||
wsIncoming.terminate();
|
||||
console.log('terminated incoming ws.');
|
||||
console.log('Terminated incoming ws.');
|
||||
}
|
||||
};
|
||||
wsOutgoing.on('error', () => terminateWsIncoming());
|
||||
wsOutgoing.on('close', () => terminateWsIncoming());
|
||||
}
|
||||
wsOutgoing.on('error', terminateWsIncoming);
|
||||
wsOutgoing.on('close', terminateWsIncoming);
|
||||
},
|
||||
);
|
||||
|
||||
this.httpsServer.keepAliveTimeout = 600 * 1000;
|
||||
@ -350,35 +158,170 @@ JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
|
||||
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
||||
this.socketMap.add(connection);
|
||||
console.log(`added connection. now ${this.socketMap.getArray().length} sockets connected.`);
|
||||
console.log(`Added connection. Now ${this.socketMap.getArray().length} sockets connected.`);
|
||||
const cleanupConnection = () => {
|
||||
if (this.socketMap.checkForObject(connection)) {
|
||||
this.socketMap.remove(connection);
|
||||
console.log(`removed connection. ${this.socketMap.getArray().length} sockets remaining.`);
|
||||
console.log(`Removed connection. ${this.socketMap.getArray().length} sockets remaining.`);
|
||||
connection.destroy();
|
||||
}
|
||||
};
|
||||
connection.on('close', () => {
|
||||
cleanupConnection();
|
||||
});
|
||||
connection.on('error', () => {
|
||||
cleanupConnection();
|
||||
});
|
||||
connection.on('end', () => {
|
||||
cleanupConnection();
|
||||
});
|
||||
connection.on('timeout', () => {
|
||||
cleanupConnection();
|
||||
});
|
||||
connection.on('close', cleanupConnection);
|
||||
connection.on('error', cleanupConnection);
|
||||
connection.on('end', cleanupConnection);
|
||||
connection.on('timeout', cleanupConnection);
|
||||
});
|
||||
|
||||
this.httpsServer.listen(this.options.port);
|
||||
console.log(
|
||||
`NetworkProxy -> OK: now listening for new connections on port ${this.options.port}`
|
||||
`NetworkProxy -> OK: now listening for new connections on port ${this.options.port}`,
|
||||
);
|
||||
}
|
||||
|
||||
public async updateProxyConfigs(proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[]) {
|
||||
/**
|
||||
* Internal async handler for processing HTTP/HTTPS requests.
|
||||
*/
|
||||
private async handleRequest(
|
||||
originRequest: plugins.http.IncomingMessage,
|
||||
originResponse: plugins.http.ServerResponse,
|
||||
): Promise<void> {
|
||||
const endOriginReqRes = (
|
||||
statusArg: number = 404,
|
||||
messageArg: string = 'This route is not available on this server.',
|
||||
headers: plugins.http.OutgoingHttpHeaders = {},
|
||||
) => {
|
||||
originResponse.writeHead(statusArg, messageArg);
|
||||
originResponse.end(messageArg);
|
||||
if (originRequest.socket !== originResponse.socket) {
|
||||
console.log('hey, something is strange.');
|
||||
}
|
||||
originResponse.destroy();
|
||||
};
|
||||
|
||||
console.log(
|
||||
`got request: ${originRequest.headers.host}${plugins.url.parse(originRequest.url).path}`,
|
||||
);
|
||||
const destinationConfig = this.router.routeReq(originRequest);
|
||||
|
||||
if (!destinationConfig) {
|
||||
console.log(
|
||||
`${originRequest.headers.host} can't be routed properly. Terminating request.`,
|
||||
);
|
||||
endOriginReqRes();
|
||||
return;
|
||||
}
|
||||
|
||||
// authentication
|
||||
if (destinationConfig.authentication) {
|
||||
const authInfo = destinationConfig.authentication;
|
||||
switch (authInfo.type) {
|
||||
case 'Basic': {
|
||||
const authHeader = originRequest.headers.authorization;
|
||||
if (!authHeader) {
|
||||
return endOriginReqRes(401, 'Authentication required', {
|
||||
'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
|
||||
});
|
||||
}
|
||||
if (!authHeader.includes('Basic ')) {
|
||||
return endOriginReqRes(401, 'Authentication required', {
|
||||
'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
|
||||
});
|
||||
}
|
||||
const authStringBase64 = authHeader.replace('Basic ', '');
|
||||
const authString: string = plugins.smartstring.base64.decode(authStringBase64);
|
||||
const userPassArray = authString.split(':');
|
||||
const user = userPassArray[0];
|
||||
const pass = userPassArray[1];
|
||||
if (user === authInfo.user && pass === authInfo.pass) {
|
||||
console.log('Request successfully authenticated');
|
||||
} else {
|
||||
return endOriginReqRes(403, 'Forbidden: Wrong credentials');
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return endOriginReqRes(
|
||||
403,
|
||||
'Forbidden: unsupported authentication method configured. Please report to the admin.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let destinationUrl: string;
|
||||
if (destinationConfig) {
|
||||
destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
||||
} else {
|
||||
return endOriginReqRes();
|
||||
}
|
||||
console.log(destinationUrl);
|
||||
try {
|
||||
const proxyResponse = await plugins.smartrequest.request(
|
||||
destinationUrl,
|
||||
{
|
||||
method: originRequest.method,
|
||||
headers: {
|
||||
...originRequest.headers,
|
||||
'X-Forwarded-Host': originRequest.headers.host,
|
||||
'X-Forwarded-Proto': 'https',
|
||||
},
|
||||
keepAlive: true,
|
||||
},
|
||||
true, // streaming (keepAlive)
|
||||
(proxyRequest) => {
|
||||
originRequest.on('data', (data) => {
|
||||
proxyRequest.write(data);
|
||||
});
|
||||
originRequest.on('end', () => {
|
||||
proxyRequest.end();
|
||||
});
|
||||
originRequest.on('error', () => {
|
||||
proxyRequest.end();
|
||||
});
|
||||
originRequest.on('close', () => {
|
||||
proxyRequest.end();
|
||||
});
|
||||
originRequest.on('timeout', () => {
|
||||
proxyRequest.end();
|
||||
originRequest.destroy();
|
||||
});
|
||||
proxyRequest.on('error', () => {
|
||||
endOriginReqRes();
|
||||
});
|
||||
},
|
||||
);
|
||||
originResponse.statusCode = proxyResponse.statusCode;
|
||||
console.log(proxyResponse.statusCode);
|
||||
for (const defaultHeader of Object.keys(this.defaultHeaders)) {
|
||||
originResponse.setHeader(defaultHeader, this.defaultHeaders[defaultHeader]);
|
||||
}
|
||||
for (const header of Object.keys(proxyResponse.headers)) {
|
||||
originResponse.setHeader(header, proxyResponse.headers[header]);
|
||||
}
|
||||
proxyResponse.on('data', (data) => {
|
||||
originResponse.write(data);
|
||||
});
|
||||
proxyResponse.on('end', () => {
|
||||
originResponse.end();
|
||||
});
|
||||
proxyResponse.on('error', () => {
|
||||
originResponse.destroy();
|
||||
});
|
||||
proxyResponse.on('close', () => {
|
||||
originResponse.end();
|
||||
});
|
||||
proxyResponse.on('timeout', () => {
|
||||
originResponse.end();
|
||||
originResponse.destroy();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error while processing request:', error);
|
||||
endOriginReqRes(502, 'Bad Gateway: Error processing the request');
|
||||
}
|
||||
}
|
||||
|
||||
public async updateProxyConfigs(
|
||||
proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[],
|
||||
) {
|
||||
console.log(`got new proxy configs`);
|
||||
this.proxyConfigs = proxyConfigsArg;
|
||||
this.router.setNewProxyConfigs(proxyConfigsArg);
|
||||
@ -416,11 +359,11 @@ JNj2Dr5H0XoLFFnvuvzcRbhlJ9J67JzR+7g=
|
||||
this.httpsServer.close(() => {
|
||||
done.resolve();
|
||||
});
|
||||
await this.socketMap.forEach(async (socket) => {
|
||||
for (const socket of this.socketMap.getArray()) {
|
||||
socket.destroy();
|
||||
});
|
||||
}
|
||||
await done.promise;
|
||||
clearInterval(this.heartbeatInterval);
|
||||
console.log('NetworkProxy -> OK: Server has been stopped and all connections closed.');
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import * as plugins from './smartproxy.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export class ProxyRouter {
|
||||
public reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
||||
@ -16,9 +16,18 @@ export class ProxyRouter {
|
||||
*/
|
||||
public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig {
|
||||
const originalHost = req.headers.host;
|
||||
if (!originalHost) {
|
||||
console.error('No host header found in request');
|
||||
return undefined;
|
||||
}
|
||||
// Strip port from host if present
|
||||
const hostWithoutPort = originalHost.split(':')[0];
|
||||
const correspodingReverseProxyConfig = this.reverseProxyConfigs.find((reverseConfig) => {
|
||||
return reverseConfig.hostName === originalHost;
|
||||
return reverseConfig.hostName === hostWithoutPort;
|
||||
});
|
||||
if (!correspodingReverseProxyConfig) {
|
||||
console.error(`No config found for host: ${hostWithoutPort}`);
|
||||
}
|
||||
return correspodingReverseProxyConfig;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as plugins from './smartproxy.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export class SslRedirect {
|
||||
httpServer: plugins.http.Server;
|
||||
|
30
ts/smartproxy.helpers.certificates.ts
Normal file
30
ts/smartproxy.helpers.certificates.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface ICertificates {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export function loadDefaultCertificates(): ICertificates {
|
||||
try {
|
||||
const certPath = path.join(__dirname, '..', 'assets', 'certs');
|
||||
const privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8');
|
||||
const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8');
|
||||
|
||||
if (!privateKey || !publicKey) {
|
||||
throw new Error('Failed to load default certificates');
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading default certificates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
@ -1,63 +1,411 @@
|
||||
import * as plugins from './smartproxy.plugins.js';
|
||||
import * as net from 'net';
|
||||
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;
|
||||
fromPort: number;
|
||||
toPort: number;
|
||||
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(fromPortArg: number, toPortArg: number) {
|
||||
this.fromPort = fromPortArg;
|
||||
this.toPort = toPortArg;
|
||||
// Overall termination statistics
|
||||
private terminationStats: {
|
||||
incoming: Record<string, number>;
|
||||
outgoing: Record<string, number>;
|
||||
} = {
|
||||
incoming: {},
|
||||
outgoing: {},
|
||||
};
|
||||
|
||||
constructor(settings: IProxySettings) {
|
||||
this.settings = {
|
||||
...settings,
|
||||
toHost: settings.toHost || 'localhost'
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to update termination stats.
|
||||
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
||||
if (!this.terminationStats[side][reason]) {
|
||||
this.terminationStats[side][reason] = 1;
|
||||
} else {
|
||||
this.terminationStats[side][reason]++;
|
||||
}
|
||||
}
|
||||
|
||||
public async start() {
|
||||
const cleanUpSockets = (from: plugins.net.Socket, to: plugins.net.Socket) => {
|
||||
from.end();
|
||||
to.end();
|
||||
from.removeAllListeners();
|
||||
to.removeAllListeners();
|
||||
from.unpipe();
|
||||
to.unpipe();
|
||||
from.destroy();
|
||||
to.destroy();
|
||||
// Adjusted cleanUpSockets: forcefully destroy both sockets if they haven't been destroyed.
|
||||
const cleanUpSockets = (from: plugins.net.Socket, to?: plugins.net.Socket) => {
|
||||
if (!from.destroyed) {
|
||||
from.destroy();
|
||||
}
|
||||
if (to && !to.destroyed) {
|
||||
to.destroy();
|
||||
}
|
||||
};
|
||||
this.netServer = net
|
||||
.createServer((from) => {
|
||||
const to = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: this.toPort,
|
||||
|
||||
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;
|
||||
|
||||
// Local termination reason trackers for each side.
|
||||
let incomingTermReason: string | null = null;
|
||||
let outgoingTermReason: string | null = null;
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Outgoing connection placeholder.
|
||||
let to: plugins.net.Socket | null = null;
|
||||
|
||||
// Handle errors by recording termination reason and cleaning up.
|
||||
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' && incomingTermReason === null) {
|
||||
incomingTermReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
} else if (side === 'outgoing' && outgoingTermReason === null) {
|
||||
outgoingTermReason = reason;
|
||||
this.incrementTerminationStat('outgoing', reason);
|
||||
}
|
||||
cleanupOnce();
|
||||
};
|
||||
|
||||
// Handle close events. If no termination reason was recorded, mark as "normal".
|
||||
const handleClose = (side: 'incoming' | 'outgoing') => () => {
|
||||
console.log(`Connection closed on ${side} side from ${remoteIP}`);
|
||||
if (side === 'incoming' && incomingTermReason === null) {
|
||||
incomingTermReason = 'normal';
|
||||
this.incrementTerminationStat('incoming', 'normal');
|
||||
} else if (side === 'outgoing' && outgoingTermReason === null) {
|
||||
outgoingTermReason = 'normal';
|
||||
this.incrementTerminationStat('outgoing', 'normal');
|
||||
}
|
||||
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();
|
||||
if (incomingTermReason === null) {
|
||||
incomingTermReason = 'rejected';
|
||||
this.incrementTerminationStat('incoming', 'rejected');
|
||||
}
|
||||
cleanupOnce();
|
||||
return;
|
||||
}
|
||||
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
|
||||
console.log(`Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`);
|
||||
socket.end();
|
||||
if (incomingTermReason === null) {
|
||||
incomingTermReason = 'rejected';
|
||||
this.incrementTerminationStat('incoming', 'rejected');
|
||||
}
|
||||
cleanupOnce();
|
||||
return;
|
||||
}
|
||||
} else if (!isDefaultAllowed && !serverName) {
|
||||
console.log(`Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`);
|
||||
socket.end();
|
||||
if (incomingTermReason === null) {
|
||||
incomingTermReason = 'rejected';
|
||||
this.incrementTerminationStat('incoming', 'rejected');
|
||||
}
|
||||
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);
|
||||
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);
|
||||
socket.pipe(to!);
|
||||
to!.pipe(socket);
|
||||
|
||||
// Attach event 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}`);
|
||||
if (incomingTermReason === null) {
|
||||
incomingTermReason = 'timeout';
|
||||
this.incrementTerminationStat('incoming', 'timeout');
|
||||
}
|
||||
cleanupOnce();
|
||||
});
|
||||
from.setTimeout(120000);
|
||||
from.pipe(to);
|
||||
to.pipe(from);
|
||||
from.on('error', () => {
|
||||
cleanUpSockets(from, to);
|
||||
to!.on('timeout', () => {
|
||||
console.log(`Timeout on outgoing side from ${remoteIP}`);
|
||||
if (outgoingTermReason === null) {
|
||||
outgoingTermReason = 'timeout';
|
||||
this.incrementTerminationStat('outgoing', 'timeout');
|
||||
}
|
||||
cleanupOnce();
|
||||
});
|
||||
to.on('error', () => {
|
||||
cleanUpSockets(from, to);
|
||||
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;
|
||||
const serverName = extractSNI(chunk) || '';
|
||||
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
|
||||
setupConnection(serverName, chunk);
|
||||
});
|
||||
from.on('close', () => {
|
||||
cleanUpSockets(from, to);
|
||||
});
|
||||
to.on('close', () => {
|
||||
cleanUpSockets(from, to);
|
||||
});
|
||||
from.on('timeout', () => {
|
||||
cleanUpSockets(from, to);
|
||||
});
|
||||
to.on('timeout', () => {
|
||||
cleanUpSockets(from, to);
|
||||
});
|
||||
from.on('end', () => {
|
||||
cleanUpSockets(from, to);
|
||||
});
|
||||
to.on('end', () => {
|
||||
cleanUpSockets(from, to);
|
||||
});
|
||||
})
|
||||
.listen(this.fromPort);
|
||||
console.log(`PortProxy -> OK: Now listening on port ${this.fromPort}`);
|
||||
} 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();
|
||||
if (incomingTermReason === null) {
|
||||
incomingTermReason = 'rejected';
|
||||
this.incrementTerminationStat('incoming', 'rejected');
|
||||
}
|
||||
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, longest running connection durations,
|
||||
// and termination statistics 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)}. Termination stats (incoming): ${JSON.stringify(this.terminationStats.incoming)}, (outgoing): ${JSON.stringify(this.terminationStats.outgoing)}`);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
@ -65,6 +413,10 @@ export class PortProxy {
|
||||
this.netServer.close(() => {
|
||||
done.resolve();
|
||||
});
|
||||
if (this.connectionLogger) {
|
||||
clearInterval(this.connectionLogger);
|
||||
this.connectionLogger = null;
|
||||
}
|
||||
await done.promise;
|
||||
}
|
||||
}
|
||||
}
|
@ -6,9 +6,11 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user