Compare commits
301 Commits
Author | SHA1 | Date | |
---|---|---|---|
6b2765a429 | |||
9b5b8225bc | |||
54e81b3c32 | |||
b7b47cd11f | |||
62061517fd | |||
531350a1c1 | |||
559a52af41 | |||
f8c86c76ae | |||
cc04e8786c | |||
9cb6e397b9 | |||
11b65bf684 | |||
4b30e377b9 | |||
b10f35be4b | |||
426249e70e | |||
ba0d9d0b8e | |||
151b8f498c | |||
0db4b07b22 | |||
b55e2da23e | |||
3593e411cf | |||
ca6f6de798 | |||
80d2f30804 | |||
22f46700f1 | |||
1611f65455 | |||
c6350e271a | |||
0fb5e5ea50 | |||
35f6739b3c | |||
4634c68ea6 | |||
e126032b61 | |||
7797c799dd | |||
e8639e1b01 | |||
60a0ad106d | |||
a70c123007 | |||
46aa7620b0 | |||
f72db86e37 | |||
d612df107e | |||
1c34578c36 | |||
1f9943b5a7 | |||
67ddf97547 | |||
8a96b45ece | |||
2b6464acd5 | |||
efbb4335d7 | |||
9dd402054d | |||
6c1efc1dc0 | |||
cad0e6a2b2 | |||
794e1292e5 | |||
ee79f9ab7c | |||
107bc3b50b | |||
97982976c8 | |||
fe60f88746 | |||
252a987344 | |||
677d30563f | |||
9aa747b5d4 | |||
1de9491e1d | |||
e2ee673197 | |||
985031e9ac | |||
4c0105ad09 | |||
06896b3102 | |||
7fe455b4df | |||
21801aa53d | |||
ddfbcdb1f3 | |||
b401d126bc | |||
baaee0ad4d | |||
fe7c4c2f5e | |||
ab1ec84832 | |||
156abbf5b4 | |||
1a90566622 | |||
b48b90d613 | |||
124f8d48b7 | |||
b2a57ada5d | |||
62a3e1f4b7 | |||
3a1485213a | |||
9dbf6fdeb5 | |||
9496dd5336 | |||
29d28fba93 | |||
8196de4fa3 | |||
6fddafe9fd | |||
1e89062167 | |||
21a24fd95b | |||
03ef5e7f6e | |||
415b82a84a | |||
f304cc67b4 | |||
0e12706176 | |||
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 | |||
98b7f3ed7f | |||
cb83caeafd | |||
7850a80452 | |||
ef8f583a90 | |||
2bdd6f8c1f | |||
99d28eafd1 | |||
788b444fcc | |||
4225abe3c4 | |||
74fdb58f84 | |||
bffdaffe39 | |||
67a4228518 | |||
681209f2e1 | |||
c415a6c361 | |||
009e3c4f0e | |||
f9c42975dc | |||
feef949afe | |||
8d3b07b1e6 | |||
51fe935f1f | |||
146fac73cf | |||
4465cac807 | |||
9d7ed21cba | |||
54fbe5beac | |||
0704853fa2 | |||
8cf22ee38b | |||
f28e68e487 | |||
499aed19f6 | |||
618b6fe2d1 | |||
d6027c11c1 | |||
bbdea52677 | |||
d8585975a8 | |||
98c61cccbb | |||
b3dcc0ae22 | |||
b96d7dec98 | |||
0d0a1c740b | |||
9bd87b8437 | |||
0e281b3243 | |||
a14b7802c4 | |||
138900ca8b | |||
cb6c2503e2 | |||
f3fd903231 | |||
0e605d9a9d | |||
1718a3b2f2 | |||
568f77e65b | |||
e212dacbf3 | |||
eea8942670 | |||
0574331b91 | |||
06e6c2eb52 | |||
edd9db31c2 | |||
d4251b2cf9 | |||
4ccc1db8a2 | |||
7e3ed93bc9 | |||
fa793f2c4a | |||
fe8106f0c8 | |||
b317ab8b3a | |||
4fd5524a0f | |||
2013d03ac6 | |||
0e888c5add | |||
7f891a304c | |||
f6cc665f12 | |||
48c5ea3b1d | |||
bd9292bf47 | |||
6532e6f0e0 | |||
8791da83b4 | |||
9ad08edf79 | |||
c0de8c59a2 | |||
3748689c16 | |||
d0b3139fda | |||
fd4f731ada | |||
ced9b5b27b | |||
eb70a86304 | |||
131d9d326e | |||
12de96a7d5 | |||
296e1fcdc7 | |||
8459e4013c | |||
191c8ac0e6 | |||
3ab483d164 | |||
fcd80dc56b | |||
8ddffcd6e5 | |||
a5a7781c17 | |||
d647e77cdf | |||
9161336197 | |||
2e63d13dd4 | |||
af6ed735d5 | |||
7d38f29ef3 | |||
0df26d4367 | |||
f9a6e2d748 | |||
1cb6302750 | |||
f336f25535 | |||
5d6b707440 | |||
622ad2ff20 | |||
dd23efd28d | |||
0ddf68a919 | |||
ec08ca51f5 | |||
29688d1379 | |||
c83f6fa278 | |||
60333b0a59 | |||
1aa409907b | |||
adee6afc76 | |||
4a0792142f | |||
f1b810a4fa | |||
96b5877c5f | |||
6d627f67f7 | |||
9af968b8e7 | |||
b3ba0c21e8 | |||
ef707a5870 | |||
6ca14edb38 | |||
5a5686b6b9 | |||
2080f419cb | |||
659aae297b | |||
fcd0f61b5c | |||
7ee35a98e3 | |||
ea0f6d2270 | |||
621ad9e681 | |||
7cea5773ee | |||
a2cb56ba65 | |||
408b793149 | |||
f6c3d2d3d0 | |||
422eb5ec40 | |||
45390c4389 | |||
0f2e6d688c | |||
3bd7b70c19 | |||
07a82a09be | |||
23253a2731 | |||
be31a9b553 | |||
a1051f78e8 | |||
aa756bd698 | |||
ff4f44d6fc | |||
63ebad06ea | |||
31e15b65ec | |||
266895ccc5 | |||
dc3d56771b | |||
38601a41bb | |||
a53e6f1019 | |||
3de35f3b2c | |||
b9210d891e | |||
133d5a47e0 | |||
f2f4e47893 | |||
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:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Install pnpm and npmci
|
- name: Install pnpm and npmci
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
|
|
||||||
- name: Run npm prepare
|
- name: Run npm prepare
|
||||||
run: npmci npm prepare
|
run: npmci npm prepare
|
||||||
|
@ -6,8 +6,8 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Audit production dependencies
|
- name: Audit production dependencies
|
||||||
@ -54,7 +54,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Test stable
|
- name: Test stable
|
||||||
@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
@ -104,7 +104,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Code quality
|
- name: Code quality
|
||||||
@ -119,6 +119,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
npmci node install stable
|
||||||
npmci npm install
|
npmci npm install
|
||||||
pnpm install -g @gitzone/tsdoc
|
pnpm install -g @git.zone/tsdoc
|
||||||
npmci command tsdoc
|
npmci command tsdoc
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,7 +3,6 @@
|
|||||||
# artifacts
|
# artifacts
|
||||||
coverage/
|
coverage/
|
||||||
public/
|
public/
|
||||||
pages/
|
|
||||||
|
|
||||||
# installs
|
# installs
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -17,4 +16,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
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-----
|
1032
changelog.md
1032
changelog.md
File diff suppressed because it is too large
Load Diff
@ -5,22 +5,26 @@
|
|||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
"gitscope": "push.rocks",
|
"gitscope": "push.rocks",
|
||||||
"gitrepo": "smartproxy",
|
"gitrepo": "smartproxy",
|
||||||
"description": "a proxy for handling high workloads of proxying",
|
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
|
||||||
"npmPackagename": "@push.rocks/smartproxy",
|
"npmPackagename": "@push.rocks/smartproxy",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "push.rocks",
|
"projectDomain": "push.rocks",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"proxy",
|
"proxy",
|
||||||
"network traffic",
|
"network",
|
||||||
|
"traffic management",
|
||||||
|
"SSL",
|
||||||
|
"TLS",
|
||||||
|
"WebSocket",
|
||||||
|
"port proxying",
|
||||||
|
"dynamic routing",
|
||||||
|
"authentication",
|
||||||
|
"real-time applications",
|
||||||
"high workload",
|
"high workload",
|
||||||
"http",
|
"HTTPS",
|
||||||
"https",
|
|
||||||
"websocket",
|
|
||||||
"network routing",
|
|
||||||
"ssl redirect",
|
|
||||||
"port mapping",
|
|
||||||
"reverse proxy",
|
"reverse proxy",
|
||||||
"authentication"
|
"server",
|
||||||
|
"network security"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
66
package.json
66
package.json
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "3.1.0",
|
"version": "5.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a proxy for handling high workloads of proxying",
|
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -15,21 +15,26 @@
|
|||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gitzone/tsbuild": "^2.1.66",
|
"@git.zone/tsbuild": "^2.2.6",
|
||||||
"@gitzone/tsrun": "^1.2.44",
|
"@git.zone/tsrun": "^1.2.44",
|
||||||
"@gitzone/tstest": "^1.0.77",
|
"@git.zone/tstest": "^1.0.77",
|
||||||
"@push.rocks/tapbundle": "^5.0.12",
|
"@push.rocks/tapbundle": "^5.5.10",
|
||||||
"@types/node": "^20.4.5"
|
"@types/node": "^22.13.10",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.0.3",
|
"@push.rocks/lik": "^6.1.0",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartpromise": "^4.0.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^2.0.18",
|
"@push.rocks/smartrequest": "^2.0.23",
|
||||||
"@push.rocks/smartstring": "^4.0.7",
|
"@push.rocks/smartstring": "^4.0.15",
|
||||||
"@tsclass/tsclass": "^4.0.42",
|
"@tsclass/tsclass": "^5.0.0",
|
||||||
"@types/ws": "^8.5.5",
|
"@types/minimatch": "^5.1.2",
|
||||||
"ws": "^8.13.0"
|
"@types/ws": "^8.18.0",
|
||||||
|
"acme-client": "^5.4.0",
|
||||||
|
"minimatch": "^10.0.1",
|
||||||
|
"pretty-ms": "^9.2.0",
|
||||||
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
@ -48,20 +53,35 @@
|
|||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"proxy",
|
"proxy",
|
||||||
"network traffic",
|
"network",
|
||||||
|
"traffic management",
|
||||||
|
"SSL",
|
||||||
|
"TLS",
|
||||||
|
"WebSocket",
|
||||||
|
"port proxying",
|
||||||
|
"dynamic routing",
|
||||||
|
"authentication",
|
||||||
|
"real-time applications",
|
||||||
"high workload",
|
"high workload",
|
||||||
"http",
|
"HTTPS",
|
||||||
"https",
|
|
||||||
"websocket",
|
|
||||||
"network routing",
|
|
||||||
"ssl redirect",
|
|
||||||
"port mapping",
|
|
||||||
"reverse proxy",
|
"reverse proxy",
|
||||||
"authentication"
|
"server",
|
||||||
|
"network security"
|
||||||
],
|
],
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartproxy",
|
"homepage": "https://code.foss.global/push.rocks/smartproxy#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://code.foss.global/push.rocks/smartproxy.git"
|
"url": "https://code.foss.global/push.rocks/smartproxy.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://code.foss.global/push.rocks/smartproxy/issues"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"mongodb-memory-server",
|
||||||
|
"puppeteer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
12235
pnpm-lock.yaml
generated
12235
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
579
readme.md
579
readme.md
@ -1,102 +1,549 @@
|
|||||||
# @push.rocks/smartproxy
|
# @push.rocks/smartproxy
|
||||||
A proxy for handling high workloads of proxying.
|
|
||||||
|
|
||||||
## Install
|
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.
|
||||||
To install `@push.rocks/smartproxy`, run the following command in your project's root directory:
|
|
||||||
|
|
||||||
```bash
|
## Architecture & Flow Diagrams
|
||||||
npm install @push.rocks/smartproxy --save
|
|
||||||
|
### Component Architecture
|
||||||
|
The diagram below illustrates the main components of SmartProxy and how they interact:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Client([Client])
|
||||||
|
|
||||||
|
subgraph "SmartProxy Components"
|
||||||
|
direction TB
|
||||||
|
HTTP80[HTTP Port 80\nSslRedirect]
|
||||||
|
HTTPS443[HTTPS Port 443\nNetworkProxy]
|
||||||
|
PortProxy[TCP Port Proxy\nwith SNI routing]
|
||||||
|
IPTables[IPTablesProxy]
|
||||||
|
Router[ProxyRouter]
|
||||||
|
ACME[Port80Handler\nACME/Let's Encrypt]
|
||||||
|
Certs[(SSL Certificates)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Backend Services"
|
||||||
|
Service1[Service 1]
|
||||||
|
Service2[Service 2]
|
||||||
|
Service3[Service 3]
|
||||||
|
end
|
||||||
|
|
||||||
|
Client -->|HTTP Request| HTTP80
|
||||||
|
HTTP80 -->|Redirect| Client
|
||||||
|
Client -->|HTTPS Request| HTTPS443
|
||||||
|
Client -->|TLS/TCP| PortProxy
|
||||||
|
|
||||||
|
HTTPS443 -->|Route Request| Router
|
||||||
|
Router -->|Proxy Request| Service1
|
||||||
|
Router -->|Proxy Request| Service2
|
||||||
|
|
||||||
|
PortProxy -->|Direct TCP| Service2
|
||||||
|
PortProxy -->|Direct TCP| Service3
|
||||||
|
|
||||||
|
IPTables -.->|Low-level forwarding| PortProxy
|
||||||
|
|
||||||
|
HTTP80 -.->|Challenge Response| ACME
|
||||||
|
ACME -.->|Generate/Manage| Certs
|
||||||
|
Certs -.->|Provide TLS Certs| HTTPS443
|
||||||
|
|
||||||
|
classDef component fill:#f9f,stroke:#333,stroke-width:2px;
|
||||||
|
classDef backend fill:#bbf,stroke:#333,stroke-width:1px;
|
||||||
|
classDef client fill:#dfd,stroke:#333,stroke-width:2px;
|
||||||
|
|
||||||
|
class Client client;
|
||||||
|
class HTTP80,HTTPS443,PortProxy,IPTables,Router,ACME component;
|
||||||
|
class Service1,Service2,Service3 backend;
|
||||||
```
|
```
|
||||||
|
|
||||||
This will add `@push.rocks/smartproxy` to your project's dependencies.
|
### HTTPS Reverse Proxy Flow
|
||||||
|
This diagram shows how HTTPS requests are handled and proxied to backend services:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant NetworkProxy
|
||||||
|
participant ProxyRouter
|
||||||
|
participant Backend
|
||||||
|
|
||||||
|
Client->>NetworkProxy: HTTPS Request
|
||||||
|
|
||||||
|
Note over NetworkProxy: TLS Termination
|
||||||
|
|
||||||
|
NetworkProxy->>ProxyRouter: Route Request
|
||||||
|
ProxyRouter->>ProxyRouter: Match hostname to config
|
||||||
|
|
||||||
|
alt Authentication Required
|
||||||
|
NetworkProxy->>Client: Request Authentication
|
||||||
|
Client->>NetworkProxy: Send Credentials
|
||||||
|
NetworkProxy->>NetworkProxy: Validate Credentials
|
||||||
|
end
|
||||||
|
|
||||||
|
NetworkProxy->>Backend: Forward Request
|
||||||
|
Backend->>NetworkProxy: Response
|
||||||
|
|
||||||
|
Note over NetworkProxy: Add Default Headers
|
||||||
|
|
||||||
|
NetworkProxy->>Client: Forward Response
|
||||||
|
|
||||||
|
alt WebSocket Request
|
||||||
|
Client->>NetworkProxy: Upgrade to WebSocket
|
||||||
|
NetworkProxy->>Backend: Upgrade to WebSocket
|
||||||
|
loop WebSocket Active
|
||||||
|
Client->>NetworkProxy: WebSocket Message
|
||||||
|
NetworkProxy->>Backend: Forward Message
|
||||||
|
Backend->>NetworkProxy: WebSocket Message
|
||||||
|
NetworkProxy->>Client: Forward Message
|
||||||
|
NetworkProxy-->>NetworkProxy: Heartbeat Check
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Proxy with SNI-based Routing
|
||||||
|
This diagram illustrates how TCP connections with SNI (Server Name Indication) are processed and forwarded:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant PortProxy
|
||||||
|
participant Backend
|
||||||
|
|
||||||
|
Client->>PortProxy: TLS Connection
|
||||||
|
|
||||||
|
alt SNI Enabled
|
||||||
|
PortProxy->>Client: Accept Connection
|
||||||
|
Client->>PortProxy: TLS ClientHello with SNI
|
||||||
|
PortProxy->>PortProxy: Extract SNI Hostname
|
||||||
|
PortProxy->>PortProxy: Match Domain Config
|
||||||
|
PortProxy->>PortProxy: Validate Client IP
|
||||||
|
|
||||||
|
alt IP Allowed
|
||||||
|
PortProxy->>Backend: Forward Connection
|
||||||
|
Note over PortProxy,Backend: Bidirectional Data Flow
|
||||||
|
else IP Rejected
|
||||||
|
PortProxy->>Client: Close Connection
|
||||||
|
end
|
||||||
|
else Port-based Routing
|
||||||
|
PortProxy->>PortProxy: Match Port Range
|
||||||
|
PortProxy->>PortProxy: Find Domain Config
|
||||||
|
PortProxy->>PortProxy: Validate Client IP
|
||||||
|
|
||||||
|
alt IP Allowed
|
||||||
|
PortProxy->>Backend: Forward Connection
|
||||||
|
Note over PortProxy,Backend: Bidirectional Data Flow
|
||||||
|
else IP Rejected
|
||||||
|
PortProxy->>Client: Close Connection
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
loop Connection Active
|
||||||
|
PortProxy-->>PortProxy: Monitor Activity
|
||||||
|
PortProxy-->>PortProxy: Check Max Lifetime
|
||||||
|
alt Inactivity or Max Lifetime Exceeded
|
||||||
|
PortProxy->>Client: Close Connection
|
||||||
|
PortProxy->>Backend: Close Connection
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Let's Encrypt Certificate Acquisition
|
||||||
|
This diagram shows how certificates are automatically acquired through the ACME protocol:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant Port80Handler
|
||||||
|
participant ACME as Let's Encrypt ACME
|
||||||
|
participant NetworkProxy
|
||||||
|
|
||||||
|
Client->>Port80Handler: HTTP Request for domain
|
||||||
|
|
||||||
|
alt Certificate Exists
|
||||||
|
Port80Handler->>Client: Redirect to HTTPS
|
||||||
|
else No Certificate
|
||||||
|
Port80Handler->>Port80Handler: Mark domain as obtaining cert
|
||||||
|
Port80Handler->>ACME: Create account & new order
|
||||||
|
ACME->>Port80Handler: Challenge information
|
||||||
|
|
||||||
|
Port80Handler->>Port80Handler: Store challenge token & key authorization
|
||||||
|
|
||||||
|
ACME->>Port80Handler: HTTP-01 Challenge Request
|
||||||
|
Port80Handler->>ACME: Challenge Response
|
||||||
|
|
||||||
|
ACME->>ACME: Validate domain ownership
|
||||||
|
ACME->>Port80Handler: Challenge validated
|
||||||
|
|
||||||
|
Port80Handler->>Port80Handler: Generate CSR
|
||||||
|
Port80Handler->>ACME: Submit CSR
|
||||||
|
ACME->>Port80Handler: Issue Certificate
|
||||||
|
|
||||||
|
Port80Handler->>Port80Handler: Store certificate & private key
|
||||||
|
Port80Handler->>Port80Handler: Mark certificate as obtained
|
||||||
|
|
||||||
|
Note over Port80Handler,NetworkProxy: Certificate available for use
|
||||||
|
|
||||||
|
Client->>Port80Handler: Another HTTP Request
|
||||||
|
Port80Handler->>Client: Redirect to HTTPS
|
||||||
|
Client->>NetworkProxy: HTTPS Request
|
||||||
|
Note over NetworkProxy: Uses new certificate
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
|
||||||
|
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
|
||||||
|
- **TCP Port Forwarding** - Advanced port forwarding with SNI inspection and domain-based routing
|
||||||
|
- **Enhanced TLS Handling** - Robust TLS handshake processing with improved certificate error handling
|
||||||
|
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
|
||||||
|
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
|
||||||
|
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
||||||
|
- **IPTables Integration** - Direct manipulation of iptables for low-level port forwarding
|
||||||
|
- **Basic Authentication** - Support for basic auth on proxied routes
|
||||||
|
- **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts
|
||||||
|
- **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @push.rocks/smartproxy
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
`@push.rocks/smartproxy` is a versatile package for setting up and handling proxies with various capabilities such as SSL redirection, port proxying, and creating network proxies with complex routing rules. Below is a comprehensive guide on using its features.
|
### Basic Reverse Proxy Setup
|
||||||
|
|
||||||
### Setting Up a Network Proxy
|
|
||||||
|
|
||||||
Create a network proxy to route incoming HTTPS requests to different local servers based on the hostname.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
import { NetworkProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Instantiate the NetworkProxy with desired options
|
// Create a reverse proxy listening on port 443
|
||||||
const myNetworkProxy = new NetworkProxy({ port: 443 });
|
const proxy = new NetworkProxy({
|
||||||
|
port: 443
|
||||||
|
});
|
||||||
|
|
||||||
// Define your reverse proxy configurations
|
// Define reverse proxy configurations
|
||||||
const proxyConfigs = [
|
const proxyConfigs = [
|
||||||
{
|
{
|
||||||
destinationIp: '127.0.0.1',
|
|
||||||
destinationPort: '3000',
|
|
||||||
hostName: 'example.com',
|
hostName: 'example.com',
|
||||||
privateKey: `-----BEGIN PRIVATE KEY-----
|
destinationIp: '127.0.0.1',
|
||||||
PRIVATE_KEY_CONTENT
|
destinationPort: 3000,
|
||||||
-----END PRIVATE KEY-----`,
|
publicKey: 'your-cert-content',
|
||||||
publicKey: `-----BEGIN CERTIFICATE-----
|
privateKey: 'your-key-content'
|
||||||
CERTIFICATE_CONTENT
|
|
||||||
-----END CERTIFICATE-----`,
|
|
||||||
},
|
},
|
||||||
// Add more reverse proxy configurations here
|
{
|
||||||
|
hostName: 'api.example.com',
|
||||||
|
destinationIp: '127.0.0.1',
|
||||||
|
destinationPort: 4000,
|
||||||
|
publicKey: 'your-cert-content',
|
||||||
|
privateKey: 'your-key-content',
|
||||||
|
// Optional basic auth
|
||||||
|
authentication: {
|
||||||
|
type: 'Basic',
|
||||||
|
user: 'admin',
|
||||||
|
pass: 'secret'
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Start the network proxy
|
// Start the proxy and update configurations
|
||||||
await myNetworkProxy.start();
|
(async () => {
|
||||||
|
await proxy.start();
|
||||||
|
await proxy.updateProxyConfigs(proxyConfigs);
|
||||||
|
|
||||||
// Update proxy configurations dynamically
|
// Add default headers to all responses
|
||||||
await myNetworkProxy.updateProxyConfigs(proxyConfigs);
|
await proxy.addDefaultHeaders({
|
||||||
|
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'
|
||||||
// Optionally, add default headers to all responses
|
});
|
||||||
await myNetworkProxy.addDefaultHeaders({
|
})();
|
||||||
'X-Powered-By': 'smartproxy',
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port Proxying
|
### HTTP to HTTPS Redirection
|
||||||
|
|
||||||
You can also set up a port proxy to forward traffic from one port to another, which is useful for dynamic port forwarding scenarios.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { PortProxy } from '@push.rocks/smartproxy';
|
|
||||||
|
|
||||||
// Create a PortProxy to forward traffic from port 5000 to port 3000
|
|
||||||
const myPortProxy = new PortProxy(5000, 3000);
|
|
||||||
|
|
||||||
// Start the port proxy
|
|
||||||
await myPortProxy.start();
|
|
||||||
|
|
||||||
// To stop the port proxy, simply call
|
|
||||||
await myPortProxy.stop();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enabling SSL Redirection
|
|
||||||
|
|
||||||
Easily redirect HTTP traffic to HTTPS using the `SslRedirect` class. This is particularly useful when ensuring all traffic uses encryption.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SslRedirect } from '@push.rocks/smartproxy';
|
import { SslRedirect } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Instantiate the SslRedirect on port 80 (HTTP)
|
// Create and start HTTP to HTTPS redirect service on port 80
|
||||||
const mySslRedirect = new SslRedirect(80);
|
const redirector = new SslRedirect(80);
|
||||||
|
redirector.start();
|
||||||
// Start listening and redirecting to HTTPS
|
|
||||||
await mySslRedirect.start();
|
|
||||||
|
|
||||||
// To stop the redirection, use
|
|
||||||
await mySslRedirect.stop();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Usage
|
### TCP Port Forwarding with Domain-based Routing
|
||||||
|
|
||||||
The package integrates seamlessly with TypeScript, allowing for advanced use cases, such as implementing custom routing logic, authentication mechanisms, and handling WebSocket connections through the network proxy.
|
```typescript
|
||||||
|
import { PortProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
For a more advanced setup involving WebSocket proxying and dynamic configuration reloading, refer to the network proxy example provided above. The WebSocket support demonstrates how seamless it is to work with real-time applications.
|
// Configure port proxy with domain-based routing
|
||||||
|
const portProxy = new PortProxy({
|
||||||
|
fromPort: 443,
|
||||||
|
toPort: 8443,
|
||||||
|
targetIP: 'localhost', // Default target host
|
||||||
|
sniEnabled: true, // Enable SNI inspection
|
||||||
|
|
||||||
Remember, when dealing with certificates and private keys for HTTPS configurations, always secure your keys and store them appropriately.
|
// Enhanced reliability settings
|
||||||
|
initialDataTimeout: 60000, // 60 seconds for initial TLS handshake
|
||||||
|
socketTimeout: 3600000, // 1 hour socket timeout
|
||||||
|
maxConnectionLifetime: 3600000, // 1 hour connection lifetime
|
||||||
|
inactivityTimeout: 3600000, // 1 hour inactivity timeout
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024, // 10MB buffer for large TLS handshakes
|
||||||
|
|
||||||
`@push.rocks/smartproxy` provides a solid foundation for handling high workloads and complex proxying requirements with ease, whether you're implementing SSL redirections, port forwarding, or extensive routing and WebSocket support in your network.
|
// Browser compatibility enhancement
|
||||||
|
enableTlsDebugLogging: false, // Enable for troubleshooting TLS issues
|
||||||
|
|
||||||
For more information on how to use the features, refer to the in-depth documentation available in the package's repository or the npm package description.
|
// Port and IP configuration
|
||||||
|
globalPortRanges: [{ from: 443, to: 443 }],
|
||||||
|
defaultAllowedIPs: ['*'], // Allow all IPs by default
|
||||||
|
|
||||||
|
// Socket optimizations for better connection stability
|
||||||
|
noDelay: true, // Disable Nagle's algorithm
|
||||||
|
keepAlive: true, // Enable TCP keepalive
|
||||||
|
enableKeepAliveProbes: true, // Enhanced keepalive for stability
|
||||||
|
|
||||||
|
// Domain-specific routing configuration
|
||||||
|
domainConfigs: [
|
||||||
|
{
|
||||||
|
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
|
||||||
|
allowedIPs: ['192.168.1.*'], // Restrict access by IP
|
||||||
|
blockedIPs: ['192.168.1.100'], // Block specific IPs
|
||||||
|
targetIPs: ['10.0.0.1', '10.0.0.2'], // Round-robin between multiple targets
|
||||||
|
portRanges: [{ from: 443, to: 443 }],
|
||||||
|
connectionTimeout: 7200000 // Domain-specific timeout (2 hours)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
preserveSourceIP: true
|
||||||
|
});
|
||||||
|
|
||||||
|
portProxy.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### IPTables Port Forwarding
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { IPTablesProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// Basic usage - forward single port
|
||||||
|
const basicProxy = new IPTablesProxy({
|
||||||
|
fromPort: 80,
|
||||||
|
toPort: 8080,
|
||||||
|
toHost: 'localhost',
|
||||||
|
preserveSourceIP: true,
|
||||||
|
deleteOnExit: true // Automatically clean up rules on process exit
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward port ranges
|
||||||
|
const rangeProxy = new IPTablesProxy({
|
||||||
|
fromPort: { from: 3000, to: 3010 }, // Forward ports 3000-3010
|
||||||
|
toPort: { from: 8000, to: 8010 }, // To ports 8000-8010
|
||||||
|
protocol: 'tcp', // TCP protocol (default)
|
||||||
|
ipv6Support: true, // Enable IPv6 support
|
||||||
|
enableLogging: true // Enable detailed logging
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multiple port specifications with IP filtering
|
||||||
|
const advancedProxy = new IPTablesProxy({
|
||||||
|
fromPort: [80, 443, { from: 8000, to: 8010 }], // Multiple ports/ranges
|
||||||
|
toPort: [8080, 8443, { from: 18000, to: 18010 }],
|
||||||
|
allowedSourceIPs: ['10.0.0.0/8', '192.168.1.0/24'], // Only allow these IPs
|
||||||
|
bannedSourceIPs: ['192.168.1.100'], // Explicitly block these IPs
|
||||||
|
addJumpRule: true, // Use custom chain for better management
|
||||||
|
checkExistingRules: true // Check for duplicate rules
|
||||||
|
});
|
||||||
|
|
||||||
|
// NetworkProxy integration for SSL termination
|
||||||
|
const sslProxy = new IPTablesProxy({
|
||||||
|
fromPort: 443,
|
||||||
|
toPort: 8443,
|
||||||
|
netProxyIntegration: {
|
||||||
|
enabled: true,
|
||||||
|
redirectLocalhost: true, // Redirect localhost traffic to NetworkProxy
|
||||||
|
sslTerminationPort: 8443 // Port where NetworkProxy handles SSL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start any of the proxies
|
||||||
|
await basicProxy.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic HTTPS Certificate Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Port80Handler } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// Create an ACME handler for Let's Encrypt
|
||||||
|
const acmeHandler = new Port80Handler();
|
||||||
|
|
||||||
|
// Add domains to manage certificates for
|
||||||
|
acmeHandler.addDomain('example.com');
|
||||||
|
acmeHandler.addDomain('api.example.com');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### NetworkProxy Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|----------------|---------------------------------------------------|---------|
|
||||||
|
| `port` | Port to listen on for HTTPS connections | - |
|
||||||
|
|
||||||
|
### PortProxy Settings
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|---------------------------|--------------------------------------------------------|-------------|
|
||||||
|
| `fromPort` | Port to listen on | - |
|
||||||
|
| `toPort` | Destination port to forward to | - |
|
||||||
|
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
|
||||||
|
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
|
||||||
|
| `defaultAllowedIPs` | IP patterns allowed by default | - |
|
||||||
|
| `defaultBlockedIPs` | IP patterns blocked by default | - |
|
||||||
|
| `preserveSourceIP` | Preserve the original client IP | false |
|
||||||
|
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 3600000 |
|
||||||
|
| `initialDataTimeout` | Timeout for initial data/handshake in ms | 60000 |
|
||||||
|
| `socketTimeout` | Socket inactivity timeout in ms | 3600000 |
|
||||||
|
| `inactivityTimeout` | Connection inactivity check timeout in ms | 3600000 |
|
||||||
|
| `inactivityCheckInterval` | How often to check for inactive connections in ms | 60000 |
|
||||||
|
| `maxPendingDataSize` | Maximum bytes to buffer during connection setup | 10485760 |
|
||||||
|
| `globalPortRanges` | Array of port ranges to listen on | - |
|
||||||
|
| `forwardAllGlobalRanges` | Forward all global range connections to targetIP | false |
|
||||||
|
| `gracefulShutdownTimeout` | Time in ms to wait during shutdown | 30000 |
|
||||||
|
| `noDelay` | Disable Nagle's algorithm | true |
|
||||||
|
| `keepAlive` | Enable TCP keepalive | true |
|
||||||
|
| `keepAliveInitialDelay` | Initial delay before sending keepalive probes in ms | 30000 |
|
||||||
|
| `enableKeepAliveProbes` | Enable enhanced TCP keep-alive probes | false |
|
||||||
|
| `enableTlsDebugLogging` | Enable detailed TLS handshake debugging | false |
|
||||||
|
| `enableDetailedLogging` | Enable detailed connection logging | false |
|
||||||
|
| `enableRandomizedTimeouts`| Randomize timeouts slightly to prevent thundering herd | true |
|
||||||
|
|
||||||
|
### IPTablesProxy Settings
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|-----------------------|---------------------------------------------------|-------------|
|
||||||
|
| `fromPort` | Source port(s) or range(s) to forward from | - |
|
||||||
|
| `toPort` | Destination port(s) or range(s) to forward to | - |
|
||||||
|
| `toHost` | Destination host to forward to | 'localhost' |
|
||||||
|
| `preserveSourceIP` | Preserve the original client IP | false |
|
||||||
|
| `deleteOnExit` | Remove iptables rules when process exits | false |
|
||||||
|
| `protocol` | Protocol to forward ('tcp', 'udp', or 'all') | 'tcp' |
|
||||||
|
| `enableLogging` | Enable detailed logging | false |
|
||||||
|
| `ipv6Support` | Enable IPv6 support with ip6tables | false |
|
||||||
|
| `allowedSourceIPs` | Array of IP addresses/CIDR allowed to connect | - |
|
||||||
|
| `bannedSourceIPs` | Array of IP addresses/CIDR blocked from connecting | - |
|
||||||
|
| `forceCleanSlate` | Clear all IPTablesProxy rules before starting | false |
|
||||||
|
| `addJumpRule` | Add a custom chain for cleaner rule management | false |
|
||||||
|
| `checkExistingRules` | Check if rules already exist before adding | true |
|
||||||
|
| `netProxyIntegration` | NetworkProxy integration options (object) | - |
|
||||||
|
|
||||||
|
#### IPTablesProxy NetworkProxy Integration Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|----------------------|---------------------------------------------------|---------|
|
||||||
|
| `enabled` | Enable NetworkProxy integration | false |
|
||||||
|
| `redirectLocalhost` | Redirect localhost traffic to NetworkProxy | false |
|
||||||
|
| `sslTerminationPort` | Port where NetworkProxy handles SSL termination | - |
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### TLS Handshake Optimization
|
||||||
|
|
||||||
|
The enhanced `PortProxy` implementation includes significant improvements for TLS handshake handling:
|
||||||
|
|
||||||
|
- Robust SNI extraction with improved error handling
|
||||||
|
- Increased buffer size for complex TLS handshakes (10MB)
|
||||||
|
- Longer initial handshake timeout (60 seconds)
|
||||||
|
- Detection and tracking of TLS connection states
|
||||||
|
- Optional detailed TLS debug logging for troubleshooting
|
||||||
|
- Browser compatibility fixes for Chrome certificate errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example configuration to solve Chrome certificate errors
|
||||||
|
const portProxy = new PortProxy({
|
||||||
|
// ... other settings
|
||||||
|
initialDataTimeout: 60000, // Give browser more time for handshake
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024, // Larger buffer for complex handshakes
|
||||||
|
enableTlsDebugLogging: true, // Enable when troubleshooting
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Management and Monitoring
|
||||||
|
|
||||||
|
The `PortProxy` class includes built-in connection tracking and monitoring:
|
||||||
|
|
||||||
|
- Automatic cleanup of idle connections with configurable timeouts
|
||||||
|
- Timeouts for connections that exceed maximum lifetime
|
||||||
|
- Detailed logging of connection states
|
||||||
|
- Termination statistics
|
||||||
|
- Randomized timeouts to prevent "thundering herd" problems
|
||||||
|
- Per-domain timeout configuration
|
||||||
|
|
||||||
|
### WebSocket Support
|
||||||
|
|
||||||
|
The `NetworkProxy` class provides WebSocket support with:
|
||||||
|
|
||||||
|
- WebSocket connection proxying
|
||||||
|
- Automatic heartbeat monitoring
|
||||||
|
- Connection cleanup for inactive WebSockets
|
||||||
|
|
||||||
|
### SNI-based Routing
|
||||||
|
|
||||||
|
The `PortProxy` class can inspect the SNI (Server Name Indication) field in TLS handshakes to route connections based on the requested domain:
|
||||||
|
|
||||||
|
- Multiple backend targets per domain
|
||||||
|
- Round-robin load balancing
|
||||||
|
- Domain-specific allowed IP ranges
|
||||||
|
- Protection against SNI renegotiation attacks
|
||||||
|
|
||||||
|
### Enhanced IPTables Management
|
||||||
|
|
||||||
|
The improved `IPTablesProxy` class offers advanced capabilities:
|
||||||
|
|
||||||
|
- Support for multiple port ranges and individual ports
|
||||||
|
- IPv6 support with ip6tables
|
||||||
|
- Source IP filtering with allow/block lists
|
||||||
|
- Custom chain creation for better rule organization
|
||||||
|
- NetworkProxy integration for SSL termination
|
||||||
|
- Automatic rule existence checking to prevent duplicates
|
||||||
|
- Comprehensive cleanup on shutdown
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Browser Certificate Errors
|
||||||
|
|
||||||
|
If you experience certificate errors in browsers, especially in Chrome, try these solutions:
|
||||||
|
|
||||||
|
1. **Increase Initial Data Timeout**: Set `initialDataTimeout` to 60 seconds or higher
|
||||||
|
2. **Increase Buffer Size**: Set `maxPendingDataSize` to 10MB or higher
|
||||||
|
3. **Enable TLS Debug Logging**: Set `enableTlsDebugLogging: true` to troubleshoot handshake issues
|
||||||
|
4. **Enable Keep-Alive Probes**: Set `enableKeepAliveProbes: true` for better connection stability
|
||||||
|
5. **Check Certificate Chain**: Ensure your certificate chain is complete and in the correct order
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Configuration to fix Chrome certificate errors
|
||||||
|
const portProxy = new PortProxy({
|
||||||
|
// ... other settings
|
||||||
|
initialDataTimeout: 60000,
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024,
|
||||||
|
enableTlsDebugLogging: true,
|
||||||
|
enableKeepAliveProbes: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Stability
|
||||||
|
|
||||||
|
For improved connection stability in high-traffic environments:
|
||||||
|
|
||||||
|
1. **Set Appropriate Timeouts**: Use longer timeouts for long-lived connections
|
||||||
|
2. **Use Domain-Specific Timeouts**: Configure per-domain timeouts for different types of services
|
||||||
|
3. **Enable TCP Keep-Alive**: Ensure `keepAlive` is set to `true`
|
||||||
|
4. **Monitor Connection Statistics**: Enable detailed logging to track termination reasons
|
||||||
|
5. **Fine-tune Inactivity Checks**: Adjust `inactivityCheckInterval` based on your traffic patterns
|
||||||
|
|
||||||
|
### IPTables Troubleshooting
|
||||||
|
|
||||||
|
If you're experiencing issues with IPTablesProxy:
|
||||||
|
|
||||||
|
1. **Enable Detailed Logging**: Set `enableLogging: true` to see all rule operations
|
||||||
|
2. **Force Clean Slate**: Use `forceCleanSlate: true` to remove any lingering rules
|
||||||
|
3. **Use Custom Chains**: Enable `addJumpRule: true` for cleaner rule management
|
||||||
|
4. **Check Permissions**: Ensure your process has sufficient permissions to modify iptables
|
||||||
|
5. **Verify IPv6 Support**: If using `ipv6Support: true`, ensure ip6tables is available
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
@ -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-----
|
|
||||||
MIIJRQIBADANBgkqhkiG9w0BAQEFAASCCS8wggkrAgEAAoICAQDi2F/0kQr96mhe
|
|
||||||
3yEWvy2mRHOZoSSBtIqg6Bre4ZcMu901/cHNIjFnynNGFl9Se61yZbW2F3PfCt7+
|
|
||||||
kQlHug1Cx+LFssvz+hLlB5cqJQZfRKx92DhbROygtxG9r7UBmx/fwx+JQ+HOHX9R
|
|
||||||
b+szLBZqxrNDBFl2SRdviconYgVnHbaqcAPj/lK6D6x94qgUEX+vMjbIruuiCe3u
|
|
||||||
RbYse/quzAednVnY/+BuGVn8SEb2EVVFnBEsOxxYpy5ZzGR48O3YnWkM2oPpJhrp
|
|
||||||
mMYLcARMnDmIQDVstD1i+MM2lVhx/pm9xKKUgWNJC7lyz2xRscZ4pOtLkfN94leH
|
|
||||||
U98nIvxfQe7tQFKN9K52yjdtoT0UaIEUFbZyddkoNka1Xx6r+rE96046BLT2lVs0
|
|
||||||
/rnTxZUFH6vP3z9UNktmpxtnZSk67Pj6QAqZtgT0amXEpBlk7vBYSjHsyJ3+5R1y
|
|
||||||
oSjhAqeejq6M67NDOflrag5LSTkeTe4dqk0laVb1gjcse18AOlgf7pw5H79zclYH
|
|
||||||
NAnoAPua683MD2ZZd4eovEww/imSZvui3NlisSSh1SomABDFxiEaHpewI98n8P1E
|
|
||||||
3vfg4lyCV5VcUjwrPjnkfEJbX1c1/PXqTtPqSqFn/pI4FuTES6qDugS2EA/XT1ln
|
|
||||||
ODHigOiFCzDbhOMuQjhI8hzuevrRRQIDAQABAoICAQC7nU+HW6qmpQebZ5nbUVT1
|
|
||||||
Deo6Js+lwudg+3a13ghqzLnBXNW7zkrkV8mNLxW5h3bFhZ+LMcxwrXIPQ29Udmlf
|
|
||||||
USiacC1E5RBZgjSg86xYgNjU4E6EFfZLWf3/T2I6KM1s6NmdUppgOX9CoHj7grwr
|
|
||||||
pZk/lUpUjVEnu+OJPQXQ6f9Y6XoeSAqtvibgmuR+bJaZFMPAqQNTqjix99Aa7JNB
|
|
||||||
nJez4R8dXUuGY8tL349pFp7bCqAdX+oq3GJ2fJigekuM+2uV6OhunUhm6Sbq8MNt
|
|
||||||
hUwEB27oMA4RXENAUraq2XLYQ9hfUMAH+v1vGmSxEIJg561/e//RnrDbyR9oJARr
|
|
||||||
SbopI3Ut5yKxVKMYOTSqcFQXVLszTExhMhQCRoOh58BpIfhb9FLCKD9LH8E6eoQf
|
|
||||||
ygPWryey9AAJ7B2PQXVbitzcOML27rzC4DXS+mLe6AVL6t2IldaeMTlumlnc620d
|
|
||||||
Yuf5wSe8qe4xpKOlrE9emnBmbL0sGivsU+mpz9oSjxEpHGA7eoTIOmQiZnuzpkmi
|
|
||||||
1ZSU4OwqNavphy6cklONShQOmE8LMI0wRbunLjIFY8fme/8u+tVvWrTuJiCGPnXQ
|
|
||||||
F2lb0qwtDVRlexyM+GTPYstU5v7HxkQB3B+uwTgYuupCmTNmO8hjSCS/EYpHzmFe
|
|
||||||
YHDEN+Cj8f+vmKxN0F/6QQKCAQEA9+wTQU2GSoVX8IB0U6T+hX0BFhQq5ISH/s76
|
|
||||||
kWIEunY1MCkRL9YygvHkKW3dsXVOzsip/axiT36MhRcyZ27hF1tz3j//Z11E3Bfq
|
|
||||||
PkzyUVuU3jpWZkBE2VhXpDXlyW8xR/y1ZOaZZ//XcZTrZf57pGKFp30H/PlDPH3C
|
|
||||||
YtjEuQNmPCgnfz8iXx+vDYx8hwLHNv+DoX2WYuThUnul/QGSKL3xh3qWd8rotnUB
|
|
||||||
c8bV4ymk35fVJu/+pTZpPnMkYrFReso/uNn07y1iga/9mwkUBNrT+fWE7RzjT7H8
|
|
||||||
ykMMOGCK6bc7joCvALZaUDne714hNW3s9a7L1clehUA8/xwplQKCAQEA6jx/CIQd
|
|
||||||
RVdJFihSSZbqdrOAblVdl+WkjhALWNRMoRCCRniNubbgxgKfQ0scKUeubYxScBVk
|
|
||||||
rlUMl6/2Gr9uzuSC0WPVAE6OLvLNcQafw1mQ1UTJiEzYvczJKwipzXcgGQWO9Q9a
|
|
||||||
T3ETh6Be62si2r6fH4agQzbp4HkTEoWgPu6MJpqqcLoc8laty0d1huqU9du1TRzT
|
|
||||||
3etjopWRd0I3ID+WkkGKjYWRQ1bkKjvkkj1v7bHenX17nfIp5WU1aXTMYUCMMszm
|
|
||||||
pgVBDeJGKpPpP3scl7go5Y4KC6H+IeYaeCEk3hWW4robpHBzupkgpRLzmBopjRlN
|
|
||||||
v3+HQ7OkviX88QKCAQEAg5IJdfKKfindzYieM3WwjW8VkH4LdVLQSW3WlCkMkVgC
|
|
||||||
ShjBQj3OeKeeik4ABRlYRW1AqZs+YSmrsUXqPfIeCqNCDoSwKk7ZKGSYr49uWbbc
|
|
||||||
fkM/buxUnXPAryjbVddos+ds7KtkZkjkMSby9iHjxA11GLnF737pK8Uh0Atx+y3O
|
|
||||||
p8Y3j9QVjZ3m7K3NuGjFCG75kE5x7PHCkl+Ea4zV4EFNWLS5/cD1Vz8pEiRHhlKn
|
|
||||||
aPHO8OcUoOELYVUBzk6EC0IiJxukXPoc+O5JDGn48cqgDFs7vApEqBqxKTYD2jeC
|
|
||||||
AR54wNuSBDLCIylTIn016oD37DpjeoVvYBADTu/HMQKCAQEA1rFuajrVrWnMpo98
|
|
||||||
pNC7xOLQM9DwwToOMtwH2np0ZiiAj+ENXgx+R1+95Gsiu79k5Cn6oZsqNhPkP+Bb
|
|
||||||
fba69M1EDnInmGloLyYDIbbFlsMwWhn7cn+lJYpfVJ9TK+0lMWoD1yAkUa4+DVDz
|
|
||||||
z2naf466wKWfnRvnEAVJcu+hqizxrqySzlH4GDNUhn7P/UJkGFkx+yUSGFUZdLsM
|
|
||||||
orfBWUCPXSzPttmXBJbO+Nr+rP+86KvgdI/AT0vYFNdINomEjxsfpaxjOAaW0wfz
|
|
||||||
8jCyWKoZ0gJNEeK32GO5UA7dcgBHD3vQWa3lijo8COsznboaJe7M6PQpa/2S2H3+
|
|
||||||
4P5msQKCAQEAx7NP3y+5ttfTd/eQ7/cg1/0y2WxvpOYNLt6MWz4rPWyD6QwidzTG
|
|
||||||
pjuQFQ5Ods+BwJ/Jbirb7l4GMAxfIbEPAkPTHpvswO0xcncSYxl0sSP/WIA6sbcM
|
|
||||||
dp7B/scdORC8Y6i8oPdCyxyCTd2SBrmGr2krAXmQquT72eusyP5E8HFhCy1iYt22
|
|
||||||
aL68dZLv9/sRAF08t9Wy+eYjD/hCj67t7uGCZQT8wJbKr8aJcjwVwJgghh+3EydK
|
|
||||||
h+7fBVO49PLL0NWy+8GT8y7a04calFfLvZEA2UMaunBis3dE1KMFfJL/0JO+sKnF
|
|
||||||
2TkK01XDDJURK5Lhuvc7WrK2rSJ/fK+0GA==
|
|
||||||
-----END PRIVATE KEY-----
|
|
343
test/test.portproxy.ts
Normal file
343
test/test.portproxy.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { expect, tap } from '@push.rocks/tapbundle';
|
||||||
|
import * as net from 'net';
|
||||||
|
import { PortProxy } from '../ts/classes.pp.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!';
|
||||||
|
|
||||||
|
// Track all created servers and proxies for proper cleanup
|
||||||
|
const allServers: net.Server[] = [];
|
||||||
|
const allProxies: PortProxy[] = [];
|
||||||
|
|
||||||
|
// Helper: Creates a test TCP server that listens on a given port and host.
|
||||||
|
function createTestServer(port: number, host: string = 'localhost'): Promise<net.Server> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
// Echo the received data back with a prefix.
|
||||||
|
socket.write(`Echo: ${data.toString()}`);
|
||||||
|
});
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.error(`[Test Server] Socket error on ${host}:${port}:`, error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.log(`[Test Server] Listening on ${host}:${port}`);
|
||||||
|
allServers.push(server); // Track this server
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Creates a test client connection.
|
||||||
|
function createTestClient(port: number, data: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
let response = '';
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
client.destroy();
|
||||||
|
reject(new Error(`Client connection timeout to port ${port}`));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
client.on('error', (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SETUP: Create a test server and a PortProxy instance.
|
||||||
|
tap.test('setup port proxy test environment', async () => {
|
||||||
|
testServer = await createTestServer(TEST_SERVER_PORT);
|
||||||
|
portProxy = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT,
|
||||||
|
toPort: TEST_SERVER_PORT,
|
||||||
|
targetIP: 'localhost',
|
||||||
|
domainConfigs: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1'],
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
allProxies.push(portProxy); // Track this proxy
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that the proxy starts and its servers are listening.
|
||||||
|
tap.test('should start port proxy', async () => {
|
||||||
|
await portProxy.start();
|
||||||
|
expect((portProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test basic TCP forwarding.
|
||||||
|
tap.test('should forward TCP connections and data to localhost', async () => {
|
||||||
|
const response = await createTestClient(PROXY_PORT, TEST_DATA);
|
||||||
|
expect(response).toEqual(`Echo: ${TEST_DATA}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test proxy with a custom target host.
|
||||||
|
tap.test('should forward TCP connections to custom host', async () => {
|
||||||
|
const customHostProxy = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 1,
|
||||||
|
toPort: TEST_SERVER_PORT,
|
||||||
|
targetIP: '127.0.0.1',
|
||||||
|
domainConfigs: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1'],
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
allProxies.push(customHostProxy); // Track this proxy
|
||||||
|
|
||||||
|
await customHostProxy.start();
|
||||||
|
const response = await createTestClient(PROXY_PORT + 1, TEST_DATA);
|
||||||
|
expect(response).toEqual(`Echo: ${TEST_DATA}`);
|
||||||
|
await customHostProxy.stop();
|
||||||
|
|
||||||
|
// Remove from tracking after stopping
|
||||||
|
const index = allProxies.indexOf(customHostProxy);
|
||||||
|
if (index !== -1) allProxies.splice(index, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test custom IP forwarding
|
||||||
|
// Modified to work in Docker/CI environments without needing 127.0.0.2
|
||||||
|
tap.test('should forward connections to custom IP', async () => {
|
||||||
|
// Set up ports that are FAR apart to avoid any possible confusion
|
||||||
|
const forcedProxyPort = PROXY_PORT + 2; // 4003 - The port that our proxy listens on
|
||||||
|
const targetServerPort = TEST_SERVER_PORT + 200; // 4200 - Target test server on different port
|
||||||
|
|
||||||
|
// Create a test server listening on a unique port on 127.0.0.1 (works in all environments)
|
||||||
|
const testServer2 = await createTestServer(targetServerPort, '127.0.0.1');
|
||||||
|
|
||||||
|
// We're simulating routing to a different IP by using a different port
|
||||||
|
// This tests the core functionality without requiring multiple IPs
|
||||||
|
const domainProxy = new PortProxy({
|
||||||
|
fromPort: forcedProxyPort, // 4003 - Listen on this port
|
||||||
|
toPort: targetServerPort, // 4200 - Forward to this port
|
||||||
|
targetIP: '127.0.0.1', // Always use localhost (works in Docker)
|
||||||
|
domainConfigs: [], // No domain configs to confuse things
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], // Allow localhost
|
||||||
|
// We'll test the functionality WITHOUT port ranges this time
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
allProxies.push(domainProxy); // Track this proxy
|
||||||
|
|
||||||
|
await domainProxy.start();
|
||||||
|
|
||||||
|
// Send a single test connection
|
||||||
|
const response = await createTestClient(forcedProxyPort, TEST_DATA);
|
||||||
|
expect(response).toEqual(`Echo: ${TEST_DATA}`);
|
||||||
|
|
||||||
|
await domainProxy.stop();
|
||||||
|
|
||||||
|
// Remove from tracking after stopping
|
||||||
|
const proxyIndex = allProxies.indexOf(domainProxy);
|
||||||
|
if (proxyIndex !== -1) allProxies.splice(proxyIndex, 1);
|
||||||
|
|
||||||
|
// Close the test server
|
||||||
|
await new Promise<void>((resolve) => testServer2.close(() => resolve()));
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
const serverIndex = allServers.indexOf(testServer2);
|
||||||
|
if (serverIndex !== -1) allServers.splice(serverIndex, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling of multiple concurrent connections.
|
||||||
|
tap.test('should handle multiple concurrent connections', async () => {
|
||||||
|
const concurrentRequests = 5;
|
||||||
|
const requests = Array(concurrentRequests).fill(null).map((_, i) =>
|
||||||
|
createTestClient(PROXY_PORT, `${TEST_DATA} ${i + 1}`)
|
||||||
|
);
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
responses.forEach((response, i) => {
|
||||||
|
expect(response).toEqual(`Echo: ${TEST_DATA} ${i + 1}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection timeout handling.
|
||||||
|
tap.test('should handle connection timeouts', async () => {
|
||||||
|
const client = new net.Socket();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
// Add a timeout to ensure we don't hang here
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
client.destroy();
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
client.connect(PROXY_PORT, 'localhost', () => {
|
||||||
|
// Do not send any data to trigger a timeout.
|
||||||
|
client.on('close', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
client.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test stopping the port proxy.
|
||||||
|
tap.test('should stop port proxy', async () => {
|
||||||
|
await portProxy.stop();
|
||||||
|
expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
const index = allProxies.indexOf(portProxy);
|
||||||
|
if (index !== -1) allProxies.splice(index, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test chained proxies with and without source IP preservation.
|
||||||
|
tap.test('should support optional source IP preservation in chained proxies', async () => {
|
||||||
|
// Chained proxies without IP preservation.
|
||||||
|
const firstProxyDefault = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 4,
|
||||||
|
toPort: PROXY_PORT + 5,
|
||||||
|
targetIP: 'localhost',
|
||||||
|
domainConfigs: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
const secondProxyDefault = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 5,
|
||||||
|
toPort: TEST_SERVER_PORT,
|
||||||
|
targetIP: 'localhost',
|
||||||
|
domainConfigs: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
|
||||||
|
allProxies.push(firstProxyDefault, secondProxyDefault); // Track these proxies
|
||||||
|
|
||||||
|
await secondProxyDefault.start();
|
||||||
|
await firstProxyDefault.start();
|
||||||
|
const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA);
|
||||||
|
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
|
||||||
|
await firstProxyDefault.stop();
|
||||||
|
await secondProxyDefault.stop();
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
const index1 = allProxies.indexOf(firstProxyDefault);
|
||||||
|
if (index1 !== -1) allProxies.splice(index1, 1);
|
||||||
|
const index2 = allProxies.indexOf(secondProxyDefault);
|
||||||
|
if (index2 !== -1) allProxies.splice(index2, 1);
|
||||||
|
|
||||||
|
// Chained proxies with IP preservation.
|
||||||
|
const firstProxyPreserved = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 6,
|
||||||
|
toPort: PROXY_PORT + 7,
|
||||||
|
targetIP: 'localhost',
|
||||||
|
domainConfigs: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1'],
|
||||||
|
preserveSourceIP: true,
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
const secondProxyPreserved = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 7,
|
||||||
|
toPort: TEST_SERVER_PORT,
|
||||||
|
targetIP: 'localhost',
|
||||||
|
domainConfigs: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1'],
|
||||||
|
preserveSourceIP: true,
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
|
||||||
|
allProxies.push(firstProxyPreserved, secondProxyPreserved); // Track these proxies
|
||||||
|
|
||||||
|
await secondProxyPreserved.start();
|
||||||
|
await firstProxyPreserved.start();
|
||||||
|
const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA);
|
||||||
|
expect(response2).toEqual(`Echo: ${TEST_DATA}`);
|
||||||
|
await firstProxyPreserved.stop();
|
||||||
|
await secondProxyPreserved.stop();
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
const index3 = allProxies.indexOf(firstProxyPreserved);
|
||||||
|
if (index3 !== -1) allProxies.splice(index3, 1);
|
||||||
|
const index4 = allProxies.indexOf(secondProxyPreserved);
|
||||||
|
if (index4 !== -1) allProxies.splice(index4, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test round-robin behavior for multiple target IPs in a domain config.
|
||||||
|
tap.test('should use round robin for multiple target IPs in domain config', async () => {
|
||||||
|
const domainConfig = {
|
||||||
|
domains: ['rr.test'],
|
||||||
|
allowedIPs: ['127.0.0.1'],
|
||||||
|
targetIPs: ['hostA', 'hostB']
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const proxyInstance = new PortProxy({
|
||||||
|
fromPort: 0,
|
||||||
|
toPort: 0,
|
||||||
|
targetIP: 'localhost',
|
||||||
|
domainConfigs: [domainConfig],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: [],
|
||||||
|
globalPortRanges: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't track this proxy as it doesn't actually start or listen
|
||||||
|
|
||||||
|
const firstTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
|
||||||
|
const secondTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
|
||||||
|
expect(firstTarget).toEqual('hostA');
|
||||||
|
expect(secondTarget).toEqual('hostB');
|
||||||
|
});
|
||||||
|
|
||||||
|
// CLEANUP: Tear down all servers and proxies
|
||||||
|
tap.test('cleanup port proxy test environment', async () => {
|
||||||
|
// Stop all remaining proxies
|
||||||
|
for (const proxy of [...allProxies]) {
|
||||||
|
try {
|
||||||
|
await proxy.stop();
|
||||||
|
const index = allProxies.indexOf(proxy);
|
||||||
|
if (index !== -1) allProxies.splice(index, 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error stopping proxy: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all remaining servers
|
||||||
|
for (const server of [...allServers]) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (server.listening) {
|
||||||
|
server.close(() => resolve());
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const index = allServers.indexOf(server);
|
||||||
|
if (index !== -1) allServers.splice(index, 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error closing server: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all resources are cleaned up
|
||||||
|
expect(allProxies.length).toEqual(0);
|
||||||
|
expect(allServers.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
392
test/test.router.ts
Normal file
392
test/test.router.ts
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
import { expect, tap } from '@push.rocks/tapbundle';
|
||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { ProxyRouter, type IRouterResult } from '../ts/classes.router.js';
|
||||||
|
|
||||||
|
// Test proxies and configurations
|
||||||
|
let router: ProxyRouter;
|
||||||
|
|
||||||
|
// Sample hostname for testing
|
||||||
|
const TEST_DOMAIN = 'example.com';
|
||||||
|
const TEST_SUBDOMAIN = 'api.example.com';
|
||||||
|
const TEST_WILDCARD = '*.example.com';
|
||||||
|
|
||||||
|
// Helper: Creates a mock HTTP request for testing
|
||||||
|
function createMockRequest(host: string, url: string = '/'): http.IncomingMessage {
|
||||||
|
const req = {
|
||||||
|
headers: { host },
|
||||||
|
url,
|
||||||
|
socket: {
|
||||||
|
remoteAddress: '127.0.0.1'
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Creates a test proxy configuration
|
||||||
|
function createProxyConfig(
|
||||||
|
hostname: string,
|
||||||
|
destinationIp: string = '10.0.0.1',
|
||||||
|
destinationPort: number = 8080
|
||||||
|
): tsclass.network.IReverseProxyConfig {
|
||||||
|
return {
|
||||||
|
hostName: hostname,
|
||||||
|
destinationIp,
|
||||||
|
destinationPort: destinationPort.toString(), // Convert to string for IReverseProxyConfig
|
||||||
|
publicKey: 'mock-cert',
|
||||||
|
privateKey: 'mock-key'
|
||||||
|
} as tsclass.network.IReverseProxyConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SETUP: Create a ProxyRouter instance
|
||||||
|
tap.test('setup proxy router test environment', async () => {
|
||||||
|
router = new ProxyRouter();
|
||||||
|
|
||||||
|
// Initialize with empty config
|
||||||
|
router.setNewProxyConfigs([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test basic routing by hostname
|
||||||
|
tap.test('should route requests by hostname', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN);
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling of hostname with port number
|
||||||
|
tap.test('should handle hostname with port number', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest(`${TEST_DOMAIN}:443`);
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test case-insensitive hostname matching
|
||||||
|
tap.test('should perform case-insensitive hostname matching', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN.toLowerCase());
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN.toUpperCase());
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling of unmatched hostnames
|
||||||
|
tap.test('should return undefined for unmatched hostnames', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest('unknown.domain.com');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test adding path patterns
|
||||||
|
tap.test('should match requests using path patterns', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
// Add a path pattern to the config
|
||||||
|
router.setPathPattern(config, '/api/users');
|
||||||
|
|
||||||
|
// Test that path matches
|
||||||
|
const req1 = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||||
|
const result1 = router.routeReqWithDetails(req1);
|
||||||
|
|
||||||
|
expect(result1).toBeTruthy();
|
||||||
|
expect(result1.config).toEqual(config);
|
||||||
|
expect(result1.pathMatch).toEqual('/api/users');
|
||||||
|
|
||||||
|
// Test that non-matching path doesn't match
|
||||||
|
const req2 = createMockRequest(TEST_DOMAIN, '/web/users');
|
||||||
|
const result2 = router.routeReqWithDetails(req2);
|
||||||
|
|
||||||
|
expect(result2).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling wildcard patterns
|
||||||
|
tap.test('should support wildcard path patterns', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
router.setPathPattern(config, '/api/*');
|
||||||
|
|
||||||
|
// Test with path that matches the wildcard pattern
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/api/users/123');
|
||||||
|
const result = router.routeReqWithDetails(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.config).toEqual(config);
|
||||||
|
expect(result.pathMatch).toEqual('/api');
|
||||||
|
|
||||||
|
// Print the actual value to diagnose issues
|
||||||
|
console.log('Path remainder value:', result.pathRemainder);
|
||||||
|
expect(result.pathRemainder).toBeTruthy();
|
||||||
|
expect(result.pathRemainder).toEqual('/users/123');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test extracting path parameters
|
||||||
|
tap.test('should extract path parameters from URL', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
router.setPathPattern(config, '/users/:id/profile');
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/users/123/profile');
|
||||||
|
const result = router.routeReqWithDetails(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.config).toEqual(config);
|
||||||
|
expect(result.pathParams).toBeTruthy();
|
||||||
|
expect(result.pathParams.id).toEqual('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test multiple configs for same hostname with different paths
|
||||||
|
tap.test('should support multiple configs for same hostname with different paths', async () => {
|
||||||
|
const apiConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||||
|
const webConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||||
|
|
||||||
|
// Add both configs
|
||||||
|
router.setNewProxyConfigs([apiConfig, webConfig]);
|
||||||
|
|
||||||
|
// Set different path patterns
|
||||||
|
router.setPathPattern(apiConfig, '/api');
|
||||||
|
router.setPathPattern(webConfig, '/web');
|
||||||
|
|
||||||
|
// Test API path routes to API config
|
||||||
|
const apiReq = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||||
|
const apiResult = router.routeReq(apiReq);
|
||||||
|
|
||||||
|
expect(apiResult).toEqual(apiConfig);
|
||||||
|
|
||||||
|
// Test web path routes to web config
|
||||||
|
const webReq = createMockRequest(TEST_DOMAIN, '/web/dashboard');
|
||||||
|
const webResult = router.routeReq(webReq);
|
||||||
|
|
||||||
|
expect(webResult).toEqual(webConfig);
|
||||||
|
|
||||||
|
// Test unknown path returns undefined
|
||||||
|
const unknownReq = createMockRequest(TEST_DOMAIN, '/unknown');
|
||||||
|
const unknownResult = router.routeReq(unknownReq);
|
||||||
|
|
||||||
|
expect(unknownResult).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test wildcard subdomains
|
||||||
|
tap.test('should match wildcard subdomains', async () => {
|
||||||
|
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
|
||||||
|
router.setNewProxyConfigs([wildcardConfig]);
|
||||||
|
|
||||||
|
// Test that subdomain.example.com matches *.example.com
|
||||||
|
const req = createMockRequest('subdomain.example.com');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
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('*');
|
||||||
|
const specificConfig = createProxyConfig(TEST_DOMAIN);
|
||||||
|
|
||||||
|
router.setNewProxyConfigs([defaultConfig, specificConfig]);
|
||||||
|
|
||||||
|
// Test specific domain routes to specific config
|
||||||
|
const specificReq = createMockRequest(TEST_DOMAIN);
|
||||||
|
const specificResult = router.routeReq(specificReq);
|
||||||
|
|
||||||
|
expect(specificResult).toEqual(specificConfig);
|
||||||
|
|
||||||
|
// Test unknown domain falls back to default config
|
||||||
|
const unknownReq = createMockRequest('unknown.com');
|
||||||
|
const unknownResult = router.routeReq(unknownReq);
|
||||||
|
|
||||||
|
expect(unknownResult).toEqual(defaultConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test priority between exact and wildcard matches
|
||||||
|
tap.test('should prioritize exact hostname over wildcard', async () => {
|
||||||
|
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
|
||||||
|
const exactConfig = createProxyConfig(TEST_SUBDOMAIN);
|
||||||
|
|
||||||
|
router.setNewProxyConfigs([wildcardConfig, exactConfig]);
|
||||||
|
|
||||||
|
// Test that exact match takes priority
|
||||||
|
const req = createMockRequest(TEST_SUBDOMAIN);
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(exactConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test adding and removing configurations
|
||||||
|
tap.test('should manage configurations correctly', async () => {
|
||||||
|
router.setNewProxyConfigs([]);
|
||||||
|
|
||||||
|
// Add a config
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.addProxyConfig(config);
|
||||||
|
|
||||||
|
// Verify routing works
|
||||||
|
const req = createMockRequest(TEST_DOMAIN);
|
||||||
|
let result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
|
||||||
|
// Remove the config and verify it no longer routes
|
||||||
|
const removed = router.removeProxyConfig(TEST_DOMAIN);
|
||||||
|
expect(removed).toBeTrue();
|
||||||
|
|
||||||
|
result = router.routeReq(req);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test path pattern specificity
|
||||||
|
tap.test('should prioritize more specific path patterns', async () => {
|
||||||
|
const genericConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||||
|
const specificConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||||
|
|
||||||
|
router.setNewProxyConfigs([genericConfig, specificConfig]);
|
||||||
|
|
||||||
|
router.setPathPattern(genericConfig, '/api/*');
|
||||||
|
router.setPathPattern(specificConfig, '/api/users');
|
||||||
|
|
||||||
|
// The more specific '/api/users' should match before the '/api/*' wildcard
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(specificConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test getHostnames method
|
||||||
|
tap.test('should retrieve all configured hostnames', async () => {
|
||||||
|
router.setNewProxyConfigs([
|
||||||
|
createProxyConfig(TEST_DOMAIN),
|
||||||
|
createProxyConfig(TEST_SUBDOMAIN)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hostnames = router.getHostnames();
|
||||||
|
|
||||||
|
expect(hostnames.length).toEqual(2);
|
||||||
|
expect(hostnames).toContain(TEST_DOMAIN.toLowerCase());
|
||||||
|
expect(hostnames).toContain(TEST_SUBDOMAIN.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling missing host header
|
||||||
|
tap.test('should handle missing host header', async () => {
|
||||||
|
const defaultConfig = createProxyConfig('*');
|
||||||
|
router.setNewProxyConfigs([defaultConfig]);
|
||||||
|
|
||||||
|
const req = createMockRequest('');
|
||||||
|
req.headers.host = undefined;
|
||||||
|
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(defaultConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test complex path parameters
|
||||||
|
tap.test('should handle complex path parameters', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
router.setPathPattern(config, '/api/:version/users/:userId/posts/:postId');
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/api/v1/users/123/posts/456');
|
||||||
|
const result = router.routeReqWithDetails(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.config).toEqual(config);
|
||||||
|
expect(result.pathParams).toBeTruthy();
|
||||||
|
expect(result.pathParams.version).toEqual('v1');
|
||||||
|
expect(result.pathParams.userId).toEqual('123');
|
||||||
|
expect(result.pathParams.postId).toEqual('456');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Performance test
|
||||||
|
tap.test('should handle many configurations efficiently', async () => {
|
||||||
|
const configs = [];
|
||||||
|
|
||||||
|
// Create many configs with different hostnames
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
configs.push(createProxyConfig(`host-${i}.example.com`));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.setNewProxyConfigs(configs);
|
||||||
|
|
||||||
|
// Test middle of the list to avoid best/worst case
|
||||||
|
const req = createMockRequest('host-50.example.com');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(configs[50]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test cleanup
|
||||||
|
tap.test('cleanup proxy router test environment', async () => {
|
||||||
|
// Clear all configurations
|
||||||
|
router.setNewProxyConfigs([]);
|
||||||
|
|
||||||
|
// Verify empty state
|
||||||
|
expect(router.getHostnames().length).toEqual(0);
|
||||||
|
expect(router.getProxyConfigs().length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
581
test/test.ts
581
test/test.ts
@ -1,116 +1,513 @@
|
|||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@push.rocks/tapbundle';
|
||||||
import * as smartproxy from '../ts/index.js';
|
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 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 () => {
|
||||||
|
// Test with the original minimal options (only port)
|
||||||
testProxy = new smartproxy.NetworkProxy({
|
testProxy = new smartproxy.NetworkProxy({
|
||||||
port: 3001,
|
port: 3001,
|
||||||
});
|
});
|
||||||
expect(testProxy).toBeInstanceOf(smartproxy.NetworkProxy);
|
expect(testProxy).toEqual(testProxy); // Instance equality check
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should start the testproxy', async () => {
|
tap.test('should create proxy instance with extended options', async () => {
|
||||||
|
// Test with extended options to verify backward compatibility
|
||||||
|
testProxy = new smartproxy.NetworkProxy({
|
||||||
|
port: 3001,
|
||||||
|
maxConnections: 5000,
|
||||||
|
keepAliveTimeout: 120000,
|
||||||
|
headersTimeout: 60000,
|
||||||
|
logLevel: 'info',
|
||||||
|
cors: {
|
||||||
|
allowOrigin: '*',
|
||||||
|
allowMethods: 'GET, POST, OPTIONS',
|
||||||
|
allowHeaders: 'Content-Type',
|
||||||
|
maxAge: 3600
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(testProxy).toEqual(testProxy); // Instance equality check
|
||||||
|
expect(testProxy.options.port).toEqual(3001);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
await testProxy.start();
|
||||||
});
|
console.log('[TEST] Proxy server started');
|
||||||
|
|
||||||
tap.test('should supply reverse proxy config', async () => {
|
// Configure proxy with test certificates
|
||||||
testProxy.updateProxyConfigs([
|
// Awaiting the update ensures that the SNI context is added before any requests come in.
|
||||||
|
await testProxy.updateProxyConfigs([
|
||||||
{
|
{
|
||||||
destinationIp: '127.0.0.1',
|
destinationIps: ['127.0.0.1'],
|
||||||
destinationPort: '3000',
|
destinationPorts: [3000],
|
||||||
hostName: 'push.rocks',
|
hostName: 'push.rocks',
|
||||||
privateKey: `-----BEGIN PRIVATE KEY-----
|
publicKey: testCertificates.publicKey,
|
||||||
MIIJRQIBADANBgkqhkiG9w0BAQEFAASCCS8wggkrAgEAAoICAQDi2F/0kQr96mhe
|
privateKey: testCertificates.privateKey,
|
||||||
3yEWvy2mRHOZoSSBtIqg6Bre4ZcMu901/cHNIjFnynNGFl9Se61yZbW2F3PfCt7+
|
|
||||||
kQlHug1Cx+LFssvz+hLlB5cqJQZfRKx92DhbROygtxG9r7UBmx/fwx+JQ+HOHX9R
|
|
||||||
b+szLBZqxrNDBFl2SRdviconYgVnHbaqcAPj/lK6D6x94qgUEX+vMjbIruuiCe3u
|
|
||||||
RbYse/quzAednVnY/+BuGVn8SEb2EVVFnBEsOxxYpy5ZzGR48O3YnWkM2oPpJhrp
|
|
||||||
mMYLcARMnDmIQDVstD1i+MM2lVhx/pm9xKKUgWNJC7lyz2xRscZ4pOtLkfN94leH
|
|
||||||
U98nIvxfQe7tQFKN9K52yjdtoT0UaIEUFbZyddkoNka1Xx6r+rE96046BLT2lVs0
|
|
||||||
/rnTxZUFH6vP3z9UNktmpxtnZSk67Pj6QAqZtgT0amXEpBlk7vBYSjHsyJ3+5R1y
|
|
||||||
oSjhAqeejq6M67NDOflrag5LSTkeTe4dqk0laVb1gjcse18AOlgf7pw5H79zclYH
|
|
||||||
NAnoAPua683MD2ZZd4eovEww/imSZvui3NlisSSh1SomABDFxiEaHpewI98n8P1E
|
|
||||||
3vfg4lyCV5VcUjwrPjnkfEJbX1c1/PXqTtPqSqFn/pI4FuTES6qDugS2EA/XT1ln
|
|
||||||
ODHigOiFCzDbhOMuQjhI8hzuevrRRQIDAQABAoICAQC7nU+HW6qmpQebZ5nbUVT1
|
|
||||||
Deo6Js+lwudg+3a13ghqzLnBXNW7zkrkV8mNLxW5h3bFhZ+LMcxwrXIPQ29Udmlf
|
|
||||||
USiacC1E5RBZgjSg86xYgNjU4E6EFfZLWf3/T2I6KM1s6NmdUppgOX9CoHj7grwr
|
|
||||||
pZk/lUpUjVEnu+OJPQXQ6f9Y6XoeSAqtvibgmuR+bJaZFMPAqQNTqjix99Aa7JNB
|
|
||||||
nJez4R8dXUuGY8tL349pFp7bCqAdX+oq3GJ2fJigekuM+2uV6OhunUhm6Sbq8MNt
|
|
||||||
hUwEB27oMA4RXENAUraq2XLYQ9hfUMAH+v1vGmSxEIJg561/e//RnrDbyR9oJARr
|
|
||||||
SbopI3Ut5yKxVKMYOTSqcFQXVLszTExhMhQCRoOh58BpIfhb9FLCKD9LH8E6eoQf
|
|
||||||
ygPWryey9AAJ7B2PQXVbitzcOML27rzC4DXS+mLe6AVL6t2IldaeMTlumlnc620d
|
|
||||||
Yuf5wSe8qe4xpKOlrE9emnBmbL0sGivsU+mpz9oSjxEpHGA7eoTIOmQiZnuzpkmi
|
|
||||||
1ZSU4OwqNavphy6cklONShQOmE8LMI0wRbunLjIFY8fme/8u+tVvWrTuJiCGPnXQ
|
|
||||||
F2lb0qwtDVRlexyM+GTPYstU5v7HxkQB3B+uwTgYuupCmTNmO8hjSCS/EYpHzmFe
|
|
||||||
YHDEN+Cj8f+vmKxN0F/6QQKCAQEA9+wTQU2GSoVX8IB0U6T+hX0BFhQq5ISH/s76
|
|
||||||
kWIEunY1MCkRL9YygvHkKW3dsXVOzsip/axiT36MhRcyZ27hF1tz3j//Z11E3Bfq
|
|
||||||
PkzyUVuU3jpWZkBE2VhXpDXlyW8xR/y1ZOaZZ//XcZTrZf57pGKFp30H/PlDPH3C
|
|
||||||
YtjEuQNmPCgnfz8iXx+vDYx8hwLHNv+DoX2WYuThUnul/QGSKL3xh3qWd8rotnUB
|
|
||||||
c8bV4ymk35fVJu/+pTZpPnMkYrFReso/uNn07y1iga/9mwkUBNrT+fWE7RzjT7H8
|
|
||||||
ykMMOGCK6bc7joCvALZaUDne714hNW3s9a7L1clehUA8/xwplQKCAQEA6jx/CIQd
|
|
||||||
RVdJFihSSZbqdrOAblVdl+WkjhALWNRMoRCCRniNubbgxgKfQ0scKUeubYxScBVk
|
|
||||||
rlUMl6/2Gr9uzuSC0WPVAE6OLvLNcQafw1mQ1UTJiEzYvczJKwipzXcgGQWO9Q9a
|
|
||||||
T3ETh6Be62si2r6fH4agQzbp4HkTEoWgPu6MJpqqcLoc8laty0d1huqU9du1TRzT
|
|
||||||
3etjopWRd0I3ID+WkkGKjYWRQ1bkKjvkkj1v7bHenX17nfIp5WU1aXTMYUCMMszm
|
|
||||||
pgVBDeJGKpPpP3scl7go5Y4KC6H+IeYaeCEk3hWW4robpHBzupkgpRLzmBopjRlN
|
|
||||||
v3+HQ7OkviX88QKCAQEAg5IJdfKKfindzYieM3WwjW8VkH4LdVLQSW3WlCkMkVgC
|
|
||||||
ShjBQj3OeKeeik4ABRlYRW1AqZs+YSmrsUXqPfIeCqNCDoSwKk7ZKGSYr49uWbbc
|
|
||||||
fkM/buxUnXPAryjbVddos+ds7KtkZkjkMSby9iHjxA11GLnF737pK8Uh0Atx+y3O
|
|
||||||
p8Y3j9QVjZ3m7K3NuGjFCG75kE5x7PHCkl+Ea4zV4EFNWLS5/cD1Vz8pEiRHhlKn
|
|
||||||
aPHO8OcUoOELYVUBzk6EC0IiJxukXPoc+O5JDGn48cqgDFs7vApEqBqxKTYD2jeC
|
|
||||||
AR54wNuSBDLCIylTIn016oD37DpjeoVvYBADTu/HMQKCAQEA1rFuajrVrWnMpo98
|
|
||||||
pNC7xOLQM9DwwToOMtwH2np0ZiiAj+ENXgx+R1+95Gsiu79k5Cn6oZsqNhPkP+Bb
|
|
||||||
fba69M1EDnInmGloLyYDIbbFlsMwWhn7cn+lJYpfVJ9TK+0lMWoD1yAkUa4+DVDz
|
|
||||||
z2naf466wKWfnRvnEAVJcu+hqizxrqySzlH4GDNUhn7P/UJkGFkx+yUSGFUZdLsM
|
|
||||||
orfBWUCPXSzPttmXBJbO+Nr+rP+86KvgdI/AT0vYFNdINomEjxsfpaxjOAaW0wfz
|
|
||||||
8jCyWKoZ0gJNEeK32GO5UA7dcgBHD3vQWa3lijo8COsznboaJe7M6PQpa/2S2H3+
|
|
||||||
4P5msQKCAQEAx7NP3y+5ttfTd/eQ7/cg1/0y2WxvpOYNLt6MWz4rPWyD6QwidzTG
|
|
||||||
pjuQFQ5Ods+BwJ/Jbirb7l4GMAxfIbEPAkPTHpvswO0xcncSYxl0sSP/WIA6sbcM
|
|
||||||
dp7B/scdORC8Y6i8oPdCyxyCTd2SBrmGr2krAXmQquT72eusyP5E8HFhCy1iYt22
|
|
||||||
aL68dZLv9/sRAF08t9Wy+eYjD/hCj67t7uGCZQT8wJbKr8aJcjwVwJgghh+3EydK
|
|
||||||
h+7fBVO49PLL0NWy+8GT8y7a04calFfLvZEA2UMaunBis3dE1KMFfJL/0JO+sKnF
|
|
||||||
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-----
|
|
||||||
`,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
console.log('[TEST] Proxy configuration updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should wait for 60 seconds', async (tools) => {
|
tap.test('should route HTTPS requests based on host header', async () => {
|
||||||
await tools.delayFor(10000);
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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([
|
||||||
|
{
|
||||||
|
destinationIps: ['127.0.0.1'],
|
||||||
|
destinationPorts: [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('should handle CORS preflight requests', async () => {
|
||||||
|
// Instead of creating a new proxy instance, let's update the options on the current one
|
||||||
|
// First ensure the existing proxy is working correctly
|
||||||
|
const initialResponse = await makeHttpsRequest({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 3001,
|
||||||
|
path: '/',
|
||||||
|
method: 'GET',
|
||||||
|
headers: { host: 'push.rocks' },
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(initialResponse.statusCode).toEqual(200);
|
||||||
|
|
||||||
|
// Add CORS headers to the existing proxy
|
||||||
|
await testProxy.addDefaultHeaders({
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow server to process the header changes
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Send OPTIONS request to simulate CORS preflight
|
||||||
|
const response = await makeHttpsRequest({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 3001,
|
||||||
|
path: '/',
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: {
|
||||||
|
host: 'push.rocks',
|
||||||
|
'Access-Control-Request-Method': 'POST',
|
||||||
|
'Access-Control-Request-Headers': 'Content-Type',
|
||||||
|
'Origin': 'https://example.com'
|
||||||
|
},
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the response has expected status code
|
||||||
|
expect(response.statusCode).toEqual(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should track connections and metrics', async () => {
|
||||||
|
// Instead of creating a new proxy instance, let's just make requests to the existing one
|
||||||
|
// and verify the metrics are being tracked
|
||||||
|
|
||||||
|
// Get initial metrics counts
|
||||||
|
const initialRequestsServed = testProxy.requestsServed || 0;
|
||||||
|
|
||||||
|
// Make a few requests to ensure we have metrics to check
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await makeHttpsRequest({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 3001,
|
||||||
|
path: '/metrics-test-' + i,
|
||||||
|
method: 'GET',
|
||||||
|
headers: { host: 'push.rocks' },
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit to let metrics update
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Verify metrics tracking is working - should have at least 3 more requests than before
|
||||||
|
expect(testProxy.connectedClients).toBeDefined();
|
||||||
|
expect(typeof testProxy.requestsServed).toEqual('number');
|
||||||
|
expect(testProxy.requestsServed).toBeGreaterThan(initialRequestsServed + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
await testProxy.stop();
|
||||||
|
console.log('[TEST] Cleanup complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
tap.start();
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '3.1.0',
|
version: '5.0.0',
|
||||||
description: 'a proxy for handling high workloads of proxying'
|
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.'
|
||||||
}
|
}
|
||||||
|
1730
ts/classes.networkproxy.ts
Normal file
1730
ts/classes.networkproxy.ts
Normal file
File diff suppressed because it is too large
Load Diff
2045
ts/classes.nftablesproxy.ts
Normal file
2045
ts/classes.nftablesproxy.ts
Normal file
File diff suppressed because it is too large
Load Diff
931
ts/classes.port80handler.ts
Normal file
931
ts/classes.port80handler.ts
Normal file
@ -0,0 +1,931 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error classes for better error handling
|
||||||
|
*/
|
||||||
|
export class Port80HandlerError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'Port80HandlerError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CertificateError extends Port80HandlerError {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly domain: string,
|
||||||
|
public readonly isRenewal: boolean = false
|
||||||
|
) {
|
||||||
|
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
|
||||||
|
this.name = 'CertificateError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerError extends Port80HandlerError {
|
||||||
|
constructor(message: string, public readonly code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ServerError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain forwarding configuration
|
||||||
|
*/
|
||||||
|
export interface IForwardConfig {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain configuration options
|
||||||
|
*/
|
||||||
|
export interface IDomainOptions {
|
||||||
|
domainName: string;
|
||||||
|
sslRedirect: boolean; // if true redirects the request to port 443
|
||||||
|
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
|
||||||
|
forward?: IForwardConfig; // forwards all http requests to that target
|
||||||
|
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a domain configuration with certificate status information
|
||||||
|
*/
|
||||||
|
interface IDomainCertificate {
|
||||||
|
options: IDomainOptions;
|
||||||
|
certObtained: boolean;
|
||||||
|
obtainingInProgress: boolean;
|
||||||
|
certificate?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
challengeToken?: string;
|
||||||
|
challengeKeyAuthorization?: string;
|
||||||
|
expiryDate?: Date;
|
||||||
|
lastRenewalAttempt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the Port80Handler
|
||||||
|
*/
|
||||||
|
interface IPort80HandlerOptions {
|
||||||
|
port?: number;
|
||||||
|
contactEmail?: string;
|
||||||
|
useProduction?: boolean;
|
||||||
|
renewThresholdDays?: number;
|
||||||
|
httpsRedirectPort?: number;
|
||||||
|
renewCheckIntervalHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate data that can be emitted via events or set from outside
|
||||||
|
*/
|
||||||
|
export interface ICertificateData {
|
||||||
|
domain: string;
|
||||||
|
certificate: string;
|
||||||
|
privateKey: string;
|
||||||
|
expiryDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by the Port80Handler
|
||||||
|
*/
|
||||||
|
export enum Port80HandlerEvents {
|
||||||
|
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||||
|
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||||
|
CERTIFICATE_FAILED = 'certificate-failed',
|
||||||
|
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||||
|
MANAGER_STARTED = 'manager-started',
|
||||||
|
MANAGER_STOPPED = 'manager-stopped',
|
||||||
|
REQUEST_FORWARDED = 'request-forwarded',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate failure payload type
|
||||||
|
*/
|
||||||
|
export interface ICertificateFailure {
|
||||||
|
domain: string;
|
||||||
|
error: string;
|
||||||
|
isRenewal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate expiry payload type
|
||||||
|
*/
|
||||||
|
export interface ICertificateExpiring {
|
||||||
|
domain: string;
|
||||||
|
expiryDate: Date;
|
||||||
|
daysRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port80Handler with ACME certificate management and request forwarding capabilities
|
||||||
|
* Now with glob pattern support for domain matching
|
||||||
|
*/
|
||||||
|
export class Port80Handler extends plugins.EventEmitter {
|
||||||
|
private domainCertificates: Map<string, IDomainCertificate>;
|
||||||
|
private server: plugins.http.Server | null = null;
|
||||||
|
private acmeClient: plugins.acme.Client | null = null;
|
||||||
|
private accountKey: string | null = null;
|
||||||
|
private renewalTimer: NodeJS.Timeout | null = null;
|
||||||
|
private isShuttingDown: boolean = false;
|
||||||
|
private options: Required<IPort80HandlerOptions>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Port80Handler
|
||||||
|
* @param options Configuration options
|
||||||
|
*/
|
||||||
|
constructor(options: IPort80HandlerOptions = {}) {
|
||||||
|
super();
|
||||||
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
||||||
|
|
||||||
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
port: options.port ?? 80,
|
||||||
|
contactEmail: options.contactEmail ?? 'admin@example.com',
|
||||||
|
useProduction: options.useProduction ?? false, // Safer default: staging
|
||||||
|
renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements
|
||||||
|
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
||||||
|
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the HTTP server for ACME challenges
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
throw new ServerError('Server is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
throw new ServerError('Server is shutting down');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
||||||
|
|
||||||
|
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'EACCES') {
|
||||||
|
reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
|
||||||
|
} else if (error.code === 'EADDRINUSE') {
|
||||||
|
reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
|
||||||
|
} else {
|
||||||
|
reject(new ServerError(error.message, error.code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.listen(this.options.port, () => {
|
||||||
|
console.log(`Port80Handler is listening on port ${this.options.port}`);
|
||||||
|
this.startRenewalTimer();
|
||||||
|
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
|
||||||
|
|
||||||
|
// Start certificate process for domains with acmeMaintenance enabled
|
||||||
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||||
|
// Skip glob patterns for certificate issuance
|
||||||
|
if (this.isGlobPattern(domain)) {
|
||||||
|
console.log(`Skipping initial certificate for glob pattern: ${domain}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
|
||||||
|
this.obtainCertificate(domain).catch(err => {
|
||||||
|
console.error(`Error obtaining initial certificate for ${domain}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error starting server';
|
||||||
|
reject(new ServerError(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the HTTP server and renewal timer
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
// Stop the renewal timer
|
||||||
|
if (this.renewalTimer) {
|
||||||
|
clearInterval(this.renewalTimer);
|
||||||
|
this.renewalTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close(() => {
|
||||||
|
this.server = null;
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
this.emit(Port80HandlerEvents.MANAGER_STOPPED);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a domain with configuration options
|
||||||
|
* @param options Domain configuration options
|
||||||
|
*/
|
||||||
|
public addDomain(options: IDomainOptions): void {
|
||||||
|
if (!options.domainName || typeof options.domainName !== 'string') {
|
||||||
|
throw new Port80HandlerError('Invalid domain name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainName = options.domainName;
|
||||||
|
|
||||||
|
if (!this.domainCertificates.has(domainName)) {
|
||||||
|
this.domainCertificates.set(domainName, {
|
||||||
|
options,
|
||||||
|
certObtained: false,
|
||||||
|
obtainingInProgress: false
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Domain added: ${domainName} with configuration:`, {
|
||||||
|
sslRedirect: options.sslRedirect,
|
||||||
|
acmeMaintenance: options.acmeMaintenance,
|
||||||
|
hasForward: !!options.forward,
|
||||||
|
hasAcmeForward: !!options.acmeForward
|
||||||
|
});
|
||||||
|
|
||||||
|
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
|
||||||
|
if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
|
||||||
|
this.obtainCertificate(domainName).catch(err => {
|
||||||
|
console.error(`Error obtaining initial certificate for ${domainName}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update existing domain with new options
|
||||||
|
const existing = this.domainCertificates.get(domainName)!;
|
||||||
|
existing.options = options;
|
||||||
|
console.log(`Domain ${domainName} configuration updated`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a domain from management
|
||||||
|
* @param domain The domain to remove
|
||||||
|
*/
|
||||||
|
public removeDomain(domain: string): void {
|
||||||
|
if (this.domainCertificates.delete(domain)) {
|
||||||
|
console.log(`Domain removed: ${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a certificate for a domain directly (for externally obtained certificates)
|
||||||
|
* @param domain The domain for the certificate
|
||||||
|
* @param certificate The certificate (PEM format)
|
||||||
|
* @param privateKey The private key (PEM format)
|
||||||
|
* @param expiryDate Optional expiry date
|
||||||
|
*/
|
||||||
|
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
||||||
|
if (!domain || !certificate || !privateKey) {
|
||||||
|
throw new Port80HandlerError('Domain, certificate and privateKey are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow setting certificates for glob patterns
|
||||||
|
if (this.isGlobPattern(domain)) {
|
||||||
|
throw new Port80HandlerError('Cannot set certificate for glob pattern domains');
|
||||||
|
}
|
||||||
|
|
||||||
|
let domainInfo = this.domainCertificates.get(domain);
|
||||||
|
|
||||||
|
if (!domainInfo) {
|
||||||
|
// Create default domain options if not already configured
|
||||||
|
const defaultOptions: IDomainOptions = {
|
||||||
|
domainName: domain,
|
||||||
|
sslRedirect: true,
|
||||||
|
acmeMaintenance: true
|
||||||
|
};
|
||||||
|
|
||||||
|
domainInfo = {
|
||||||
|
options: defaultOptions,
|
||||||
|
certObtained: false,
|
||||||
|
obtainingInProgress: false
|
||||||
|
};
|
||||||
|
this.domainCertificates.set(domain, domainInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
domainInfo.certificate = certificate;
|
||||||
|
domainInfo.privateKey = privateKey;
|
||||||
|
domainInfo.certObtained = true;
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
|
||||||
|
if (expiryDate) {
|
||||||
|
domainInfo.expiryDate = expiryDate;
|
||||||
|
} else {
|
||||||
|
// Extract expiry date from certificate
|
||||||
|
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Certificate set for ${domain}`);
|
||||||
|
|
||||||
|
// Emit certificate event
|
||||||
|
this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
|
||||||
|
domain,
|
||||||
|
certificate,
|
||||||
|
privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the certificate for a domain if it exists
|
||||||
|
* @param domain The domain to get the certificate for
|
||||||
|
*/
|
||||||
|
public getCertificate(domain: string): ICertificateData | null {
|
||||||
|
// Can't get certificates for glob patterns
|
||||||
|
if (this.isGlobPattern(domain)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
|
||||||
|
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
certificate: domainInfo.certificate,
|
||||||
|
privateKey: domainInfo.privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain is a glob pattern
|
||||||
|
* @param domain Domain to check
|
||||||
|
* @returns True if the domain is a glob pattern
|
||||||
|
*/
|
||||||
|
private isGlobPattern(domain: string): boolean {
|
||||||
|
return domain.includes('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain info for a specific domain, using glob pattern matching if needed
|
||||||
|
* @param requestDomain The actual domain from the request
|
||||||
|
* @returns The domain info or null if not found
|
||||||
|
*/
|
||||||
|
private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null {
|
||||||
|
// Try direct match first
|
||||||
|
if (this.domainCertificates.has(requestDomain)) {
|
||||||
|
return {
|
||||||
|
domainInfo: this.domainCertificates.get(requestDomain)!,
|
||||||
|
pattern: requestDomain
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try glob patterns
|
||||||
|
for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
|
||||||
|
if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
|
||||||
|
return { domainInfo, pattern };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain matches a glob pattern
|
||||||
|
* @param domain The domain to check
|
||||||
|
* @param pattern The pattern to match against
|
||||||
|
* @returns True if the domain matches the pattern
|
||||||
|
*/
|
||||||
|
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
||||||
|
// Handle different glob pattern styles
|
||||||
|
if (pattern.startsWith('*.')) {
|
||||||
|
// *.example.com matches any subdomain
|
||||||
|
const suffix = pattern.substring(2);
|
||||||
|
return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
|
||||||
|
} else if (pattern.endsWith('.*')) {
|
||||||
|
// example.* matches any TLD
|
||||||
|
const prefix = pattern.substring(0, pattern.length - 2);
|
||||||
|
const domainParts = domain.split('.');
|
||||||
|
return domain.startsWith(prefix + '.') && domainParts.length >= 2;
|
||||||
|
} else if (pattern === '*') {
|
||||||
|
// Wildcard matches everything
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Exact match (shouldn't reach here as we check exact matches first)
|
||||||
|
return domain === pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy initialization of the ACME client
|
||||||
|
* @returns An ACME client instance
|
||||||
|
*/
|
||||||
|
private async getAcmeClient(): Promise<plugins.acme.Client> {
|
||||||
|
if (this.acmeClient) {
|
||||||
|
return this.acmeClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate a new account key
|
||||||
|
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
|
||||||
|
|
||||||
|
this.acmeClient = new plugins.acme.Client({
|
||||||
|
directoryUrl: this.options.useProduction
|
||||||
|
? plugins.acme.directory.letsencrypt.production
|
||||||
|
: plugins.acme.directory.letsencrypt.staging,
|
||||||
|
accountKey: this.accountKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new account
|
||||||
|
await this.acmeClient.createAccount({
|
||||||
|
termsOfServiceAgreed: true,
|
||||||
|
contact: [`mailto:${this.options.contactEmail}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.acmeClient;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client';
|
||||||
|
throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming HTTP requests
|
||||||
|
* @param req The HTTP request
|
||||||
|
* @param res The HTTP response
|
||||||
|
*/
|
||||||
|
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||||
|
const hostHeader = req.headers.host;
|
||||||
|
if (!hostHeader) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Bad Request: Host header is missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domain (ignoring any port in the Host header)
|
||||||
|
const domain = hostHeader.split(':')[0];
|
||||||
|
|
||||||
|
// Get domain config, using glob pattern matching if needed
|
||||||
|
const domainMatch = this.getDomainInfoForRequest(domain);
|
||||||
|
|
||||||
|
if (!domainMatch) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Domain not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { domainInfo, pattern } = domainMatch;
|
||||||
|
const options = domainInfo.options;
|
||||||
|
|
||||||
|
// If the request is for an ACME HTTP-01 challenge, handle it
|
||||||
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) {
|
||||||
|
// Check if we should forward ACME requests
|
||||||
|
if (options.acmeForward) {
|
||||||
|
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle ACME challenges for non-glob patterns
|
||||||
|
if (!this.isGlobPattern(pattern)) {
|
||||||
|
this.handleAcmeChallenge(req, res, domain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should forward non-ACME requests
|
||||||
|
if (options.forward) {
|
||||||
|
this.forwardRequest(req, res, options.forward, 'HTTP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
|
||||||
|
// (Skip for glob patterns as they won't have certificates)
|
||||||
|
if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
|
||||||
|
const httpsPort = this.options.httpsRedirectPort;
|
||||||
|
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
||||||
|
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
||||||
|
|
||||||
|
res.statusCode = 301;
|
||||||
|
res.setHeader('Location', redirectUrl);
|
||||||
|
res.end(`Redirecting to ${redirectUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where certificate maintenance is enabled but not yet obtained
|
||||||
|
// (Skip for glob patterns as they can't have certificates)
|
||||||
|
if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
|
||||||
|
// Trigger certificate issuance if not already running
|
||||||
|
if (!domainInfo.obtainingInProgress) {
|
||||||
|
this.obtainCertificate(domain).catch(err => {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
||||||
|
domain,
|
||||||
|
error: errorMessage,
|
||||||
|
isRenewal: false
|
||||||
|
});
|
||||||
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.end('Certificate issuance in progress, please try again later.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default response for unhandled request
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('No handlers configured for this request');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards an HTTP request to the specified target
|
||||||
|
* @param req The original request
|
||||||
|
* @param res The response object
|
||||||
|
* @param target The forwarding target (IP and port)
|
||||||
|
* @param requestType Type of request for logging
|
||||||
|
*/
|
||||||
|
private forwardRequest(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
target: IForwardConfig,
|
||||||
|
requestType: string
|
||||||
|
): void {
|
||||||
|
const options = {
|
||||||
|
hostname: target.ip,
|
||||||
|
port: target.port,
|
||||||
|
path: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers: { ...req.headers }
|
||||||
|
};
|
||||||
|
|
||||||
|
const domain = req.headers.host?.split(':')[0] || 'unknown';
|
||||||
|
console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
|
||||||
|
|
||||||
|
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
||||||
|
// Copy status code
|
||||||
|
res.statusCode = proxyRes.statusCode || 500;
|
||||||
|
|
||||||
|
// Copy headers
|
||||||
|
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||||
|
if (value) res.setHeader(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipe response data
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
|
||||||
|
this.emit(Port80HandlerEvents.REQUEST_FORWARDED, {
|
||||||
|
domain,
|
||||||
|
requestType,
|
||||||
|
target: `${target.ip}:${target.port}`,
|
||||||
|
statusCode: proxyRes.statusCode
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (error) => {
|
||||||
|
console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.end(`Proxy error: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipe original request to proxy request
|
||||||
|
if (req.readable) {
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
} else {
|
||||||
|
proxyReq.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the ACME HTTP-01 challenge response
|
||||||
|
* @param req The HTTP request
|
||||||
|
* @param res The HTTP response
|
||||||
|
* @param domain The domain for the challenge
|
||||||
|
*/
|
||||||
|
private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void {
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
if (!domainInfo) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Domain not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is the last part of the URL
|
||||||
|
const urlParts = req.url?.split('/');
|
||||||
|
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
||||||
|
|
||||||
|
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end(domainInfo.challengeKeyAuthorization);
|
||||||
|
console.log(`Served ACME challenge response for ${domain}`);
|
||||||
|
} else {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Challenge token not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
||||||
|
* @param domain The domain to obtain a certificate for
|
||||||
|
* @param isRenewal Whether this is a renewal attempt
|
||||||
|
*/
|
||||||
|
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
||||||
|
// Don't allow certificate issuance for glob patterns
|
||||||
|
if (this.isGlobPattern(domain)) {
|
||||||
|
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the domain info
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
if (!domainInfo) {
|
||||||
|
throw new CertificateError('Domain not found', domain, isRenewal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that acmeMaintenance is enabled
|
||||||
|
if (!domainInfo.options.acmeMaintenance) {
|
||||||
|
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent concurrent certificate issuance
|
||||||
|
if (domainInfo.obtainingInProgress) {
|
||||||
|
console.log(`Certificate issuance already in progress for ${domain}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
domainInfo.obtainingInProgress = true;
|
||||||
|
domainInfo.lastRenewalAttempt = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await this.getAcmeClient();
|
||||||
|
|
||||||
|
// Create a new order for the domain
|
||||||
|
const order = await client.createOrder({
|
||||||
|
identifiers: [{ type: 'dns', value: domain }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the authorizations for the order
|
||||||
|
const authorizations = await client.getAuthorizations(order);
|
||||||
|
|
||||||
|
// Process each authorization
|
||||||
|
await this.processAuthorizations(client, domain, authorizations);
|
||||||
|
|
||||||
|
// Generate a CSR and private key
|
||||||
|
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
|
||||||
|
commonName: domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
const csr = csrBuffer.toString();
|
||||||
|
const privateKey = privateKeyBuffer.toString();
|
||||||
|
|
||||||
|
// Finalize the order with our CSR
|
||||||
|
await client.finalizeOrder(order, csr);
|
||||||
|
|
||||||
|
// Get the certificate with the full chain
|
||||||
|
const certificate = await client.getCertificate(order);
|
||||||
|
|
||||||
|
// Store the certificate and key
|
||||||
|
domainInfo.certificate = certificate;
|
||||||
|
domainInfo.privateKey = privateKey;
|
||||||
|
domainInfo.certObtained = true;
|
||||||
|
|
||||||
|
// Clear challenge data
|
||||||
|
delete domainInfo.challengeToken;
|
||||||
|
delete domainInfo.challengeKeyAuthorization;
|
||||||
|
|
||||||
|
// Extract expiry date from certificate
|
||||||
|
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
|
||||||
|
|
||||||
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
||||||
|
|
||||||
|
// Emit the appropriate event
|
||||||
|
const eventType = isRenewal
|
||||||
|
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
||||||
|
: Port80HandlerEvents.CERTIFICATE_ISSUED;
|
||||||
|
|
||||||
|
this.emitCertificateEvent(eventType, {
|
||||||
|
domain,
|
||||||
|
certificate,
|
||||||
|
privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check for rate limit errors
|
||||||
|
if (error.message && (
|
||||||
|
error.message.includes('rateLimited') ||
|
||||||
|
error.message.includes('too many certificates') ||
|
||||||
|
error.message.includes('rate limit')
|
||||||
|
)) {
|
||||||
|
console.error(`Rate limit reached for ${domain}. Waiting before retry.`);
|
||||||
|
} else {
|
||||||
|
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit failure event
|
||||||
|
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
||||||
|
domain,
|
||||||
|
error: error.message || 'Unknown error',
|
||||||
|
isRenewal
|
||||||
|
} as ICertificateFailure);
|
||||||
|
|
||||||
|
throw new CertificateError(
|
||||||
|
error.message || 'Certificate issuance failed',
|
||||||
|
domain,
|
||||||
|
isRenewal
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Reset flag whether successful or not
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process ACME authorizations by verifying and completing challenges
|
||||||
|
* @param client ACME client
|
||||||
|
* @param domain Domain name
|
||||||
|
* @param authorizations Authorizations to process
|
||||||
|
*/
|
||||||
|
private async processAuthorizations(
|
||||||
|
client: plugins.acme.Client,
|
||||||
|
domain: string,
|
||||||
|
authorizations: plugins.acme.Authorization[]
|
||||||
|
): Promise<void> {
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
if (!domainInfo) {
|
||||||
|
throw new CertificateError('Domain not found during authorization', domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const authz of authorizations) {
|
||||||
|
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
||||||
|
if (!challenge) {
|
||||||
|
throw new CertificateError('HTTP-01 challenge not found', domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the key authorization for the challenge
|
||||||
|
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
||||||
|
|
||||||
|
// Store the challenge data
|
||||||
|
domainInfo.challengeToken = challenge.token;
|
||||||
|
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
||||||
|
|
||||||
|
// ACME client type definition workaround - use compatible approach
|
||||||
|
// First check if challenge verification is needed
|
||||||
|
const authzUrl = authz.url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if authzUrl exists and perform verification
|
||||||
|
if (authzUrl) {
|
||||||
|
await client.verifyChallenge(authz, challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete the challenge
|
||||||
|
await client.completeChallenge(challenge);
|
||||||
|
|
||||||
|
// Wait for validation
|
||||||
|
await client.waitForValidStatus(challenge);
|
||||||
|
console.log(`HTTP-01 challenge completed for ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error';
|
||||||
|
console.error(`Challenge error for ${domain}:`, error);
|
||||||
|
throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the certificate renewal timer
|
||||||
|
*/
|
||||||
|
private startRenewalTimer(): void {
|
||||||
|
if (this.renewalTimer) {
|
||||||
|
clearInterval(this.renewalTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hours to milliseconds
|
||||||
|
const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval);
|
||||||
|
|
||||||
|
// Prevent the timer from keeping the process alive
|
||||||
|
if (this.renewalTimer.unref) {
|
||||||
|
this.renewalTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for certificates that need renewal
|
||||||
|
*/
|
||||||
|
private checkForRenewals(): void {
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Checking for certificates that need renewal...');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||||
|
// Skip glob patterns
|
||||||
|
if (this.isGlobPattern(domain)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip domains with acmeMaintenance disabled
|
||||||
|
if (!domainInfo.options.acmeMaintenance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip domains without certificates or already in renewal
|
||||||
|
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip domains without expiry dates
|
||||||
|
if (!domainInfo.expiryDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime();
|
||||||
|
|
||||||
|
// Check if certificate is near expiry
|
||||||
|
if (timeUntilExpiry <= renewThresholdMs) {
|
||||||
|
console.log(`Certificate for ${domain} expires soon, renewing...`);
|
||||||
|
|
||||||
|
const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, {
|
||||||
|
domain,
|
||||||
|
expiryDate: domainInfo.expiryDate,
|
||||||
|
daysRemaining
|
||||||
|
} as ICertificateExpiring);
|
||||||
|
|
||||||
|
// Start renewal process
|
||||||
|
this.obtainCertificate(domain, true).catch(err => {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
console.error(`Error renewing certificate for ${domain}:`, errorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract expiry date from certificate using a more robust approach
|
||||||
|
* @param certificate Certificate PEM string
|
||||||
|
* @param domain Domain for logging
|
||||||
|
* @returns Extracted expiry date or default
|
||||||
|
*/
|
||||||
|
private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
|
||||||
|
try {
|
||||||
|
// This is still using regex, but in a real implementation you would use
|
||||||
|
// a library like node-forge or x509 to properly parse the certificate
|
||||||
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||||
|
if (matches && matches[1]) {
|
||||||
|
const expiryDate = new Date(matches[1]);
|
||||||
|
|
||||||
|
// Validate that we got a valid date
|
||||||
|
if (!isNaN(expiryDate.getTime())) {
|
||||||
|
console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
|
||||||
|
return expiryDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
|
||||||
|
return this.getDefaultExpiryDate();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
|
||||||
|
return this.getDefaultExpiryDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a default expiry date (90 days from now)
|
||||||
|
* @returns Default expiry date
|
||||||
|
*/
|
||||||
|
private getDefaultExpiryDate(): Date {
|
||||||
|
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a certificate event with the certificate data
|
||||||
|
* @param eventType The event type to emit
|
||||||
|
* @param data The certificate data
|
||||||
|
*/
|
||||||
|
private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
|
||||||
|
this.emit(eventType, data);
|
||||||
|
}
|
||||||
|
}
|
149
ts/classes.pp.acmemanager.ts
Normal file
149
ts/classes.pp.acmemanager.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import type { IPortProxySettings } from './classes.pp.interfaces.js';
|
||||||
|
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages ACME certificate operations
|
||||||
|
*/
|
||||||
|
export class AcmeManager {
|
||||||
|
constructor(
|
||||||
|
private settings: IPortProxySettings,
|
||||||
|
private networkProxyBridge: NetworkProxyBridge
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current ACME settings
|
||||||
|
*/
|
||||||
|
public getAcmeSettings(): IPortProxySettings['acme'] {
|
||||||
|
return this.settings.acme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ACME is enabled
|
||||||
|
*/
|
||||||
|
public isAcmeEnabled(): boolean {
|
||||||
|
return !!this.settings.acme?.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ACME certificate settings
|
||||||
|
*/
|
||||||
|
public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise<void> {
|
||||||
|
console.log('Updating ACME certificate settings');
|
||||||
|
|
||||||
|
// Check if enabled state is changing
|
||||||
|
const enabledChanging = this.settings.acme?.enabled !== acmeSettings.enabled;
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
this.settings.acme = {
|
||||||
|
...this.settings.acme,
|
||||||
|
...acmeSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get NetworkProxy instance
|
||||||
|
const networkProxy = this.networkProxyBridge.getNetworkProxy();
|
||||||
|
|
||||||
|
if (!networkProxy) {
|
||||||
|
console.log('Cannot update ACME settings - NetworkProxy not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If enabled state changed, we need to restart NetworkProxy
|
||||||
|
if (enabledChanging) {
|
||||||
|
console.log(`ACME enabled state changed to: ${acmeSettings.enabled}`);
|
||||||
|
|
||||||
|
// Stop the current NetworkProxy
|
||||||
|
await this.networkProxyBridge.stop();
|
||||||
|
|
||||||
|
// Reinitialize with new settings
|
||||||
|
await this.networkProxyBridge.initialize();
|
||||||
|
|
||||||
|
// Start NetworkProxy with new settings
|
||||||
|
await this.networkProxyBridge.start();
|
||||||
|
} else {
|
||||||
|
// Just update the settings in the existing NetworkProxy
|
||||||
|
console.log('Updating ACME settings in NetworkProxy without restart');
|
||||||
|
|
||||||
|
// Update settings in NetworkProxy
|
||||||
|
if (networkProxy.options && networkProxy.options.acme) {
|
||||||
|
networkProxy.options.acme = { ...this.settings.acme };
|
||||||
|
|
||||||
|
// For certificate renewals, we might want to trigger checks with the new settings
|
||||||
|
if (acmeSettings.renewThresholdDays !== undefined) {
|
||||||
|
console.log(`Setting new renewal threshold to ${acmeSettings.renewThresholdDays} days`);
|
||||||
|
networkProxy.options.acme.renewThresholdDays = acmeSettings.renewThresholdDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update other settings that might affect certificate operations
|
||||||
|
if (acmeSettings.useProduction !== undefined) {
|
||||||
|
console.log(`Setting ACME to ${acmeSettings.useProduction ? 'production' : 'staging'} mode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acmeSettings.autoRenew !== undefined) {
|
||||||
|
console.log(`Setting auto-renewal to ${acmeSettings.autoRenew ? 'enabled' : 'disabled'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error updating ACME settings: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a certificate for a specific domain
|
||||||
|
*/
|
||||||
|
public async requestCertificate(domain: string): Promise<boolean> {
|
||||||
|
// Validate domain format
|
||||||
|
if (!this.isValidDomain(domain)) {
|
||||||
|
console.log(`Invalid domain format: ${domain}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to NetworkProxyManager
|
||||||
|
return this.networkProxyBridge.requestCertificate(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic domain validation
|
||||||
|
*/
|
||||||
|
private isValidDomain(domain: string): boolean {
|
||||||
|
// Very basic domain validation
|
||||||
|
if (!domain || domain.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard domains (they can't get ACME certs)
|
||||||
|
if (domain.includes('*')) {
|
||||||
|
console.log(`Wildcard domains like "${domain}" are not supported for ACME certificates`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if domain has at least one dot and no invalid characters
|
||||||
|
const validDomainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
if (!validDomainRegex.test(domain)) {
|
||||||
|
console.log(`Domain "${domain}" has invalid format`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get eligible domains for ACME certificates
|
||||||
|
*/
|
||||||
|
public getEligibleDomains(): string[] {
|
||||||
|
// Collect all eligible domains from domain configs
|
||||||
|
const domains: string[] = [];
|
||||||
|
|
||||||
|
for (const config of this.settings.domainConfigs) {
|
||||||
|
// Skip domains that can't be used with ACME
|
||||||
|
const eligibleDomains = config.domains.filter(domain =>
|
||||||
|
!domain.includes('*') && this.isValidDomain(domain)
|
||||||
|
);
|
||||||
|
|
||||||
|
domains.push(...eligibleDomains);
|
||||||
|
}
|
||||||
|
|
||||||
|
return domains;
|
||||||
|
}
|
||||||
|
}
|
1069
ts/classes.pp.connectionhandler.ts
Normal file
1069
ts/classes.pp.connectionhandler.ts
Normal file
File diff suppressed because it is too large
Load Diff
446
ts/classes.pp.connectionmanager.ts
Normal file
446
ts/classes.pp.connectionmanager.ts
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { IConnectionRecord, IPortProxySettings } from './classes.pp.interfaces.js';
|
||||||
|
import { SecurityManager } from './classes.pp.securitymanager.js';
|
||||||
|
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages connection lifecycle, tracking, and cleanup
|
||||||
|
*/
|
||||||
|
export class ConnectionManager {
|
||||||
|
private connectionRecords: Map<string, IConnectionRecord> = new Map();
|
||||||
|
private terminationStats: {
|
||||||
|
incoming: Record<string, number>;
|
||||||
|
outgoing: Record<string, number>;
|
||||||
|
} = { incoming: {}, outgoing: {} };
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private settings: IPortProxySettings,
|
||||||
|
private securityManager: SecurityManager,
|
||||||
|
private timeoutManager: TimeoutManager
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique connection ID
|
||||||
|
*/
|
||||||
|
public generateConnectionId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 15) +
|
||||||
|
Math.random().toString(36).substring(2, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and track a new connection
|
||||||
|
*/
|
||||||
|
public createConnection(socket: plugins.net.Socket): IConnectionRecord {
|
||||||
|
const connectionId = this.generateConnectionId();
|
||||||
|
const remoteIP = socket.remoteAddress || '';
|
||||||
|
const localPort = socket.localPort || 0;
|
||||||
|
|
||||||
|
const record: IConnectionRecord = {
|
||||||
|
id: connectionId,
|
||||||
|
incoming: socket,
|
||||||
|
outgoing: null,
|
||||||
|
incomingStartTime: Date.now(),
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
connectionClosed: false,
|
||||||
|
pendingData: [],
|
||||||
|
pendingDataSize: 0,
|
||||||
|
bytesReceived: 0,
|
||||||
|
bytesSent: 0,
|
||||||
|
remoteIP,
|
||||||
|
localPort,
|
||||||
|
isTLS: false,
|
||||||
|
tlsHandshakeComplete: false,
|
||||||
|
hasReceivedInitialData: false,
|
||||||
|
hasKeepAlive: false,
|
||||||
|
incomingTerminationReason: null,
|
||||||
|
outgoingTerminationReason: null,
|
||||||
|
usingNetworkProxy: false,
|
||||||
|
isBrowserConnection: false,
|
||||||
|
domainSwitches: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
this.trackConnection(connectionId, record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track an existing connection
|
||||||
|
*/
|
||||||
|
public trackConnection(connectionId: string, record: IConnectionRecord): void {
|
||||||
|
this.connectionRecords.set(connectionId, record);
|
||||||
|
this.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a connection by ID
|
||||||
|
*/
|
||||||
|
public getConnection(connectionId: string): IConnectionRecord | undefined {
|
||||||
|
return this.connectionRecords.get(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active connections
|
||||||
|
*/
|
||||||
|
public getConnections(): Map<string, IConnectionRecord> {
|
||||||
|
return this.connectionRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of active connections
|
||||||
|
*/
|
||||||
|
public getConnectionCount(): number {
|
||||||
|
return this.connectionRecords.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates cleanup once for a connection
|
||||||
|
*/
|
||||||
|
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record.incomingTerminationReason === null ||
|
||||||
|
record.incomingTerminationReason === undefined
|
||||||
|
) {
|
||||||
|
record.incomingTerminationReason = reason;
|
||||||
|
this.incrementTerminationStat('incoming', reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanupConnection(record, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a connection record
|
||||||
|
*/
|
||||||
|
public cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||||
|
if (!record.connectionClosed) {
|
||||||
|
record.connectionClosed = true;
|
||||||
|
|
||||||
|
// Track connection termination
|
||||||
|
this.securityManager.removeConnectionByIP(record.remoteIP, record.id);
|
||||||
|
|
||||||
|
if (record.cleanupTimer) {
|
||||||
|
clearTimeout(record.cleanupTimer);
|
||||||
|
record.cleanupTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed logging data
|
||||||
|
const duration = Date.now() - record.incomingStartTime;
|
||||||
|
const bytesReceived = record.bytesReceived;
|
||||||
|
const bytesSent = record.bytesSent;
|
||||||
|
|
||||||
|
// Remove all data handlers to make sure we clean up properly
|
||||||
|
if (record.incoming) {
|
||||||
|
try {
|
||||||
|
// Remove our safe data handler
|
||||||
|
record.incoming.removeAllListeners('data');
|
||||||
|
// Reset the handler references
|
||||||
|
record.renegotiationHandler = undefined;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[${record.id}] Error removing data handlers: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming socket
|
||||||
|
this.cleanupSocket(record, 'incoming', record.incoming);
|
||||||
|
|
||||||
|
// Handle outgoing socket
|
||||||
|
if (record.outgoing) {
|
||||||
|
this.cleanupSocket(record, 'outgoing', record.outgoing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pendingData to avoid memory leaks
|
||||||
|
record.pendingData = [];
|
||||||
|
record.pendingDataSize = 0;
|
||||||
|
|
||||||
|
// Remove the record from the tracking map
|
||||||
|
this.connectionRecords.delete(record.id);
|
||||||
|
|
||||||
|
// Log connection details
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(
|
||||||
|
`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` +
|
||||||
|
` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
|
||||||
|
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` +
|
||||||
|
`${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` +
|
||||||
|
`${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to clean up a socket
|
||||||
|
*/
|
||||||
|
private cleanupSocket(record: IConnectionRecord, side: 'incoming' | 'outgoing', socket: plugins.net.Socket): void {
|
||||||
|
try {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
// Try graceful shutdown first, then force destroy after a short timeout
|
||||||
|
socket.end();
|
||||||
|
const socketTimeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[${record.id}] Error destroying ${side} socket: ${err}`);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Ensure the timeout doesn't block Node from exiting
|
||||||
|
if (socketTimeout.unref) {
|
||||||
|
socketTimeout.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[${record.id}] Error closing ${side} socket: ${err}`);
|
||||||
|
try {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (destroyErr) {
|
||||||
|
console.log(`[${record.id}] Error destroying ${side} socket: ${destroyErr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a generic error handler for incoming or outgoing sockets
|
||||||
|
*/
|
||||||
|
public handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
|
||||||
|
return (err: Error) => {
|
||||||
|
const code = (err as any).code;
|
||||||
|
let reason = 'error';
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const connectionDuration = now - record.incomingStartTime;
|
||||||
|
const lastActivityAge = now - record.lastActivity;
|
||||||
|
|
||||||
|
if (code === 'ECONNRESET') {
|
||||||
|
reason = 'econnreset';
|
||||||
|
console.log(
|
||||||
|
`[${record.id}] ECONNRESET on ${side} side from ${record.remoteIP}: ${err.message}. ` +
|
||||||
|
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
|
||||||
|
);
|
||||||
|
} else if (code === 'ETIMEDOUT') {
|
||||||
|
reason = 'etimedout';
|
||||||
|
console.log(
|
||||||
|
`[${record.id}] ETIMEDOUT on ${side} side from ${record.remoteIP}: ${err.message}. ` +
|
||||||
|
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[${record.id}] Error on ${side} side from ${record.remoteIP}: ${err.message}. ` +
|
||||||
|
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side === 'incoming' && record.incomingTerminationReason === null) {
|
||||||
|
record.incomingTerminationReason = reason;
|
||||||
|
this.incrementTerminationStat('incoming', reason);
|
||||||
|
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
|
||||||
|
record.outgoingTerminationReason = reason;
|
||||||
|
this.incrementTerminationStat('outgoing', reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initiateCleanupOnce(record, reason);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a generic close handler for incoming or outgoing sockets
|
||||||
|
*/
|
||||||
|
public handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
|
||||||
|
return () => {
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(`[${record.id}] Connection closed on ${side} side from ${record.remoteIP}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side === 'incoming' && record.incomingTerminationReason === null) {
|
||||||
|
record.incomingTerminationReason = 'normal';
|
||||||
|
this.incrementTerminationStat('incoming', 'normal');
|
||||||
|
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
|
||||||
|
record.outgoingTerminationReason = 'normal';
|
||||||
|
this.incrementTerminationStat('outgoing', 'normal');
|
||||||
|
// Record the time when outgoing socket closed.
|
||||||
|
record.outgoingClosedTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initiateCleanupOnce(record, 'closed_' + side);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment termination statistics
|
||||||
|
*/
|
||||||
|
public incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
||||||
|
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get termination statistics
|
||||||
|
*/
|
||||||
|
public getTerminationStats(): { incoming: Record<string, number>; outgoing: Record<string, number> } {
|
||||||
|
return this.terminationStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for stalled/inactive connections
|
||||||
|
*/
|
||||||
|
public performInactivityCheck(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const connectionIds = [...this.connectionRecords.keys()];
|
||||||
|
|
||||||
|
for (const id of connectionIds) {
|
||||||
|
const record = this.connectionRecords.get(id);
|
||||||
|
if (!record) continue;
|
||||||
|
|
||||||
|
// Skip inactivity check if disabled or for immortal keep-alive connections
|
||||||
|
if (
|
||||||
|
this.settings.disableInactivityCheck ||
|
||||||
|
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inactivityTime = now - record.lastActivity;
|
||||||
|
|
||||||
|
// Use extended timeout for extended-treatment keep-alive connections
|
||||||
|
let effectiveTimeout = this.settings.inactivityTimeout!;
|
||||||
|
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
|
||||||
|
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
|
||||||
|
effectiveTimeout = effectiveTimeout * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
|
||||||
|
// For keep-alive connections, issue a warning first
|
||||||
|
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
|
||||||
|
console.log(
|
||||||
|
`[${id}] Warning: Keep-alive connection from ${record.remoteIP} inactive for ${
|
||||||
|
plugins.prettyMs(inactivityTime)
|
||||||
|
}. Will close in 10 minutes if no activity.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set warning flag and add grace period
|
||||||
|
record.inactivityWarningIssued = true;
|
||||||
|
record.lastActivity = now - (effectiveTimeout - 600000);
|
||||||
|
|
||||||
|
// Try to stimulate activity with a probe packet
|
||||||
|
if (record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
try {
|
||||||
|
record.outgoing.write(Buffer.alloc(0));
|
||||||
|
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(`[${id}] Sent probe packet to test keep-alive connection`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[${id}] Error sending probe packet: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-keep-alive or after warning, close the connection
|
||||||
|
console.log(
|
||||||
|
`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` +
|
||||||
|
`for ${plugins.prettyMs(inactivityTime)}.` +
|
||||||
|
(record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '')
|
||||||
|
);
|
||||||
|
this.cleanupConnection(record, 'inactivity');
|
||||||
|
}
|
||||||
|
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
|
||||||
|
// If activity detected after warning, clear the warning
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(
|
||||||
|
`[${id}] Connection activity detected after inactivity warning, resetting warning`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record.inactivityWarningIssued = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parity check: if outgoing socket closed and incoming remains active
|
||||||
|
if (
|
||||||
|
record.outgoingClosedTime &&
|
||||||
|
!record.incoming.destroyed &&
|
||||||
|
!record.connectionClosed &&
|
||||||
|
now - record.outgoingClosedTime > 120000
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`[${id}] Parity check: Incoming socket for ${record.remoteIP} still active ${
|
||||||
|
plugins.prettyMs(now - record.outgoingClosedTime)
|
||||||
|
} after outgoing closed.`
|
||||||
|
);
|
||||||
|
this.cleanupConnection(record, 'parity_check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all connections (for shutdown)
|
||||||
|
*/
|
||||||
|
public clearConnections(): void {
|
||||||
|
// Create a copy of the keys to avoid modification during iteration
|
||||||
|
const connectionIds = [...this.connectionRecords.keys()];
|
||||||
|
|
||||||
|
// First pass: End all connections gracefully
|
||||||
|
for (const id of connectionIds) {
|
||||||
|
const record = this.connectionRecords.get(id);
|
||||||
|
if (record) {
|
||||||
|
try {
|
||||||
|
// Clear any timers
|
||||||
|
if (record.cleanupTimer) {
|
||||||
|
clearTimeout(record.cleanupTimer);
|
||||||
|
record.cleanupTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End sockets gracefully
|
||||||
|
if (record.incoming && !record.incoming.destroyed) {
|
||||||
|
record.incoming.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
record.outgoing.end();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error during graceful connection end for ${id}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short delay to allow graceful ends to process
|
||||||
|
setTimeout(() => {
|
||||||
|
// Second pass: Force destroy everything
|
||||||
|
for (const id of connectionIds) {
|
||||||
|
const record = this.connectionRecords.get(id);
|
||||||
|
if (record) {
|
||||||
|
try {
|
||||||
|
// Remove all listeners to prevent memory leaks
|
||||||
|
if (record.incoming) {
|
||||||
|
record.incoming.removeAllListeners();
|
||||||
|
if (!record.incoming.destroyed) {
|
||||||
|
record.incoming.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.outgoing) {
|
||||||
|
record.outgoing.removeAllListeners();
|
||||||
|
if (!record.outgoing.destroyed) {
|
||||||
|
record.outgoing.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error during forced connection destruction for ${id}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all maps
|
||||||
|
this.connectionRecords.clear();
|
||||||
|
this.terminationStats = { incoming: {}, outgoing: {} };
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
123
ts/classes.pp.domainconfigmanager.ts
Normal file
123
ts/classes.pp.domainconfigmanager.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages domain configurations and target selection
|
||||||
|
*/
|
||||||
|
export class DomainConfigManager {
|
||||||
|
// Track round-robin indices for domain configs
|
||||||
|
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
|
||||||
|
|
||||||
|
constructor(private settings: IPortProxySettings) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the domain configurations
|
||||||
|
*/
|
||||||
|
public updateDomainConfigs(newDomainConfigs: IDomainConfig[]): void {
|
||||||
|
this.settings.domainConfigs = newDomainConfigs;
|
||||||
|
|
||||||
|
// Reset target indices for removed configs
|
||||||
|
const currentConfigSet = new Set(newDomainConfigs);
|
||||||
|
for (const [config] of this.domainTargetIndices) {
|
||||||
|
if (!currentConfigSet.has(config)) {
|
||||||
|
this.domainTargetIndices.delete(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all domain configurations
|
||||||
|
*/
|
||||||
|
public getDomainConfigs(): IDomainConfig[] {
|
||||||
|
return this.settings.domainConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find domain config matching a server name
|
||||||
|
*/
|
||||||
|
public findDomainConfig(serverName: string): IDomainConfig | undefined {
|
||||||
|
if (!serverName) return undefined;
|
||||||
|
|
||||||
|
return this.settings.domainConfigs.find((config) =>
|
||||||
|
config.domains.some((d) => plugins.minimatch(serverName, d))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find domain config for a specific port
|
||||||
|
*/
|
||||||
|
public findDomainConfigForPort(port: number): IDomainConfig | undefined {
|
||||||
|
return this.settings.domainConfigs.find(
|
||||||
|
(domain) =>
|
||||||
|
domain.portRanges &&
|
||||||
|
domain.portRanges.length > 0 &&
|
||||||
|
this.isPortInRanges(port, domain.portRanges)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a port is within any of the given ranges
|
||||||
|
*/
|
||||||
|
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
|
||||||
|
return ranges.some((range) => port >= range.from && port <= range.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get target IP with round-robin support
|
||||||
|
*/
|
||||||
|
public getTargetIP(domainConfig: IDomainConfig): string {
|
||||||
|
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
|
||||||
|
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
|
||||||
|
const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length];
|
||||||
|
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.settings.targetIP || 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a domain should use NetworkProxy
|
||||||
|
*/
|
||||||
|
public shouldUseNetworkProxy(domainConfig: IDomainConfig): boolean {
|
||||||
|
return !!domainConfig.useNetworkProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the NetworkProxy port for a domain
|
||||||
|
*/
|
||||||
|
public getNetworkProxyPort(domainConfig: IDomainConfig): number | undefined {
|
||||||
|
return domainConfig.useNetworkProxy
|
||||||
|
? (domainConfig.networkProxyPort || this.settings.networkProxyPort)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get effective allowed and blocked IPs for a domain
|
||||||
|
*/
|
||||||
|
public getEffectiveIPRules(domainConfig: IDomainConfig): {
|
||||||
|
allowedIPs: string[],
|
||||||
|
blockedIPs: string[]
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
allowedIPs: [
|
||||||
|
...domainConfig.allowedIPs,
|
||||||
|
...(this.settings.defaultAllowedIPs || [])
|
||||||
|
],
|
||||||
|
blockedIPs: [
|
||||||
|
...(domainConfig.blockedIPs || []),
|
||||||
|
...(this.settings.defaultBlockedIPs || [])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connection timeout for a domain
|
||||||
|
*/
|
||||||
|
public getConnectionTimeout(domainConfig?: IDomainConfig): number {
|
||||||
|
if (domainConfig?.connectionTimeout) {
|
||||||
|
return domainConfig.connectionTimeout;
|
||||||
|
}
|
||||||
|
return this.settings.maxConnectionLifetime || 86400000; // 24 hours default
|
||||||
|
}
|
||||||
|
}
|
137
ts/classes.pp.interfaces.ts
Normal file
137
ts/classes.pp.interfaces.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
/** Domain configuration with per-domain allowed port ranges */
|
||||||
|
export interface IDomainConfig {
|
||||||
|
domains: string[]; // Glob patterns for domain(s)
|
||||||
|
allowedIPs: string[]; // Glob patterns for allowed IPs
|
||||||
|
blockedIPs?: string[]; // Glob patterns for blocked IPs
|
||||||
|
targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
|
||||||
|
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
|
||||||
|
// Allow domain-specific timeout override
|
||||||
|
connectionTimeout?: number; // Connection timeout override (ms)
|
||||||
|
|
||||||
|
// NetworkProxy integration options for this specific domain
|
||||||
|
useNetworkProxy?: boolean; // Whether to use NetworkProxy for this domain
|
||||||
|
networkProxyPort?: number; // Override default NetworkProxy port for this domain
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port proxy settings including global allowed port ranges */
|
||||||
|
export interface IPortProxySettings {
|
||||||
|
fromPort: number;
|
||||||
|
toPort: number;
|
||||||
|
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
|
||||||
|
domainConfigs: IDomainConfig[];
|
||||||
|
sniEnabled?: boolean;
|
||||||
|
defaultAllowedIPs?: string[];
|
||||||
|
defaultBlockedIPs?: string[];
|
||||||
|
preserveSourceIP?: boolean;
|
||||||
|
|
||||||
|
// TLS options
|
||||||
|
pfx?: Buffer;
|
||||||
|
key?: string | Buffer | Array<Buffer | string>;
|
||||||
|
passphrase?: string;
|
||||||
|
cert?: string | Buffer | Array<string | Buffer>;
|
||||||
|
ca?: string | Buffer | Array<string | Buffer>;
|
||||||
|
ciphers?: string;
|
||||||
|
honorCipherOrder?: boolean;
|
||||||
|
rejectUnauthorized?: boolean;
|
||||||
|
secureProtocol?: string;
|
||||||
|
servername?: string;
|
||||||
|
minVersion?: string;
|
||||||
|
maxVersion?: string;
|
||||||
|
|
||||||
|
// Timeout settings
|
||||||
|
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
|
||||||
|
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
|
||||||
|
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
|
||||||
|
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 86400000 (24h)
|
||||||
|
inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h)
|
||||||
|
|
||||||
|
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
|
||||||
|
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
|
||||||
|
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
|
||||||
|
|
||||||
|
// Socket optimization settings
|
||||||
|
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
|
||||||
|
keepAlive?: boolean; // Enable TCP keepalive (default: true)
|
||||||
|
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
|
||||||
|
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
|
||||||
|
|
||||||
|
// Enhanced features
|
||||||
|
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
|
||||||
|
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
|
||||||
|
enableDetailedLogging?: boolean; // Enable detailed connection logging
|
||||||
|
enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging
|
||||||
|
enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd
|
||||||
|
allowSessionTicket?: boolean; // Allow TLS session ticket for reconnection (default: true)
|
||||||
|
|
||||||
|
// Rate limiting and security
|
||||||
|
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
|
||||||
|
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
|
||||||
|
|
||||||
|
// Enhanced keep-alive settings
|
||||||
|
keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections
|
||||||
|
keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections
|
||||||
|
extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms)
|
||||||
|
|
||||||
|
// NetworkProxy integration
|
||||||
|
useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
|
||||||
|
networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced connection record
|
||||||
|
*/
|
||||||
|
export interface IConnectionRecord {
|
||||||
|
id: string; // Unique connection identifier
|
||||||
|
incoming: plugins.net.Socket;
|
||||||
|
outgoing: plugins.net.Socket | null;
|
||||||
|
incomingStartTime: number;
|
||||||
|
outgoingStartTime?: number;
|
||||||
|
outgoingClosedTime?: number;
|
||||||
|
lockedDomain?: string; // Used to lock this connection to the initial SNI
|
||||||
|
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
|
||||||
|
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
|
||||||
|
alertFallbackTimeout?: NodeJS.Timeout; // Timer for fallback after alert
|
||||||
|
lastActivity: number; // Last activity timestamp for inactivity detection
|
||||||
|
pendingData: Buffer[]; // Buffer to hold data during connection setup
|
||||||
|
pendingDataSize: number; // Track total size of pending data
|
||||||
|
|
||||||
|
// Enhanced tracking fields
|
||||||
|
bytesReceived: number; // Total bytes received
|
||||||
|
bytesSent: number; // Total bytes sent
|
||||||
|
remoteIP: string; // Remote IP (cached for logging after socket close)
|
||||||
|
localPort: number; // Local port (cached for logging)
|
||||||
|
isTLS: boolean; // Whether this connection is a TLS connection
|
||||||
|
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
|
||||||
|
hasReceivedInitialData: boolean; // Whether initial data has been received
|
||||||
|
domainConfig?: IDomainConfig; // Associated domain config for this connection
|
||||||
|
|
||||||
|
// Keep-alive tracking
|
||||||
|
hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection
|
||||||
|
inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued
|
||||||
|
incomingTerminationReason?: string | null; // Reason for incoming termination
|
||||||
|
outgoingTerminationReason?: string | null; // Reason for outgoing termination
|
||||||
|
|
||||||
|
// NetworkProxy tracking
|
||||||
|
usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy
|
||||||
|
|
||||||
|
// Renegotiation handler
|
||||||
|
renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection
|
||||||
|
|
||||||
|
// Browser connection tracking
|
||||||
|
isBrowserConnection?: boolean; // Whether this connection appears to be from a browser
|
||||||
|
domainSwitches?: number; // Number of times the domain has been switched on this connection
|
||||||
|
}
|
258
ts/classes.pp.networkproxybridge.ts
Normal file
258
ts/classes.pp.networkproxybridge.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { NetworkProxy } from './classes.networkproxy.js';
|
||||||
|
import type { IConnectionRecord, IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages NetworkProxy integration for TLS termination
|
||||||
|
*/
|
||||||
|
export class NetworkProxyBridge {
|
||||||
|
private networkProxy: NetworkProxy | null = null;
|
||||||
|
|
||||||
|
constructor(private settings: IPortProxySettings) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize NetworkProxy instance
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
if (!this.networkProxy && this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
|
||||||
|
// Configure NetworkProxy options based on PortProxy settings
|
||||||
|
const networkProxyOptions: any = {
|
||||||
|
port: this.settings.networkProxyPort!,
|
||||||
|
portProxyIntegration: true,
|
||||||
|
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add ACME settings if configured
|
||||||
|
if (this.settings.acme) {
|
||||||
|
networkProxyOptions.acme = { ...this.settings.acme };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.networkProxy = new NetworkProxy(networkProxyOptions);
|
||||||
|
|
||||||
|
console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`);
|
||||||
|
|
||||||
|
// Convert and apply domain configurations to NetworkProxy
|
||||||
|
await this.syncDomainConfigsToNetworkProxy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the NetworkProxy instance
|
||||||
|
*/
|
||||||
|
public getNetworkProxy(): NetworkProxy | null {
|
||||||
|
return this.networkProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the NetworkProxy port
|
||||||
|
*/
|
||||||
|
public getNetworkProxyPort(): number {
|
||||||
|
return this.networkProxy ? this.networkProxy.getListeningPort() : this.settings.networkProxyPort || 8443;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start NetworkProxy
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.networkProxy) {
|
||||||
|
await this.networkProxy.start();
|
||||||
|
console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`);
|
||||||
|
|
||||||
|
// Log ACME status
|
||||||
|
if (this.settings.acme?.enabled) {
|
||||||
|
console.log(
|
||||||
|
`ACME certificate management is enabled (${
|
||||||
|
this.settings.acme.useProduction ? 'Production' : 'Staging'
|
||||||
|
} mode)`
|
||||||
|
);
|
||||||
|
console.log(`ACME HTTP challenge server on port ${this.settings.acme.port}`);
|
||||||
|
|
||||||
|
// Register domains for ACME certificates if enabled
|
||||||
|
if (this.networkProxy.options.acme?.enabled) {
|
||||||
|
console.log('Registering domains with ACME certificate manager...');
|
||||||
|
// The NetworkProxy will handle this internally via registerDomainsWithAcmeManager()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop NetworkProxy
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.networkProxy) {
|
||||||
|
try {
|
||||||
|
console.log('Stopping NetworkProxy...');
|
||||||
|
await this.networkProxy.stop();
|
||||||
|
console.log('NetworkProxy stopped successfully');
|
||||||
|
|
||||||
|
// Log ACME shutdown if it was enabled
|
||||||
|
if (this.settings.acme?.enabled) {
|
||||||
|
console.log('ACME certificate manager stopped');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error stopping NetworkProxy: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards a TLS connection to a NetworkProxy for handling
|
||||||
|
*/
|
||||||
|
public forwardToNetworkProxy(
|
||||||
|
connectionId: string,
|
||||||
|
socket: plugins.net.Socket,
|
||||||
|
record: IConnectionRecord,
|
||||||
|
initialData: Buffer,
|
||||||
|
customProxyPort?: number,
|
||||||
|
onError?: (reason: string) => void
|
||||||
|
): void {
|
||||||
|
// Ensure NetworkProxy is initialized
|
||||||
|
if (!this.networkProxy) {
|
||||||
|
console.log(
|
||||||
|
`[${connectionId}] NetworkProxy not initialized. Cannot forward connection.`
|
||||||
|
);
|
||||||
|
if (onError) {
|
||||||
|
onError('network_proxy_not_initialized');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the custom port if provided, otherwise use the default NetworkProxy port
|
||||||
|
const proxyPort = customProxyPort || this.networkProxy.getListeningPort();
|
||||||
|
const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally
|
||||||
|
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(
|
||||||
|
`[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a connection to the NetworkProxy
|
||||||
|
const proxySocket = plugins.net.connect({
|
||||||
|
host: proxyHost,
|
||||||
|
port: proxyPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the outgoing socket in the record
|
||||||
|
record.outgoing = proxySocket;
|
||||||
|
record.outgoingStartTime = Date.now();
|
||||||
|
record.usingNetworkProxy = true;
|
||||||
|
|
||||||
|
// Set up error handlers
|
||||||
|
proxySocket.on('error', (err) => {
|
||||||
|
console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`);
|
||||||
|
if (onError) {
|
||||||
|
onError('network_proxy_connect_error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle connection to NetworkProxy
|
||||||
|
proxySocket.on('connect', () => {
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First send the initial data that contains the TLS ClientHello
|
||||||
|
proxySocket.write(initialData);
|
||||||
|
|
||||||
|
// Now set up bidirectional piping between client and NetworkProxy
|
||||||
|
socket.pipe(proxySocket);
|
||||||
|
proxySocket.pipe(socket);
|
||||||
|
|
||||||
|
// Update activity on data transfer (caller should handle this)
|
||||||
|
if (this.settings.enableDetailedLogging) {
|
||||||
|
console.log(`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes domain configurations to NetworkProxy
|
||||||
|
*/
|
||||||
|
public async syncDomainConfigsToNetworkProxy(): Promise<void> {
|
||||||
|
if (!this.networkProxy) {
|
||||||
|
console.log('Cannot sync configurations - NetworkProxy not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get SSL certificates from assets
|
||||||
|
// Import fs directly since it's not in plugins
|
||||||
|
const fs = await import('fs');
|
||||||
|
|
||||||
|
let certPair;
|
||||||
|
try {
|
||||||
|
certPair = {
|
||||||
|
key: fs.readFileSync('assets/certs/key.pem', 'utf8'),
|
||||||
|
cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'),
|
||||||
|
};
|
||||||
|
} catch (certError) {
|
||||||
|
console.log(`Warning: Could not read default certificates: ${certError}`);
|
||||||
|
console.log(
|
||||||
|
'Using empty certificate placeholders - ACME will generate proper certificates if enabled'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use empty placeholders - NetworkProxy will use its internal defaults
|
||||||
|
// or ACME will generate proper ones if enabled
|
||||||
|
certPair = {
|
||||||
|
key: '',
|
||||||
|
cert: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert domain configs to NetworkProxy configs
|
||||||
|
const proxyConfigs = this.networkProxy.convertPortProxyConfigs(
|
||||||
|
this.settings.domainConfigs,
|
||||||
|
certPair
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log ACME-eligible domains if ACME is enabled
|
||||||
|
if (this.settings.acme?.enabled) {
|
||||||
|
const acmeEligibleDomains = proxyConfigs
|
||||||
|
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards
|
||||||
|
.map((config) => config.hostName);
|
||||||
|
|
||||||
|
if (acmeEligibleDomains.length > 0) {
|
||||||
|
console.log(`Domains eligible for ACME certificates: ${acmeEligibleDomains.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
console.log('No domains eligible for ACME certificates found in configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update NetworkProxy with the converted configs
|
||||||
|
await this.networkProxy.updateProxyConfigs(proxyConfigs);
|
||||||
|
console.log(`Successfully synchronized ${proxyConfigs.length} domain configurations to NetworkProxy`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Failed to sync configurations: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a certificate for a specific domain
|
||||||
|
*/
|
||||||
|
public async requestCertificate(domain: string): Promise<boolean> {
|
||||||
|
if (!this.networkProxy) {
|
||||||
|
console.log('Cannot request certificate - NetworkProxy not initialized');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.acme?.enabled) {
|
||||||
|
console.log('Cannot request certificate - ACME is not enabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.networkProxy.requestCertificate(domain);
|
||||||
|
if (result) {
|
||||||
|
console.log(`Certificate request for ${domain} submitted successfully`);
|
||||||
|
} else {
|
||||||
|
console.log(`Certificate request for ${domain} failed`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error requesting certificate: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
344
ts/classes.pp.portproxy.ts
Normal file
344
ts/classes.pp.portproxy.ts
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js';
|
||||||
|
import { ConnectionManager } from './classes.pp.connectionmanager.js';
|
||||||
|
import { SecurityManager } from './classes.pp.securitymanager.js';
|
||||||
|
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
|
||||||
|
import { TlsManager } from './classes.pp.tlsmanager.js';
|
||||||
|
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
|
||||||
|
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
||||||
|
import { AcmeManager } from './classes.pp.acmemanager.js';
|
||||||
|
import { PortRangeManager } from './classes.pp.portrangemanager.js';
|
||||||
|
import { ConnectionHandler } from './classes.pp.connectionhandler.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PortProxy - Main class that coordinates all components
|
||||||
|
*/
|
||||||
|
export class PortProxy {
|
||||||
|
private netServers: plugins.net.Server[] = [];
|
||||||
|
private connectionLogger: NodeJS.Timeout | null = null;
|
||||||
|
private isShuttingDown: boolean = false;
|
||||||
|
|
||||||
|
// Component managers
|
||||||
|
private connectionManager: ConnectionManager;
|
||||||
|
private securityManager: SecurityManager;
|
||||||
|
public domainConfigManager: DomainConfigManager;
|
||||||
|
private tlsManager: TlsManager;
|
||||||
|
private networkProxyBridge: NetworkProxyBridge;
|
||||||
|
private timeoutManager: TimeoutManager;
|
||||||
|
private acmeManager: AcmeManager;
|
||||||
|
private portRangeManager: PortRangeManager;
|
||||||
|
private connectionHandler: ConnectionHandler;
|
||||||
|
|
||||||
|
constructor(settingsArg: IPortProxySettings) {
|
||||||
|
// Set reasonable defaults for all settings
|
||||||
|
this.settings = {
|
||||||
|
...settingsArg,
|
||||||
|
targetIP: settingsArg.targetIP || 'localhost',
|
||||||
|
initialDataTimeout: settingsArg.initialDataTimeout || 120000,
|
||||||
|
socketTimeout: settingsArg.socketTimeout || 3600000,
|
||||||
|
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000,
|
||||||
|
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 86400000,
|
||||||
|
inactivityTimeout: settingsArg.inactivityTimeout || 14400000,
|
||||||
|
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
|
||||||
|
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
|
||||||
|
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
|
||||||
|
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000,
|
||||||
|
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024,
|
||||||
|
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
|
||||||
|
enableKeepAliveProbes:
|
||||||
|
settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true,
|
||||||
|
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
|
||||||
|
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
|
||||||
|
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
|
||||||
|
allowSessionTicket:
|
||||||
|
settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true,
|
||||||
|
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
|
||||||
|
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
|
||||||
|
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
|
||||||
|
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
|
||||||
|
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000,
|
||||||
|
networkProxyPort: settingsArg.networkProxyPort || 8443,
|
||||||
|
acme: settingsArg.acme || {
|
||||||
|
enabled: false,
|
||||||
|
port: 80,
|
||||||
|
contactEmail: 'admin@example.com',
|
||||||
|
useProduction: false,
|
||||||
|
renewThresholdDays: 30,
|
||||||
|
autoRenew: true,
|
||||||
|
certificateStore: './certs',
|
||||||
|
skipConfiguredCerts: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize component managers
|
||||||
|
this.timeoutManager = new TimeoutManager(this.settings);
|
||||||
|
this.securityManager = new SecurityManager(this.settings);
|
||||||
|
this.connectionManager = new ConnectionManager(
|
||||||
|
this.settings,
|
||||||
|
this.securityManager,
|
||||||
|
this.timeoutManager
|
||||||
|
);
|
||||||
|
this.domainConfigManager = new DomainConfigManager(this.settings);
|
||||||
|
this.tlsManager = new TlsManager(this.settings);
|
||||||
|
this.networkProxyBridge = new NetworkProxyBridge(this.settings);
|
||||||
|
this.portRangeManager = new PortRangeManager(this.settings);
|
||||||
|
this.acmeManager = new AcmeManager(this.settings, this.networkProxyBridge);
|
||||||
|
|
||||||
|
// Initialize connection handler
|
||||||
|
this.connectionHandler = new ConnectionHandler(
|
||||||
|
this.settings,
|
||||||
|
this.connectionManager,
|
||||||
|
this.securityManager,
|
||||||
|
this.domainConfigManager,
|
||||||
|
this.tlsManager,
|
||||||
|
this.networkProxyBridge,
|
||||||
|
this.timeoutManager,
|
||||||
|
this.portRangeManager
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings for the port proxy
|
||||||
|
*/
|
||||||
|
public settings: IPortProxySettings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the proxy server
|
||||||
|
*/
|
||||||
|
public async start() {
|
||||||
|
// Don't start if already shutting down
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
console.log("Cannot start PortProxy while it's shutting down");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize and start NetworkProxy if needed
|
||||||
|
if (
|
||||||
|
this.settings.useNetworkProxy &&
|
||||||
|
this.settings.useNetworkProxy.length > 0
|
||||||
|
) {
|
||||||
|
await this.networkProxyBridge.initialize();
|
||||||
|
await this.networkProxyBridge.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate port configuration
|
||||||
|
const configWarnings = this.portRangeManager.validateConfiguration();
|
||||||
|
if (configWarnings.length > 0) {
|
||||||
|
console.log("Port configuration warnings:");
|
||||||
|
for (const warning of configWarnings) {
|
||||||
|
console.log(` - ${warning}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get listening ports from PortRangeManager
|
||||||
|
const listeningPorts = this.portRangeManager.getListeningPorts();
|
||||||
|
|
||||||
|
// Create servers for each port
|
||||||
|
for (const port of listeningPorts) {
|
||||||
|
const server = plugins.net.createServer((socket) => {
|
||||||
|
// Check if shutting down
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
socket.end();
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to connection handler
|
||||||
|
this.connectionHandler.handleConnection(socket);
|
||||||
|
}).on('error', (err: Error) => {
|
||||||
|
console.log(`Server Error on port ${port}: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port);
|
||||||
|
console.log(
|
||||||
|
`PortProxy -> OK: Now listening on port ${port}${
|
||||||
|
this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : ''
|
||||||
|
}${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.netServers.push(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up periodic connection logging and inactivity checks
|
||||||
|
this.connectionLogger = setInterval(() => {
|
||||||
|
// Immediately return if shutting down
|
||||||
|
if (this.isShuttingDown) return;
|
||||||
|
|
||||||
|
// Perform inactivity check
|
||||||
|
this.connectionManager.performInactivityCheck();
|
||||||
|
|
||||||
|
// Log connection statistics
|
||||||
|
const now = Date.now();
|
||||||
|
let maxIncoming = 0;
|
||||||
|
let maxOutgoing = 0;
|
||||||
|
let tlsConnections = 0;
|
||||||
|
let nonTlsConnections = 0;
|
||||||
|
let completedTlsHandshakes = 0;
|
||||||
|
let pendingTlsHandshakes = 0;
|
||||||
|
let keepAliveConnections = 0;
|
||||||
|
let networkProxyConnections = 0;
|
||||||
|
|
||||||
|
// Get connection records for analysis
|
||||||
|
const connectionRecords = this.connectionManager.getConnections();
|
||||||
|
|
||||||
|
// Analyze active connections
|
||||||
|
for (const record of connectionRecords.values()) {
|
||||||
|
// Track connection stats
|
||||||
|
if (record.isTLS) {
|
||||||
|
tlsConnections++;
|
||||||
|
if (record.tlsHandshakeComplete) {
|
||||||
|
completedTlsHandshakes++;
|
||||||
|
} else {
|
||||||
|
pendingTlsHandshakes++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nonTlsConnections++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.hasKeepAlive) {
|
||||||
|
keepAliveConnections++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.usingNetworkProxy) {
|
||||||
|
networkProxyConnections++;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
|
||||||
|
if (record.outgoingStartTime) {
|
||||||
|
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get termination stats
|
||||||
|
const terminationStats = this.connectionManager.getTerminationStats();
|
||||||
|
|
||||||
|
// Log detailed stats
|
||||||
|
console.log(
|
||||||
|
`Active connections: ${connectionRecords.size}. ` +
|
||||||
|
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` +
|
||||||
|
`Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` +
|
||||||
|
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` +
|
||||||
|
`Termination stats: ${JSON.stringify({
|
||||||
|
IN: terminationStats.incoming,
|
||||||
|
OUT: terminationStats.outgoing,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}, this.settings.inactivityCheckInterval || 60000);
|
||||||
|
|
||||||
|
// Make sure the interval doesn't keep the process alive
|
||||||
|
if (this.connectionLogger.unref) {
|
||||||
|
this.connectionLogger.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the proxy server
|
||||||
|
*/
|
||||||
|
public async stop() {
|
||||||
|
console.log('PortProxy shutting down...');
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
// Stop accepting new connections
|
||||||
|
const closeServerPromises: Promise<void>[] = this.netServers.map(
|
||||||
|
(server) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (!server.listening) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log(`Error closing server: ${err.message}`);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop the connection logger
|
||||||
|
if (this.connectionLogger) {
|
||||||
|
clearInterval(this.connectionLogger);
|
||||||
|
this.connectionLogger = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for servers to close
|
||||||
|
await Promise.all(closeServerPromises);
|
||||||
|
console.log('All servers closed. Cleaning up active connections...');
|
||||||
|
|
||||||
|
// Clean up all active connections
|
||||||
|
this.connectionManager.clearConnections();
|
||||||
|
|
||||||
|
// Stop NetworkProxy
|
||||||
|
await this.networkProxyBridge.stop();
|
||||||
|
|
||||||
|
// Clear all servers
|
||||||
|
this.netServers = [];
|
||||||
|
|
||||||
|
console.log('PortProxy shutdown complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the domain configurations for the proxy
|
||||||
|
*/
|
||||||
|
public async updateDomainConfigs(newDomainConfigs: IDomainConfig[]): Promise<void> {
|
||||||
|
console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`);
|
||||||
|
|
||||||
|
// Update domain configs in DomainConfigManager
|
||||||
|
this.domainConfigManager.updateDomainConfigs(newDomainConfigs);
|
||||||
|
|
||||||
|
// If NetworkProxy is initialized, resync the configurations
|
||||||
|
if (this.networkProxyBridge.getNetworkProxy()) {
|
||||||
|
await this.networkProxyBridge.syncDomainConfigsToNetworkProxy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the ACME certificate settings
|
||||||
|
*/
|
||||||
|
public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise<void> {
|
||||||
|
console.log('Updating ACME certificate settings');
|
||||||
|
|
||||||
|
// Delegate to AcmeManager
|
||||||
|
await this.acmeManager.updateAcmeSettings(acmeSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a certificate for a specific domain
|
||||||
|
*/
|
||||||
|
public async requestCertificate(domain: string): Promise<boolean> {
|
||||||
|
// Delegate to AcmeManager
|
||||||
|
return this.acmeManager.requestCertificate(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about current connections
|
||||||
|
*/
|
||||||
|
public getStatistics(): any {
|
||||||
|
const connectionRecords = this.connectionManager.getConnections();
|
||||||
|
const terminationStats = this.connectionManager.getTerminationStats();
|
||||||
|
|
||||||
|
let tlsConnections = 0;
|
||||||
|
let nonTlsConnections = 0;
|
||||||
|
let keepAliveConnections = 0;
|
||||||
|
let networkProxyConnections = 0;
|
||||||
|
|
||||||
|
// Analyze active connections
|
||||||
|
for (const record of connectionRecords.values()) {
|
||||||
|
if (record.isTLS) tlsConnections++;
|
||||||
|
else nonTlsConnections++;
|
||||||
|
if (record.hasKeepAlive) keepAliveConnections++;
|
||||||
|
if (record.usingNetworkProxy) networkProxyConnections++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeConnections: connectionRecords.size,
|
||||||
|
tlsConnections,
|
||||||
|
nonTlsConnections,
|
||||||
|
keepAliveConnections,
|
||||||
|
networkProxyConnections,
|
||||||
|
terminationStats
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
214
ts/classes.pp.portrangemanager.ts
Normal file
214
ts/classes.pp.portrangemanager.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import type{ IPortProxySettings } from './classes.pp.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages port ranges and port-based configuration
|
||||||
|
*/
|
||||||
|
export class PortRangeManager {
|
||||||
|
constructor(private settings: IPortProxySettings) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all ports that should be listened on
|
||||||
|
*/
|
||||||
|
public getListeningPorts(): Set<number> {
|
||||||
|
const listeningPorts = new Set<number>();
|
||||||
|
|
||||||
|
// Always include the main fromPort
|
||||||
|
listeningPorts.add(this.settings.fromPort);
|
||||||
|
|
||||||
|
// Add ports from global port ranges if defined
|
||||||
|
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
|
||||||
|
for (const range of this.settings.globalPortRanges) {
|
||||||
|
for (let port = range.from; port <= range.to; port++) {
|
||||||
|
listeningPorts.add(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listeningPorts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a port should use NetworkProxy for forwarding
|
||||||
|
*/
|
||||||
|
public shouldUseNetworkProxy(port: number): boolean {
|
||||||
|
return !!this.settings.useNetworkProxy && this.settings.useNetworkProxy.includes(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if port should use global forwarding
|
||||||
|
*/
|
||||||
|
public shouldUseGlobalForwarding(port: number): boolean {
|
||||||
|
return (
|
||||||
|
!!this.settings.forwardAllGlobalRanges &&
|
||||||
|
this.isPortInGlobalRanges(port)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a port is in global ranges
|
||||||
|
*/
|
||||||
|
public isPortInGlobalRanges(port: number): boolean {
|
||||||
|
return (
|
||||||
|
this.settings.globalPortRanges &&
|
||||||
|
this.isPortInRanges(port, this.settings.globalPortRanges)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a port falls within the specified ranges
|
||||||
|
*/
|
||||||
|
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
|
||||||
|
return ranges.some((range) => port >= range.from && port <= range.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get forwarding port for a specific listening port
|
||||||
|
* This determines what port to connect to on the target
|
||||||
|
*/
|
||||||
|
public getForwardingPort(listeningPort: number): number {
|
||||||
|
// If using global forwarding, forward to the original port
|
||||||
|
if (this.settings.forwardAllGlobalRanges && this.isPortInGlobalRanges(listeningPort)) {
|
||||||
|
return listeningPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use the configured toPort
|
||||||
|
return this.settings.toPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find domain-specific port ranges that include a given port
|
||||||
|
*/
|
||||||
|
public findDomainPortRange(port: number): {
|
||||||
|
domainIndex: number,
|
||||||
|
range: { from: number, to: number }
|
||||||
|
} | undefined {
|
||||||
|
for (let i = 0; i < this.settings.domainConfigs.length; i++) {
|
||||||
|
const domain = this.settings.domainConfigs[i];
|
||||||
|
if (domain.portRanges) {
|
||||||
|
for (const range of domain.portRanges) {
|
||||||
|
if (port >= range.from && port <= range.to) {
|
||||||
|
return { domainIndex: i, range };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of all configured ports
|
||||||
|
* This includes the fromPort, NetworkProxy ports, and ports from all ranges
|
||||||
|
*/
|
||||||
|
public getAllConfiguredPorts(): number[] {
|
||||||
|
const ports = new Set<number>();
|
||||||
|
|
||||||
|
// Add main listening port
|
||||||
|
ports.add(this.settings.fromPort);
|
||||||
|
|
||||||
|
// Add NetworkProxy port if configured
|
||||||
|
if (this.settings.networkProxyPort) {
|
||||||
|
ports.add(this.settings.networkProxyPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add NetworkProxy ports
|
||||||
|
if (this.settings.useNetworkProxy) {
|
||||||
|
for (const port of this.settings.useNetworkProxy) {
|
||||||
|
ports.add(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ACME HTTP challenge port if enabled
|
||||||
|
if (this.settings.acme?.enabled && this.settings.acme.port) {
|
||||||
|
ports.add(this.settings.acme.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add global port ranges
|
||||||
|
if (this.settings.globalPortRanges) {
|
||||||
|
for (const range of this.settings.globalPortRanges) {
|
||||||
|
for (let port = range.from; port <= range.to; port++) {
|
||||||
|
ports.add(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add domain-specific port ranges
|
||||||
|
for (const domain of this.settings.domainConfigs) {
|
||||||
|
if (domain.portRanges) {
|
||||||
|
for (const range of domain.portRanges) {
|
||||||
|
for (let port = range.from; port <= range.to; port++) {
|
||||||
|
ports.add(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add domain-specific NetworkProxy port if configured
|
||||||
|
if (domain.useNetworkProxy && domain.networkProxyPort) {
|
||||||
|
ports.add(domain.networkProxyPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(ports);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate port configuration
|
||||||
|
* Returns array of warning messages
|
||||||
|
*/
|
||||||
|
public validateConfiguration(): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Check for overlapping port ranges
|
||||||
|
const portMappings = new Map<number, string[]>();
|
||||||
|
|
||||||
|
// Track global port ranges
|
||||||
|
if (this.settings.globalPortRanges) {
|
||||||
|
for (const range of this.settings.globalPortRanges) {
|
||||||
|
for (let port = range.from; port <= range.to; port++) {
|
||||||
|
if (!portMappings.has(port)) {
|
||||||
|
portMappings.set(port, []);
|
||||||
|
}
|
||||||
|
portMappings.get(port)!.push('Global Port Range');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track domain-specific port ranges
|
||||||
|
for (const domain of this.settings.domainConfigs) {
|
||||||
|
if (domain.portRanges) {
|
||||||
|
for (const range of domain.portRanges) {
|
||||||
|
for (let port = range.from; port <= range.to; port++) {
|
||||||
|
if (!portMappings.has(port)) {
|
||||||
|
portMappings.set(port, []);
|
||||||
|
}
|
||||||
|
portMappings.get(port)!.push(`Domain: ${domain.domains.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ports with multiple mappings
|
||||||
|
for (const [port, mappings] of portMappings.entries()) {
|
||||||
|
if (mappings.length > 1) {
|
||||||
|
warnings.push(`Port ${port} has multiple mappings: ${mappings.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if main ports are used elsewhere
|
||||||
|
if (portMappings.has(this.settings.fromPort) && portMappings.get(this.settings.fromPort)!.length > 0) {
|
||||||
|
warnings.push(`Main listening port ${this.settings.fromPort} is also used in port ranges`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.settings.networkProxyPort && portMappings.has(this.settings.networkProxyPort)) {
|
||||||
|
warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ACME port
|
||||||
|
if (this.settings.acme?.enabled && this.settings.acme.port) {
|
||||||
|
if (portMappings.has(this.settings.acme.port)) {
|
||||||
|
warnings.push(`ACME HTTP challenge port ${this.settings.acme.port} is also used in port ranges`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
}
|
147
ts/classes.pp.securitymanager.ts
Normal file
147
ts/classes.pp.securitymanager.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { IPortProxySettings } from './classes.pp.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles security aspects like IP tracking, rate limiting, and authorization
|
||||||
|
*/
|
||||||
|
export class SecurityManager {
|
||||||
|
private connectionsByIP: Map<string, Set<string>> = new Map();
|
||||||
|
private connectionRateByIP: Map<string, number[]> = new Map();
|
||||||
|
|
||||||
|
constructor(private settings: IPortProxySettings) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connections count by IP
|
||||||
|
*/
|
||||||
|
public getConnectionCountByIP(ip: string): number {
|
||||||
|
return this.connectionsByIP.get(ip)?.size || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and update connection rate for an IP
|
||||||
|
* @returns true if within rate limit, false if exceeding limit
|
||||||
|
*/
|
||||||
|
public checkConnectionRate(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const minute = 60 * 1000;
|
||||||
|
|
||||||
|
if (!this.connectionRateByIP.has(ip)) {
|
||||||
|
this.connectionRateByIP.set(ip, [now]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get timestamps and filter out entries older than 1 minute
|
||||||
|
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
|
||||||
|
timestamps.push(now);
|
||||||
|
this.connectionRateByIP.set(ip, timestamps);
|
||||||
|
|
||||||
|
// Check if rate exceeds limit
|
||||||
|
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track connection by IP
|
||||||
|
*/
|
||||||
|
public trackConnectionByIP(ip: string, connectionId: string): void {
|
||||||
|
if (!this.connectionsByIP.has(ip)) {
|
||||||
|
this.connectionsByIP.set(ip, new Set());
|
||||||
|
}
|
||||||
|
this.connectionsByIP.get(ip)!.add(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove connection tracking for an IP
|
||||||
|
*/
|
||||||
|
public removeConnectionByIP(ip: string, connectionId: string): void {
|
||||||
|
if (this.connectionsByIP.has(ip)) {
|
||||||
|
const connections = this.connectionsByIP.get(ip)!;
|
||||||
|
connections.delete(connectionId);
|
||||||
|
if (connections.size === 0) {
|
||||||
|
this.connectionsByIP.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP is allowed using glob patterns
|
||||||
|
*/
|
||||||
|
public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean {
|
||||||
|
// Skip IP validation if allowedIPs is empty
|
||||||
|
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if IP is blocked
|
||||||
|
if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check if IP is allowed
|
||||||
|
return this.isGlobIPMatch(ip, allowedIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the IP matches any of the glob patterns
|
||||||
|
*/
|
||||||
|
private isGlobIPMatch(ip: string, patterns: string[]): boolean {
|
||||||
|
if (!ip || !patterns || patterns.length === 0) return false;
|
||||||
|
|
||||||
|
const normalizeIP = (ip: string): string[] => {
|
||||||
|
if (!ip) return [];
|
||||||
|
if (ip.startsWith('::ffff:')) {
|
||||||
|
const ipv4 = ip.slice(7);
|
||||||
|
return [ip, ipv4];
|
||||||
|
}
|
||||||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
||||||
|
return [ip, `::ffff:${ip}`];
|
||||||
|
}
|
||||||
|
return [ip];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedIPVariants = normalizeIP(ip);
|
||||||
|
if (normalizedIPVariants.length === 0) return false;
|
||||||
|
|
||||||
|
const expandedPatterns = patterns.flatMap(normalizeIP);
|
||||||
|
return normalizedIPVariants.some((ipVariant) =>
|
||||||
|
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP should be allowed considering connection rate and max connections
|
||||||
|
* @returns Object with result and reason
|
||||||
|
*/
|
||||||
|
public validateIP(ip: string): { allowed: boolean; reason?: string } {
|
||||||
|
// Check connection count limit
|
||||||
|
if (
|
||||||
|
this.settings.maxConnectionsPerIP &&
|
||||||
|
this.getConnectionCountByIP(ip) >= this.settings.maxConnectionsPerIP
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check connection rate limit
|
||||||
|
if (
|
||||||
|
this.settings.connectionRateLimitPerMinute &&
|
||||||
|
!this.checkConnectionRate(ip)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all IP tracking data (for shutdown)
|
||||||
|
*/
|
||||||
|
public clearIPTracking(): void {
|
||||||
|
this.connectionsByIP.clear();
|
||||||
|
this.connectionRateByIP.clear();
|
||||||
|
}
|
||||||
|
}
|
1281
ts/classes.pp.snihandler.ts
Normal file
1281
ts/classes.pp.snihandler.ts
Normal file
File diff suppressed because it is too large
Load Diff
190
ts/classes.pp.timeoutmanager.ts
Normal file
190
ts/classes.pp.timeoutmanager.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import type { IConnectionRecord, IPortProxySettings } from './classes.pp.interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages timeouts and inactivity tracking for connections
|
||||||
|
*/
|
||||||
|
export class TimeoutManager {
|
||||||
|
constructor(private settings: IPortProxySettings) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure timeout values don't exceed Node.js max safe integer
|
||||||
|
*/
|
||||||
|
public ensureSafeTimeout(timeout: number): number {
|
||||||
|
const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1)
|
||||||
|
return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a slightly randomized timeout to prevent thundering herd
|
||||||
|
*/
|
||||||
|
public randomizeTimeout(baseTimeout: number, variationPercent: number = 5): number {
|
||||||
|
const safeBaseTimeout = this.ensureSafeTimeout(baseTimeout);
|
||||||
|
const variation = safeBaseTimeout * (variationPercent / 100);
|
||||||
|
return this.ensureSafeTimeout(
|
||||||
|
safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update connection activity timestamp
|
||||||
|
*/
|
||||||
|
public updateActivity(record: IConnectionRecord): void {
|
||||||
|
record.lastActivity = Date.now();
|
||||||
|
|
||||||
|
// Clear any inactivity warning
|
||||||
|
if (record.inactivityWarningIssued) {
|
||||||
|
record.inactivityWarningIssued = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate effective inactivity timeout based on connection type
|
||||||
|
*/
|
||||||
|
public getEffectiveInactivityTimeout(record: IConnectionRecord): number {
|
||||||
|
let effectiveTimeout = this.settings.inactivityTimeout || 14400000; // 4 hours default
|
||||||
|
|
||||||
|
// For immortal keep-alive connections, use an extremely long timeout
|
||||||
|
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For extended keep-alive connections, apply multiplier
|
||||||
|
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
|
||||||
|
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
|
||||||
|
effectiveTimeout = effectiveTimeout * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ensureSafeTimeout(effectiveTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate effective max lifetime based on connection type
|
||||||
|
*/
|
||||||
|
public getEffectiveMaxLifetime(record: IConnectionRecord): number {
|
||||||
|
// Use domain-specific timeout if available
|
||||||
|
const baseTimeout = record.domainConfig?.connectionTimeout ||
|
||||||
|
this.settings.maxConnectionLifetime ||
|
||||||
|
86400000; // 24 hours default
|
||||||
|
|
||||||
|
// For immortal keep-alive connections, use an extremely long lifetime
|
||||||
|
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For extended keep-alive connections, use the extended lifetime setting
|
||||||
|
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
|
||||||
|
return this.ensureSafeTimeout(
|
||||||
|
this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply randomization if enabled
|
||||||
|
if (this.settings.enableRandomizedTimeouts) {
|
||||||
|
return this.randomizeTimeout(baseTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ensureSafeTimeout(baseTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup connection timeout
|
||||||
|
* @returns The cleanup timer
|
||||||
|
*/
|
||||||
|
public setupConnectionTimeout(
|
||||||
|
record: IConnectionRecord,
|
||||||
|
onTimeout: (record: IConnectionRecord, reason: string) => void
|
||||||
|
): NodeJS.Timeout {
|
||||||
|
// Clear any existing timer
|
||||||
|
if (record.cleanupTimer) {
|
||||||
|
clearTimeout(record.cleanupTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate effective timeout
|
||||||
|
const effectiveLifetime = this.getEffectiveMaxLifetime(record);
|
||||||
|
|
||||||
|
// Set up the timeout
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Call the provided callback
|
||||||
|
onTimeout(record, 'connection_timeout');
|
||||||
|
}, effectiveLifetime);
|
||||||
|
|
||||||
|
// Make sure timeout doesn't keep the process alive
|
||||||
|
if (timer.unref) {
|
||||||
|
timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for inactivity on a connection
|
||||||
|
* @returns Object with check results
|
||||||
|
*/
|
||||||
|
public checkInactivity(record: IConnectionRecord): {
|
||||||
|
isInactive: boolean;
|
||||||
|
shouldWarn: boolean;
|
||||||
|
inactivityTime: number;
|
||||||
|
effectiveTimeout: number;
|
||||||
|
} {
|
||||||
|
// Skip for connections with inactivity check disabled
|
||||||
|
if (this.settings.disableInactivityCheck) {
|
||||||
|
return {
|
||||||
|
isInactive: false,
|
||||||
|
shouldWarn: false,
|
||||||
|
inactivityTime: 0,
|
||||||
|
effectiveTimeout: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip for immortal keep-alive connections
|
||||||
|
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
|
||||||
|
return {
|
||||||
|
isInactive: false,
|
||||||
|
shouldWarn: false,
|
||||||
|
inactivityTime: 0,
|
||||||
|
effectiveTimeout: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const inactivityTime = now - record.lastActivity;
|
||||||
|
const effectiveTimeout = this.getEffectiveInactivityTimeout(record);
|
||||||
|
|
||||||
|
// Check if inactive
|
||||||
|
const isInactive = inactivityTime > effectiveTimeout;
|
||||||
|
|
||||||
|
// For keep-alive connections, we should warn first
|
||||||
|
const shouldWarn = record.hasKeepAlive &&
|
||||||
|
isInactive &&
|
||||||
|
!record.inactivityWarningIssued;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInactive,
|
||||||
|
shouldWarn,
|
||||||
|
inactivityTime,
|
||||||
|
effectiveTimeout
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply socket timeout settings
|
||||||
|
*/
|
||||||
|
public applySocketTimeouts(record: IConnectionRecord): void {
|
||||||
|
// Skip for immortal keep-alive connections
|
||||||
|
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
|
||||||
|
// Disable timeouts completely for immortal connections
|
||||||
|
record.incoming.setTimeout(0);
|
||||||
|
if (record.outgoing) {
|
||||||
|
record.outgoing.setTimeout(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply normal timeouts
|
||||||
|
const timeout = this.ensureSafeTimeout(this.settings.socketTimeout || 3600000); // 1 hour default
|
||||||
|
record.incoming.setTimeout(timeout);
|
||||||
|
if (record.outgoing) {
|
||||||
|
record.outgoing.setTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
258
ts/classes.pp.tlsalert.ts
Normal file
258
ts/classes.pp.tlsalert.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TlsAlert class for managing TLS alert messages
|
||||||
|
*/
|
||||||
|
export class TlsAlert {
|
||||||
|
// TLS Alert Levels
|
||||||
|
static readonly LEVEL_WARNING = 0x01;
|
||||||
|
static readonly LEVEL_FATAL = 0x02;
|
||||||
|
|
||||||
|
// TLS Alert Description Codes - RFC 8446 (TLS 1.3) / RFC 5246 (TLS 1.2)
|
||||||
|
static readonly CLOSE_NOTIFY = 0x00;
|
||||||
|
static readonly UNEXPECTED_MESSAGE = 0x0A;
|
||||||
|
static readonly BAD_RECORD_MAC = 0x14;
|
||||||
|
static readonly DECRYPTION_FAILED = 0x15; // TLS 1.0 only
|
||||||
|
static readonly RECORD_OVERFLOW = 0x16;
|
||||||
|
static readonly DECOMPRESSION_FAILURE = 0x1E; // TLS 1.2 and below
|
||||||
|
static readonly HANDSHAKE_FAILURE = 0x28;
|
||||||
|
static readonly NO_CERTIFICATE = 0x29; // SSLv3 only
|
||||||
|
static readonly BAD_CERTIFICATE = 0x2A;
|
||||||
|
static readonly UNSUPPORTED_CERTIFICATE = 0x2B;
|
||||||
|
static readonly CERTIFICATE_REVOKED = 0x2C;
|
||||||
|
static readonly CERTIFICATE_EXPIRED = 0x2F;
|
||||||
|
static readonly CERTIFICATE_UNKNOWN = 0x30;
|
||||||
|
static readonly ILLEGAL_PARAMETER = 0x2F;
|
||||||
|
static readonly UNKNOWN_CA = 0x30;
|
||||||
|
static readonly ACCESS_DENIED = 0x31;
|
||||||
|
static readonly DECODE_ERROR = 0x32;
|
||||||
|
static readonly DECRYPT_ERROR = 0x33;
|
||||||
|
static readonly EXPORT_RESTRICTION = 0x3C; // TLS 1.0 only
|
||||||
|
static readonly PROTOCOL_VERSION = 0x46;
|
||||||
|
static readonly INSUFFICIENT_SECURITY = 0x47;
|
||||||
|
static readonly INTERNAL_ERROR = 0x50;
|
||||||
|
static readonly INAPPROPRIATE_FALLBACK = 0x56;
|
||||||
|
static readonly USER_CANCELED = 0x5A;
|
||||||
|
static readonly NO_RENEGOTIATION = 0x64; // TLS 1.2 and below
|
||||||
|
static readonly MISSING_EXTENSION = 0x6D; // TLS 1.3
|
||||||
|
static readonly UNSUPPORTED_EXTENSION = 0x6E; // TLS 1.3
|
||||||
|
static readonly CERTIFICATE_REQUIRED = 0x6F; // TLS 1.3
|
||||||
|
static readonly UNRECOGNIZED_NAME = 0x70;
|
||||||
|
static readonly BAD_CERTIFICATE_STATUS_RESPONSE = 0x71;
|
||||||
|
static readonly BAD_CERTIFICATE_HASH_VALUE = 0x72; // TLS 1.2 and below
|
||||||
|
static readonly UNKNOWN_PSK_IDENTITY = 0x73;
|
||||||
|
static readonly CERTIFICATE_REQUIRED_1_3 = 0x74; // TLS 1.3
|
||||||
|
static readonly NO_APPLICATION_PROTOCOL = 0x78;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a TLS alert buffer with the specified level and description code
|
||||||
|
*
|
||||||
|
* @param level Alert level (warning or fatal)
|
||||||
|
* @param description Alert description code
|
||||||
|
* @param tlsVersion TLS version bytes (default is TLS 1.2: 0x0303)
|
||||||
|
* @returns Buffer containing the TLS alert message
|
||||||
|
*/
|
||||||
|
static create(
|
||||||
|
level: number,
|
||||||
|
description: number,
|
||||||
|
tlsVersion: [number, number] = [0x03, 0x03]
|
||||||
|
): Buffer {
|
||||||
|
return Buffer.from([
|
||||||
|
0x15, // Alert record type
|
||||||
|
tlsVersion[0],
|
||||||
|
tlsVersion[1], // TLS version (default to TLS 1.2: 0x0303)
|
||||||
|
0x00,
|
||||||
|
0x02, // Length
|
||||||
|
level, // Alert level
|
||||||
|
description, // Alert description
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a warning-level TLS alert
|
||||||
|
*
|
||||||
|
* @param description Alert description code
|
||||||
|
* @returns Buffer containing the warning-level TLS alert message
|
||||||
|
*/
|
||||||
|
static createWarning(description: number): Buffer {
|
||||||
|
return this.create(this.LEVEL_WARNING, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fatal-level TLS alert
|
||||||
|
*
|
||||||
|
* @param description Alert description code
|
||||||
|
* @returns Buffer containing the fatal-level TLS alert message
|
||||||
|
*/
|
||||||
|
static createFatal(description: number): Buffer {
|
||||||
|
return this.create(this.LEVEL_FATAL, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a TLS alert to a socket and optionally close the connection
|
||||||
|
*
|
||||||
|
* @param socket The socket to send the alert to
|
||||||
|
* @param level Alert level (warning or fatal)
|
||||||
|
* @param description Alert description code
|
||||||
|
* @param closeAfterSend Whether to close the connection after sending the alert
|
||||||
|
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
|
||||||
|
* @returns Promise that resolves when the alert has been sent
|
||||||
|
*/
|
||||||
|
static async send(
|
||||||
|
socket: net.Socket,
|
||||||
|
level: number,
|
||||||
|
description: number,
|
||||||
|
closeAfterSend: boolean = false,
|
||||||
|
closeDelay: number = 200
|
||||||
|
): Promise<void> {
|
||||||
|
const alert = this.create(level, description);
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// Ensure the alert is written as a single packet
|
||||||
|
socket.cork();
|
||||||
|
const writeSuccessful = socket.write(alert, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeAfterSend) {
|
||||||
|
setTimeout(() => {
|
||||||
|
socket.end();
|
||||||
|
resolve();
|
||||||
|
}, closeDelay);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.uncork();
|
||||||
|
|
||||||
|
// If write wasn't successful immediately, wait for drain
|
||||||
|
if (!writeSuccessful && !closeAfterSend) {
|
||||||
|
socket.once('drain', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-defined TLS alert messages
|
||||||
|
*/
|
||||||
|
static readonly alerts = {
|
||||||
|
// Warning level alerts
|
||||||
|
closeNotify: TlsAlert.createWarning(TlsAlert.CLOSE_NOTIFY),
|
||||||
|
unsupportedExtension: TlsAlert.createWarning(TlsAlert.UNSUPPORTED_EXTENSION),
|
||||||
|
certificateRequired: TlsAlert.createWarning(TlsAlert.CERTIFICATE_REQUIRED),
|
||||||
|
unrecognizedName: TlsAlert.createWarning(TlsAlert.UNRECOGNIZED_NAME),
|
||||||
|
noRenegotiation: TlsAlert.createWarning(TlsAlert.NO_RENEGOTIATION),
|
||||||
|
userCanceled: TlsAlert.createWarning(TlsAlert.USER_CANCELED),
|
||||||
|
|
||||||
|
// Warning level alerts for session resumption
|
||||||
|
certificateExpiredWarning: TlsAlert.createWarning(TlsAlert.CERTIFICATE_EXPIRED),
|
||||||
|
handshakeFailureWarning: TlsAlert.createWarning(TlsAlert.HANDSHAKE_FAILURE),
|
||||||
|
insufficientSecurityWarning: TlsAlert.createWarning(TlsAlert.INSUFFICIENT_SECURITY),
|
||||||
|
|
||||||
|
// Fatal level alerts
|
||||||
|
unexpectedMessage: TlsAlert.createFatal(TlsAlert.UNEXPECTED_MESSAGE),
|
||||||
|
badRecordMac: TlsAlert.createFatal(TlsAlert.BAD_RECORD_MAC),
|
||||||
|
recordOverflow: TlsAlert.createFatal(TlsAlert.RECORD_OVERFLOW),
|
||||||
|
handshakeFailure: TlsAlert.createFatal(TlsAlert.HANDSHAKE_FAILURE),
|
||||||
|
badCertificate: TlsAlert.createFatal(TlsAlert.BAD_CERTIFICATE),
|
||||||
|
certificateExpired: TlsAlert.createFatal(TlsAlert.CERTIFICATE_EXPIRED),
|
||||||
|
certificateUnknown: TlsAlert.createFatal(TlsAlert.CERTIFICATE_UNKNOWN),
|
||||||
|
illegalParameter: TlsAlert.createFatal(TlsAlert.ILLEGAL_PARAMETER),
|
||||||
|
unknownCA: TlsAlert.createFatal(TlsAlert.UNKNOWN_CA),
|
||||||
|
accessDenied: TlsAlert.createFatal(TlsAlert.ACCESS_DENIED),
|
||||||
|
decodeError: TlsAlert.createFatal(TlsAlert.DECODE_ERROR),
|
||||||
|
decryptError: TlsAlert.createFatal(TlsAlert.DECRYPT_ERROR),
|
||||||
|
protocolVersion: TlsAlert.createFatal(TlsAlert.PROTOCOL_VERSION),
|
||||||
|
insufficientSecurity: TlsAlert.createFatal(TlsAlert.INSUFFICIENT_SECURITY),
|
||||||
|
internalError: TlsAlert.createFatal(TlsAlert.INTERNAL_ERROR),
|
||||||
|
unrecognizedNameFatal: TlsAlert.createFatal(TlsAlert.UNRECOGNIZED_NAME),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility method to send a warning-level unrecognized_name alert
|
||||||
|
* Specifically designed for SNI issues to encourage the client to retry with SNI
|
||||||
|
*
|
||||||
|
* @param socket The socket to send the alert to
|
||||||
|
* @returns Promise that resolves when the alert has been sent
|
||||||
|
*/
|
||||||
|
static async sendSniRequired(socket: net.Socket): Promise<void> {
|
||||||
|
return this.send(socket, this.LEVEL_WARNING, this.UNRECOGNIZED_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility method to send a close_notify alert and close the connection
|
||||||
|
*
|
||||||
|
* @param socket The socket to send the alert to
|
||||||
|
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
|
||||||
|
* @returns Promise that resolves when the alert has been sent and the connection closed
|
||||||
|
*/
|
||||||
|
static async sendCloseNotify(socket: net.Socket, closeDelay: number = 200): Promise<void> {
|
||||||
|
return this.send(socket, this.LEVEL_WARNING, this.CLOSE_NOTIFY, true, closeDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility method to send a certificate_expired alert to force new TLS session
|
||||||
|
*
|
||||||
|
* @param socket The socket to send the alert to
|
||||||
|
* @param fatal Whether to send as a fatal alert (default: false)
|
||||||
|
* @param closeAfterSend Whether to close the connection after sending the alert (default: true)
|
||||||
|
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
|
||||||
|
* @returns Promise that resolves when the alert has been sent
|
||||||
|
*/
|
||||||
|
static async sendCertificateExpired(
|
||||||
|
socket: net.Socket,
|
||||||
|
fatal: boolean = false,
|
||||||
|
closeAfterSend: boolean = true,
|
||||||
|
closeDelay: number = 200
|
||||||
|
): Promise<void> {
|
||||||
|
const level = fatal ? this.LEVEL_FATAL : this.LEVEL_WARNING;
|
||||||
|
return this.send(socket, level, this.CERTIFICATE_EXPIRED, closeAfterSend, closeDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a sequence of alerts to force SNI from clients
|
||||||
|
* This combines multiple alerts to ensure maximum browser compatibility
|
||||||
|
*
|
||||||
|
* @param socket The socket to send the alerts to
|
||||||
|
* @returns Promise that resolves when all alerts have been sent
|
||||||
|
*/
|
||||||
|
static async sendForceSniSequence(socket: net.Socket): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Send unrecognized_name (warning)
|
||||||
|
socket.cork();
|
||||||
|
socket.write(this.alerts.unrecognizedName);
|
||||||
|
socket.uncork();
|
||||||
|
|
||||||
|
// Give the socket time to send the alert
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 50);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a fatal level alert that immediately terminates the connection
|
||||||
|
*
|
||||||
|
* @param socket The socket to send the alert to
|
||||||
|
* @param description Alert description code
|
||||||
|
* @param closeDelay Milliseconds to wait before closing the connection (default: 100ms)
|
||||||
|
* @returns Promise that resolves when the alert has been sent and the connection closed
|
||||||
|
*/
|
||||||
|
static async sendFatalAndClose(
|
||||||
|
socket: net.Socket,
|
||||||
|
description: number,
|
||||||
|
closeDelay: number = 100
|
||||||
|
): Promise<void> {
|
||||||
|
return this.send(socket, this.LEVEL_FATAL, description, true, closeDelay);
|
||||||
|
}
|
||||||
|
}
|
206
ts/classes.pp.tlsmanager.ts
Normal file
206
ts/classes.pp.tlsmanager.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { IPortProxySettings } from './classes.pp.interfaces.js';
|
||||||
|
import { SniHandler } from './classes.pp.snihandler.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for connection information used for SNI extraction
|
||||||
|
*/
|
||||||
|
interface IConnectionInfo {
|
||||||
|
sourceIp: string;
|
||||||
|
sourcePort: number;
|
||||||
|
destIp: string;
|
||||||
|
destPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages TLS-related operations including SNI extraction and validation
|
||||||
|
*/
|
||||||
|
export class TlsManager {
|
||||||
|
constructor(private settings: IPortProxySettings) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a data chunk appears to be a TLS handshake
|
||||||
|
*/
|
||||||
|
public isTlsHandshake(chunk: Buffer): boolean {
|
||||||
|
return SniHandler.isTlsHandshake(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a data chunk appears to be a TLS ClientHello
|
||||||
|
*/
|
||||||
|
public isClientHello(chunk: Buffer): boolean {
|
||||||
|
return SniHandler.isClientHello(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract Server Name Indication (SNI) from TLS handshake
|
||||||
|
*/
|
||||||
|
public extractSNI(
|
||||||
|
chunk: Buffer,
|
||||||
|
connInfo: IConnectionInfo,
|
||||||
|
previousDomain?: string
|
||||||
|
): string | undefined {
|
||||||
|
// Use the SniHandler to process the TLS packet
|
||||||
|
return SniHandler.processTlsPacket(
|
||||||
|
chunk,
|
||||||
|
connInfo,
|
||||||
|
this.settings.enableTlsDebugLogging || false,
|
||||||
|
previousDomain
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle session resumption attempts
|
||||||
|
*/
|
||||||
|
public handleSessionResumption(
|
||||||
|
chunk: Buffer,
|
||||||
|
connectionId: string,
|
||||||
|
hasSNI: boolean
|
||||||
|
): { shouldBlock: boolean; reason?: string } {
|
||||||
|
// Skip if session tickets are allowed
|
||||||
|
if (this.settings.allowSessionTicket !== false) {
|
||||||
|
return { shouldBlock: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session resumption attempt
|
||||||
|
const resumptionInfo = SniHandler.hasSessionResumption(
|
||||||
|
chunk,
|
||||||
|
this.settings.enableTlsDebugLogging || false
|
||||||
|
);
|
||||||
|
|
||||||
|
// If this is a resumption attempt without SNI, block it
|
||||||
|
if (resumptionInfo.isResumption && !hasSNI && !resumptionInfo.hasSNI) {
|
||||||
|
if (this.settings.enableTlsDebugLogging) {
|
||||||
|
console.log(
|
||||||
|
`[${connectionId}] Session resumption detected without SNI and allowSessionTicket=false. ` +
|
||||||
|
`Terminating connection to force new TLS handshake.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shouldBlock: true,
|
||||||
|
reason: 'session_ticket_blocked'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldBlock: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for SNI mismatch during renegotiation
|
||||||
|
*/
|
||||||
|
public checkRenegotiationSNI(
|
||||||
|
chunk: Buffer,
|
||||||
|
connInfo: IConnectionInfo,
|
||||||
|
expectedDomain: string,
|
||||||
|
connectionId: string
|
||||||
|
): { hasMismatch: boolean; extractedSNI?: string } {
|
||||||
|
// Only process if this looks like a TLS ClientHello
|
||||||
|
if (!this.isClientHello(chunk)) {
|
||||||
|
return { hasMismatch: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract SNI with renegotiation support
|
||||||
|
const newSNI = SniHandler.extractSNIWithResumptionSupport(
|
||||||
|
chunk,
|
||||||
|
connInfo,
|
||||||
|
this.settings.enableTlsDebugLogging || false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip if no SNI was found
|
||||||
|
if (!newSNI) return { hasMismatch: false };
|
||||||
|
|
||||||
|
// Check for SNI mismatch
|
||||||
|
if (newSNI !== expectedDomain) {
|
||||||
|
if (this.settings.enableTlsDebugLogging) {
|
||||||
|
console.log(
|
||||||
|
`[${connectionId}] Renegotiation with different SNI: ${expectedDomain} -> ${newSNI}. ` +
|
||||||
|
`Terminating connection - SNI domain switching is not allowed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { hasMismatch: true, extractedSNI: newSNI };
|
||||||
|
} else if (this.settings.enableTlsDebugLogging) {
|
||||||
|
console.log(
|
||||||
|
`[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(
|
||||||
|
`[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasMismatch: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a renegotiation handler function for a connection
|
||||||
|
*/
|
||||||
|
public createRenegotiationHandler(
|
||||||
|
connectionId: string,
|
||||||
|
lockedDomain: string,
|
||||||
|
connInfo: IConnectionInfo,
|
||||||
|
onMismatch: (connectionId: string, reason: string) => void
|
||||||
|
): (chunk: Buffer) => void {
|
||||||
|
return (chunk: Buffer) => {
|
||||||
|
const result = this.checkRenegotiationSNI(chunk, connInfo, lockedDomain, connectionId);
|
||||||
|
if (result.hasMismatch) {
|
||||||
|
onMismatch(connectionId, 'sni_mismatch');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze TLS connection for browser fingerprinting
|
||||||
|
* This helps identify browser vs non-browser connections
|
||||||
|
*/
|
||||||
|
public analyzeClientHello(chunk: Buffer): {
|
||||||
|
isBrowserConnection: boolean;
|
||||||
|
isRenewal: boolean;
|
||||||
|
hasSNI: boolean;
|
||||||
|
} {
|
||||||
|
// Default result
|
||||||
|
const result = {
|
||||||
|
isBrowserConnection: false,
|
||||||
|
isRenewal: false,
|
||||||
|
hasSNI: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if it's a ClientHello
|
||||||
|
if (!this.isClientHello(chunk)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session resumption
|
||||||
|
const resumptionInfo = SniHandler.hasSessionResumption(
|
||||||
|
chunk,
|
||||||
|
this.settings.enableTlsDebugLogging || false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract SNI
|
||||||
|
const sni = SniHandler.extractSNI(
|
||||||
|
chunk,
|
||||||
|
this.settings.enableTlsDebugLogging || false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update result
|
||||||
|
result.isRenewal = resumptionInfo.isResumption;
|
||||||
|
result.hasSNI = !!sni;
|
||||||
|
|
||||||
|
// Browsers typically:
|
||||||
|
// 1. Send SNI extension
|
||||||
|
// 2. Have a variety of extensions (ALPN, etc.)
|
||||||
|
// 3. Use standard cipher suites
|
||||||
|
// ...more complex heuristics could be implemented here
|
||||||
|
|
||||||
|
// Simple heuristic: presence of SNI suggests browser
|
||||||
|
result.isBrowserConnection = !!sni;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error analyzing ClientHello: ${err}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
432
ts/classes.router.ts
Normal file
432
ts/classes.router.ts
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
import * as url from 'url';
|
||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional path pattern configuration that can be added to proxy configs
|
||||||
|
*/
|
||||||
|
export interface IPathPatternConfig {
|
||||||
|
pathPattern?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for router result with additional metadata
|
||||||
|
*/
|
||||||
|
export interface IRouterResult {
|
||||||
|
config: tsclass.network.IReverseProxyConfig;
|
||||||
|
pathMatch?: string;
|
||||||
|
pathParams?: Record<string, string>;
|
||||||
|
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[] = [];
|
||||||
|
// Default config to use when no match is found (optional)
|
||||||
|
private defaultConfig?: tsclass.network.IReverseProxyConfig;
|
||||||
|
// Store path patterns separately since they're not in the original interface
|
||||||
|
private pathPatterns: Map<tsclass.network.IReverseProxyConfig, string> = new Map();
|
||||||
|
// Logger interface
|
||||||
|
private logger: {
|
||||||
|
error: (message: string, data?: any) => void;
|
||||||
|
warn: (message: string, data?: any) => void;
|
||||||
|
info: (message: string, data?: any) => void;
|
||||||
|
debug: (message: string, data?: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
configs?: tsclass.network.IReverseProxyConfig[],
|
||||||
|
logger?: {
|
||||||
|
error: (message: string, data?: any) => void;
|
||||||
|
warn: (message: string, data?: any) => void;
|
||||||
|
info: (message: string, data?: any) => void;
|
||||||
|
debug: (message: string, data?: any) => void;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.logger = logger || console;
|
||||||
|
if (configs) {
|
||||||
|
this.setNewProxyConfigs(configs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a new set of reverse configs to be routed to
|
||||||
|
* @param reverseCandidatesArg Array of reverse proxy configurations
|
||||||
|
*/
|
||||||
|
public setNewProxyConfigs(reverseCandidatesArg: tsclass.network.IReverseProxyConfig[]): void {
|
||||||
|
this.reverseProxyConfigs = [...reverseCandidatesArg];
|
||||||
|
|
||||||
|
// Find default config if any (config with "*" as hostname)
|
||||||
|
this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*');
|
||||||
|
|
||||||
|
this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.getHostnames().length} unique hosts)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a request based on hostname and path
|
||||||
|
* @param req The incoming HTTP request
|
||||||
|
* @returns The matching proxy config or undefined if no match found
|
||||||
|
*/
|
||||||
|
public routeReq(req: http.IncomingMessage): tsclass.network.IReverseProxyConfig {
|
||||||
|
const result = this.routeReqWithDetails(req);
|
||||||
|
return result ? result.config : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a request with detailed matching information
|
||||||
|
* @param req The incoming HTTP request
|
||||||
|
* @returns Detailed routing result including matched config and path information
|
||||||
|
*/
|
||||||
|
public routeReqWithDetails(req: http.IncomingMessage): IRouterResult | undefined {
|
||||||
|
// Extract and validate host header
|
||||||
|
const originalHost = req.headers.host;
|
||||||
|
if (!originalHost) {
|
||||||
|
this.logger.error('No host header found in request');
|
||||||
|
return this.defaultConfig ? { config: this.defaultConfig } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL for path matching
|
||||||
|
const parsedUrl = url.parse(req.url || '/');
|
||||||
|
const urlPath = parsedUrl.pathname || '/';
|
||||||
|
|
||||||
|
// Extract hostname without port
|
||||||
|
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
|
||||||
|
|
||||||
|
// First try exact hostname match
|
||||||
|
const exactConfig = this.findConfigForHost(hostWithoutPort, urlPath);
|
||||||
|
if (exactConfig) {
|
||||||
|
return exactConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try various wildcard patterns
|
||||||
|
if (hostWithoutPort.includes('.')) {
|
||||||
|
const domainParts = hostWithoutPort.split('.');
|
||||||
|
|
||||||
|
// Try wildcard subdomain (*.example.com)
|
||||||
|
if (domainParts.length > 2) {
|
||||||
|
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
|
||||||
|
const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath);
|
||||||
|
if (wildcardConfig) {
|
||||||
|
return wildcardConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try TLD wildcard (example.*)
|
||||||
|
const baseDomain = domainParts.slice(0, -1).join('.');
|
||||||
|
const tldWildcardDomain = `${baseDomain}.*`;
|
||||||
|
const tldWildcardConfig = this.findConfigForHost(tldWildcardDomain, urlPath);
|
||||||
|
if (tldWildcardConfig) {
|
||||||
|
return tldWildcardConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try complex wildcard patterns
|
||||||
|
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
|
||||||
|
for (const pattern of wildcardPatterns) {
|
||||||
|
const wildcardConfig = this.findConfigForHost(pattern, urlPath);
|
||||||
|
if (wildcardConfig) {
|
||||||
|
return wildcardConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default config if available
|
||||||
|
if (this.defaultConfig) {
|
||||||
|
this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`);
|
||||||
|
return { config: this.defaultConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(`No config found for host: ${hostWithoutPort}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find potential wildcard patterns that could match a given hostname
|
||||||
|
* Handles complex patterns like "*.lossless*" or other partial matches
|
||||||
|
* @param hostname The hostname to find wildcard matches for
|
||||||
|
* @returns Array of potential wildcard patterns that could match
|
||||||
|
*/
|
||||||
|
private findWildcardMatches(hostname: string): string[] {
|
||||||
|
const patterns: string[] = [];
|
||||||
|
const hostnameParts = hostname.split('.');
|
||||||
|
|
||||||
|
// Find all configured hostnames that contain wildcards
|
||||||
|
const wildcardConfigs = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName.includes('*')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract unique wildcard patterns
|
||||||
|
const wildcardPatterns = [...new Set(
|
||||||
|
wildcardConfigs.map(config => config.hostName.toLowerCase())
|
||||||
|
)];
|
||||||
|
|
||||||
|
// For each wildcard pattern, check if it could match the hostname
|
||||||
|
// using simplified regex pattern matching
|
||||||
|
for (const pattern of wildcardPatterns) {
|
||||||
|
// Skip the default wildcard '*'
|
||||||
|
if (pattern === '*') continue;
|
||||||
|
|
||||||
|
// Skip already checked patterns (*.domain.com and domain.*)
|
||||||
|
if (pattern.startsWith('*.') && pattern.indexOf('*', 2) === -1) continue;
|
||||||
|
if (pattern.endsWith('.*') && pattern.indexOf('*') === pattern.length - 1) continue;
|
||||||
|
|
||||||
|
// Convert wildcard pattern to regex
|
||||||
|
const regexPattern = pattern
|
||||||
|
.replace(/\./g, '\\.') // Escape dots
|
||||||
|
.replace(/\*/g, '.*'); // Convert * to .* for regex
|
||||||
|
|
||||||
|
// Create regex object with case insensitive flag
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||||
|
|
||||||
|
// If hostname matches this complex pattern, add it to the list
|
||||||
|
if (regex.test(hostname)) {
|
||||||
|
patterns.push(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a config for a specific host and path
|
||||||
|
*/
|
||||||
|
private findConfigForHost(hostname: string, path: string): IRouterResult | undefined {
|
||||||
|
// Find all configs for this hostname
|
||||||
|
const configs = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName.toLowerCase() === hostname.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (configs.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First try configs with path patterns
|
||||||
|
const configsWithPaths = configs.filter(config => this.pathPatterns.has(config));
|
||||||
|
|
||||||
|
// Sort by path pattern specificity - more specific first
|
||||||
|
configsWithPaths.sort((a, b) => {
|
||||||
|
const aPattern = this.pathPatterns.get(a) || '';
|
||||||
|
const bPattern = this.pathPatterns.get(b) || '';
|
||||||
|
|
||||||
|
// Exact patterns come before wildcard patterns
|
||||||
|
const aHasWildcard = aPattern.includes('*');
|
||||||
|
const bHasWildcard = bPattern.includes('*');
|
||||||
|
|
||||||
|
if (aHasWildcard && !bHasWildcard) return 1;
|
||||||
|
if (!aHasWildcard && bHasWildcard) return -1;
|
||||||
|
|
||||||
|
// Longer patterns are considered more specific
|
||||||
|
return bPattern.length - aPattern.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check each config with path pattern
|
||||||
|
for (const config of configsWithPaths) {
|
||||||
|
const pathPattern = this.pathPatterns.get(config);
|
||||||
|
if (pathPattern) {
|
||||||
|
const pathMatch = this.matchPath(path, pathPattern);
|
||||||
|
if (pathMatch) {
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
pathMatch: pathMatch.matched,
|
||||||
|
pathParams: pathMatch.params,
|
||||||
|
pathRemainder: pathMatch.remainder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no path pattern matched, use the first config without a path pattern
|
||||||
|
const configWithoutPath = configs.find(config => !this.pathPatterns.has(config));
|
||||||
|
if (configWithoutPath) {
|
||||||
|
return { config: configWithoutPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a URL path against a pattern
|
||||||
|
* Supports:
|
||||||
|
* - Exact matches: /users/profile
|
||||||
|
* - Wildcards: /api/* (matches any path starting with /api/)
|
||||||
|
* - Path parameters: /users/:id (captures id as a parameter)
|
||||||
|
*
|
||||||
|
* @param path The URL path to match
|
||||||
|
* @param pattern The pattern to match against
|
||||||
|
* @returns Match result with params and remainder, or null if no match
|
||||||
|
*/
|
||||||
|
private matchPath(path: string, pattern: string): {
|
||||||
|
matched: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
remainder: string;
|
||||||
|
} | null {
|
||||||
|
// Handle exact match
|
||||||
|
if (path === pattern) {
|
||||||
|
return {
|
||||||
|
matched: pattern,
|
||||||
|
params: {},
|
||||||
|
remainder: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard match
|
||||||
|
if (pattern.endsWith('/*')) {
|
||||||
|
const prefix = pattern.slice(0, -2);
|
||||||
|
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
||||||
|
return {
|
||||||
|
matched: prefix,
|
||||||
|
params: {},
|
||||||
|
remainder: path.slice(prefix.length)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle path parameters
|
||||||
|
const patternParts = pattern.split('/').filter(p => p);
|
||||||
|
const pathParts = path.split('/').filter(p => p);
|
||||||
|
|
||||||
|
// Too few path parts to match
|
||||||
|
if (pathParts.length < patternParts.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Compare each part
|
||||||
|
for (let i = 0; i < patternParts.length; i++) {
|
||||||
|
const patternPart = patternParts[i];
|
||||||
|
const pathPart = pathParts[i];
|
||||||
|
|
||||||
|
// Handle parameter
|
||||||
|
if (patternPart.startsWith(':')) {
|
||||||
|
const paramName = patternPart.slice(1);
|
||||||
|
params[paramName] = pathPart;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard at the end
|
||||||
|
if (patternPart === '*' && i === patternParts.length - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exact match for this part
|
||||||
|
if (patternPart !== pathPart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the remainder - the unmatched path parts
|
||||||
|
const remainderParts = pathParts.slice(patternParts.length);
|
||||||
|
const remainder = remainderParts.length ? '/' + remainderParts.join('/') : '';
|
||||||
|
|
||||||
|
// Calculate the matched path
|
||||||
|
const matchedParts = patternParts.map((part, i) => {
|
||||||
|
return part.startsWith(':') ? pathParts[i] : part;
|
||||||
|
});
|
||||||
|
const matched = '/' + matchedParts.join('/');
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
params,
|
||||||
|
remainder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all currently active proxy configurations
|
||||||
|
* @returns Array of all active configurations
|
||||||
|
*/
|
||||||
|
public getProxyConfigs(): tsclass.network.IReverseProxyConfig[] {
|
||||||
|
return [...this.reverseProxyConfigs];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all hostnames that this router is configured to handle
|
||||||
|
* @returns Array of hostnames
|
||||||
|
*/
|
||||||
|
public getHostnames(): string[] {
|
||||||
|
const hostnames = new Set<string>();
|
||||||
|
for (const config of this.reverseProxyConfigs) {
|
||||||
|
if (config.hostName !== '*') {
|
||||||
|
hostnames.add(config.hostName.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(hostnames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a single new proxy configuration
|
||||||
|
* @param config The configuration to add
|
||||||
|
* @param pathPattern Optional path pattern for route matching
|
||||||
|
*/
|
||||||
|
public addProxyConfig(
|
||||||
|
config: tsclass.network.IReverseProxyConfig,
|
||||||
|
pathPattern?: string
|
||||||
|
): void {
|
||||||
|
this.reverseProxyConfigs.push(config);
|
||||||
|
|
||||||
|
// Store path pattern if provided
|
||||||
|
if (pathPattern) {
|
||||||
|
this.pathPatterns.set(config, pathPattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a path pattern for an existing config
|
||||||
|
* @param config The existing configuration
|
||||||
|
* @param pathPattern The path pattern to set
|
||||||
|
* @returns Boolean indicating if the config was found and updated
|
||||||
|
*/
|
||||||
|
public setPathPattern(
|
||||||
|
config: tsclass.network.IReverseProxyConfig,
|
||||||
|
pathPattern: string
|
||||||
|
): boolean {
|
||||||
|
const exists = this.reverseProxyConfigs.includes(config);
|
||||||
|
if (exists) {
|
||||||
|
this.pathPatterns.set(config, pathPattern);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a proxy configuration by hostname
|
||||||
|
* @param hostname The hostname to remove
|
||||||
|
* @returns Boolean indicating whether any configs were removed
|
||||||
|
*/
|
||||||
|
public removeProxyConfig(hostname: string): boolean {
|
||||||
|
const initialCount = this.reverseProxyConfigs.length;
|
||||||
|
|
||||||
|
// Find configs to remove
|
||||||
|
const configsToRemove = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName === hostname
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove them from the patterns map
|
||||||
|
for (const config of configsToRemove) {
|
||||||
|
this.pathPatterns.delete(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter them out of the configs array
|
||||||
|
this.reverseProxyConfigs = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName !== hostname
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.reverseProxyConfigs.length !== initialCount;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
export class SslRedirect {
|
export class SslRedirect {
|
||||||
httpServer: plugins.http.Server;
|
httpServer: plugins.http.Server;
|
30
ts/helpers.certificates.ts
Normal file
30
ts/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;
|
||||||
|
}
|
||||||
|
}
|
10
ts/index.ts
10
ts/index.ts
@ -1,3 +1,7 @@
|
|||||||
export * from './smartproxy.classes.networkproxy.js';
|
export * from './classes.nftablesproxy.js';
|
||||||
export * from './smartproxy.portproxy.js';
|
export * from './classes.networkproxy.js';
|
||||||
export * from './smartproxy.classes.sslredirect.js';
|
export * from './classes.port80handler.js';
|
||||||
|
export * from './classes.sslredirect.js';
|
||||||
|
export * from './classes.pp.portproxy.js';
|
||||||
|
export * from './classes.pp.snihandler.js';
|
||||||
|
export * from './classes.pp.interfaces.js';
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
// node native scope
|
// node native scope
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
|
import * as tls from 'tls';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
|
|
||||||
export { http, https, net, url };
|
|
||||||
|
export { EventEmitter, http, https, net, tls, url };
|
||||||
|
|
||||||
// tsclass scope
|
// tsclass scope
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
@ -21,7 +24,10 @@ import * as smartstring from '@push.rocks/smartstring';
|
|||||||
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
||||||
|
|
||||||
// third party scope
|
// third party scope
|
||||||
|
import * as acme from 'acme-client';
|
||||||
|
import prettyMs from 'pretty-ms';
|
||||||
import * as ws from 'ws';
|
import * as ws from 'ws';
|
||||||
import wsDefault from 'ws';
|
import wsDefault from 'ws';
|
||||||
|
import { minimatch } from 'minimatch';
|
||||||
|
|
||||||
export { wsDefault, ws };
|
export { acme, prettyMs, ws, wsDefault, minimatch };
|
@ -1,426 +0,0 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
|
||||||
import { ProxyRouter } from './smartproxy.classes.router.js';
|
|
||||||
|
|
||||||
export interface INetworkProxyOptions {
|
|
||||||
port: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebSocketWithHeartbeat extends plugins.wsDefault {
|
|
||||||
lastPong: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NetworkProxy {
|
|
||||||
// INSTANCE
|
|
||||||
public options: INetworkProxyOptions;
|
|
||||||
public proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
|
||||||
public httpsServer: plugins.https.Server;
|
|
||||||
public router = new ProxyRouter();
|
|
||||||
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
|
||||||
public defaultHeaders: { [key: string]: string } = {};
|
|
||||||
public heartbeatInterval: NodeJS.Timeout;
|
|
||||||
|
|
||||||
public alreadyAddedReverseConfigs: {
|
|
||||||
[hostName: string]: plugins.tsclass.network.IReverseProxyConfig;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
constructor(optionsArg: INetworkProxyOptions) {
|
|
||||||
this.options = optionsArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* starts the proxyInstance
|
|
||||||
*/
|
|
||||||
public async start() {
|
|
||||||
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-----
|
|
||||||
MIIJRQIBADANBgkqhkiG9w0BAQEFAASCCS8wggkrAgEAAoICAQDi2F/0kQr96mhe
|
|
||||||
3yEWvy2mRHOZoSSBtIqg6Bre4ZcMu901/cHNIjFnynNGFl9Se61yZbW2F3PfCt7+
|
|
||||||
kQlHug1Cx+LFssvz+hLlB5cqJQZfRKx92DhbROygtxG9r7UBmx/fwx+JQ+HOHX9R
|
|
||||||
b+szLBZqxrNDBFl2SRdviconYgVnHbaqcAPj/lK6D6x94qgUEX+vMjbIruuiCe3u
|
|
||||||
RbYse/quzAednVnY/+BuGVn8SEb2EVVFnBEsOxxYpy5ZzGR48O3YnWkM2oPpJhrp
|
|
||||||
mMYLcARMnDmIQDVstD1i+MM2lVhx/pm9xKKUgWNJC7lyz2xRscZ4pOtLkfN94leH
|
|
||||||
U98nIvxfQe7tQFKN9K52yjdtoT0UaIEUFbZyddkoNka1Xx6r+rE96046BLT2lVs0
|
|
||||||
/rnTxZUFH6vP3z9UNktmpxtnZSk67Pj6QAqZtgT0amXEpBlk7vBYSjHsyJ3+5R1y
|
|
||||||
oSjhAqeejq6M67NDOflrag5LSTkeTe4dqk0laVb1gjcse18AOlgf7pw5H79zclYH
|
|
||||||
NAnoAPua683MD2ZZd4eovEww/imSZvui3NlisSSh1SomABDFxiEaHpewI98n8P1E
|
|
||||||
3vfg4lyCV5VcUjwrPjnkfEJbX1c1/PXqTtPqSqFn/pI4FuTES6qDugS2EA/XT1ln
|
|
||||||
ODHigOiFCzDbhOMuQjhI8hzuevrRRQIDAQABAoICAQC7nU+HW6qmpQebZ5nbUVT1
|
|
||||||
Deo6Js+lwudg+3a13ghqzLnBXNW7zkrkV8mNLxW5h3bFhZ+LMcxwrXIPQ29Udmlf
|
|
||||||
USiacC1E5RBZgjSg86xYgNjU4E6EFfZLWf3/T2I6KM1s6NmdUppgOX9CoHj7grwr
|
|
||||||
pZk/lUpUjVEnu+OJPQXQ6f9Y6XoeSAqtvibgmuR+bJaZFMPAqQNTqjix99Aa7JNB
|
|
||||||
nJez4R8dXUuGY8tL349pFp7bCqAdX+oq3GJ2fJigekuM+2uV6OhunUhm6Sbq8MNt
|
|
||||||
hUwEB27oMA4RXENAUraq2XLYQ9hfUMAH+v1vGmSxEIJg561/e//RnrDbyR9oJARr
|
|
||||||
SbopI3Ut5yKxVKMYOTSqcFQXVLszTExhMhQCRoOh58BpIfhb9FLCKD9LH8E6eoQf
|
|
||||||
ygPWryey9AAJ7B2PQXVbitzcOML27rzC4DXS+mLe6AVL6t2IldaeMTlumlnc620d
|
|
||||||
Yuf5wSe8qe4xpKOlrE9emnBmbL0sGivsU+mpz9oSjxEpHGA7eoTIOmQiZnuzpkmi
|
|
||||||
1ZSU4OwqNavphy6cklONShQOmE8LMI0wRbunLjIFY8fme/8u+tVvWrTuJiCGPnXQ
|
|
||||||
F2lb0qwtDVRlexyM+GTPYstU5v7HxkQB3B+uwTgYuupCmTNmO8hjSCS/EYpHzmFe
|
|
||||||
YHDEN+Cj8f+vmKxN0F/6QQKCAQEA9+wTQU2GSoVX8IB0U6T+hX0BFhQq5ISH/s76
|
|
||||||
kWIEunY1MCkRL9YygvHkKW3dsXVOzsip/axiT36MhRcyZ27hF1tz3j//Z11E3Bfq
|
|
||||||
PkzyUVuU3jpWZkBE2VhXpDXlyW8xR/y1ZOaZZ//XcZTrZf57pGKFp30H/PlDPH3C
|
|
||||||
YtjEuQNmPCgnfz8iXx+vDYx8hwLHNv+DoX2WYuThUnul/QGSKL3xh3qWd8rotnUB
|
|
||||||
c8bV4ymk35fVJu/+pTZpPnMkYrFReso/uNn07y1iga/9mwkUBNrT+fWE7RzjT7H8
|
|
||||||
ykMMOGCK6bc7joCvALZaUDne714hNW3s9a7L1clehUA8/xwplQKCAQEA6jx/CIQd
|
|
||||||
RVdJFihSSZbqdrOAblVdl+WkjhALWNRMoRCCRniNubbgxgKfQ0scKUeubYxScBVk
|
|
||||||
rlUMl6/2Gr9uzuSC0WPVAE6OLvLNcQafw1mQ1UTJiEzYvczJKwipzXcgGQWO9Q9a
|
|
||||||
T3ETh6Be62si2r6fH4agQzbp4HkTEoWgPu6MJpqqcLoc8laty0d1huqU9du1TRzT
|
|
||||||
3etjopWRd0I3ID+WkkGKjYWRQ1bkKjvkkj1v7bHenX17nfIp5WU1aXTMYUCMMszm
|
|
||||||
pgVBDeJGKpPpP3scl7go5Y4KC6H+IeYaeCEk3hWW4robpHBzupkgpRLzmBopjRlN
|
|
||||||
v3+HQ7OkviX88QKCAQEAg5IJdfKKfindzYieM3WwjW8VkH4LdVLQSW3WlCkMkVgC
|
|
||||||
ShjBQj3OeKeeik4ABRlYRW1AqZs+YSmrsUXqPfIeCqNCDoSwKk7ZKGSYr49uWbbc
|
|
||||||
fkM/buxUnXPAryjbVddos+ds7KtkZkjkMSby9iHjxA11GLnF737pK8Uh0Atx+y3O
|
|
||||||
p8Y3j9QVjZ3m7K3NuGjFCG75kE5x7PHCkl+Ea4zV4EFNWLS5/cD1Vz8pEiRHhlKn
|
|
||||||
aPHO8OcUoOELYVUBzk6EC0IiJxukXPoc+O5JDGn48cqgDFs7vApEqBqxKTYD2jeC
|
|
||||||
AR54wNuSBDLCIylTIn016oD37DpjeoVvYBADTu/HMQKCAQEA1rFuajrVrWnMpo98
|
|
||||||
pNC7xOLQM9DwwToOMtwH2np0ZiiAj+ENXgx+R1+95Gsiu79k5Cn6oZsqNhPkP+Bb
|
|
||||||
fba69M1EDnInmGloLyYDIbbFlsMwWhn7cn+lJYpfVJ9TK+0lMWoD1yAkUa4+DVDz
|
|
||||||
z2naf466wKWfnRvnEAVJcu+hqizxrqySzlH4GDNUhn7P/UJkGFkx+yUSGFUZdLsM
|
|
||||||
orfBWUCPXSzPttmXBJbO+Nr+rP+86KvgdI/AT0vYFNdINomEjxsfpaxjOAaW0wfz
|
|
||||||
8jCyWKoZ0gJNEeK32GO5UA7dcgBHD3vQWa3lijo8COsznboaJe7M6PQpa/2S2H3+
|
|
||||||
4P5msQKCAQEAx7NP3y+5ttfTd/eQ7/cg1/0y2WxvpOYNLt6MWz4rPWyD6QwidzTG
|
|
||||||
pjuQFQ5Ods+BwJ/Jbirb7l4GMAxfIbEPAkPTHpvswO0xcncSYxl0sSP/WIA6sbcM
|
|
||||||
dp7B/scdORC8Y6i8oPdCyxyCTd2SBrmGr2krAXmQquT72eusyP5E8HFhCy1iYt22
|
|
||||||
aL68dZLv9/sRAF08t9Wy+eYjD/hCj67t7uGCZQT8wJbKr8aJcjwVwJgghh+3EydK
|
|
||||||
h+7fBVO49PLL0NWy+8GT8y7a04calFfLvZEA2UMaunBis3dE1KMFfJL/0JO+sKnF
|
|
||||||
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-----
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
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', () => {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enable websockets
|
|
||||||
const wsServer = new plugins.ws.WebSocketServer({ server: this.httpsServer });
|
|
||||||
|
|
||||||
// Set up the heartbeat interval
|
|
||||||
this.heartbeatInterval = setInterval(() => {
|
|
||||||
wsServer.clients.forEach((ws: plugins.wsDefault) => {
|
|
||||||
const wsIncoming = ws as WebSocketWithHeartbeat;
|
|
||||||
if (!wsIncoming.lastPong) {
|
|
||||||
wsIncoming.lastPong = Date.now();
|
|
||||||
}
|
|
||||||
if (Date.now() - wsIncoming.lastPong > 5 * 60 * 1000) {
|
|
||||||
console.log('Terminating websocket due to missing pong for 5 minutes.');
|
|
||||||
wsIncoming.terminate();
|
|
||||||
} else {
|
|
||||||
wsIncoming.ping();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 60000); // runs every 1 minute
|
|
||||||
|
|
||||||
wsServer.on(
|
|
||||||
'connection',
|
|
||||||
async (wsIncoming: WebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
|
|
||||||
console.log(
|
|
||||||
`wss proxy: got connection for wsc for https://${reqArg.headers.host}${reqArg.url}`
|
|
||||||
);
|
|
||||||
|
|
||||||
wsIncoming.lastPong = Date.now();
|
|
||||||
wsIncoming.on('pong', () => {
|
|
||||||
wsIncoming.lastPong = Date.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
let wsOutgoing: plugins.wsDefault;
|
|
||||||
|
|
||||||
const outGoingDeferred = plugins.smartpromise.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
wsOutgoing = new plugins.wsDefault(
|
|
||||||
`ws://${this.router.routeReq(reqArg).destinationIp}:${
|
|
||||||
this.router.routeReq(reqArg).destinationPort
|
|
||||||
}${reqArg.url}`
|
|
||||||
);
|
|
||||||
console.log('wss proxy: initiated outgoing proxy');
|
|
||||||
wsOutgoing.on('open', async () => {
|
|
||||||
outGoingDeferred.resolve();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
wsIncoming.terminate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
wsIncoming.on('message', async (message, isBinary) => {
|
|
||||||
try {
|
|
||||||
await outGoingDeferred.promise;
|
|
||||||
wsOutgoing.send(message, { binary: isBinary });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending message to wsOutgoing:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wsOutgoing.on('message', async (message, isBinary) => {
|
|
||||||
try {
|
|
||||||
wsIncoming.send(message, { binary: isBinary });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending message to wsIncoming:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const terminateWsOutgoing = () => {
|
|
||||||
if (wsOutgoing) {
|
|
||||||
wsOutgoing.terminate();
|
|
||||||
console.log('terminated outgoing ws.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
wsIncoming.on('error', () => terminateWsOutgoing());
|
|
||||||
wsIncoming.on('close', () => terminateWsOutgoing());
|
|
||||||
|
|
||||||
const terminateWsIncoming = () => {
|
|
||||||
if (wsIncoming) {
|
|
||||||
wsIncoming.terminate();
|
|
||||||
console.log('terminated incoming ws.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
wsOutgoing.on('error', () => terminateWsIncoming());
|
|
||||||
wsOutgoing.on('close', () => terminateWsIncoming());
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.httpsServer.keepAliveTimeout = 600 * 1000;
|
|
||||||
this.httpsServer.headersTimeout = 600 * 1000;
|
|
||||||
|
|
||||||
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
|
||||||
this.socketMap.add(connection);
|
|
||||||
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.`);
|
|
||||||
connection.destroy();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateProxyConfigs(proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[]) {
|
|
||||||
console.log(`got new proxy configs`);
|
|
||||||
this.proxyConfigs = proxyConfigsArg;
|
|
||||||
this.router.setNewProxyConfigs(proxyConfigsArg);
|
|
||||||
for (const hostCandidate of this.proxyConfigs) {
|
|
||||||
const existingHostNameConfig = this.alreadyAddedReverseConfigs[hostCandidate.hostName];
|
|
||||||
|
|
||||||
if (!existingHostNameConfig) {
|
|
||||||
this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
|
|
||||||
} else {
|
|
||||||
if (
|
|
||||||
existingHostNameConfig.publicKey === hostCandidate.publicKey &&
|
|
||||||
existingHostNameConfig.privateKey === hostCandidate.privateKey
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.httpsServer.addContext(hostCandidate.hostName, {
|
|
||||||
cert: hostCandidate.publicKey,
|
|
||||||
key: hostCandidate.privateKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async addDefaultHeaders(headersArg: { [key: string]: string }) {
|
|
||||||
for (const headerKey of Object.keys(headersArg)) {
|
|
||||||
this.defaultHeaders[headerKey] = headersArg[headerKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
const done = plugins.smartpromise.defer();
|
|
||||||
this.httpsServer.close(() => {
|
|
||||||
done.resolve();
|
|
||||||
});
|
|
||||||
await this.socketMap.forEach(async (socket) => {
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
await done.promise;
|
|
||||||
clearInterval(this.heartbeatInterval);
|
|
||||||
console.log('NetworkProxy -> OK: Server has been stopped and all connections closed.');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
|
||||||
|
|
||||||
export class ProxyRouter {
|
|
||||||
public reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sets a new set of reverse configs to be routed to
|
|
||||||
* @param reverseCandidatesArg
|
|
||||||
*/
|
|
||||||
public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]) {
|
|
||||||
this.reverseProxyConfigs = reverseCandidatesArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* routes a request
|
|
||||||
*/
|
|
||||||
public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig {
|
|
||||||
const originalHost = req.headers.host;
|
|
||||||
const correspodingReverseProxyConfig = this.reverseProxyConfigs.find((reverseConfig) => {
|
|
||||||
return reverseConfig.hostName === originalHost;
|
|
||||||
});
|
|
||||||
return correspodingReverseProxyConfig;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
|
||||||
import * as net from 'net';
|
|
||||||
|
|
||||||
export class PortProxy {
|
|
||||||
netServer: plugins.net.Server;
|
|
||||||
fromPort: number;
|
|
||||||
toPort: number;
|
|
||||||
|
|
||||||
constructor(fromPortArg: number, toPortArg: number) {
|
|
||||||
this.fromPort = fromPortArg;
|
|
||||||
this.toPort = toPortArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
this.netServer = net
|
|
||||||
.createServer((from) => {
|
|
||||||
const to = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: this.toPort,
|
|
||||||
});
|
|
||||||
from.setTimeout(120000);
|
|
||||||
from.pipe(to);
|
|
||||||
to.pipe(from);
|
|
||||||
from.on('error', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
to.on('error', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
const done = plugins.smartpromise.defer();
|
|
||||||
this.netServer.close(() => {
|
|
||||||
done.resolve();
|
|
||||||
});
|
|
||||||
await done.promise;
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,7 +6,9 @@
|
|||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist_*/**/*.d.ts"
|
"dist_*/**/*.d.ts"
|
||||||
|
Reference in New Issue
Block a user