Compare commits
474 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 491e51f40b | |||
| b46247d9cb | |||
| 9c0e46ff4e | |||
| f62bc4a526 | |||
| 8f23600ec1 | |||
| 141f185fbf | |||
| 6f4a5f19e7 | |||
| 9d8354e58f | |||
| 947637eed7 | |||
| 5202c2ea27 | |||
| 6684dc43da | |||
| 04ec387ce5 | |||
| f145798f39 | |||
| 55f5465a9a | |||
| 0577f45ced | |||
| 7d23617f15 | |||
| 02415f8c53 | |||
| 73a47e5a97 | |||
| 5e980812b0 | |||
| 76e9735cde | |||
| 8bfc0c2fa2 | |||
| 55699f6618 | |||
| 6344c2deae | |||
| c1452131fa | |||
| 81f8e543e1 | |||
| bb6c26484d | |||
| 193a4bb180 | |||
| 0d9e6a4925 | |||
| ece9e46be9 | |||
| 918390a6a4 | |||
| 4ec0b67a71 | |||
| 356d6eca77 | |||
| 39c77accf8 | |||
| b8fba52cb3 | |||
| f247c77807 | |||
| e88938cf95 | |||
| 4f705a591e | |||
| 29687670e8 | |||
| 95daee1d8f | |||
| 11ca64a1cd | |||
| cfb727b86d | |||
| 1e4b9997f4 | |||
| bb32f23d77 | |||
| 1aa6451dba | |||
| eb0408c036 | |||
| 098a2567fa | |||
| c6534df362 | |||
| 2e4b375ad5 | |||
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 | |||
| 450ec4816e | |||
| ab4310b775 | |||
| 6efd986406 | |||
| 7370d7f0e7 | |||
| e733067c25 | |||
| bc2ed808f9 | |||
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 | |||
| 99b40fea3f | |||
| 6f72e4fdbc | |||
| fbe845cd8e | |||
| 31413d28be | |||
| cd286cede6 | |||
| 36a3060cce | |||
| d2b108317e | |||
| dcd75f5e47 | |||
| 3d443fa147 | |||
| 2efdd2f16b | |||
| ec0348a83c | |||
| 6c4adf70c7 | |||
| 29d6076355 | |||
| fa96a41e68 | |||
| 1ea38ed5d2 | |||
| 7209903d02 | |||
| 20eda1ab3e | |||
| 44f2a7f0a9 | |||
| 0195a21f30 | |||
| 4dca747386 | |||
| 7663f502fa | |||
| 104cd417d8 | |||
| 93254d5d3d | |||
| 9a3f121a9c | |||
| bef74eb1aa | |||
| 308d8e4851 | |||
| dc010dc3ae | |||
| 61d5d3b1ad | |||
| dd70790d40 | |||
| 2f8c04edc4 | |||
| 474cc328dd | |||
| 39ff159bf7 | |||
| c7fe7aeb50 | |||
| 2cf362020f | |||
| b62bad3616 | |||
| 3d372863a4 | |||
| 1045dc04fe | |||
| 89ef7597df | |||
| 0804544564 | |||
| 671e72452a | |||
| 647c705b81 | |||
| 40c3202082 | |||
| 3b91ed3d5a | |||
| 133b17f136 | |||
| efa45dfdc9 | |||
| 79b4ea6bd9 | |||
| b483412a2e | |||
| d964515ff9 | |||
| e2c453423e | |||
| c44b7d513a | |||
| 2487f77b8a | |||
| ea80ef005c | |||
| dd45b7fbe7 | |||
| ca73da7b9b | |||
| f6e1951aa2 | |||
| 76fd563e21 | |||
| ee831ea057 | |||
| a65c2ec096 | |||
| 65822278d5 | |||
| aa3955fc67 | |||
| d4605062bb | |||
| cd3f08d55f | |||
| 6d447f0086 | |||
| c7de3873d8 | |||
| 6d4e30e8a9 | |||
| 0e308b692b | |||
| 9f74b6e063 | |||
| 1d0f47f256 | |||
| 4e9301ae2a | |||
| 7e2142ce53 | |||
| 67190605a6 | |||
| 9479a07ddf | |||
| fbed56092f | |||
| 547b82b35b | |||
| 3dc63fa02e | |||
| e0154f5b70 | |||
| b268409897 | |||
| f3a9fd12c5 | |||
| ef741d84fb | |||
| b0ea97b922 | |||
| d1560811f5 | |||
| 5e872c4e6a | |||
| 3620e4549a | |||
| b32865e790 | |||
| ebe71a2a94 | |||
| 877a2ad0ee | |||
| 7be1aaedb3 | |||
| 05eb8e9723 | |||
| d95d89ea6f | |||
| 5d1b988579 | |||
| bae85eea9e | |||
| 2be7974991 | |||
| ac03b1f081 | |||
| 5ca209dd5a | |||
| 867e93b246 | |||
| aa9c4c1c28 | |||
| 207f21cb77 | |||
| 96a47ef588 | |||
| 3bac80eb41 | |||
| 19d67a644c | |||
| 341e4113bd | |||
| 81eb19a9ab | |||
| e0221c0d05 | |||
| e6a1f23c84 | |||
| f77772e1c0 | |||
| 0a706c03c4 | |||
| df5547fda0 | |||
| a677ee081c | |||
| 7339a91513 | |||
| cd27645489 | |||
| 71f2d53e45 | |||
| f74fcc68de | |||
| abcaf9c858 | |||
| c9f185dd82 | |||
| 5e3186d311 | |||
| db35c01a09 | |||
| 3cf6c84a60 | |||
| d570d5c916 | |||
| 4f6afb62f3 | |||
| e5edfdc052 | |||
| 55cbb92a00 | |||
| 928be6a5c6 | |||
| dff3e3c80d | |||
| 8db2118a78 | |||
| dc6da4ce34 | |||
| 63eb99ae5d | |||
| 6b533fba9f | |||
| d3e102b6d2 | |||
| 2bc9761d21 | |||
| d60b9fb8e2 | |||
| b2705f3e88 | |||
| 4dea263cb0 | |||
| 54593d0a9b | |||
| 225a75a81b | |||
| 72ae4e8a9b | |||
| b171590179 | |||
| c46f79914f | |||
| 914223972a | |||
| 559435d13c | |||
| 47300e4ced | |||
| a35a35f302 | |||
| 3b09587ca9 | |||
| 1cb2de2c3e | |||
| f972c38784 | |||
| 90736c3668 | |||
| f27ef5c768 | |||
| 68ecd18646 | |||
| e3d7c91705 | |||
| d3c2fa436c | |||
| 53b7da7e3f | |||
| 8e312d80c0 | |||
| c52d0ca759 | |||
| ca024345f6 | |||
| 89ea875ca8 | |||
| f15414e509 | |||
| cfaafab057 | |||
| 0aed608578 | |||
| 00a24051c9 | |||
| 310b43d1a6 | |||
| e7f56fb870 | |||
| 1f6eee20d3 | |||
| 7db7f92abd | |||
| fdf995cf61 | |||
| fa5adbbf61 | |||
| 1bdda9f501 | |||
| 39a5d6aaa6 | |||
| 1d46ec709b | |||
| 2b4bf42812 | |||
| 6faccc643b | |||
| 9f63908f7d | |||
| 5889eb5210 | |||
| 1de40c0d4e | |||
| e201efe0b4 | |||
| f8c582ee9b | |||
| bf9e85f518 | |||
| 0366ec8160 | |||
| 58bd4a0d33 | |||
| bbff76814e | |||
| 292486c33b | |||
| 2df8937d86 | |||
| 14aa1fa1d4 | |||
| 7a4214a7b8 | |||
| bd6130013c | |||
| 0f814bbcdd | |||
| 8ec94b7dae | |||
| d5dfe439c7 | |||
| aaf3c9cb1c | |||
| abde872ab2 | |||
| ca2d2b09ad | |||
| fb7d4d988b | |||
| 26e6eea5d5 | |||
| 2458dd08d8 | |||
| dee648b3bc | |||
| f4ed32cee4 | |||
| e9c72952ab | |||
| 1bd485c43e | |||
| 421a0390ba | |||
| c7f87a7c22 | |||
| 390d5c648f | |||
| ec651c1cdb | |||
| 6f82c393e7 | |||
| afdb48367b | |||
| 53526ca3ba | |||
| 07e8f4489b | |||
| 14101a09d3 | |||
| 5344d53806 | |||
| 971535926c | |||
| c13a4ae4be | |||
| e7a03c48ae | |||
| a682329a3f | |||
| c4580f9874 | |||
| b331065b8c | |||
| 4675ca3e89 | |||
| 70e2c8e17d | |||
| db53d87cc5 | |||
| ff6244d3d1 | |||
| f0aafe9027 | |||
| 487f2acac8 | |||
| 0a5e35c58e | |||
| 34c0cab5dc | |||
| 3a666e9300 | |||
| cbe1b5d37d | |||
| 30f2044d9f | |||
| 593b000ca3 | |||
| 60c298c396 | |||
| d7f1c16454 | |||
| 4290d4be86 | |||
| bc34cb5eab | |||
| eda12f3ce3 | |||
| 65f19aac72 | |||
| 29a992a695 | |||
| dbb2166a8f | |||
| 22691329a5 | |||
| e098e1a2ad | |||
| 16d64ec988 | |||
| cb1332ff76 | |||
| 3e52060788 | |||
| f041891a3f | |||
| f902c2c1db | |||
| e1a9e1f997 | |||
| d7b39a3017 | |||
| 0f41b0d8c7 | |||
| 2d33c037ba | |||
| dca7b37eb8 | |||
| b56598ba00 | |||
| bbf550b183 | |||
| f4fc5eb1fd | |||
| d9e88cf5f9 | |||
| eccb9706f2 | |||
| 285e681413 | |||
| 4f3958d94d | |||
| d19f22255d | |||
| 87ec55619a | |||
| b91dab0f85 | |||
| df573d498e | |||
| da2b838019 | |||
| 107adeee1d | |||
| 45f933b473 | |||
| ad16bc44f1 | |||
| 96d5b7e01a | |||
| 93ffcf86b3 | |||
| de98b070db | |||
| d3d2bde440 | |||
| 0840b2b571 | |||
| fa2e784eaa | |||
| 64f2854023 | |||
| 03e3261755 | |||
| c724e68b8c | |||
| f8f66d1392 | |||
| c66bdc9f88 | |||
| 8d57547ace | |||
| 54eaf23298 | |||
| 7148306381 | |||
| d3aefef78d | |||
| ecd0cc0066 | |||
| eac490297a | |||
| de65641f6f | |||
| ffddc1a5f5 | |||
| 26152e0520 | |||
| f79ad07a57 | |||
| 76d5b9bf7c | |||
| 670b67eecf | |||
| 174af5cf86 | |||
| a1f5e45e94 | |||
| d06165bd0c | |||
| 8f3c6fdf23 | |||
| 106ef2919e | |||
| 3d7fd233cf | |||
| 34d40f7370 | |||
| 89b9d01628 | |||
| ed3964e892 | |||
| baab152fd3 | |||
| 9baf09ff61 | |||
| 71f23302d3 | |||
| ecbaab3000 | |||
| 8cb1f3c12d | |||
| c7d7f92759 | |||
| 02e1b9231f | |||
| 4ec4dd2bdb | |||
| aa543160e2 | |||
| 94fa0f04d8 | |||
| 17deb481e0 | |||
| e452ffd38e | |||
| 865b4a53e6 | |||
| c07f3975e9 | |||
| 476505537a | |||
| 74ad5cec90 | |||
| 59a3f7978e | |||
| 7dc976b59e | |||
| 345effee13 | |||
| dee6897931 | |||
| 56f41d70b3 | |||
| 8f570ae8a0 | |||
| e58e24a92d | |||
| 12070bc7b5 | |||
| 37d62c51f3 | |||
| ea9427d46b | |||
| bc77321752 | |||
| 65aa546c1c | |||
| 54484518dc | |||
| 6fe1247d4d | |||
| e59d80a3b3 | |||
| 6c4feba711 | |||
| 006a9af20c | |||
| dfb3b0ac37 | |||
| 44c1a3a928 | |||
| 0c4e28455e | |||
| cfc4cf378f | |||
| a09e69a28b | |||
| 82dd19e274 | |||
| c1d8afdbf7 | |||
| 9b7426f1e6 | |||
| 3c9c865841 | |||
| 8421c9fe46 | |||
| 907e3df156 | |||
| aaa0956148 | |||
| 118019fcf5 | |||
| deb80f4fd0 | |||
| 7d28cea937 | |||
| 2bd5e5c7c5 | |||
| 4d6ac81c59 | |||
| 2ebe0de92d | |||
| f5028ffb60 | |||
| 90016d1217 | |||
| 48d3d1218f | |||
| 4759c4f011 | |||
| 0fbd8d1cdd | |||
| 447cf44d68 | |||
| 82ce17a941 | |||
| 15da996e70 | |||
| 582e19e6a6 | |||
| 79765d6729 | |||
| ffc93eb9d3 | |||
| 1337a4905a | |||
| c7418d9e1a | |||
| 2a94ffd4c9 | |||
| b2fe6caf33 | |||
| 822bbc1957 | |||
| eacddc7ce1 | |||
| dc6ce341bd | |||
| 1aadc93f92 | |||
| 8fdcd479d6 | |||
| d24dde8eff | |||
| 40a34073e9 | |||
| 9ac297c197 | |||
| ddd0662fb8 | |||
| 11bc0dde6c | |||
| 610d691244 | |||
| c88410ea53 | |||
| 9cbdd24281 | |||
| dce1de8c4b | |||
| 86e6c4f600 | |||
| 0618755236 | |||
| b21f3385e1 | |||
| dd61e0c962 | |||
| ac3a42fc41 | |||
| c23f16149c | |||
| 529a4bae00 | |||
| 49606ae007 | |||
| 31a6510d8b | |||
| b5e760ae07 | |||
| ea32babaac | |||
| a4ddedaf46 | |||
| 7ce09c53ca | |||
| 69be2295f1 | |||
| 018efa32f6 | |||
| 2530918dc6 | |||
| 0b09ea1573 | |||
| 21157477b4 | |||
| fcf36e5cd5 | |||
| f5740fa565 | |||
| 4a9fba53a9 | |||
| da61adc9a2 | |||
| 616066ffd0 | |||
| bd5cccb405 | |||
| fbade85cda | |||
| 9060d26f3a | |||
| c889141ec3 | |||
| fb472f353c | |||
| 090bd747e1 | |||
| 4d77a94bbb | |||
| 7f5284b10f | |||
| 9cd5db2d81 | |||
| de0b7d1fe0 |
@@ -1 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.nogit/
|
||||||
|
.git/
|
||||||
|
.playwright-mcp/
|
||||||
|
.vscode/
|
||||||
|
test/
|
||||||
|
test_watch/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
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.lossless.digital/${{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}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
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.lossless.digital/${{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}}
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
|
image: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -82,15 +82,13 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @git.zone/tsdocker
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
run: |
|
||||||
npmci docker login
|
tsdocker login
|
||||||
npmci docker build
|
tsdocker build
|
||||||
npmci docker test
|
tsdocker push
|
||||||
# npmci docker push gitea.lossless.digital
|
|
||||||
npmci docker push dockerregistry.lossless.digital
|
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ dist_*/
|
|||||||
**/.claude/settings.local.json
|
**/.claude/settings.local.json
|
||||||
.nogit/data/
|
.nogit/data/
|
||||||
readme.plan.md
|
readme.plan.md
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"to": "./dist_serve/bundle.js",
|
"to": "./dist_serve/bundle.js",
|
||||||
"outputMode": "bundle",
|
"outputMode": "bundle",
|
||||||
"bundler": "esbuild",
|
"bundler": "esbuild",
|
||||||
"production": true
|
"production": true,
|
||||||
|
"includeFiles": ["./html/**/*.html"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -71,9 +72,14 @@
|
|||||||
"dockerRegistryRepoMap": {
|
"dockerRegistryRepoMap": {
|
||||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
|
||||||
},
|
|
||||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||||
|
},
|
||||||
|
"@git.zone/tsdocker": {
|
||||||
|
"registries": ["code.foss.global"],
|
||||||
|
"registryRepoMap": {
|
||||||
|
"code.foss.global": "serve.zone/dcrouter",
|
||||||
|
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
|
||||||
|
},
|
||||||
|
"platforms": ["linux/amd64", "linux/arm64"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
54
Dockerfile
54
Dockerfile
@@ -1,46 +1,34 @@
|
|||||||
# gitzone dockerfile_service
|
# gitzone dockerfile_service
|
||||||
## STAGE 1 // BUILD
|
## STAGE 1 // BUILD
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
|
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||||
COPY ./ /app
|
COPY ./ /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
RUN pnpm config set store-dir .pnpm-store
|
||||||
RUN rm -rf node_modules && pnpm install
|
RUN rm -rf node_modules && pnpm install
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
|
||||||
|
|
||||||
|
## STAGE 2 // PRODUCTION
|
||||||
|
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
|
||||||
|
|
||||||
|
# gcompat + libstdc++ for glibc-linked Rust binaries (smartproxy, smartmta, remoteingress)
|
||||||
|
RUN apk add --no-cache gcompat libstdc++
|
||||||
|
|
||||||
# gitzone dockerfile_service
|
|
||||||
## STAGE 2 // install production
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=node1 /app /app
|
COPY --from=build /app /app
|
||||||
RUN rm -rf .pnpm-store
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
RUN rm -rf node_modules/ && pnpm install --prod
|
|
||||||
|
|
||||||
|
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||||
|
ENV DCROUTER_HEAP_SIZE=512
|
||||||
|
ENV UV_THREADPOOL_SIZE=16
|
||||||
|
|
||||||
## STAGE 3 // rebuild dependencies for alpine
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node2 /app /app
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
RUN pnpm rebuild -r
|
|
||||||
|
|
||||||
## STAGE 4 // the final production image with all dependencies in place
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node3 /app /app
|
|
||||||
|
|
||||||
### Healthchecks
|
|
||||||
RUN pnpm install -g @servezone/healthy
|
RUN pnpm install -g @servezone/healthy
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
|
||||||
|
|
||||||
EXPOSE 80
|
LABEL org.opencontainers.image.title="dcrouter" \
|
||||||
CMD ["npm", "start"]
|
org.opencontainers.image.description="Multi-service datacenter gateway" \
|
||||||
|
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
|
||||||
|
|
||||||
|
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
|
||||||
|
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]
|
||||||
|
|||||||
1554
changelog.md
1554
changelog.md
File diff suppressed because it is too large
Load Diff
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
70
package.json
70
package.json
@@ -1,63 +1,73 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "6.2.2",
|
"version": "12.5.1",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist_ts/index.js",
|
".": "./dist_ts/index.js",
|
||||||
"./interfaces": "./dist_ts_interfaces/index.js"
|
"./interfaces": "./dist_ts_interfaces/index.js",
|
||||||
|
"./apiclient": "./dist_ts_apiclient/index.js"
|
||||||
},
|
},
|
||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --logfile --timeout 60)",
|
"test": "(tstest test/ --logfile --timeout 60)",
|
||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
|
"build:docker": "tsdocker build --verbose",
|
||||||
|
"release:docker": "tsdocker push --verbose",
|
||||||
"bundle": "(tsbundle)",
|
"bundle": "(tsbundle)",
|
||||||
"watch": "tswatch"
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.1.2",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsbundle": "^2.8.3",
|
"@git.zone/tsbundle": "^2.10.0",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.1.8",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@git.zone/tswatch": "^3.1.0",
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
"@types/node": "^25.2.3"
|
"@types/node": "^25.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.2.6",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedserver": "^8.3.0",
|
"@api.global/typedserver": "^8.4.6",
|
||||||
"@api.global/typedsocket": "^4.1.0",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@design.estate/dees-catalog": "^3.42.0",
|
"@design.estate/dees-catalog": "^3.50.2",
|
||||||
"@design.estate/dees-element": "^2.1.6",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^9.0.0",
|
"@push.rocks/smartacme": "^9.3.1",
|
||||||
"@push.rocks/smartdata": "^7.0.15",
|
"@push.rocks/smartdata": "^7.1.3",
|
||||||
"@push.rocks/smartdns": "^7.8.1",
|
"@push.rocks/smartdb": "^2.1.1",
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
|
"@push.rocks/smartfs": "^1.5.0",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartlog": "^3.1.11",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
"@push.rocks/smartmetrics": "^2.0.10",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmongo": "^5.1.0",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartmta": "^5.2.2",
|
"@push.rocks/smartnetwork": "^4.5.2",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^25.4.0",
|
"@push.rocks/smartproxy": "^27.1.0",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.0.30",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"@push.rocks/smartvpn": "1.19.1",
|
||||||
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
|
"@serve.zone/catalog": "^2.11.0",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"lru-cache": "^11.2.6",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"lru-cache": "^11.2.7",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -97,13 +107,15 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
"ts_web/**/*",
|
"ts_web/**/*",
|
||||||
|
"ts_apiclient/**/*",
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist_*/**/*",
|
"dist_*/**/*",
|
||||||
"dist_ts/**/*",
|
"dist_ts/**/*",
|
||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
|
"dist_ts_apiclient/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
5886
pnpm-lock.yaml
generated
5886
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -133,7 +133,7 @@ The project now uses tswatch for development:
|
|||||||
```bash
|
```bash
|
||||||
pnpm run watch
|
pnpm run watch
|
||||||
```
|
```
|
||||||
Configuration in `npmextra.json`:
|
Configuration in `.smartconfig.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@git.zone/tswatch": {
|
"@git.zone/tswatch": {
|
||||||
|
|||||||
84
readme.storage.md
Normal file
84
readme.storage.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# DCRouter Storage Overview
|
||||||
|
|
||||||
|
DCRouter uses a **unified database layer** backed by `@push.rocks/smartdata` for all persistent data. All data is stored as typed document classes in a single database.
|
||||||
|
|
||||||
|
## Database Modes
|
||||||
|
|
||||||
|
### Embedded Mode (default)
|
||||||
|
When no external MongoDB URL is provided, DCRouter starts an embedded `LocalSmartDb` (Rust-based MongoDB-compatible engine) via `@push.rocks/smartdb`.
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.serve.zone/dcrouter/tsmdb/
|
||||||
|
```
|
||||||
|
|
||||||
|
### External Mode
|
||||||
|
Connect to any MongoDB-compatible database by providing a connection URL.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dbConfig: {
|
||||||
|
mongoDbUrl: 'mongodb://host:27017',
|
||||||
|
dbName: 'dcrouter',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dbConfig: {
|
||||||
|
enabled: true, // default: true
|
||||||
|
mongoDbUrl: undefined, // default: embedded LocalSmartDb
|
||||||
|
storagePath: '~/.serve.zone/dcrouter/tsmdb', // default (embedded mode only)
|
||||||
|
dbName: 'dcrouter', // default
|
||||||
|
cleanupIntervalHours: 1, // TTL cleanup interval
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Document Classes
|
||||||
|
|
||||||
|
All data is stored as smartdata document classes in `ts/db/documents/`.
|
||||||
|
|
||||||
|
| Document Class | Collection | Unique Key | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `StoredRouteDoc` | storedRoutes | `id` | Programmatic routes (created via API) |
|
||||||
|
| `RouteOverrideDoc` | routeOverrides | `routeName` | Hardcoded route enable/disable overrides |
|
||||||
|
| `ApiTokenDoc` | apiTokens | `id` | API tokens (hashed secrets, scopes, expiry) |
|
||||||
|
| `VpnServerKeysDoc` | vpnServerKeys | `configId` (singleton) | VPN server Noise + WireGuard keypairs |
|
||||||
|
| `VpnClientDoc` | vpnClients | `clientId` | VPN client registrations |
|
||||||
|
| `AcmeCertDoc` | acmeCerts | `domainName` | ACME certificates and keys |
|
||||||
|
| `ProxyCertDoc` | proxyCerts | `domain` | SmartProxy TLS certificates |
|
||||||
|
| `CertBackoffDoc` | certBackoff | `domain` | Per-domain cert provision backoff state |
|
||||||
|
| `RemoteIngressEdgeDoc` | remoteIngressEdges | `id` | Edge node registrations |
|
||||||
|
| `VlanMappingsDoc` | vlanMappings | `configId` (singleton) | MAC-to-VLAN mapping table |
|
||||||
|
| `AccountingSessionDoc` | accountingSessions | `sessionId` | RADIUS accounting sessions |
|
||||||
|
| `CachedEmail` | cachedEmails | `id` | Email metadata (TTL: 30 days) |
|
||||||
|
| `CachedIPReputation` | cachedIPReputation | `ipAddress` | IP reputation results (TTL: 24 hours) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
DcRouterDb (singleton)
|
||||||
|
├── LocalSmartDb (embedded, Rust) ─── or ─── External MongoDB
|
||||||
|
└── SmartdataDb (ORM)
|
||||||
|
└── @Collection(() => getDb())
|
||||||
|
├── StoredRouteDoc
|
||||||
|
├── RouteOverrideDoc
|
||||||
|
├── ApiTokenDoc
|
||||||
|
├── VpnServerKeysDoc / VpnClientDoc
|
||||||
|
├── AcmeCertDoc / ProxyCertDoc / CertBackoffDoc
|
||||||
|
├── RemoteIngressEdgeDoc
|
||||||
|
├── VlanMappingsDoc / AccountingSessionDoc
|
||||||
|
├── CachedEmail (TTL)
|
||||||
|
└── CachedIPReputation (TTL)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TTL Cleanup
|
||||||
|
|
||||||
|
`CacheCleaner` runs on a configurable interval (default: 1 hour) and removes expired documents where `expiresAt < now()`.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
For tests or lightweight deployments without persistence:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dbConfig: { enabled: false }
|
||||||
|
```
|
||||||
376
test/test.apiclient.ts
Normal file
376
test/test.apiclient.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import {
|
||||||
|
DcRouterApiClient,
|
||||||
|
Route,
|
||||||
|
RouteBuilder,
|
||||||
|
RouteManager,
|
||||||
|
Certificate,
|
||||||
|
CertificateManager,
|
||||||
|
ApiToken,
|
||||||
|
ApiTokenBuilder,
|
||||||
|
ApiTokenManager,
|
||||||
|
RemoteIngress,
|
||||||
|
RemoteIngressBuilder,
|
||||||
|
RemoteIngressManager,
|
||||||
|
Email,
|
||||||
|
EmailManager,
|
||||||
|
StatsManager,
|
||||||
|
ConfigManager,
|
||||||
|
LogManager,
|
||||||
|
RadiusManager,
|
||||||
|
RadiusClientManager,
|
||||||
|
RadiusVlanManager,
|
||||||
|
RadiusSessionManager,
|
||||||
|
} from '../ts_apiclient/index.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Instantiation & Structure
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('DcRouterApiClient - should instantiate with baseUrl', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
expect(client).toBeTruthy();
|
||||||
|
expect(client.baseUrl).toEqual('https://localhost:3000');
|
||||||
|
expect(client.identity).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DcRouterApiClient - should strip trailing slashes from baseUrl', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000///' });
|
||||||
|
expect(client.baseUrl).toEqual('https://localhost:3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DcRouterApiClient - should accept optional apiToken', async () => {
|
||||||
|
const client = new DcRouterApiClient({
|
||||||
|
baseUrl: 'https://localhost:3000',
|
||||||
|
apiToken: 'dcr_test_token',
|
||||||
|
});
|
||||||
|
expect(client.apiToken).toEqual('dcr_test_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DcRouterApiClient - should have all resource managers', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
expect(client.routes).toBeInstanceOf(RouteManager);
|
||||||
|
expect(client.certificates).toBeInstanceOf(CertificateManager);
|
||||||
|
expect(client.apiTokens).toBeInstanceOf(ApiTokenManager);
|
||||||
|
expect(client.remoteIngress).toBeInstanceOf(RemoteIngressManager);
|
||||||
|
expect(client.stats).toBeInstanceOf(StatsManager);
|
||||||
|
expect(client.config).toBeInstanceOf(ConfigManager);
|
||||||
|
expect(client.logs).toBeInstanceOf(LogManager);
|
||||||
|
expect(client.emails).toBeInstanceOf(EmailManager);
|
||||||
|
expect(client.radius).toBeInstanceOf(RadiusManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// buildRequestPayload
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('DcRouterApiClient - buildRequestPayload includes identity when set', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const identity = {
|
||||||
|
jwt: 'test-jwt',
|
||||||
|
userId: 'user1',
|
||||||
|
name: 'Admin',
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
};
|
||||||
|
client.identity = identity;
|
||||||
|
|
||||||
|
const payload = client.buildRequestPayload({ extra: 'data' });
|
||||||
|
expect(payload.identity).toEqual(identity);
|
||||||
|
expect(payload.extra).toEqual('data');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DcRouterApiClient - buildRequestPayload includes apiToken when set', async () => {
|
||||||
|
const client = new DcRouterApiClient({
|
||||||
|
baseUrl: 'https://localhost:3000',
|
||||||
|
apiToken: 'dcr_abc123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = client.buildRequestPayload();
|
||||||
|
expect(payload.apiToken).toEqual('dcr_abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DcRouterApiClient - buildRequestPayload with both identity and apiToken', async () => {
|
||||||
|
const client = new DcRouterApiClient({
|
||||||
|
baseUrl: 'https://localhost:3000',
|
||||||
|
apiToken: 'dcr_abc123',
|
||||||
|
});
|
||||||
|
client.identity = {
|
||||||
|
jwt: 'test-jwt',
|
||||||
|
userId: 'user1',
|
||||||
|
name: 'Admin',
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = client.buildRequestPayload({ foo: 'bar' });
|
||||||
|
expect(payload.identity).toBeTruthy();
|
||||||
|
expect(payload.apiToken).toEqual('dcr_abc123');
|
||||||
|
expect(payload.foo).toEqual('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Route Builder
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('RouteBuilder - should support fluent builder pattern', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const builder = client.routes.build();
|
||||||
|
expect(builder).toBeInstanceOf(RouteBuilder);
|
||||||
|
|
||||||
|
// Fluent methods return `this` (same reference)
|
||||||
|
const result = builder
|
||||||
|
.setName('test-route')
|
||||||
|
.setMatch({ ports: 443, domains: 'example.com' })
|
||||||
|
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||||
|
.setEnabled(true);
|
||||||
|
|
||||||
|
expect(result === builder).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ApiToken Builder
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('ApiTokenBuilder - should support fluent builder pattern', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const builder = client.apiTokens.build();
|
||||||
|
expect(builder).toBeInstanceOf(ApiTokenBuilder);
|
||||||
|
|
||||||
|
const result = builder
|
||||||
|
.setName('ci-token')
|
||||||
|
.setScopes(['routes:read', 'routes:write'])
|
||||||
|
.addScope('config:read')
|
||||||
|
.setExpiresInDays(30);
|
||||||
|
|
||||||
|
expect(result === builder).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RemoteIngress Builder
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('RemoteIngressBuilder - should support fluent builder pattern', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const builder = client.remoteIngress.build();
|
||||||
|
expect(builder).toBeInstanceOf(RemoteIngressBuilder);
|
||||||
|
|
||||||
|
const result = builder
|
||||||
|
.setName('edge-1')
|
||||||
|
.setListenPorts([80, 443])
|
||||||
|
.setAutoDerivePorts(true)
|
||||||
|
.setTags(['production']);
|
||||||
|
|
||||||
|
expect(result === builder).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Route resource class
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const route = new Route(client, {
|
||||||
|
route: {
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 443, domains: 'example.com' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||||
|
},
|
||||||
|
source: 'programmatic',
|
||||||
|
enabled: true,
|
||||||
|
overridden: false,
|
||||||
|
storedRouteId: 'route-123',
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(route.name).toEqual('test-route');
|
||||||
|
expect(route.source).toEqual('programmatic');
|
||||||
|
expect(route.enabled).toEqual(true);
|
||||||
|
expect(route.overridden).toEqual(false);
|
||||||
|
expect(route.storedRouteId).toEqual('route-123');
|
||||||
|
expect(route.routeConfig.match.ports).toEqual(443);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const route = new Route(client, {
|
||||||
|
route: {
|
||||||
|
name: 'hardcoded-route',
|
||||||
|
match: { ports: 80 },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
|
||||||
|
},
|
||||||
|
source: 'hardcoded',
|
||||||
|
enabled: true,
|
||||||
|
overridden: false,
|
||||||
|
// No storedRouteId for hardcoded routes
|
||||||
|
});
|
||||||
|
|
||||||
|
let updateError: Error | undefined;
|
||||||
|
try {
|
||||||
|
await route.update({ name: 'new-name' });
|
||||||
|
} catch (e) {
|
||||||
|
updateError = e as Error;
|
||||||
|
}
|
||||||
|
expect(updateError).toBeTruthy();
|
||||||
|
expect(updateError!.message).toInclude('hardcoded');
|
||||||
|
|
||||||
|
let deleteError: Error | undefined;
|
||||||
|
try {
|
||||||
|
await route.delete();
|
||||||
|
} catch (e) {
|
||||||
|
deleteError = e as Error;
|
||||||
|
}
|
||||||
|
expect(deleteError).toBeTruthy();
|
||||||
|
|
||||||
|
let toggleError: Error | undefined;
|
||||||
|
try {
|
||||||
|
await route.toggle(false);
|
||||||
|
} catch (e) {
|
||||||
|
toggleError = e as Error;
|
||||||
|
}
|
||||||
|
expect(toggleError).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Certificate resource class
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('Certificate - should hydrate from ICertificateInfo data', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const cert = new Certificate(client, {
|
||||||
|
domain: 'example.com',
|
||||||
|
routeNames: ['main-route'],
|
||||||
|
status: 'valid',
|
||||||
|
source: 'acme',
|
||||||
|
tlsMode: 'terminate',
|
||||||
|
expiryDate: '2027-01-01T00:00:00Z',
|
||||||
|
issuer: "Let's Encrypt",
|
||||||
|
canReprovision: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cert.domain).toEqual('example.com');
|
||||||
|
expect(cert.status).toEqual('valid');
|
||||||
|
expect(cert.source).toEqual('acme');
|
||||||
|
expect(cert.canReprovision).toEqual(true);
|
||||||
|
expect(cert.routeNames.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ApiToken resource class
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('ApiToken - should hydrate from IApiTokenInfo data', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const token = new ApiToken(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
id: 'token-1',
|
||||||
|
name: 'ci-token',
|
||||||
|
scopes: ['routes:read', 'routes:write'],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
'dcr_secret_value',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(token.id).toEqual('token-1');
|
||||||
|
expect(token.name).toEqual('ci-token');
|
||||||
|
expect(token.scopes.length).toEqual(2);
|
||||||
|
expect(token.enabled).toEqual(true);
|
||||||
|
expect(token.tokenValue).toEqual('dcr_secret_value');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RemoteIngress resource class
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('RemoteIngress - should hydrate from IRemoteIngress data', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const edge = new RemoteIngress(client, {
|
||||||
|
id: 'edge-1',
|
||||||
|
name: 'test-edge',
|
||||||
|
secret: 'secret123',
|
||||||
|
listenPorts: [80, 443],
|
||||||
|
enabled: true,
|
||||||
|
autoDerivePorts: true,
|
||||||
|
tags: ['prod'],
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 2000,
|
||||||
|
effectiveListenPorts: [80, 443, 8080],
|
||||||
|
manualPorts: [80, 443],
|
||||||
|
derivedPorts: [8080],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(edge.id).toEqual('edge-1');
|
||||||
|
expect(edge.name).toEqual('test-edge');
|
||||||
|
expect(edge.listenPorts.length).toEqual(2);
|
||||||
|
expect(edge.effectiveListenPorts!.length).toEqual(3);
|
||||||
|
expect(edge.autoDerivePorts).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Email resource class
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('Email - should hydrate from IEmail data', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
const email = new Email(client, {
|
||||||
|
id: 'email-1',
|
||||||
|
direction: 'inbound',
|
||||||
|
status: 'delivered',
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Test email',
|
||||||
|
timestamp: '2026-03-06T00:00:00Z',
|
||||||
|
messageId: '<msg-1@example.com>',
|
||||||
|
size: '1234',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(email.id).toEqual('email-1');
|
||||||
|
expect(email.direction).toEqual('inbound');
|
||||||
|
expect(email.status).toEqual('delivered');
|
||||||
|
expect(email.from).toEqual('sender@example.com');
|
||||||
|
expect(email.subject).toEqual('Test email');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RadiusManager structure
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('RadiusManager - should have sub-managers', async () => {
|
||||||
|
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||||
|
expect(client.radius.clients).toBeInstanceOf(RadiusClientManager);
|
||||||
|
expect(client.radius.vlans).toBeInstanceOf(RadiusVlanManager);
|
||||||
|
expect(client.radius.sessions).toBeInstanceOf(RadiusSessionManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Exports verification
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
tap.test('Exports - all expected classes should be importable', async () => {
|
||||||
|
expect(DcRouterApiClient).toBeTruthy();
|
||||||
|
expect(Route).toBeTruthy();
|
||||||
|
expect(RouteBuilder).toBeTruthy();
|
||||||
|
expect(RouteManager).toBeTruthy();
|
||||||
|
expect(Certificate).toBeTruthy();
|
||||||
|
expect(CertificateManager).toBeTruthy();
|
||||||
|
expect(ApiToken).toBeTruthy();
|
||||||
|
expect(ApiTokenBuilder).toBeTruthy();
|
||||||
|
expect(ApiTokenManager).toBeTruthy();
|
||||||
|
expect(RemoteIngress).toBeTruthy();
|
||||||
|
expect(RemoteIngressBuilder).toBeTruthy();
|
||||||
|
expect(RemoteIngressManager).toBeTruthy();
|
||||||
|
expect(Email).toBeTruthy();
|
||||||
|
expect(EmailManager).toBeTruthy();
|
||||||
|
expect(StatsManager).toBeTruthy();
|
||||||
|
expect(ConfigManager).toBeTruthy();
|
||||||
|
expect(LogManager).toBeTruthy();
|
||||||
|
expect(RadiusManager).toBeTruthy();
|
||||||
|
expect(RadiusClientManager).toBeTruthy();
|
||||||
|
expect(RadiusVlanManager).toBeTruthy();
|
||||||
|
expect(RadiusSessionManager).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -129,7 +129,8 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
tls: {
|
tls: {
|
||||||
contactEmail: 'test@example.com'
|
contactEmail: 'test@example.com'
|
||||||
},
|
},
|
||||||
cacheConfig: {
|
opsServerPort: 3104,
|
||||||
|
dbConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
|
|||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
cacheConfig: { enabled: false }
|
opsServerPort: 3100,
|
||||||
|
dbConfig: { enabled: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
await dcRouter.start();
|
await dcRouter.start();
|
||||||
|
|||||||
304
test/test.http3-augmentation.ts
Normal file
304
test/test.http3-augmentation.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import {
|
||||||
|
routeQualifiesForHttp3,
|
||||||
|
augmentRouteWithHttp3,
|
||||||
|
augmentRoutesWithHttp3,
|
||||||
|
type IHttp3Config,
|
||||||
|
} from '../ts/http3/index.js';
|
||||||
|
import type * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Helper to create a basic HTTPS forward route on port 443
|
||||||
|
function makeRoute(
|
||||||
|
overrides: Partial<plugins.smartproxy.IRouteConfig> = {},
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
return {
|
||||||
|
match: { ports: 443, ...overrides.match },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
...overrides.action,
|
||||||
|
},
|
||||||
|
name: overrides.name ?? 'test-https-route',
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(overrides).filter(([k]) => !['match', 'action', 'name'].includes(k)),
|
||||||
|
),
|
||||||
|
} as plugins.smartproxy.IRouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfig: IHttp3Config = { enabled: true };
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Qualification tests
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should augment qualifying HTTPS route on port 443', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp).toBeTruthy();
|
||||||
|
expect(result.action.udp!.quic).toBeTruthy();
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment route on non-443 port', async () => {
|
||||||
|
const route = makeRoute({ match: { ports: 8080 } });
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
expect(result.action.udp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment socket-handler type route', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'socket-handler' as any,
|
||||||
|
socketHandler: (() => {}) as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment route without TLS', async () => {
|
||||||
|
const route: plugins.smartproxy.IRouteConfig = {
|
||||||
|
match: { ports: 443 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
},
|
||||||
|
name: 'no-tls-route',
|
||||||
|
};
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment email routes', async () => {
|
||||||
|
const emailNames = ['smtp-route', 'submission-route', 'smtps-route', 'email-port-2525-route'];
|
||||||
|
for (const name of emailNames) {
|
||||||
|
const route = makeRoute({ name });
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respect per-route opt-out (options.http3 = false)', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
options: { http3: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
expect(result.action.udp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respect per-route opt-in when global is disabled', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
options: { http3: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, { enabled: false });
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT double-augment routes with transport: all', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: 443, transport: 'all' as any },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
// Should be the exact same object (no augmentation)
|
||||||
|
expect(result).toEqual(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT double-augment routes with existing udp.quic', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
udp: { quic: { enableHttp3: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result).toEqual(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment route with port range including 443', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: [{ from: 400, to: 500 }] },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment route with port array including 443', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: [80, 443] },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment route with port range NOT including 443', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: [{ from: 8000, to: 9000 }] },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment TLS passthrough routes', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'passthrough' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment terminate-and-reencrypt routes', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Configuration tests
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should apply default QUIC settings when none provided', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
|
||||||
|
// Undefined means SmartProxy will use its own defaults
|
||||||
|
expect(result.action.udp!.quic!.maxIdleTimeout).toBeUndefined();
|
||||||
|
expect(result.action.udp!.quic!.altSvcPort).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should apply custom QUIC settings', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const config: IHttp3Config = {
|
||||||
|
enabled: true,
|
||||||
|
quicSettings: {
|
||||||
|
maxIdleTimeout: 60000,
|
||||||
|
maxConcurrentBidiStreams: 200,
|
||||||
|
maxConcurrentUniStreams: 50,
|
||||||
|
initialCongestionWindow: 65536,
|
||||||
|
},
|
||||||
|
altSvc: {
|
||||||
|
port: 8443,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
|
udpSettings: {
|
||||||
|
sessionTimeout: 120000,
|
||||||
|
maxSessionsPerIP: 500,
|
||||||
|
maxDatagramSize: 32768,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = augmentRouteWithHttp3(route, config);
|
||||||
|
|
||||||
|
expect(result.action.udp!.quic!.maxIdleTimeout).toEqual(60000);
|
||||||
|
expect(result.action.udp!.quic!.maxConcurrentBidiStreams).toEqual(200);
|
||||||
|
expect(result.action.udp!.quic!.maxConcurrentUniStreams).toEqual(50);
|
||||||
|
expect(result.action.udp!.quic!.initialCongestionWindow).toEqual(65536);
|
||||||
|
expect(result.action.udp!.quic!.altSvcPort).toEqual(8443);
|
||||||
|
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(3600);
|
||||||
|
expect(result.action.udp!.sessionTimeout).toEqual(120000);
|
||||||
|
expect(result.action.udp!.maxSessionsPerIP).toEqual(500);
|
||||||
|
expect(result.action.udp!.maxDatagramSize).toEqual(32768);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should not mutate the original route', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const originalTransport = route.match.transport;
|
||||||
|
const originalUdp = route.action.udp;
|
||||||
|
|
||||||
|
augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(route.match.transport).toEqual(originalTransport);
|
||||||
|
expect(route.action.udp).toEqual(originalUdp);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch augmentation
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should augment multiple routes in a batch', async () => {
|
||||||
|
const routes = [
|
||||||
|
makeRoute({ name: 'web-app' }),
|
||||||
|
makeRoute({ name: 'smtp-route', match: { ports: 25 } }),
|
||||||
|
makeRoute({ name: 'api-gateway' }),
|
||||||
|
makeRoute({
|
||||||
|
name: 'dns-query',
|
||||||
|
action: { type: 'socket-handler' as any, socketHandler: (() => {}) as any },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = augmentRoutesWithHttp3(routes, defaultConfig);
|
||||||
|
|
||||||
|
// web-app and api-gateway should be augmented
|
||||||
|
expect(results[0].match.transport).toEqual('all');
|
||||||
|
expect(results[2].match.transport).toEqual('all');
|
||||||
|
|
||||||
|
// smtp and dns should NOT be augmented
|
||||||
|
expect(results[1].match.transport).toBeUndefined();
|
||||||
|
expect(results[3].match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Default enabled behavior
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should treat undefined enabled as true (default on)', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, {}); // no enabled field at all
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should disable when enabled is explicitly false', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, { enabled: false });
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
expect(result.action.udp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -9,7 +9,8 @@ let identity: interfaces.data.IIdentity;
|
|||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
cacheConfig: { enabled: false },
|
opsServerPort: 3102,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
|
|
||||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
|||||||
|
|
||||||
tap.test('should verify valid JWT identity', async () => {
|
tap.test('should verify valid JWT identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ tap.test('should verify valid JWT identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject invalid JWT', async () => {
|
tap.test('should reject invalid JWT', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ tap.test('should reject invalid JWT', async () => {
|
|||||||
|
|
||||||
tap.test('should verify JWT matches identity data', async () => {
|
tap.test('should verify JWT matches identity data', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ tap.test('should verify JWT matches identity data', async () => {
|
|||||||
|
|
||||||
tap.test('should handle logout', async () => {
|
tap.test('should handle logout', async () => {
|
||||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLogout'
|
'adminLogout'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ tap.test('should handle logout', async () => {
|
|||||||
|
|
||||||
tap.test('should reject wrong credentials', async () => {
|
tap.test('should reject wrong credentials', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,45 @@ import { TypedRequest } from '@api.global/typedrequest';
|
|||||||
import * as interfaces from '../ts_interfaces/index.js';
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
let testDcRouter: DcRouter;
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
cacheConfig: { enabled: false },
|
opsServerPort: 3101,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'http://localhost:3101/typedrequest',
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('should respond to health status request', async () => {
|
tap.test('should respond to health status request', async () => {
|
||||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await healthRequest.fire({
|
const response = await healthRequest.fire({
|
||||||
detailed: false
|
identity: adminIdentity,
|
||||||
|
detailed: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('health');
|
expect(response).toHaveProperty('health');
|
||||||
expect(response.health.healthy).toBeTrue();
|
expect(response.health.healthy).toBeTrue();
|
||||||
expect(response.health.services).toHaveProperty('OpsServer');
|
expect(response.health.services).toHaveProperty('OpsServer');
|
||||||
@@ -32,14 +50,15 @@ tap.test('should respond to health status request', async () => {
|
|||||||
|
|
||||||
tap.test('should respond to server statistics request', async () => {
|
tap.test('should respond to server statistics request', async () => {
|
||||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getServerStatistics'
|
'getServerStatistics'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await statsRequest.fire({
|
const response = await statsRequest.fire({
|
||||||
includeHistory: false
|
identity: adminIdentity,
|
||||||
|
includeHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('stats');
|
expect(response).toHaveProperty('stats');
|
||||||
expect(response.stats).toHaveProperty('uptime');
|
expect(response.stats).toHaveProperty('uptime');
|
||||||
expect(response.stats).toHaveProperty('cpuUsage');
|
expect(response.stats).toHaveProperty('cpuUsage');
|
||||||
@@ -48,37 +67,58 @@ tap.test('should respond to server statistics request', async () => {
|
|||||||
|
|
||||||
tap.test('should respond to configuration request', async () => {
|
tap.test('should respond to configuration request', async () => {
|
||||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getConfiguration'
|
'getConfiguration'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await configRequest.fire({});
|
const response = await configRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('config');
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
expect(response.config).toHaveProperty('email');
|
expect(response.config).toHaveProperty('email');
|
||||||
expect(response.config).toHaveProperty('dns');
|
expect(response.config).toHaveProperty('dns');
|
||||||
expect(response.config).toHaveProperty('proxy');
|
expect(response.config).toHaveProperty('tls');
|
||||||
expect(response.config).toHaveProperty('security');
|
expect(response.config).toHaveProperty('cache');
|
||||||
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should handle log retrieval request', async () => {
|
tap.test('should handle log retrieval request', async () => {
|
||||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getRecentLogs'
|
'getRecentLogs'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await logsRequest.fire({
|
const response = await logsRequest.fire({
|
||||||
limit: 10
|
identity: adminIdentity,
|
||||||
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('logs');
|
expect(response).toHaveProperty('logs');
|
||||||
expect(response).toHaveProperty('total');
|
expect(response).toHaveProperty('total');
|
||||||
expect(response).toHaveProperty('hasMore');
|
expect(response).toHaveProperty('hasMore');
|
||||||
expect(response.logs).toBeArray();
|
expect(response.logs).toBeArray();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated requests', async () => {
|
||||||
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
|
'http://localhost:3101/typedrequest',
|
||||||
|
'getHealthStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('should stop DCRouter', async () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
await testDcRouter.stop();
|
await testDcRouter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ let adminIdentity: interfaces.data.IIdentity;
|
|||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
cacheConfig: { enabled: false },
|
opsServerPort: 3103,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
|
|
||||||
tap.test('should login as admin', async () => {
|
tap.test('should login as admin', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ tap.test('should login as admin', async () => {
|
|||||||
|
|
||||||
tap.test('should allow admin to verify identity', async () => {
|
tap.test('should allow admin to verify identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ tap.test('should allow admin to verify identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject verify identity without identity', async () => {
|
tap.test('should reject verify identity without identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ tap.test('should reject verify identity without identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,35 +83,42 @@ tap.test('should reject verify identity with invalid JWT', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow access to public endpoints without auth', async () => {
|
tap.test('should reject protected endpoints without auth', async () => {
|
||||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
// No identity provided
|
try {
|
||||||
const response = await healthRequest.fire({});
|
// No identity provided — should be rejected
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
expect(response).toHaveProperty('health');
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
expect(response.health.healthy).toBeTrue();
|
} catch (error) {
|
||||||
console.log('Public endpoint accessible without auth');
|
expect(error).toBeTruthy();
|
||||||
|
console.log('Protected endpoint correctly rejects unauthenticated request');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow read-only config access', async () => {
|
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'getConfiguration'
|
'getConfiguration'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Config is read-only and doesn't require auth
|
const response = await configRequest.fire({
|
||||||
const response = await configRequest.fire({});
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('config');
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
expect(response.config).toHaveProperty('email');
|
expect(response.config).toHaveProperty('email');
|
||||||
expect(response.config).toHaveProperty('dns');
|
expect(response.config).toHaveProperty('dns');
|
||||||
expect(response.config).toHaveProperty('proxy');
|
expect(response.config).toHaveProperty('tls');
|
||||||
expect(response.config).toHaveProperty('security');
|
expect(response.config).toHaveProperty('cache');
|
||||||
console.log('Configuration read successfully');
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
|
console.log('Authenticated access to config successful');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should stop DCRouter', async () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
|||||||
371
test/test.reference-resolver.ts
Normal file
371
test/test.reference-resolver.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||||
|
import type { ISecurityProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers: access private maps for direct unit testing without DB
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void {
|
||||||
|
(resolver as any).profiles.set(profile.id, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
|
||||||
|
(resolver as any).targets.set(target.id, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile {
|
||||||
|
return {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: 'STANDARD',
|
||||||
|
description: 'Test profile',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
|
||||||
|
maxConnections: 1000,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
|
||||||
|
return {
|
||||||
|
id: 'target-1',
|
||||||
|
name: 'INFRA',
|
||||||
|
description: 'Test target',
|
||||||
|
host: '192.168.5.247',
|
||||||
|
port: 443,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||||
|
return {
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 443, domains: 'test.example.com' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
|
||||||
|
...overrides,
|
||||||
|
} as IRouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Resolution tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let resolver: ReferenceResolver;
|
||||||
|
|
||||||
|
tap.test('should create ReferenceResolver instance', async () => {
|
||||||
|
resolver = new ReferenceResolver();
|
||||||
|
expect(resolver).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should list empty profiles and targets initially', async () => {
|
||||||
|
expect(resolver.listProfiles()).toBeArray();
|
||||||
|
expect(resolver.listProfiles().length).toEqual(0);
|
||||||
|
expect(resolver.listTargets()).toBeArray();
|
||||||
|
expect(resolver.listTargets().length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Security profile resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve security profile onto a route', async () => {
|
||||||
|
const profile = makeProfile();
|
||||||
|
injectProfile(resolver, profile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
expect(result.route.security).toBeTruthy();
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||||
|
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
||||||
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should merge inline route security with profile security', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['127.0.0.1'],
|
||||||
|
maxConnections: 5000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// IP lists are unioned
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
|
||||||
|
|
||||||
|
// Inline maxConnections overrides profile
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should deduplicate IP lists during merge', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
||||||
|
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
||||||
|
expect(count).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle missing profile gracefully', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route should be unchanged
|
||||||
|
expect(result.route.security).toBeUndefined();
|
||||||
|
expect(result.metadata.securityProfileName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Profile inheritance ----
|
||||||
|
|
||||||
|
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||||
|
const baseProfile = makeProfile({
|
||||||
|
id: 'base-profile',
|
||||||
|
name: 'BASE',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['10.0.0.0/8'],
|
||||||
|
maxConnections: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
injectProfile(resolver, baseProfile);
|
||||||
|
|
||||||
|
const extendedProfile = makeProfile({
|
||||||
|
id: 'extended-profile',
|
||||||
|
name: 'EXTENDED',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['160.79.104.0/21'],
|
||||||
|
},
|
||||||
|
extendsProfiles: ['base-profile'],
|
||||||
|
});
|
||||||
|
injectProfile(resolver, extendedProfile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Should have IPs from both base and extended profiles
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||||
|
// maxConnections from base (extended doesn't override)
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(500);
|
||||||
|
expect(result.metadata.securityProfileName).toEqual('EXTENDED');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should detect circular profile inheritance', async () => {
|
||||||
|
const profileA = makeProfile({
|
||||||
|
id: 'circular-a',
|
||||||
|
name: 'A',
|
||||||
|
security: { ipAllowList: ['1.1.1.1'] },
|
||||||
|
extendsProfiles: ['circular-b'],
|
||||||
|
});
|
||||||
|
const profileB = makeProfile({
|
||||||
|
id: 'circular-b',
|
||||||
|
name: 'B',
|
||||||
|
security: { ipAllowList: ['2.2.2.2'] },
|
||||||
|
extendsProfiles: ['circular-a'],
|
||||||
|
});
|
||||||
|
injectProfile(resolver, profileA);
|
||||||
|
injectProfile(resolver, profileB);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' };
|
||||||
|
|
||||||
|
// Should not infinite loop — resolves what it can
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
expect(result.route.security).toBeTruthy();
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Network target resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve network target onto a route', async () => {
|
||||||
|
const target = makeTarget();
|
||||||
|
injectTarget(resolver, target);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
expect(result.route.action.targets).toBeTruthy();
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||||
|
expect(result.route.action.targets![0].port).toEqual(443);
|
||||||
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||||
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle missing target gracefully', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route targets should be unchanged (still the placeholder)
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||||
|
expect(result.metadata.networkTargetName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Combined resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = {
|
||||||
|
securityProfileRef: 'profile-1',
|
||||||
|
networkTargetRef: 'target-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Security from profile
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||||
|
|
||||||
|
// Target from network target
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||||
|
expect(result.route.action.targets![0].port).toEqual(443);
|
||||||
|
|
||||||
|
// Both names recorded
|
||||||
|
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
||||||
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should skip resolution when no metadata refs', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: { ipAllowList: ['1.2.3.4'] },
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = {};
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route should be completely unchanged
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
||||||
|
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = {
|
||||||
|
securityProfileRef: 'profile-1',
|
||||||
|
networkTargetRef: 'target-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = resolver.resolveRoute(route, metadata);
|
||||||
|
const second = resolver.resolveRoute(first.route, first.metadata);
|
||||||
|
|
||||||
|
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
||||||
|
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
||||||
|
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Lookup helpers ----
|
||||||
|
|
||||||
|
tap.test('should find routes by profile ref (sync)', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-a', {
|
||||||
|
id: 'route-a',
|
||||||
|
route: makeRoute({ name: 'route-a' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { securityProfileRef: 'profile-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-b', {
|
||||||
|
id: 'route-b',
|
||||||
|
route: makeRoute({ name: 'route-b' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-c', {
|
||||||
|
id: 'route-c',
|
||||||
|
route: makeRoute({ name: 'route-c' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||||
|
expect(profileRefs.length).toEqual(2);
|
||||||
|
expect(profileRefs).toContain('route-a');
|
||||||
|
expect(profileRefs).toContain('route-c');
|
||||||
|
|
||||||
|
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||||
|
expect(targetRefs.length).toEqual(2);
|
||||||
|
expect(targetRefs).toContain('route-b');
|
||||||
|
expect(targetRefs).toContain('route-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-x', {
|
||||||
|
id: 'route-x',
|
||||||
|
route: makeRoute({ name: 'my-route' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { securityProfileRef: 'profile-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||||
|
expect(usage.length).toEqual(1);
|
||||||
|
expect(usage[0].id).toEqual('route-x');
|
||||||
|
expect(usage[0].routeName).toEqual('my-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get target usage for a specific target ID', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-y', {
|
||||||
|
id: 'route-y',
|
||||||
|
route: makeRoute({ name: 'other-route' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||||
|
expect(usage.length).toEqual(1);
|
||||||
|
expect(usage[0].id).toEqual('route-y');
|
||||||
|
expect(usage[0].routeName).toEqual('other-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Profile/target getters ----
|
||||||
|
|
||||||
|
tap.test('should get profile by name', async () => {
|
||||||
|
const profile = resolver.getProfileByName('STANDARD');
|
||||||
|
expect(profile).toBeTruthy();
|
||||||
|
expect(profile!.id).toEqual('profile-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get target by name', async () => {
|
||||||
|
const target = resolver.getTargetByName('INFRA');
|
||||||
|
expect(target).toBeTruthy();
|
||||||
|
expect(target!.id).toEqual('target-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return undefined for nonexistent profile name', async () => {
|
||||||
|
const profile = resolver.getProfileByName('NONEXISTENT');
|
||||||
|
expect(profile).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return undefined for nonexistent target name', async () => {
|
||||||
|
const target = resolver.getTargetByName('NONEXISTENT');
|
||||||
|
expect(target).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
208
test/test.security-profiles-api.ts
Normal file
208
test/test.security-profiles-api.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3200;
|
||||||
|
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup — db disabled, handlers return graceful fallbacks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
opsServerPort: TEST_PORT,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
TEST_URL,
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profiles).toBeArray();
|
||||||
|
expect(response.profiles.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profile).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'createSecurityProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
security: { ipAllowList: ['*'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfileUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty targets list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.targets).toBeArray();
|
||||||
|
expect(response.targets.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.target).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'createNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 443,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty target usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargetUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth rejection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated profile requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated target requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import * as paths from '../ts/paths.js';
|
|
||||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// Test data
|
|
||||||
const testData = {
|
|
||||||
string: 'Hello, World!',
|
|
||||||
json: { name: 'test', value: 42, nested: { data: true } },
|
|
||||||
largeString: 'x'.repeat(10000)
|
|
||||||
};
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Memory Backend', async () => {
|
|
||||||
// Create StorageManager without config (defaults to memory)
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Test basic get/set
|
|
||||||
await storage.set('/test/key', testData.string);
|
|
||||||
const value = await storage.get('/test/key');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Test JSON helpers
|
|
||||||
await storage.setJSON('/test/json', testData.json);
|
|
||||||
const jsonValue = await storage.getJSON('/test/json');
|
|
||||||
expect(jsonValue).toEqual(testData.json);
|
|
||||||
|
|
||||||
// Test exists
|
|
||||||
expect(await storage.exists('/test/key')).toEqual(true);
|
|
||||||
expect(await storage.exists('/nonexistent')).toEqual(false);
|
|
||||||
|
|
||||||
// Test delete
|
|
||||||
await storage.delete('/test/key');
|
|
||||||
expect(await storage.exists('/test/key')).toEqual(false);
|
|
||||||
|
|
||||||
// Test list
|
|
||||||
await storage.set('/items/1', 'one');
|
|
||||||
await storage.set('/items/2', 'two');
|
|
||||||
await storage.set('/other/3', 'three');
|
|
||||||
|
|
||||||
const items = await storage.list('/items');
|
|
||||||
expect(items.length).toEqual(2);
|
|
||||||
expect(items).toContain('/items/1');
|
|
||||||
expect(items).toContain('/items/2');
|
|
||||||
|
|
||||||
// Verify memory backend
|
|
||||||
expect(storage.getBackend()).toEqual('memory');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Filesystem Backend', async () => {
|
|
||||||
const testDir = path.join(paths.dataDir, '.test-storage');
|
|
||||||
|
|
||||||
// Clean up test directory if it exists
|
|
||||||
try {
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Create StorageManager with filesystem path
|
|
||||||
const storage = new StorageManager({ fsPath: testDir });
|
|
||||||
|
|
||||||
// Test basic operations
|
|
||||||
await storage.set('/test/file', testData.string);
|
|
||||||
const value = await storage.get('/test/file');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Verify file exists on disk
|
|
||||||
const filePath = path.join(testDir, 'test', 'file');
|
|
||||||
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
|
||||||
expect(fileExists).toEqual(true);
|
|
||||||
|
|
||||||
// Test atomic writes (temp file should not exist)
|
|
||||||
const tempPath = filePath + '.tmp';
|
|
||||||
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
|
||||||
expect(tempExists).toEqual(false);
|
|
||||||
|
|
||||||
// Test nested paths
|
|
||||||
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
|
||||||
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
|
||||||
expect(nestedValue).toEqual(testData.largeString);
|
|
||||||
|
|
||||||
// Test list with filesystem
|
|
||||||
await storage.set('/fs/items/a', 'alpha');
|
|
||||||
await storage.set('/fs/items/b', 'beta');
|
|
||||||
await storage.set('/fs/other/c', 'gamma');
|
|
||||||
|
|
||||||
// Filesystem backend now properly supports list
|
|
||||||
const fsItems = await storage.list('/fs/items');
|
|
||||||
expect(fsItems.length).toEqual(2); // Should find both items
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Custom Function Backend', async () => {
|
|
||||||
// Create in-memory storage for custom functions
|
|
||||||
const customStore = new Map<string, string>();
|
|
||||||
|
|
||||||
const storage = new StorageManager({
|
|
||||||
readFunction: async (key: string) => {
|
|
||||||
return customStore.get(key) || null;
|
|
||||||
},
|
|
||||||
writeFunction: async (key: string, value: string) => {
|
|
||||||
customStore.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test basic operations
|
|
||||||
await storage.set('/custom/key', testData.string);
|
|
||||||
expect(customStore.has('/custom/key')).toEqual(true);
|
|
||||||
|
|
||||||
const value = await storage.get('/custom/key');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Test that delete sets empty value (as per implementation)
|
|
||||||
await storage.delete('/custom/key');
|
|
||||||
expect(customStore.get('/custom/key')).toEqual('');
|
|
||||||
|
|
||||||
// Verify custom backend (filesystem is implemented as custom backend internally)
|
|
||||||
expect(storage.getBackend()).toEqual('custom');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Key Validation', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Test key normalization
|
|
||||||
await storage.set('test/key', 'value1'); // Missing leading slash
|
|
||||||
const value1 = await storage.get('/test/key');
|
|
||||||
expect(value1).toEqual('value1');
|
|
||||||
|
|
||||||
// Test dangerous path elements are removed
|
|
||||||
await storage.set('/test/../danger/key', 'value2');
|
|
||||||
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
|
||||||
expect(value2).toEqual('value2');
|
|
||||||
|
|
||||||
// Test multiple slashes are normalized
|
|
||||||
await storage.set('/test///multiple////slashes', 'value3');
|
|
||||||
const value3 = await storage.get('/test/multiple/slashes');
|
|
||||||
expect(value3).toEqual('value3');
|
|
||||||
|
|
||||||
// Test invalid keys throw errors
|
|
||||||
let emptyKeyError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set('', 'value');
|
|
||||||
} catch (error) {
|
|
||||||
emptyKeyError = error as Error;
|
|
||||||
}
|
|
||||||
expect(emptyKeyError).toBeTruthy();
|
|
||||||
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
|
||||||
|
|
||||||
let nullKeyError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set(null as any, 'value');
|
|
||||||
} catch (error) {
|
|
||||||
nullKeyError = error as Error;
|
|
||||||
}
|
|
||||||
expect(nullKeyError).toBeTruthy();
|
|
||||||
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Concurrent Access', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
const promises: Promise<void>[] = [];
|
|
||||||
|
|
||||||
// Simulate concurrent writes
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
// Verify all writes succeeded
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const value = await storage.get(`/concurrent/key${i}`);
|
|
||||||
expect(value).toEqual(`value${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test concurrent reads
|
|
||||||
const readPromises: Promise<string | null>[] = [];
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
readPromises.push(storage.get(`/concurrent/key${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(readPromises);
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
expect(results[i]).toEqual(`value${i}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Backend Priority', async () => {
|
|
||||||
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
|
||||||
|
|
||||||
// Test that custom functions take priority over fsPath
|
|
||||||
let warningLogged = false;
|
|
||||||
const originalWarn = console.warn;
|
|
||||||
console.warn = (message: string) => {
|
|
||||||
if (message.includes('Using custom read/write functions')) {
|
|
||||||
warningLogged = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const storage = new StorageManager({
|
|
||||||
fsPath: testDir,
|
|
||||||
readFunction: async () => 'custom-value',
|
|
||||||
writeFunction: async () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.warn = originalWarn;
|
|
||||||
|
|
||||||
expect(warningLogged).toEqual(true);
|
|
||||||
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
try {
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Error Handling', async () => {
|
|
||||||
// Test filesystem errors
|
|
||||||
const storage = new StorageManager({
|
|
||||||
readFunction: async () => {
|
|
||||||
throw new Error('Read error');
|
|
||||||
},
|
|
||||||
writeFunction: async () => {
|
|
||||||
throw new Error('Write error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read errors should return null
|
|
||||||
const value = await storage.get('/error/key');
|
|
||||||
expect(value).toEqual(null);
|
|
||||||
|
|
||||||
// Write errors should propagate
|
|
||||||
let writeError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set('/error/key', 'value');
|
|
||||||
} catch (error) {
|
|
||||||
writeError = error as Error;
|
|
||||||
}
|
|
||||||
expect(writeError).toBeTruthy();
|
|
||||||
expect(writeError?.message).toEqual('Write error');
|
|
||||||
|
|
||||||
// Test JSON parse errors
|
|
||||||
const jsonStorage = new StorageManager({
|
|
||||||
readFunction: async () => 'invalid json',
|
|
||||||
writeFunction: async () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test JSON parse errors
|
|
||||||
let jsonError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await jsonStorage.getJSON('/invalid/json');
|
|
||||||
} catch (error) {
|
|
||||||
jsonError = error as Error;
|
|
||||||
}
|
|
||||||
expect(jsonError).toBeTruthy();
|
|
||||||
expect(jsonError?.message).toContain('JSON');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - List Operations', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Populate storage with hierarchical data
|
|
||||||
await storage.set('/app/config/database', 'db-config');
|
|
||||||
await storage.set('/app/config/cache', 'cache-config');
|
|
||||||
await storage.set('/app/data/users/1', 'user1');
|
|
||||||
await storage.set('/app/data/users/2', 'user2');
|
|
||||||
await storage.set('/app/logs/error.log', 'errors');
|
|
||||||
|
|
||||||
// List root
|
|
||||||
const rootItems = await storage.list('/');
|
|
||||||
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
|
||||||
|
|
||||||
// List specific paths
|
|
||||||
const configItems = await storage.list('/app/config');
|
|
||||||
expect(configItems.length).toEqual(2);
|
|
||||||
expect(configItems).toContain('/app/config/database');
|
|
||||||
expect(configItems).toContain('/app/config/cache');
|
|
||||||
|
|
||||||
const userItems = await storage.list('/app/data/users');
|
|
||||||
expect(userItems.length).toEqual(2);
|
|
||||||
|
|
||||||
// List non-existent path
|
|
||||||
const emptyList = await storage.list('/nonexistent/path');
|
|
||||||
expect(emptyList.length).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,21 +1,55 @@
|
|||||||
import { DcRouter } from '../ts/index.js';
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
const devRouter = new DcRouter({
|
const devRouter = new DcRouter({
|
||||||
// Configure services as needed for development
|
// Server public IP (used for VPN AllowedIPs)
|
||||||
// OpsServer always starts on port 3000
|
publicIp: '203.0.113.1',
|
||||||
|
// SmartProxy routes for development/demo
|
||||||
// Example: Add SmartProxy routes
|
smartProxyConfig: {
|
||||||
// smartProxyConfig: {
|
routes: [
|
||||||
// routes: [...]
|
{
|
||||||
// },
|
name: 'web-traffic',
|
||||||
|
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||||
// Example: Add email configuration
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||||
// emailConfig: {
|
},
|
||||||
// ports: [2525],
|
{
|
||||||
// hostname: 'localhost',
|
name: 'api-gateway',
|
||||||
// domains: [],
|
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||||
// routes: []
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
|
||||||
// },
|
},
|
||||||
|
{
|
||||||
|
name: 'tls-passthrough',
|
||||||
|
match: { ports: [18443], domains: ['secure.example.com'] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 4443 }],
|
||||||
|
tls: { mode: 'passthrough' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-internal-app',
|
||||||
|
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||||
|
vpn: { enabled: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-eng-dashboard',
|
||||||
|
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||||
|
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||||
|
},
|
||||||
|
] as any[],
|
||||||
|
},
|
||||||
|
// VPN with pre-defined clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.dev.local',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
|
||||||
|
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
|
||||||
|
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dbConfig: { enabled: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Starting DcRouter in development mode...');
|
console.log('Starting DcRouter in development mode...');
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '6.2.2',
|
version: '12.5.1',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
155
ts/cache/classes.cachedb.ts
vendored
155
ts/cache/classes.cachedb.ts
vendored
@@ -1,155 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { defaultTsmDbPath } from '../paths.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for CacheDb
|
|
||||||
*/
|
|
||||||
export interface ICacheDbOptions {
|
|
||||||
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
|
||||||
storagePath?: string;
|
|
||||||
/** Database name (default: dcrouter) */
|
|
||||||
dbName?: string;
|
|
||||||
/** Enable debug logging */
|
|
||||||
debug?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
|
||||||
*
|
|
||||||
* Provides persistent caching using smartdata as the ORM layer
|
|
||||||
* and LocalTsmDb as the embedded database engine.
|
|
||||||
*/
|
|
||||||
export class CacheDb {
|
|
||||||
private static instance: CacheDb | null = null;
|
|
||||||
|
|
||||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
|
||||||
private smartdataDb: plugins.smartdata.SmartdataDb;
|
|
||||||
private options: Required<ICacheDbOptions>;
|
|
||||||
private isStarted: boolean = false;
|
|
||||||
|
|
||||||
constructor(options: ICacheDbOptions = {}) {
|
|
||||||
this.options = {
|
|
||||||
storagePath: options.storagePath || defaultTsmDbPath,
|
|
||||||
dbName: options.dbName || 'dcrouter',
|
|
||||||
debug: options.debug || false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create the singleton instance
|
|
||||||
*/
|
|
||||||
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
|
||||||
if (!CacheDb.instance) {
|
|
||||||
CacheDb.instance = new CacheDb(options);
|
|
||||||
}
|
|
||||||
return CacheDb.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the singleton instance (useful for testing)
|
|
||||||
*/
|
|
||||||
public static resetInstance(): void {
|
|
||||||
CacheDb.instance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the cache database
|
|
||||||
* - Initializes LocalTsmDb with file persistence
|
|
||||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
|
||||||
*/
|
|
||||||
public async start(): Promise<void> {
|
|
||||||
if (this.isStarted) {
|
|
||||||
logger.log('warn', 'CacheDb already started');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure storage directory exists
|
|
||||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
|
||||||
|
|
||||||
// Create LocalTsmDb instance
|
|
||||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
|
||||||
folderPath: this.options.storagePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start LocalTsmDb and get connection info
|
|
||||||
const connectionInfo = await this.localTsmDb.start();
|
|
||||||
|
|
||||||
if (this.options.debug) {
|
|
||||||
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize smartdata with the connection URI
|
|
||||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
|
||||||
mongoDbUrl: connectionInfo.connectionUri,
|
|
||||||
mongoDbName: this.options.dbName,
|
|
||||||
});
|
|
||||||
await this.smartdataDb.init();
|
|
||||||
|
|
||||||
this.isStarted = true;
|
|
||||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the cache database
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
if (!this.isStarted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Close smartdata connection
|
|
||||||
if (this.smartdataDb) {
|
|
||||||
await this.smartdataDb.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop LocalTsmDb
|
|
||||||
if (this.localTsmDb) {
|
|
||||||
await this.localTsmDb.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isStarted = false;
|
|
||||||
logger.log('info', 'CacheDb stopped');
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the smartdata database instance
|
|
||||||
*/
|
|
||||||
public getDb(): plugins.smartdata.SmartdataDb {
|
|
||||||
if (!this.isStarted) {
|
|
||||||
throw new Error('CacheDb not started. Call start() first.');
|
|
||||||
}
|
|
||||||
return this.smartdataDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the database is ready
|
|
||||||
*/
|
|
||||||
public isReady(): boolean {
|
|
||||||
return this.isStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the storage path
|
|
||||||
*/
|
|
||||||
public getStoragePath(): string {
|
|
||||||
return this.options.storagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the database name
|
|
||||||
*/
|
|
||||||
public getDbName(): string {
|
|
||||||
return this.options.dbName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
ts/cache/documents/index.ts
vendored
2
ts/cache/documents/index.ts
vendored
@@ -1,2 +0,0 @@
|
|||||||
export * from './classes.cached.email.js';
|
|
||||||
export * from './classes.cached.ip.reputation.js';
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { StorageManager } from './storage/index.js';
|
import { CertBackoffDoc } from './db/index.js';
|
||||||
|
|
||||||
interface IBackoffEntry {
|
interface IBackoffEntry {
|
||||||
failures: number;
|
failures: number;
|
||||||
@@ -10,65 +10,86 @@ interface IBackoffEntry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages certificate provisioning scheduling with:
|
* Manages certificate provisioning scheduling with:
|
||||||
* - Per-domain exponential backoff persisted in StorageManager
|
* - Per-domain exponential backoff persisted via CertBackoffDoc
|
||||||
*
|
*
|
||||||
* Note: Serial stagger queue was removed — smartacme v9 handles
|
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||||
* concurrency, per-domain dedup, and rate limiting internally.
|
* concurrency, per-domain dedup, and rate limiting internally.
|
||||||
*/
|
*/
|
||||||
export class CertProvisionScheduler {
|
export class CertProvisionScheduler {
|
||||||
private storageManager: StorageManager;
|
|
||||||
private maxBackoffHours: number;
|
private maxBackoffHours: number;
|
||||||
|
|
||||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||||
private backoffCache = new Map<string, IBackoffEntry>();
|
private backoffCache = new Map<string, IBackoffEntry>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
storageManager: StorageManager,
|
|
||||||
options?: { maxBackoffHours?: number }
|
options?: { maxBackoffHours?: number }
|
||||||
) {
|
) {
|
||||||
this.storageManager = storageManager;
|
|
||||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage key for a domain's backoff entry
|
* Sanitized domain key for storage lookups
|
||||||
*/
|
*/
|
||||||
private backoffKey(domain: string): string {
|
private sanitizeDomain(domain: string): string {
|
||||||
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
return `/cert-backoff/${clean}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load backoff entry from storage (with in-memory cache)
|
* Load backoff entry from database (with in-memory cache)
|
||||||
*/
|
*/
|
||||||
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||||
const cached = this.backoffCache.get(domain);
|
const cached = this.backoffCache.get(domain);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
const sanitized = this.sanitizeDomain(domain);
|
||||||
if (entry) {
|
const doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||||
|
if (doc) {
|
||||||
|
const entry: IBackoffEntry = {
|
||||||
|
failures: doc.failures,
|
||||||
|
lastFailure: doc.lastFailure,
|
||||||
|
retryAfter: doc.retryAfter,
|
||||||
|
lastError: doc.lastError,
|
||||||
|
};
|
||||||
this.backoffCache.set(domain, entry);
|
this.backoffCache.set(domain, entry);
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
return entry;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save backoff entry to both cache and storage
|
* Save backoff entry to both cache and database
|
||||||
*/
|
*/
|
||||||
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||||
this.backoffCache.set(domain, entry);
|
this.backoffCache.set(domain, entry);
|
||||||
await this.storageManager.setJSON(this.backoffKey(domain), entry);
|
const sanitized = this.sanitizeDomain(domain);
|
||||||
|
let doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||||
|
if (!doc) {
|
||||||
|
doc = new CertBackoffDoc();
|
||||||
|
doc.domain = sanitized;
|
||||||
|
}
|
||||||
|
doc.failures = entry.failures;
|
||||||
|
doc.lastFailure = entry.lastFailure;
|
||||||
|
doc.retryAfter = entry.retryAfter;
|
||||||
|
doc.lastError = entry.lastError || '';
|
||||||
|
await doc.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a domain is currently in backoff
|
* Check if a domain is currently in backoff.
|
||||||
|
* Expired entries are pruned from the cache to prevent unbounded growth.
|
||||||
*/
|
*/
|
||||||
async isInBackoff(domain: string): Promise<boolean> {
|
async isInBackoff(domain: string): Promise<boolean> {
|
||||||
const entry = await this.loadBackoff(domain);
|
const entry = await this.loadBackoff(domain);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
|
||||||
const retryAfter = new Date(entry.retryAfter);
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
return retryAfter.getTime() > Date.now();
|
if (retryAfter.getTime() > Date.now()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff has expired — prune the stale entry
|
||||||
|
this.backoffCache.delete(domain);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,12 +121,23 @@ export class CertProvisionScheduler {
|
|||||||
async clearBackoff(domain: string): Promise<void> {
|
async clearBackoff(domain: string): Promise<void> {
|
||||||
this.backoffCache.delete(domain);
|
this.backoffCache.delete(domain);
|
||||||
try {
|
try {
|
||||||
await this.storageManager.delete(this.backoffKey(domain));
|
const sanitized = this.sanitizeDomain(domain);
|
||||||
|
const doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore delete errors (key may not exist)
|
// Ignore delete errors (doc may not exist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all in-memory backoff cache entries
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.backoffCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get backoff info for UI display
|
* Get backoff info for UI display
|
||||||
*/
|
*/
|
||||||
@@ -117,9 +149,12 @@ export class CertProvisionScheduler {
|
|||||||
const entry = await this.loadBackoff(domain);
|
const entry = await this.loadBackoff(domain);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
// Only return if still in backoff
|
// Only return if still in backoff — prune expired entries
|
||||||
const retryAfter = new Date(entry.retryAfter);
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
if (retryAfter.getTime() <= Date.now()) return null;
|
if (retryAfter.getTime() <= Date.now()) {
|
||||||
|
this.backoffCache.delete(domain);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
failures: entry.failures,
|
failures: entry.failures,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,58 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { StorageManager } from './storage/index.js';
|
import { AcmeCertDoc } from './db/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ICertManager implementation backed by StorageManager.
|
* ICertManager implementation backed by smartdata document classes.
|
||||||
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
* Persists SmartAcme certificates via AcmeCertDoc so they
|
||||||
* survive process restarts without re-hitting ACME.
|
* survive process restarts without re-hitting ACME.
|
||||||
*/
|
*/
|
||||||
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||||
private keyPrefix = '/certs/';
|
constructor() {}
|
||||||
|
|
||||||
constructor(private storageManager: StorageManager) {}
|
|
||||||
|
|
||||||
async init(): Promise<void> {}
|
async init(): Promise<void> {}
|
||||||
|
|
||||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||||
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||||
if (!data) return null;
|
if (!doc) return null;
|
||||||
return new plugins.smartacme.Cert(data);
|
return new plugins.smartacme.Cert({
|
||||||
}
|
id: doc.id,
|
||||||
|
domainName: doc.domainName,
|
||||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
created: doc.created,
|
||||||
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
privateKey: doc.privateKey,
|
||||||
id: cert.id,
|
publicKey: doc.publicKey,
|
||||||
domainName: cert.domainName,
|
csr: doc.csr,
|
||||||
created: cert.created,
|
validUntil: doc.validUntil,
|
||||||
privateKey: cert.privateKey,
|
|
||||||
publicKey: cert.publicKey,
|
|
||||||
csr: cert.csr,
|
|
||||||
validUntil: cert.validUntil,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||||
|
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
|
||||||
|
if (!doc) {
|
||||||
|
doc = new AcmeCertDoc();
|
||||||
|
doc.domainName = cert.domainName;
|
||||||
|
}
|
||||||
|
doc.id = cert.id;
|
||||||
|
doc.created = cert.created;
|
||||||
|
doc.privateKey = cert.privateKey;
|
||||||
|
doc.publicKey = cert.publicKey;
|
||||||
|
doc.csr = cert.csr;
|
||||||
|
doc.validUntil = cert.validUntil;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteCertificate(domainName: string): Promise<void> {
|
async deleteCertificate(domainName: string): Promise<void> {
|
||||||
await this.storageManager.delete(this.keyPrefix + domainName);
|
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {}
|
async close(): Promise<void> {}
|
||||||
|
|
||||||
async wipe(): Promise<void> {
|
async wipe(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(this.keyPrefix);
|
const docs = await AcmeCertDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
await this.storageManager.delete(key);
|
await doc.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
204
ts/config/classes.api-token-manager.ts
Normal file
204
ts/config/classes.api-token-manager.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { ApiTokenDoc } from '../db/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredApiToken,
|
||||||
|
IApiTokenInfo,
|
||||||
|
TApiTokenScope,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const TOKEN_PREFIX_STR = 'dcr_';
|
||||||
|
|
||||||
|
export class ApiTokenManager {
|
||||||
|
private tokens = new Map<string, IStoredApiToken>();
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadTokens();
|
||||||
|
if (this.tokens.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Token lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API token. Returns the raw token value (shown once).
|
||||||
|
*/
|
||||||
|
public async createToken(
|
||||||
|
name: string,
|
||||||
|
scopes: TApiTokenScope[],
|
||||||
|
expiresInDays: number | null,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<{ id: string; rawToken: string }> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const randomBytes = plugins.crypto.randomBytes(32);
|
||||||
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||||
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||||
|
|
||||||
|
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const stored: IStoredApiToken = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
tokenHash,
|
||||||
|
scopes,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdBy,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tokens.set(id, stored);
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${name}' created (id: ${id})`);
|
||||||
|
return { id, rawToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a raw token string. Returns the stored token if valid, null otherwise.
|
||||||
|
* Also updates lastUsedAt.
|
||||||
|
*/
|
||||||
|
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
||||||
|
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
||||||
|
|
||||||
|
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
|
||||||
|
for (const stored of this.tokens.values()) {
|
||||||
|
if (stored.tokenHash === hash) {
|
||||||
|
if (!stored.enabled) return null;
|
||||||
|
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
|
||||||
|
|
||||||
|
// Update lastUsedAt (fire and forget)
|
||||||
|
stored.lastUsedAt = Date.now();
|
||||||
|
this.persistToken(stored).catch(() => {});
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a token has a specific scope.
|
||||||
|
*/
|
||||||
|
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||||
|
return token.scopes.includes(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tokens (safe info only, no hashes).
|
||||||
|
*/
|
||||||
|
public listTokens(): IApiTokenInfo[] {
|
||||||
|
const result: IApiTokenInfo[] = [];
|
||||||
|
for (const stored of this.tokens.values()) {
|
||||||
|
result.push({
|
||||||
|
id: stored.id,
|
||||||
|
name: stored.name,
|
||||||
|
scopes: stored.scopes,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
expiresAt: stored.expiresAt,
|
||||||
|
lastUsedAt: stored.lastUsedAt,
|
||||||
|
enabled: stored.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (delete) a token.
|
||||||
|
*/
|
||||||
|
public async revokeToken(id: string): Promise<boolean> {
|
||||||
|
if (!this.tokens.has(id)) return false;
|
||||||
|
const token = this.tokens.get(id)!;
|
||||||
|
this.tokens.delete(id);
|
||||||
|
const doc = await ApiTokenDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll (regenerate) a token's secret while keeping its identity.
|
||||||
|
* Returns the new raw token value (shown once).
|
||||||
|
*/
|
||||||
|
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
|
||||||
|
const stored = this.tokens.get(id);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
const randomBytes = plugins.crypto.randomBytes(32);
|
||||||
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||||
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||||
|
|
||||||
|
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
|
||||||
|
return { id, rawToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable a token.
|
||||||
|
*/
|
||||||
|
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
|
||||||
|
const stored = this.tokens.get(id);
|
||||||
|
if (!stored) return false;
|
||||||
|
stored.enabled = enabled;
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadTokens(): Promise<void> {
|
||||||
|
const docs = await ApiTokenDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.tokens.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
tokenHash: doc.tokenHash,
|
||||||
|
scopes: doc.scopes,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
expiresAt: doc.expiresAt,
|
||||||
|
lastUsedAt: doc.lastUsedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||||
|
const existing = await ApiTokenDoc.findById(stored.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.name = stored.name;
|
||||||
|
existing.tokenHash = stored.tokenHash;
|
||||||
|
existing.scopes = stored.scopes;
|
||||||
|
existing.createdAt = stored.createdAt;
|
||||||
|
existing.expiresAt = stored.expiresAt;
|
||||||
|
existing.lastUsedAt = stored.lastUsedAt;
|
||||||
|
existing.createdBy = stored.createdBy;
|
||||||
|
existing.enabled = stored.enabled;
|
||||||
|
await existing.save();
|
||||||
|
} else {
|
||||||
|
const doc = new ApiTokenDoc();
|
||||||
|
doc.id = stored.id;
|
||||||
|
doc.name = stored.name;
|
||||||
|
doc.tokenHash = stored.tokenHash;
|
||||||
|
doc.scopes = stored.scopes;
|
||||||
|
doc.createdAt = stored.createdAt;
|
||||||
|
doc.expiresAt = stored.expiresAt;
|
||||||
|
doc.lastUsedAt = stored.lastUsedAt;
|
||||||
|
doc.createdBy = stored.createdBy;
|
||||||
|
doc.enabled = stored.enabled;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ts/config/classes.db-seeder.ts
Normal file
95
ts/config/classes.db-seeder.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
import type { IRouteSecurity } from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
export interface ISeedData {
|
||||||
|
profiles?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
}>;
|
||||||
|
targets?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DbSeeder {
|
||||||
|
constructor(private referenceResolver: ReferenceResolver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if DB is empty and seed if configured.
|
||||||
|
* Called once during ConfigManagers service startup, after initialize().
|
||||||
|
*/
|
||||||
|
public async seedIfEmpty(
|
||||||
|
seedOnEmpty?: boolean,
|
||||||
|
seedData?: ISeedData,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!seedOnEmpty) return;
|
||||||
|
|
||||||
|
const existingProfiles = this.referenceResolver.listProfiles();
|
||||||
|
const existingTargets = this.referenceResolver.listTargets();
|
||||||
|
|
||||||
|
if (existingProfiles.length > 0 || existingTargets.length > 0) {
|
||||||
|
logger.log('info', 'DB already contains profiles/targets, skipping seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'Seeding database with initial profiles and targets...');
|
||||||
|
|
||||||
|
const profilesToSeed: NonNullable<ISeedData['profiles']> = seedData?.profiles ?? DEFAULT_PROFILES;
|
||||||
|
const targetsToSeed: NonNullable<ISeedData['targets']> = seedData?.targets ?? DEFAULT_TARGETS;
|
||||||
|
|
||||||
|
for (const p of profilesToSeed) {
|
||||||
|
await this.referenceResolver.createProfile({
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
security: p.security,
|
||||||
|
extendsProfiles: p.extendsProfiles,
|
||||||
|
createdBy: 'system-seed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of targetsToSeed) {
|
||||||
|
await this.referenceResolver.createTarget({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
createdBy: 'system-seed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Seeded ${profilesToSeed.length} profile(s) and ${targetsToSeed.length} target(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
|
||||||
|
{
|
||||||
|
name: 'PUBLIC',
|
||||||
|
description: 'Allow all traffic — no IP restrictions',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'STANDARD',
|
||||||
|
description: 'Standard internal access with common private subnets',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8', '127.0.0.1', '::1'],
|
||||||
|
maxConnections: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TARGETS: Array<NonNullable<ISeedData['targets']>[number]> = [
|
||||||
|
{
|
||||||
|
name: 'LOCALHOST',
|
||||||
|
description: 'Local machine on port 443',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 443,
|
||||||
|
},
|
||||||
|
];
|
||||||
576
ts/config/classes.reference-resolver.ts
Normal file
576
ts/config/classes.reference-resolver.ts
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||||
|
import type {
|
||||||
|
ISecurityProfile,
|
||||||
|
INetworkTarget,
|
||||||
|
IRouteMetadata,
|
||||||
|
IStoredRoute,
|
||||||
|
IRouteSecurity,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const MAX_INHERITANCE_DEPTH = 5;
|
||||||
|
|
||||||
|
export class ReferenceResolver {
|
||||||
|
private profiles = new Map<string, ISecurityProfile>();
|
||||||
|
private targets = new Map<string, INetworkTarget>();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadProfiles();
|
||||||
|
await this.loadTargets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Profile CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createProfile(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const profile: ISecurityProfile = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
security: data.security,
|
||||||
|
extendsProfiles: data.extendsProfiles,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.profiles.set(id, profile);
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Created security profile '${profile.name}' (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProfile(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||||
|
): Promise<{ affectedRouteIds: string[] }> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`Security profile '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined) profile.name = patch.name;
|
||||||
|
if (patch.description !== undefined) profile.description = patch.description;
|
||||||
|
if (patch.security !== undefined) profile.security = patch.security;
|
||||||
|
if (patch.extendsProfiles !== undefined) profile.extendsProfiles = patch.extendsProfiles;
|
||||||
|
profile.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
|
||||||
|
|
||||||
|
// Find routes referencing this profile
|
||||||
|
const affectedRouteIds = await this.findRoutesByProfileRef(id);
|
||||||
|
return { affectedRouteIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteProfile(
|
||||||
|
id: string,
|
||||||
|
force: boolean,
|
||||||
|
storedRoutes?: Map<string, IStoredRoute>,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, message: `Security profile '${id}' not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check usage
|
||||||
|
const affectedIds = storedRoutes
|
||||||
|
? this.findRoutesByProfileRefSync(id, storedRoutes)
|
||||||
|
: await this.findRoutesByProfileRef(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Profile '${profile.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from DB
|
||||||
|
const doc = await SecurityProfileDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.profiles.delete(id);
|
||||||
|
|
||||||
|
// If force-deleting with referencing routes, clear refs but keep resolved values
|
||||||
|
if (affectedIds.length > 0) {
|
||||||
|
await this.clearProfileRefsOnRoutes(affectedIds);
|
||||||
|
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Deleted security profile '${profile.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfile(id: string): ISecurityProfile | undefined {
|
||||||
|
return this.profiles.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileByName(name: string): ISecurityProfile | undefined {
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
if (profile.name === name) return profile;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public listProfiles(): ISecurityProfile[] {
|
||||||
|
return [...this.profiles.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||||
|
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
usage.set(profile.id, []);
|
||||||
|
}
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
const ref = stored.metadata?.securityProfileRef;
|
||||||
|
if (ref && usage.has(ref)) {
|
||||||
|
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileUsageForId(
|
||||||
|
profileId: string,
|
||||||
|
storedRoutes: Map<string, IStoredRoute>,
|
||||||
|
): Array<{ id: string; routeName: string }> {
|
||||||
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.securityProfileRef === profileId) {
|
||||||
|
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Target CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createTarget(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const target: INetworkTarget = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
host: data.host,
|
||||||
|
port: data.port,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.targets.set(id, target);
|
||||||
|
await this.persistTarget(target);
|
||||||
|
logger.log('info', `Created network target '${target.name}' (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateTarget(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<INetworkTarget, 'id' | 'createdAt' | 'createdBy'>>,
|
||||||
|
): Promise<{ affectedRouteIds: string[] }> {
|
||||||
|
const target = this.targets.get(id);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`Network target '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined) target.name = patch.name;
|
||||||
|
if (patch.description !== undefined) target.description = patch.description;
|
||||||
|
if (patch.host !== undefined) target.host = patch.host;
|
||||||
|
if (patch.port !== undefined) target.port = patch.port;
|
||||||
|
target.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistTarget(target);
|
||||||
|
logger.log('info', `Updated network target '${target.name}' (${id})`);
|
||||||
|
|
||||||
|
const affectedRouteIds = await this.findRoutesByTargetRef(id);
|
||||||
|
return { affectedRouteIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteTarget(
|
||||||
|
id: string,
|
||||||
|
force: boolean,
|
||||||
|
storedRoutes?: Map<string, IStoredRoute>,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const target = this.targets.get(id);
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, message: `Network target '${id}' not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedIds = storedRoutes
|
||||||
|
? this.findRoutesByTargetRefSync(id, storedRoutes)
|
||||||
|
: await this.findRoutesByTargetRef(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Target '${target.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await NetworkTargetDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.targets.delete(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0) {
|
||||||
|
await this.clearTargetRefsOnRoutes(affectedIds);
|
||||||
|
logger.log('warn', `Force-deleted target '${target.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Deleted network target '${target.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTarget(id: string): INetworkTarget | undefined {
|
||||||
|
return this.targets.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTargetByName(name: string): INetworkTarget | undefined {
|
||||||
|
for (const target of this.targets.values()) {
|
||||||
|
if (target.name === name) return target;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public listTargets(): INetworkTarget[] {
|
||||||
|
return [...this.targets.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTargetUsageForId(
|
||||||
|
targetId: string,
|
||||||
|
storedRoutes: Map<string, IStoredRoute>,
|
||||||
|
): Array<{ id: string; routeName: string }> {
|
||||||
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.networkTargetRef === targetId) {
|
||||||
|
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Resolution
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve references for a single route.
|
||||||
|
* Materializes security profile and/or network target into the route's fields.
|
||||||
|
* Returns the resolved route and updated metadata.
|
||||||
|
*/
|
||||||
|
public resolveRoute(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
metadata?: IRouteMetadata,
|
||||||
|
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||||
|
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||||
|
|
||||||
|
if (resolvedMetadata.securityProfileRef) {
|
||||||
|
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
|
||||||
|
if (resolvedSecurity) {
|
||||||
|
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
|
||||||
|
// Merge: profile provides base, route's inline values override
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
security: this.mergeSecurityFields(resolvedSecurity, route.security),
|
||||||
|
};
|
||||||
|
resolvedMetadata.securityProfileName = profile?.name;
|
||||||
|
resolvedMetadata.lastResolvedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedMetadata.networkTargetRef) {
|
||||||
|
const target = this.targets.get(resolvedMetadata.networkTargetRef);
|
||||||
|
if (target) {
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
action: {
|
||||||
|
...route.action,
|
||||||
|
targets: [{
|
||||||
|
host: target.host as string,
|
||||||
|
port: target.port,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
resolvedMetadata.networkTargetName = target.name;
|
||||||
|
resolvedMetadata.lastResolvedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
logger.log('warn', `Network target '${resolvedMetadata.networkTargetRef}' not found during resolution`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route, metadata: resolvedMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Reference lookup helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||||
|
const docs = await StoredRouteDoc.findAll();
|
||||||
|
return docs
|
||||||
|
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
|
||||||
|
.map((doc) => doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||||
|
const docs = await StoredRouteDoc.findAll();
|
||||||
|
return docs
|
||||||
|
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||||
|
.map((doc) => doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.securityProfileRef === profileId) {
|
||||||
|
ids.push(routeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.networkTargetRef === targetId) {
|
||||||
|
ids.push(routeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: security profile resolution with inheritance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private resolveSecurityProfile(
|
||||||
|
profileId: string,
|
||||||
|
visited: Set<string> = new Set(),
|
||||||
|
depth: number = 0,
|
||||||
|
): IRouteSecurity | null {
|
||||||
|
if (depth > MAX_INHERITANCE_DEPTH) {
|
||||||
|
logger.log('warn', `Max inheritance depth (${MAX_INHERITANCE_DEPTH}) exceeded resolving profile '${profileId}'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(profileId)) {
|
||||||
|
logger.log('warn', `Circular inheritance detected for profile '${profileId}'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = this.profiles.get(profileId);
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
visited.add(profileId);
|
||||||
|
|
||||||
|
// Start with an empty base
|
||||||
|
let baseSecurity: IRouteSecurity = {};
|
||||||
|
|
||||||
|
// Resolve parent profiles first (top-down, later overrides earlier)
|
||||||
|
if (profile.extendsProfiles?.length) {
|
||||||
|
for (const parentId of profile.extendsProfiles) {
|
||||||
|
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1);
|
||||||
|
if (parentSecurity) {
|
||||||
|
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply this profile's security on top
|
||||||
|
return this.mergeSecurityFields(baseSecurity, profile.security);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two IRouteSecurity objects.
|
||||||
|
* `override` values take precedence over `base` values.
|
||||||
|
* For ipAllowList/ipBlockList: union arrays and deduplicate.
|
||||||
|
* For scalar/object fields: override wins if present.
|
||||||
|
*/
|
||||||
|
private mergeSecurityFields(
|
||||||
|
base: IRouteSecurity | undefined,
|
||||||
|
override: IRouteSecurity | undefined,
|
||||||
|
): IRouteSecurity {
|
||||||
|
if (!base && !override) return {};
|
||||||
|
if (!base) return { ...override };
|
||||||
|
if (!override) return { ...base };
|
||||||
|
|
||||||
|
const merged: IRouteSecurity = { ...base };
|
||||||
|
|
||||||
|
// IP lists: union
|
||||||
|
if (override.ipAllowList || base.ipAllowList) {
|
||||||
|
merged.ipAllowList = [...new Set([
|
||||||
|
...(base.ipAllowList || []),
|
||||||
|
...(override.ipAllowList || []),
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (override.ipBlockList || base.ipBlockList) {
|
||||||
|
merged.ipBlockList = [...new Set([
|
||||||
|
...(base.ipBlockList || []),
|
||||||
|
...(override.ipBlockList || []),
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar/object fields: override wins
|
||||||
|
if (override.maxConnections !== undefined) merged.maxConnections = override.maxConnections;
|
||||||
|
if (override.rateLimit !== undefined) merged.rateLimit = override.rateLimit;
|
||||||
|
if (override.authentication !== undefined) merged.authentication = override.authentication;
|
||||||
|
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
|
||||||
|
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadProfiles(): Promise<void> {
|
||||||
|
const docs = await SecurityProfileDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.profiles.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
security: doc.security,
|
||||||
|
extendsProfiles: doc.extendsProfiles,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.profiles.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTargets(): Promise<void> {
|
||||||
|
const docs = await NetworkTargetDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.targets.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
host: doc.host,
|
||||||
|
port: doc.port,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.targets.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.targets.size} network target(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistProfile(profile: ISecurityProfile): Promise<void> {
|
||||||
|
const existingDoc = await SecurityProfileDoc.findById(profile.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.name = profile.name;
|
||||||
|
existingDoc.description = profile.description;
|
||||||
|
existingDoc.security = profile.security;
|
||||||
|
existingDoc.extendsProfiles = profile.extendsProfiles;
|
||||||
|
existingDoc.updatedAt = profile.updatedAt;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new SecurityProfileDoc();
|
||||||
|
doc.id = profile.id;
|
||||||
|
doc.name = profile.name;
|
||||||
|
doc.description = profile.description;
|
||||||
|
doc.security = profile.security;
|
||||||
|
doc.extendsProfiles = profile.extendsProfiles;
|
||||||
|
doc.createdAt = profile.createdAt;
|
||||||
|
doc.updatedAt = profile.updatedAt;
|
||||||
|
doc.createdBy = profile.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistTarget(target: INetworkTarget): Promise<void> {
|
||||||
|
const existingDoc = await NetworkTargetDoc.findById(target.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.name = target.name;
|
||||||
|
existingDoc.description = target.description;
|
||||||
|
existingDoc.host = target.host;
|
||||||
|
existingDoc.port = target.port;
|
||||||
|
existingDoc.updatedAt = target.updatedAt;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new NetworkTargetDoc();
|
||||||
|
doc.id = target.id;
|
||||||
|
doc.name = target.name;
|
||||||
|
doc.description = target.description;
|
||||||
|
doc.host = target.host;
|
||||||
|
doc.port = target.port;
|
||||||
|
doc.createdAt = target.createdAt;
|
||||||
|
doc.updatedAt = target.updatedAt;
|
||||||
|
doc.createdBy = target.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: ref cleanup on force-delete
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const doc = await StoredRouteDoc.findById(routeId);
|
||||||
|
if (doc?.metadata) {
|
||||||
|
doc.metadata = {
|
||||||
|
...doc.metadata,
|
||||||
|
securityProfileRef: undefined,
|
||||||
|
securityProfileName: undefined,
|
||||||
|
};
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const doc = await StoredRouteDoc.findById(routeId);
|
||||||
|
if (doc?.metadata) {
|
||||||
|
doc.metadata = {
|
||||||
|
...doc.metadata,
|
||||||
|
networkTargetRef: undefined,
|
||||||
|
networkTargetName: undefined,
|
||||||
|
};
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
405
ts/config/classes.route-config-manager.ts
Normal file
405
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredRoute,
|
||||||
|
IRouteOverride,
|
||||||
|
IMergedRoute,
|
||||||
|
IRouteWarning,
|
||||||
|
IRouteMetadata,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
|
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||||
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
|
||||||
|
export class RouteConfigManager {
|
||||||
|
private storedRoutes = new Map<string, IStoredRoute>();
|
||||||
|
private overrides = new Map<string, IRouteOverride>();
|
||||||
|
private warnings: IRouteWarning[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||||
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
|
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||||
|
private referenceResolver?: ReferenceResolver,
|
||||||
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Expose stored routes map for reference resolution lookups. */
|
||||||
|
public getStoredRoutes(): Map<string, IStoredRoute> {
|
||||||
|
return this.storedRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadStoredRoutes();
|
||||||
|
await this.loadOverrides();
|
||||||
|
this.computeWarnings();
|
||||||
|
this.logWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Merged view
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||||
|
const merged: IMergedRoute[] = [];
|
||||||
|
|
||||||
|
// Hardcoded routes
|
||||||
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
|
const name = route.name || '';
|
||||||
|
const override = this.overrides.get(name);
|
||||||
|
merged.push({
|
||||||
|
route,
|
||||||
|
source: 'hardcoded',
|
||||||
|
enabled: override ? override.enabled : true,
|
||||||
|
overridden: !!override,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
merged.push({
|
||||||
|
route: stored.route,
|
||||||
|
source: 'programmatic',
|
||||||
|
enabled: stored.enabled,
|
||||||
|
overridden: false,
|
||||||
|
storedRouteId: stored.id,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
updatedAt: stored.updatedAt,
|
||||||
|
metadata: stored.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routes: merged, warnings: [...this.warnings] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Programmatic route CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createRoute(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
createdBy: string,
|
||||||
|
enabled = true,
|
||||||
|
metadata?: IRouteMetadata,
|
||||||
|
): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Ensure route has a name
|
||||||
|
if (!route.name) {
|
||||||
|
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve references if metadata has refs and resolver is available
|
||||||
|
let resolvedMetadata = metadata;
|
||||||
|
if (metadata && this.referenceResolver) {
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(route, metadata);
|
||||||
|
route = resolved.route;
|
||||||
|
resolvedMetadata = resolved.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored: IStoredRoute = {
|
||||||
|
id,
|
||||||
|
route,
|
||||||
|
enabled,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy,
|
||||||
|
metadata: resolvedMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.storedRoutes.set(id, stored);
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRoute(
|
||||||
|
id: string,
|
||||||
|
patch: {
|
||||||
|
route?: Partial<plugins.smartproxy.IRouteConfig>;
|
||||||
|
enabled?: boolean;
|
||||||
|
metadata?: Partial<IRouteMetadata>;
|
||||||
|
},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const stored = this.storedRoutes.get(id);
|
||||||
|
if (!stored) return false;
|
||||||
|
|
||||||
|
if (patch.route) {
|
||||||
|
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
|
||||||
|
}
|
||||||
|
if (patch.enabled !== undefined) {
|
||||||
|
stored.enabled = patch.enabled;
|
||||||
|
}
|
||||||
|
if (patch.metadata !== undefined) {
|
||||||
|
stored.metadata = { ...stored.metadata, ...patch.metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-resolve if metadata refs exist and resolver is available
|
||||||
|
if (stored.metadata && this.referenceResolver) {
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
|
stored.route = resolved.route;
|
||||||
|
stored.metadata = resolved.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
stored.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRoute(id: string): Promise<boolean> {
|
||||||
|
if (!this.storedRoutes.has(id)) return false;
|
||||||
|
this.storedRoutes.delete(id);
|
||||||
|
const doc = await StoredRouteDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
||||||
|
return this.updateRoute(id, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hardcoded route overrides
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||||
|
const override: IRouteOverride = {
|
||||||
|
routeName,
|
||||||
|
enabled,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
updatedBy,
|
||||||
|
};
|
||||||
|
this.overrides.set(routeName, override);
|
||||||
|
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.enabled = override.enabled;
|
||||||
|
existingDoc.updatedAt = override.updatedAt;
|
||||||
|
existingDoc.updatedBy = override.updatedBy;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new RouteOverrideDoc();
|
||||||
|
doc.routeName = override.routeName;
|
||||||
|
doc.enabled = override.enabled;
|
||||||
|
doc.updatedAt = override.updatedAt;
|
||||||
|
doc.updatedBy = override.updatedBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeOverride(routeName: string): Promise<boolean> {
|
||||||
|
if (!this.overrides.has(routeName)) return false;
|
||||||
|
this.overrides.delete(routeName);
|
||||||
|
const doc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadStoredRoutes(): Promise<void> {
|
||||||
|
const docs = await StoredRouteDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.storedRoutes.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
route: doc.route,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
metadata: doc.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.storedRoutes.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOverrides(): Promise<void> {
|
||||||
|
const docs = await RouteOverrideDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.routeName) {
|
||||||
|
this.overrides.set(doc.routeName, {
|
||||||
|
routeName: doc.routeName,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
updatedBy: doc.updatedBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.overrides.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||||
|
const existingDoc = await StoredRouteDoc.findById(stored.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.route = stored.route;
|
||||||
|
existingDoc.enabled = stored.enabled;
|
||||||
|
existingDoc.updatedAt = stored.updatedAt;
|
||||||
|
existingDoc.createdBy = stored.createdBy;
|
||||||
|
existingDoc.metadata = stored.metadata;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new StoredRouteDoc();
|
||||||
|
doc.id = stored.id;
|
||||||
|
doc.route = stored.route;
|
||||||
|
doc.enabled = stored.enabled;
|
||||||
|
doc.createdAt = stored.createdAt;
|
||||||
|
doc.updatedAt = stored.updatedAt;
|
||||||
|
doc.createdBy = stored.createdBy;
|
||||||
|
doc.metadata = stored.metadata;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: warnings
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private computeWarnings(): void {
|
||||||
|
this.warnings = [];
|
||||||
|
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||||
|
|
||||||
|
// Check overrides
|
||||||
|
for (const [routeName, override] of this.overrides) {
|
||||||
|
if (!hardcodedNames.has(routeName)) {
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'orphaned-override',
|
||||||
|
routeName,
|
||||||
|
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||||
|
});
|
||||||
|
} else if (!override.enabled) {
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'disabled-hardcoded',
|
||||||
|
routeName,
|
||||||
|
message: `Route '${routeName}' is disabled via API override`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check disabled programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
if (!stored.enabled) {
|
||||||
|
const name = stored.route.name || stored.id;
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'disabled-programmatic',
|
||||||
|
routeName: name,
|
||||||
|
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logWarnings(): void {
|
||||||
|
for (const w of this.warnings) {
|
||||||
|
logger.log('warn', w.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Re-resolve routes after profile/target changes
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve specific routes by ID (after a profile or target is updated).
|
||||||
|
* Persists each route and calls applyRoutes() once at the end.
|
||||||
|
*/
|
||||||
|
public async reResolveRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||||
|
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const stored = this.storedRoutes.get(routeId);
|
||||||
|
if (!stored?.metadata) continue;
|
||||||
|
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
|
stored.route = resolved.route;
|
||||||
|
stored.metadata = resolved.metadata;
|
||||||
|
stored.updatedAt = Date.now();
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applyRoutes();
|
||||||
|
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: apply merged routes to SmartProxy
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async applyRoutes(): Promise<void> {
|
||||||
|
const smartProxy = this.getSmartProxy();
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
|
const http3Config = this.getHttp3Config?.();
|
||||||
|
const vpnAllowList = this.getVpnAllowList;
|
||||||
|
|
||||||
|
// Helper: inject VPN security into a route if vpn.enabled is set
|
||||||
|
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
|
||||||
|
if (!vpnAllowList) return route;
|
||||||
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
|
if (!dcRoute.vpn?.enabled) return route;
|
||||||
|
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
|
||||||
|
const mandatory = dcRoute.vpn.mandatory === true; // defaults to false
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: mandatory
|
||||||
|
? allowList
|
||||||
|
: [...(route.security?.ipAllowList || []), ...allowList],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||||
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
|
const name = route.name || '';
|
||||||
|
const override = this.overrides.get(name);
|
||||||
|
if (override && !override.enabled) {
|
||||||
|
continue; // Skip disabled hardcoded route
|
||||||
|
}
|
||||||
|
enabledRoutes.push(injectVpn(route));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
if (stored.enabled) {
|
||||||
|
let route = stored.route;
|
||||||
|
if (http3Config && http3Config.enabled !== false) {
|
||||||
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
|
}
|
||||||
|
enabledRoutes.push(injectVpn(route));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await smartProxy.updateRoutes(enabledRoutes);
|
||||||
|
|
||||||
|
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||||
|
if (this.onRoutesApplied) {
|
||||||
|
this.onRoutesApplied(enabledRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
// Export validation tools only
|
// Export validation tools only
|
||||||
export * from './validator.js';
|
export * from './validator.js';
|
||||||
|
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||||
|
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||||
|
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
export { DbSeeder } from './classes.db-seeder.js';
|
||||||
@@ -170,7 +170,7 @@ export class ConfigValidator {
|
|||||||
} else if (rules.items.schema && itemType === 'object') {
|
} else if (rules.items.schema && itemType === 'object') {
|
||||||
const itemResult = this.validate(value[i], rules.items.schema);
|
const itemResult = this.validate(value[i], rules.items.schema);
|
||||||
if (!itemResult.valid) {
|
if (!itemResult.valid) {
|
||||||
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ export class ConfigValidator {
|
|||||||
if (rules.schema) {
|
if (rules.schema) {
|
||||||
const nestedResult = this.validate(value, rules.schema);
|
const nestedResult = this.validate(value, rules.schema);
|
||||||
if (!nestedResult.valid) {
|
if (!nestedResult.valid) {
|
||||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||||
}
|
}
|
||||||
validatedConfig[key] = nestedResult.config;
|
validatedConfig[key] = nestedResult.config;
|
||||||
}
|
}
|
||||||
@@ -233,8 +233,8 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
// Apply defaults to array items
|
// Apply defaults to array items
|
||||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||||
result[key] = result[key].map(item =>
|
result[key] = result[key].map(item =>
|
||||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||||
'CONFIG_VALIDATION_ERROR',
|
'CONFIG_VALIDATION_ERROR',
|
||||||
{ data: { errors: result.errors } }
|
{ data: { errors: result.errors } }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { CacheDb } from './classes.cachedb.js';
|
import { DcRouterDb } from './classes.dcrouter-db.js';
|
||||||
|
|
||||||
// Import document classes for cleanup
|
// Import document classes for cleanup
|
||||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||||
@@ -26,10 +26,10 @@ export class CacheCleaner {
|
|||||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private isRunning: boolean = false;
|
private isRunning: boolean = false;
|
||||||
private options: Required<ICacheCleanerOptions>;
|
private options: Required<ICacheCleanerOptions>;
|
||||||
private cacheDb: CacheDb;
|
private dcRouterDb: DcRouterDb;
|
||||||
|
|
||||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
|
||||||
this.cacheDb = cacheDb;
|
this.dcRouterDb = dcRouterDb;
|
||||||
this.options = {
|
this.options = {
|
||||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||||
verbose: options.verbose || false,
|
verbose: options.verbose || false,
|
||||||
@@ -48,14 +48,14 @@ export class CacheCleaner {
|
|||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
// Run cleanup immediately on start
|
// Run cleanup immediately on start
|
||||||
this.runCleanup().catch((error) => {
|
this.runCleanup().catch((error: unknown) => {
|
||||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schedule periodic cleanup
|
// Schedule periodic cleanup
|
||||||
this.cleanupInterval = setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
this.runCleanup().catch((error) => {
|
this.runCleanup().catch((error: unknown) => {
|
||||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
}, this.options.intervalMs);
|
}, this.options.intervalMs);
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ export class CacheCleaner {
|
|||||||
* Run a single cleanup cycle
|
* Run a single cleanup cycle
|
||||||
*/
|
*/
|
||||||
public async runCleanup(): Promise<void> {
|
public async runCleanup(): Promise<void> {
|
||||||
if (!this.cacheDb.isReady()) {
|
if (!this.dcRouterDb.isReady()) {
|
||||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +113,8 @@ export class CacheCleaner {
|
|||||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,14 +138,14 @@ export class CacheCleaner {
|
|||||||
try {
|
try {
|
||||||
await doc.delete();
|
await doc.delete();
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
} catch (deleteError) {
|
} catch (deleteError: unknown) {
|
||||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ export abstract class CachedDocument<T extends CachedDocument<T>> extends plugin
|
|||||||
* Timestamp when the document expires and should be cleaned up
|
* Timestamp when the document expires and should be cleaned up
|
||||||
* NOTE: Subclasses must add @svDb() decorator
|
* NOTE: Subclasses must add @svDb() decorator
|
||||||
*/
|
*/
|
||||||
public expiresAt: Date;
|
public expiresAt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp of last access (for LRU-style eviction if needed)
|
* Timestamp of last access (for LRU-style eviction if needed)
|
||||||
179
ts/db/classes.dcrouter-db.ts
Normal file
179
ts/db/classes.dcrouter-db.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { defaultTsmDbPath } from '../paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the unified DCRouter database
|
||||||
|
*/
|
||||||
|
export interface IDcRouterDbConfig {
|
||||||
|
/** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
|
||||||
|
mongoDbUrl?: string;
|
||||||
|
/** Storage path for embedded LocalSmartDb data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||||
|
storagePath?: string;
|
||||||
|
/** Database name (default: dcrouter) */
|
||||||
|
dbName?: string;
|
||||||
|
/** Enable debug logging */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DcRouterDb - Unified database layer for DCRouter
|
||||||
|
*
|
||||||
|
* Replaces both StorageManager (flat-file key-value) and CacheDb (embedded MongoDB).
|
||||||
|
* All data is stored as smartdata document classes in a single database.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - **Embedded** (default): Spawns a LocalSmartDb (Rust-based MongoDB-compatible engine)
|
||||||
|
* - **External**: Connects to a provided MongoDB URL
|
||||||
|
*/
|
||||||
|
export class DcRouterDb {
|
||||||
|
private static instance: DcRouterDb | null = null;
|
||||||
|
|
||||||
|
private localSmartDb: plugins.smartdb.LocalSmartDb | null = null;
|
||||||
|
private smartdataDb!: plugins.smartdata.SmartdataDb;
|
||||||
|
private options: Required<IDcRouterDbConfig>;
|
||||||
|
private isStarted: boolean = false;
|
||||||
|
|
||||||
|
constructor(options: IDcRouterDbConfig = {}) {
|
||||||
|
this.options = {
|
||||||
|
mongoDbUrl: options.mongoDbUrl || '',
|
||||||
|
storagePath: options.storagePath || defaultTsmDbPath,
|
||||||
|
dbName: options.dbName || 'dcrouter',
|
||||||
|
debug: options.debug || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(options?: IDcRouterDbConfig): DcRouterDb {
|
||||||
|
if (!DcRouterDb.instance) {
|
||||||
|
DcRouterDb.instance = new DcRouterDb(options);
|
||||||
|
}
|
||||||
|
return DcRouterDb.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (useful for testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
DcRouterDb.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the database
|
||||||
|
* - If mongoDbUrl is provided, connects directly to external MongoDB
|
||||||
|
* - Otherwise, starts an embedded LocalSmartDb instance
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.isStarted) {
|
||||||
|
logger.log('warn', 'DcRouterDb already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let connectionUri: string;
|
||||||
|
|
||||||
|
if (this.options.mongoDbUrl) {
|
||||||
|
// External MongoDB mode
|
||||||
|
connectionUri = this.options.mongoDbUrl;
|
||||||
|
logger.log('info', `DcRouterDb connecting to external MongoDB`);
|
||||||
|
} else {
|
||||||
|
// Embedded LocalSmartDb mode
|
||||||
|
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||||
|
|
||||||
|
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
|
||||||
|
folderPath: this.options.storagePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionInfo = await this.localSmartDb.start();
|
||||||
|
connectionUri = connectionInfo.connectionUri;
|
||||||
|
|
||||||
|
if (this.options.debug) {
|
||||||
|
logger.log('debug', `LocalSmartDb started with URI: ${connectionUri}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `DcRouterDb started embedded instance at ${this.options.storagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize smartdata ORM
|
||||||
|
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: connectionUri,
|
||||||
|
mongoDbName: this.options.dbName,
|
||||||
|
});
|
||||||
|
await this.smartdataDb.init();
|
||||||
|
|
||||||
|
this.isStarted = true;
|
||||||
|
logger.log('info', `DcRouterDb ready (db: ${this.options.dbName})`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Failed to start DcRouterDb: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the database
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close smartdata connection
|
||||||
|
if (this.smartdataDb) {
|
||||||
|
await this.smartdataDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop embedded LocalSmartDb if running
|
||||||
|
if (this.localSmartDb) {
|
||||||
|
await this.localSmartDb.stop();
|
||||||
|
this.localSmartDb = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = false;
|
||||||
|
logger.log('info', 'DcRouterDb stopped');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Error stopping DcRouterDb: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the smartdata database instance for @Collection decorators
|
||||||
|
*/
|
||||||
|
public getDb(): plugins.smartdata.SmartdataDb {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
throw new Error('DcRouterDb not started. Call start() first.');
|
||||||
|
}
|
||||||
|
return this.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the database is ready
|
||||||
|
*/
|
||||||
|
public isReady(): boolean {
|
||||||
|
return this.isStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether running in embedded mode (LocalSmartDb) vs external MongoDB
|
||||||
|
*/
|
||||||
|
public isEmbedded(): boolean {
|
||||||
|
return !this.options.mongoDbUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage path (only relevant for embedded mode)
|
||||||
|
*/
|
||||||
|
public getStoragePath(): string {
|
||||||
|
return this.options.storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the database name
|
||||||
|
*/
|
||||||
|
public getDbName(): string {
|
||||||
|
return this.options.dbName;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AccountingSessionDoc extends plugins.smartdata.SmartDataDbDoc<AccountingSessionDoc, AccountingSessionDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public sessionId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public username!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public macAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasIpAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasPort!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasPortType!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasIdentifier!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public vlanId!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public framedIpAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public calledStationId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public callingStationId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public startTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public endTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastUpdateTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.index()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public status!: 'active' | 'stopped' | 'terminated';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public terminateCause!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public inputOctets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public outputOctets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public inputPackets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public outputPackets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public sessionTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public serviceType!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findBySessionId(sessionId: string): Promise<AccountingSessionDoc | null> {
|
||||||
|
return await AccountingSessionDoc.getInstance({ sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findActive(): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ status: 'active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByUsername(username: string): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ username });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByNas(nasIpAddress: string): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ nasIpAddress });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByVlan(vlanId: number): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ vlanId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findStoppedBefore(cutoffTime: number): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({
|
||||||
|
status: { $in: ['stopped', 'terminated'] } as any,
|
||||||
|
endTime: { $lt: cutoffTime, $gt: 0 } as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AcmeCertDoc extends plugins.smartdata.SmartDataDbDoc<AcmeCertDoc, AcmeCertDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domainName!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public created!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public privateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public publicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public csr!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validUntil!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domainName: string): Promise<AcmeCertDoc | null> {
|
||||||
|
return await AcmeCertDoc.getInstance({ domainName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<AcmeCertDoc[]> {
|
||||||
|
return await AcmeCertDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
56
ts/db/documents/classes.api-token.doc.ts
Normal file
56
ts/db/documents/classes.api-token.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, ApiTokenDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public tokenHash!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public scopes!: TApiTokenScope[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastUsedAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByTokenHash(tokenHash: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ tokenHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||||
import { CacheDb } from '../classes.cachedb.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email status in the cache
|
* Email status in the cache
|
||||||
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
|
|||||||
/**
|
/**
|
||||||
* Helper to get the smartdata database instance
|
* Helper to get the smartdata database instance
|
||||||
*/
|
*/
|
||||||
const getDb = () => CacheDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CachedEmail - Stores email queue items in the cache
|
* CachedEmail - Stores email queue items in the cache
|
||||||
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
*/
|
*/
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public id: string;
|
public id!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email message ID (RFC 822 Message-ID header)
|
* Email message ID (RFC 822 Message-ID header)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public messageId: string;
|
public messageId!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sender email address (envelope from)
|
* Sender email address (envelope from)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public from: string;
|
public from!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recipient email addresses
|
* Recipient email addresses
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public to: string[];
|
public to!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CC recipients
|
* CC recipients
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public cc: string[];
|
public cc!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BCC recipients
|
* BCC recipients
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public bcc: string[];
|
public bcc!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email subject
|
* Email subject
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public subject: string;
|
public subject!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw RFC822 email content
|
* Raw RFC822 email content
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public rawContent: string;
|
public rawContent!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current status of the email
|
* Current status of the email
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public status: TCachedEmailStatus;
|
public status!: TCachedEmailStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of delivery attempts
|
* Number of delivery attempts
|
||||||
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
* Timestamp for next delivery attempt
|
* Timestamp for next delivery attempt
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public nextAttempt: Date;
|
public nextAttempt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last error message if delivery failed
|
* Last error message if delivery failed
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public lastError: string;
|
public lastError!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp when the email was successfully delivered
|
* Timestamp when the email was successfully delivered
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public deliveredAt: Date;
|
public deliveredAt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sender domain (for querying/filtering)
|
* Sender domain (for querying/filtering)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public senderDomain: string;
|
public senderDomain!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Priority level (higher = more important)
|
* Priority level (higher = more important)
|
||||||
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
* JSON-serialized route data
|
* JSON-serialized route data
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public routeData: string;
|
public routeData!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DKIM signature status
|
* DKIM signature status
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||||
import { CacheDb } from '../classes.cachedb.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get the smartdata database instance
|
* Helper to get the smartdata database instance
|
||||||
*/
|
*/
|
||||||
const getDb = () => CacheDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IP reputation result data
|
* IP reputation result data
|
||||||
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
|||||||
*/
|
*/
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public ipAddress: string;
|
public ipAddress!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reputation score (0-100, higher = better)
|
* Reputation score (0-100, higher = better)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public score: number;
|
public score!: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is flagged as spam source
|
* Whether the IP is flagged as spam source
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isSpam: boolean;
|
public isSpam!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a known proxy
|
* Whether the IP is a known proxy
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isProxy: boolean;
|
public isProxy!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a Tor exit node
|
* Whether the IP is a Tor exit node
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isTor: boolean;
|
public isTor!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a VPN endpoint
|
* Whether the IP is a VPN endpoint
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isVPN: boolean;
|
public isVPN!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Country code (ISO 3166-1 alpha-2)
|
* Country code (ISO 3166-1 alpha-2)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public country: string;
|
public country!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autonomous System Number
|
* Autonomous System Number
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public asn: string;
|
public asn!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organization name
|
* Organization name
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public org: string;
|
public org!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of blacklists the IP appears on
|
* List of blacklists the IP appears on
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public blacklists: string[];
|
public blacklists!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of times this IP has been checked
|
* Number of times this IP has been checked
|
||||||
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CertBackoffDoc extends plugins.smartdata.SmartDataDbDoc<CertBackoffDoc, CertBackoffDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domain!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public failures!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastFailure!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public retryAfter!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastError!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<CertBackoffDoc | null> {
|
||||||
|
return await CertBackoffDoc.getInstance({ domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<CertBackoffDoc[]> {
|
||||||
|
return await CertBackoffDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ts/db/documents/classes.network-target.doc.ts
Normal file
48
ts/db/documents/classes.network-target.doc.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public host!: string | string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public port!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<NetworkTargetDoc[]> {
|
||||||
|
return await NetworkTargetDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class ProxyCertDoc extends plugins.smartdata.SmartDataDbDoc<ProxyCertDoc, ProxyCertDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domain!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public publicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public privateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public ca!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validUntil!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validFrom!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<ProxyCertDoc | null> {
|
||||||
|
return await ProxyCertDoc.getInstance({ domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<ProxyCertDoc[]> {
|
||||||
|
return await ProxyCertDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressEdgeDoc, RemoteIngressEdgeDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public secret!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public listenPorts!: number[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public listenPortsUdp!: number[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public autoDerivePorts!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public tags!: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<RemoteIngressEdgeDoc | null> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<RemoteIngressEdgeDoc[]> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<RemoteIngressEdgeDoc[]> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ts/db/documents/classes.route-override.doc.ts
Normal file
32
ts/db/documents/classes.route-override.doc.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public routeName!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
|
||||||
|
return await RouteOverrideDoc.getInstance({ routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<RouteOverrideDoc[]> {
|
||||||
|
return await RouteOverrideDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
49
ts/db/documents/classes.security-profile.doc.ts
Normal file
49
ts/db/documents/classes.security-profile.doc.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public security!: IRouteSecurity;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public extendsProfiles?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<SecurityProfileDoc | null> {
|
||||||
|
return await SecurityProfileDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<SecurityProfileDoc | null> {
|
||||||
|
return await SecurityProfileDoc.getInstance({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<SecurityProfileDoc[]> {
|
||||||
|
return await SecurityProfileDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
42
ts/db/documents/classes.stored-route.doc.ts
Normal file
42
ts/db/documents/classes.stored-route.doc.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public route!: plugins.smartproxy.IRouteConfig;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public metadata?: IRouteMetadata;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<StoredRouteDoc | null> {
|
||||||
|
return await StoredRouteDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<StoredRouteDoc[]> {
|
||||||
|
return await StoredRouteDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
export interface IMacVlanMapping {
|
||||||
|
mac: string;
|
||||||
|
vlan: number;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VlanMappingsDoc extends plugins.smartdata.SmartDataDbDoc<VlanMappingsDoc, VlanMappingsDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'vlan-mappings';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public mappings!: IMacVlanMapping[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.mappings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<VlanMappingsDoc | null> {
|
||||||
|
return await VlanMappingsDoc.getInstance({ configId: 'vlan-mappings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
ts/db/documents/classes.vpn-client.doc.ts
Normal file
81
ts/db/documents/classes.vpn-client.doc.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc, VpnClientDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public clientId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public serverDefinedClientTags?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public assignedIp?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPrivateKey?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public forceDestinationSmartproxy: boolean = true;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public destinationAllowList?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public destinationBlockList?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useHostIp?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useDhcp?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public staticIp?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public forceVlan?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public vlanId?: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByClientId(clientId: string): Promise<VpnClientDoc | null> {
|
||||||
|
return await VpnClientDoc.getInstance({ clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||||
|
return await VpnClientDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<VpnClientDoc[]> {
|
||||||
|
return await VpnClientDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VpnServerKeysDoc extends plugins.smartdata.SmartDataDbDoc<VpnServerKeysDoc, VpnServerKeysDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'vpn-server-keys';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePrivateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPrivateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPublicKey!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<VpnServerKeysDoc | null> {
|
||||||
|
return await VpnServerKeysDoc.getInstance({ configId: 'vpn-server-keys' });
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ts/db/documents/index.ts
Normal file
26
ts/db/documents/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Cached/TTL document classes
|
||||||
|
export * from './classes.cached.email.js';
|
||||||
|
export * from './classes.cached.ip.reputation.js';
|
||||||
|
|
||||||
|
// Config document classes
|
||||||
|
export * from './classes.stored-route.doc.js';
|
||||||
|
export * from './classes.route-override.doc.js';
|
||||||
|
export * from './classes.api-token.doc.js';
|
||||||
|
export * from './classes.security-profile.doc.js';
|
||||||
|
export * from './classes.network-target.doc.js';
|
||||||
|
|
||||||
|
// VPN document classes
|
||||||
|
export * from './classes.vpn-server-keys.doc.js';
|
||||||
|
export * from './classes.vpn-client.doc.js';
|
||||||
|
|
||||||
|
// Certificate document classes
|
||||||
|
export * from './classes.acme-cert.doc.js';
|
||||||
|
export * from './classes.proxy-cert.doc.js';
|
||||||
|
export * from './classes.cert-backoff.doc.js';
|
||||||
|
|
||||||
|
// Remote ingress document classes
|
||||||
|
export * from './classes.remote-ingress-edge.doc.js';
|
||||||
|
|
||||||
|
// RADIUS document classes
|
||||||
|
export * from './classes.vlan-mappings.doc.js';
|
||||||
|
export * from './classes.accounting-session.doc.js';
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
// Core cache infrastructure
|
// Unified database manager
|
||||||
export * from './classes.cachedb.js';
|
export * from './classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
// TTL base class and constants
|
||||||
export * from './classes.cached.document.js';
|
export * from './classes.cached.document.js';
|
||||||
|
|
||||||
|
// Cache cleaner
|
||||||
export * from './classes.cache.cleaner.js';
|
export * from './classes.cache.cleaner.js';
|
||||||
|
|
||||||
// Document classes
|
// Document classes
|
||||||
@@ -227,7 +227,7 @@ export class PlatformError extends Error {
|
|||||||
const { retry } = this.context;
|
const { retry } = this.context;
|
||||||
if (!retry) return false;
|
if (!retry) return false;
|
||||||
|
|
||||||
return retry.currentRetry < retry.maxRetries;
|
return (retry.currentRetry ?? 0) < (retry.maxRetries ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
153
ts/http3/http3-route-augmentation.ts
Normal file
153
ts/http3/http3-route-augmentation.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for HTTP/3 (QUIC) route augmentation.
|
||||||
|
* HTTP/3 is enabled by default on all qualifying HTTPS routes.
|
||||||
|
*/
|
||||||
|
export interface IHttp3Config {
|
||||||
|
/** Enable HTTP/3 augmentation on qualifying routes (default: true) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** QUIC-specific settings applied to all augmented routes */
|
||||||
|
quicSettings?: {
|
||||||
|
/** QUIC connection idle timeout in ms (default: 30000) */
|
||||||
|
maxIdleTimeout?: number;
|
||||||
|
/** Max concurrent bidirectional streams per connection (default: 100) */
|
||||||
|
maxConcurrentBidiStreams?: number;
|
||||||
|
/** Max concurrent unidirectional streams per connection (default: 100) */
|
||||||
|
maxConcurrentUniStreams?: number;
|
||||||
|
/** Initial congestion window size in bytes */
|
||||||
|
initialCongestionWindow?: number;
|
||||||
|
};
|
||||||
|
/** Alt-Svc header settings */
|
||||||
|
altSvc?: {
|
||||||
|
/** Port advertised in Alt-Svc header (default: same as listening port) */
|
||||||
|
port?: number;
|
||||||
|
/** Max age for Alt-Svc advertisement in seconds (default: 86400) */
|
||||||
|
maxAge?: number;
|
||||||
|
};
|
||||||
|
/** UDP session settings */
|
||||||
|
udpSettings?: {
|
||||||
|
/** Idle timeout for UDP sessions in ms (default: 60000) */
|
||||||
|
sessionTimeout?: number;
|
||||||
|
/** Max concurrent UDP sessions per source IP (default: 1000) */
|
||||||
|
maxSessionsPerIP?: number;
|
||||||
|
/** Max accepted datagram size in bytes (default: 65535) */
|
||||||
|
maxDatagramSize?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a TPortRange includes port 443.
|
||||||
|
*/
|
||||||
|
function portRangeIncludes443(ports: TPortRange): boolean {
|
||||||
|
if (typeof ports === 'number') return ports === 443;
|
||||||
|
if (Array.isArray(ports)) {
|
||||||
|
return ports.some((p) => {
|
||||||
|
if (typeof p === 'number') return p === 443;
|
||||||
|
return p.from <= 443 && p.to >= 443;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a route name indicates an email route that should not get HTTP/3.
|
||||||
|
*/
|
||||||
|
function isEmailRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||||
|
const name = route.name?.toLowerCase() || '';
|
||||||
|
return (
|
||||||
|
name.startsWith('smtp-') ||
|
||||||
|
name.startsWith('submission-') ||
|
||||||
|
name.startsWith('smtps-') ||
|
||||||
|
name.startsWith('email-')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a route qualifies for HTTP/3 augmentation.
|
||||||
|
*/
|
||||||
|
export function routeQualifiesForHttp3(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
globalConfig: IHttp3Config,
|
||||||
|
): boolean {
|
||||||
|
// Check global enable + per-route override
|
||||||
|
const globalEnabled = globalConfig.enabled !== false; // default true
|
||||||
|
const perRouteOverride = route.action.options?.http3;
|
||||||
|
|
||||||
|
// If per-route explicitly set, use that; otherwise use global
|
||||||
|
const shouldAugment =
|
||||||
|
perRouteOverride !== undefined ? perRouteOverride : globalEnabled;
|
||||||
|
if (!shouldAugment) return false;
|
||||||
|
|
||||||
|
// Must be forward type
|
||||||
|
if (route.action.type !== 'forward') return false;
|
||||||
|
|
||||||
|
// Must include port 443
|
||||||
|
if (!portRangeIncludes443(route.match.ports)) return false;
|
||||||
|
|
||||||
|
// Must have TLS
|
||||||
|
if (!route.action.tls) return false;
|
||||||
|
|
||||||
|
// Skip email routes
|
||||||
|
if (isEmailRoute(route)) return false;
|
||||||
|
|
||||||
|
// Skip if already configured with transport 'all' or 'udp'
|
||||||
|
if (route.match.transport === 'all' || route.match.transport === 'udp') return false;
|
||||||
|
|
||||||
|
// Skip if already has QUIC config
|
||||||
|
if (route.action.udp?.quic) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augment a single route with HTTP/3 fields.
|
||||||
|
* Returns a new route object (does not mutate the original).
|
||||||
|
*/
|
||||||
|
export function augmentRouteWithHttp3(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
config: IHttp3Config,
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
if (!routeQualifiesForHttp3(route, config)) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
match: {
|
||||||
|
...route.match,
|
||||||
|
transport: 'all' as const,
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
...route.action,
|
||||||
|
udp: {
|
||||||
|
...(route.action.udp || {}),
|
||||||
|
sessionTimeout: config.udpSettings?.sessionTimeout,
|
||||||
|
maxSessionsPerIP: config.udpSettings?.maxSessionsPerIP,
|
||||||
|
maxDatagramSize: config.udpSettings?.maxDatagramSize,
|
||||||
|
quic: {
|
||||||
|
enableHttp3: true,
|
||||||
|
maxIdleTimeout: config.quicSettings?.maxIdleTimeout,
|
||||||
|
maxConcurrentBidiStreams: config.quicSettings?.maxConcurrentBidiStreams,
|
||||||
|
maxConcurrentUniStreams: config.quicSettings?.maxConcurrentUniStreams,
|
||||||
|
altSvcPort: config.altSvc?.port,
|
||||||
|
altSvcMaxAge: config.altSvc?.maxAge ?? 86400,
|
||||||
|
initialCongestionWindow: config.quicSettings?.initialCongestionWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augment all qualifying routes in an array.
|
||||||
|
* Returns a new array (does not mutate originals).
|
||||||
|
*/
|
||||||
|
export function augmentRoutesWithHttp3(
|
||||||
|
routes: plugins.smartproxy.IRouteConfig[],
|
||||||
|
config: IHttp3Config,
|
||||||
|
): plugins.smartproxy.IRouteConfig[] {
|
||||||
|
return routes.map((route) => augmentRouteWithHttp3(route, config));
|
||||||
|
}
|
||||||
1
ts/http3/index.ts
Normal file
1
ts/http3/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './http3-route-augmentation.js';
|
||||||
29
ts/index.ts
29
ts/index.ts
@@ -5,9 +5,36 @@ export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
|||||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// DcRouter
|
// DcRouter
|
||||||
|
import { DcRouter } from './classes.dcrouter.js';
|
||||||
export * from './classes.dcrouter.js';
|
export * from './classes.dcrouter.js';
|
||||||
|
|
||||||
// RADIUS module
|
// RADIUS module
|
||||||
export * from './radius/index.js';
|
export * from './radius/index.js';
|
||||||
|
|
||||||
export const runCli = async () => {};
|
// Remote Ingress module
|
||||||
|
export * from './remoteingress/index.js';
|
||||||
|
|
||||||
|
// HTTP/3 module
|
||||||
|
export type { IHttp3Config } from './http3/index.js';
|
||||||
|
|
||||||
|
export const runCli = async () => {
|
||||||
|
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
|
||||||
|
|
||||||
|
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
|
||||||
|
const { getOciContainerConfig } = await import('../ts_oci_container/index.js');
|
||||||
|
options = getOciContainerConfig();
|
||||||
|
console.log('[DCRouter] Starting in OCI Container mode...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dcRouter = new DcRouter(options);
|
||||||
|
await dcRouter.start();
|
||||||
|
console.log('[DCRouter] Running. Send SIGTERM or SIGINT to stop.');
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log('[DCRouter] Shutting down...');
|
||||||
|
await dcRouter.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.once('SIGINT', shutdown);
|
||||||
|
process.once('SIGTERM', shutdown);
|
||||||
|
};
|
||||||
|
|||||||
11
ts/logger.ts
11
ts/logger.ts
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
|
||||||
|
|
||||||
// Map NODE_ENV to valid TEnvironment
|
// Map NODE_ENV to valid TEnvironment
|
||||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||||
@@ -10,8 +11,11 @@ const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
|||||||
'production': 'production'
|
'production': 'production'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default Smartlog instance
|
// In-memory log buffer for the OpsServer UI
|
||||||
const baseLogger = new plugins.smartlog.Smartlog({
|
export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
|
||||||
|
|
||||||
|
// Default Smartlog instance (exported so OpsServer can add push destinations)
|
||||||
|
export const baseLogger = new plugins.smartlog.Smartlog({
|
||||||
logContext: {
|
logContext: {
|
||||||
environment: envMap[nodeEnv] || 'production',
|
environment: envMap[nodeEnv] || 'production',
|
||||||
runtime: 'node',
|
runtime: 'node',
|
||||||
@@ -19,6 +23,9 @@ const baseLogger = new plugins.smartlog.Smartlog({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire the buffer destination so all logs are captured
|
||||||
|
baseLogger.addLogDestination(logBuffer);
|
||||||
|
|
||||||
// Extended logger compatible with the original enhanced logger API
|
// Extended logger compatible with the original enhanced logger API
|
||||||
class StandardLogger {
|
class StandardLogger {
|
||||||
private defaultContext: Record<string, any> = {};
|
private defaultContext: Record<string, any> = {};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { DcRouter } from '../classes.dcrouter.js';
|
import { DcRouter } from '../classes.dcrouter.js';
|
||||||
import { MetricsCache } from './classes.metricscache.js';
|
import { MetricsCache } from './classes.metricscache.js';
|
||||||
|
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
export class MetricsManager {
|
export class MetricsManager {
|
||||||
private logger: plugins.smartlog.Smartlog;
|
private metricsLogger: plugins.smartlog.Smartlog;
|
||||||
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
||||||
private dcRouter: DcRouter;
|
private dcRouter: DcRouter;
|
||||||
private resetInterval?: NodeJS.Timeout;
|
private resetInterval?: NodeJS.Timeout;
|
||||||
@@ -33,10 +35,17 @@ export class MetricsManager {
|
|||||||
queryTypes: {} as Record<string, number>,
|
queryTypes: {} as Record<string, number>,
|
||||||
topDomains: new Map<string, number>(),
|
topDomains: new Map<string, number>(),
|
||||||
lastResetDate: new Date().toDateString(),
|
lastResetDate: new Date().toDateString(),
|
||||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
// Per-second query count ring buffer (300 entries = 5 minutes)
|
||||||
|
queryRing: new Int32Array(300),
|
||||||
|
queryRingLastSecond: 0, // last epoch second that was written
|
||||||
responseTimes: [] as number[], // Track response times in ms
|
responseTimes: [] as number[], // Track response times in ms
|
||||||
|
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Per-minute time-series buckets for charts
|
||||||
|
private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
|
||||||
|
private dnsMinuteBuckets = new Map<number, { queries: number }>();
|
||||||
|
|
||||||
// Track security-specific metrics
|
// Track security-specific metrics
|
||||||
private securityMetrics = {
|
private securityMetrics = {
|
||||||
blockedIPs: 0,
|
blockedIPs: 0,
|
||||||
@@ -50,15 +59,15 @@ export class MetricsManager {
|
|||||||
|
|
||||||
constructor(dcRouter: DcRouter) {
|
constructor(dcRouter: DcRouter) {
|
||||||
this.dcRouter = dcRouter;
|
this.dcRouter = dcRouter;
|
||||||
// Create a new Smartlog instance for metrics
|
// Create a Smartlog instance for SmartMetrics (requires its own instance)
|
||||||
this.logger = new plugins.smartlog.Smartlog({
|
this.metricsLogger = new plugins.smartlog.Smartlog({
|
||||||
logContext: {
|
logContext: {
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
runtime: 'node',
|
runtime: 'node',
|
||||||
zone: 'dcrouter-metrics',
|
zone: 'dcrouter-metrics',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter');
|
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
|
||||||
// Initialize metrics cache with 500ms TTL
|
// Initialize metrics cache with 500ms TTL
|
||||||
this.metricsCache = new MetricsCache(500);
|
this.metricsCache = new MetricsCache(500);
|
||||||
}
|
}
|
||||||
@@ -88,11 +97,13 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses = 0;
|
this.dnsMetrics.cacheMisses = 0;
|
||||||
this.dnsMetrics.queryTypes = {};
|
this.dnsMetrics.queryTypes = {};
|
||||||
this.dnsMetrics.topDomains.clear();
|
this.dnsMetrics.topDomains.clear();
|
||||||
this.dnsMetrics.queryTimestamps = [];
|
this.dnsMetrics.queryRing.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = 0;
|
||||||
this.dnsMetrics.responseTimes = [];
|
this.dnsMetrics.responseTimes = [];
|
||||||
|
this.dnsMetrics.recentQueries = [];
|
||||||
this.dnsMetrics.lastResetDate = currentDate;
|
this.dnsMetrics.lastResetDate = currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentDate !== this.securityMetrics.lastResetDate) {
|
if (currentDate !== this.securityMetrics.lastResetDate) {
|
||||||
this.securityMetrics.blockedIPs = 0;
|
this.securityMetrics.blockedIPs = 0;
|
||||||
this.securityMetrics.authFailures = 0;
|
this.securityMetrics.authFailures = 0;
|
||||||
@@ -102,20 +113,29 @@ export class MetricsManager {
|
|||||||
this.securityMetrics.incidents = [];
|
this.securityMetrics.incidents = [];
|
||||||
this.securityMetrics.lastResetDate = currentDate;
|
this.securityMetrics.lastResetDate = currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune old time-series buckets every minute (don't wait for lazy query)
|
||||||
|
this.pruneOldBuckets();
|
||||||
}, 60000); // Check every minute
|
}, 60000); // Check every minute
|
||||||
|
|
||||||
this.logger.log('info', 'MetricsManager started');
|
logger.log('info', 'MetricsManager started');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
// Clear the reset interval
|
// Clear the reset interval
|
||||||
if (this.resetInterval) {
|
if (this.resetInterval) {
|
||||||
clearInterval(this.resetInterval);
|
clearInterval(this.resetInterval);
|
||||||
this.resetInterval = undefined;
|
this.resetInterval = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.smartMetrics.stop();
|
this.smartMetrics.stop();
|
||||||
this.logger.log('info', 'MetricsManager stopped');
|
|
||||||
|
// Clear caches and time-series buckets on shutdown
|
||||||
|
this.metricsCache.clear();
|
||||||
|
this.emailMinuteBuckets.clear();
|
||||||
|
this.dnsMinuteBuckets.clear();
|
||||||
|
|
||||||
|
logger.log('info', 'MetricsManager stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get server metrics from SmartMetrics and SmartProxy
|
// Get server metrics from SmartMetrics and SmartProxy
|
||||||
@@ -124,23 +144,23 @@ export class MetricsManager {
|
|||||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||||
|
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
startTime: Date.now() - (process.uptime() * 1000),
|
startTime: Date.now() - (process.uptime() * 1000),
|
||||||
memoryUsage: {
|
memoryUsage: {
|
||||||
heapUsed: process.memoryUsage().heapUsed,
|
heapUsed,
|
||||||
heapTotal: process.memoryUsage().heapTotal,
|
heapTotal,
|
||||||
external: process.memoryUsage().external,
|
external,
|
||||||
rss: process.memoryUsage().rss,
|
rss,
|
||||||
// Add SmartMetrics memory data
|
|
||||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||||
},
|
},
|
||||||
cpuUsage: {
|
cpuUsage: {
|
||||||
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
|
user: smartMetricsData.cpuPercentage,
|
||||||
system: 0, // SmartMetrics doesn't separate user/system
|
system: 0,
|
||||||
},
|
},
|
||||||
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
||||||
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
||||||
@@ -202,11 +222,8 @@ export class MetricsManager {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([domain, count]) => ({ domain, count }));
|
.map(([domain, count]) => ({ domain, count }));
|
||||||
|
|
||||||
// Calculate queries per second from recent timestamps
|
// Calculate queries per second from ring buffer (sum last 60 seconds)
|
||||||
const now = Date.now();
|
const queriesPerSecond = this.getQueryRingSum(60) / 60;
|
||||||
const oneMinuteAgo = now - 60000;
|
|
||||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
|
||||||
const queriesPerSecond = recentQueries.length / 60;
|
|
||||||
|
|
||||||
// Calculate average response time
|
// Calculate average response time
|
||||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||||
@@ -223,24 +240,50 @@ export class MetricsManager {
|
|||||||
queryTypes: this.dnsMetrics.queryTypes,
|
queryTypes: this.dnsMetrics.queryTypes,
|
||||||
averageResponseTime: Math.round(avgResponseTime),
|
averageResponseTime: Math.round(avgResponseTime),
|
||||||
activeDomains: this.dnsMetrics.topDomains.size,
|
activeDomains: this.dnsMetrics.topDomains.size,
|
||||||
|
recentQueries: this.dnsMetrics.recentQueries.slice(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync security metrics from the SecurityLogger singleton (last 24h).
|
||||||
|
* Called before returning security stats so counters reflect real events.
|
||||||
|
*/
|
||||||
|
private syncFromSecurityLogger(): void {
|
||||||
|
try {
|
||||||
|
const securityLogger = SecurityLogger.getInstance();
|
||||||
|
const summary = securityLogger.getEventsSummary(86400000); // last 24h
|
||||||
|
|
||||||
|
this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
|
||||||
|
this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
|
||||||
|
this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
|
||||||
|
this.securityMetrics.authFailures =
|
||||||
|
summary.byType[SecurityEventType.AUTHENTICATION] || 0;
|
||||||
|
this.securityMetrics.blockedIPs =
|
||||||
|
(summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
|
||||||
|
(summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
|
||||||
|
} catch {
|
||||||
|
// SecurityLogger may not be initialized yet — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get security metrics
|
// Get security metrics
|
||||||
public async getSecurityStats() {
|
public async getSecurityStats() {
|
||||||
return this.metricsCache.get('securityStats', () => {
|
return this.metricsCache.get('securityStats', () => {
|
||||||
|
// Sync counters from the real SecurityLogger events
|
||||||
|
this.syncFromSecurityLogger();
|
||||||
|
|
||||||
// Get recent incidents (last 20)
|
// Get recent incidents (last 20)
|
||||||
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blockedIPs: this.securityMetrics.blockedIPs,
|
blockedIPs: this.securityMetrics.blockedIPs,
|
||||||
authFailures: this.securityMetrics.authFailures,
|
authFailures: this.securityMetrics.authFailures,
|
||||||
spamDetected: this.securityMetrics.spamDetected,
|
spamDetected: this.securityMetrics.spamDetected,
|
||||||
malwareDetected: this.securityMetrics.malwareDetected,
|
malwareDetected: this.securityMetrics.malwareDetected,
|
||||||
phishingDetected: this.securityMetrics.phishingDetected,
|
phishingDetected: this.securityMetrics.phishingDetected,
|
||||||
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
||||||
this.securityMetrics.malwareDetected +
|
this.securityMetrics.malwareDetected +
|
||||||
this.securityMetrics.phishingDetected,
|
this.securityMetrics.phishingDetected,
|
||||||
recentIncidents,
|
recentIncidents,
|
||||||
};
|
};
|
||||||
@@ -253,11 +296,11 @@ export class MetricsManager {
|
|||||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||||
|
|
||||||
if (!proxyMetrics) {
|
if (!proxyMetrics) {
|
||||||
return [];
|
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||||
const connectionInfo = [];
|
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
|
||||||
|
|
||||||
for (const [routeName, count] of connectionsByRoute) {
|
for (const [routeName, count] of connectionsByRoute) {
|
||||||
connectionInfo.push({
|
connectionInfo.push({
|
||||||
@@ -275,10 +318,19 @@ export class MetricsManager {
|
|||||||
// Email event tracking methods
|
// Email event tracking methods
|
||||||
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
||||||
this.emailMetrics.sentToday++;
|
this.emailMetrics.sentToday++;
|
||||||
|
this.incrementEmailBucket('sent');
|
||||||
|
|
||||||
if (recipient) {
|
if (recipient) {
|
||||||
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
||||||
this.emailMetrics.recipients.set(recipient, count + 1);
|
this.emailMetrics.recipients.set(recipient, count + 1);
|
||||||
|
|
||||||
|
// Cap recipients map to prevent unbounded growth within a day
|
||||||
|
if (this.emailMetrics.recipients.size > this.MAX_TOP_DOMAINS) {
|
||||||
|
const sorted = Array.from(this.emailMetrics.recipients.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8));
|
||||||
|
this.emailMetrics.recipients = new Map(sorted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deliveryTimeMs) {
|
if (deliveryTimeMs) {
|
||||||
@@ -303,6 +355,7 @@ export class MetricsManager {
|
|||||||
|
|
||||||
public trackEmailReceived(sender?: string): void {
|
public trackEmailReceived(sender?: string): void {
|
||||||
this.emailMetrics.receivedToday++;
|
this.emailMetrics.receivedToday++;
|
||||||
|
this.incrementEmailBucket('received');
|
||||||
|
|
||||||
this.emailMetrics.recentActivity.push({
|
this.emailMetrics.recentActivity.push({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -318,6 +371,7 @@ export class MetricsManager {
|
|||||||
|
|
||||||
public trackEmailFailed(recipient?: string, reason?: string): void {
|
public trackEmailFailed(recipient?: string, reason?: string): void {
|
||||||
this.emailMetrics.failedToday++;
|
this.emailMetrics.failedToday++;
|
||||||
|
this.incrementEmailBucket('failed');
|
||||||
|
|
||||||
this.emailMetrics.recentActivity.push({
|
this.emailMetrics.recentActivity.push({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -351,8 +405,21 @@ export class MetricsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DNS event tracking methods
|
// DNS event tracking methods
|
||||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
|
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void {
|
||||||
this.dnsMetrics.totalQueries++;
|
this.dnsMetrics.totalQueries++;
|
||||||
|
this.incrementDnsBucket();
|
||||||
|
|
||||||
|
// Store recent query entry
|
||||||
|
this.dnsMetrics.recentQueries.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
domain,
|
||||||
|
type: queryType,
|
||||||
|
answered: answered ?? true,
|
||||||
|
responseTimeMs: responseTimeMs ?? 0,
|
||||||
|
});
|
||||||
|
if (this.dnsMetrics.recentQueries.length > 100) {
|
||||||
|
this.dnsMetrics.recentQueries.shift();
|
||||||
|
}
|
||||||
|
|
||||||
if (cacheHit) {
|
if (cacheHit) {
|
||||||
this.dnsMetrics.cacheHits++;
|
this.dnsMetrics.cacheHits++;
|
||||||
@@ -360,12 +427,8 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses++;
|
this.dnsMetrics.cacheMisses++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track query timestamp
|
// Increment per-second query counter in ring buffer
|
||||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
this.incrementQueryRing();
|
||||||
|
|
||||||
// Keep only timestamps from last 5 minutes
|
|
||||||
const fiveMinutesAgo = Date.now() - 300000;
|
|
||||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
|
|
||||||
|
|
||||||
// Track response time if provided
|
// Track response time if provided
|
||||||
if (responseTimeMs) {
|
if (responseTimeMs) {
|
||||||
@@ -495,6 +558,7 @@ export class MetricsManager {
|
|||||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
|
backends: [] as Array<any>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +591,110 @@ export class MetricsManager {
|
|||||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||||
const requestsTotal = proxyMetrics.requests.total();
|
const requestsTotal = proxyMetrics.requests.total();
|
||||||
|
|
||||||
|
// Collect backend protocol data
|
||||||
|
const backendMetrics = proxyMetrics.backends.byBackend();
|
||||||
|
const protocolCache = proxyMetrics.backends.detectedProtocols();
|
||||||
|
|
||||||
|
// Group protocol cache entries by host:port so we can match them to backend metrics.
|
||||||
|
// The protocol cache is keyed by (host, port, domain) in Rust, so the same host:port
|
||||||
|
// can have multiple entries for different domains.
|
||||||
|
const cacheByBackend = new Map<string, (typeof protocolCache)[number][]>();
|
||||||
|
for (const entry of protocolCache) {
|
||||||
|
const backendKey = `${entry.host}:${entry.port}`;
|
||||||
|
let entries = cacheByBackend.get(backendKey);
|
||||||
|
if (!entries) {
|
||||||
|
entries = [];
|
||||||
|
cacheByBackend.set(backendKey, entries);
|
||||||
|
}
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backends: Array<any> = [];
|
||||||
|
const seenCacheKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, bm] of backendMetrics) {
|
||||||
|
const cacheEntries = cacheByBackend.get(key);
|
||||||
|
if (!cacheEntries || cacheEntries.length === 0) {
|
||||||
|
// No protocol cache entry — emit one row with backend metrics only
|
||||||
|
backends.push({
|
||||||
|
backend: key,
|
||||||
|
domain: null,
|
||||||
|
protocol: bm.protocol,
|
||||||
|
activeConnections: bm.activeConnections,
|
||||||
|
totalConnections: bm.totalConnections,
|
||||||
|
connectErrors: bm.connectErrors,
|
||||||
|
handshakeErrors: bm.handshakeErrors,
|
||||||
|
requestErrors: bm.requestErrors,
|
||||||
|
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||||
|
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||||
|
h2Failures: bm.h2Failures,
|
||||||
|
h2Suppressed: false,
|
||||||
|
h3Suppressed: false,
|
||||||
|
h2CooldownRemainingSecs: null,
|
||||||
|
h3CooldownRemainingSecs: null,
|
||||||
|
h2ConsecutiveFailures: null,
|
||||||
|
h3ConsecutiveFailures: null,
|
||||||
|
h3Port: null,
|
||||||
|
cacheAgeSecs: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// One row per domain, each enriched with the shared backend metrics
|
||||||
|
for (const cache of cacheEntries) {
|
||||||
|
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
|
||||||
|
seenCacheKeys.add(compositeKey);
|
||||||
|
backends.push({
|
||||||
|
backend: key,
|
||||||
|
domain: cache.domain ?? null,
|
||||||
|
protocol: cache.protocol ?? bm.protocol,
|
||||||
|
activeConnections: bm.activeConnections,
|
||||||
|
totalConnections: bm.totalConnections,
|
||||||
|
connectErrors: bm.connectErrors,
|
||||||
|
handshakeErrors: bm.handshakeErrors,
|
||||||
|
requestErrors: bm.requestErrors,
|
||||||
|
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||||
|
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||||
|
h2Failures: bm.h2Failures,
|
||||||
|
h2Suppressed: cache.h2Suppressed,
|
||||||
|
h3Suppressed: cache.h3Suppressed,
|
||||||
|
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
|
||||||
|
h3CooldownRemainingSecs: cache.h3CooldownRemainingSecs,
|
||||||
|
h2ConsecutiveFailures: cache.h2ConsecutiveFailures,
|
||||||
|
h3ConsecutiveFailures: cache.h3ConsecutiveFailures,
|
||||||
|
h3Port: cache.h3Port,
|
||||||
|
cacheAgeSecs: cache.ageSecs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include protocol cache entries with no matching backend metric
|
||||||
|
for (const entry of protocolCache) {
|
||||||
|
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
|
||||||
|
if (!seenCacheKeys.has(compositeKey)) {
|
||||||
|
backends.push({
|
||||||
|
backend: `${entry.host}:${entry.port}`,
|
||||||
|
domain: entry.domain,
|
||||||
|
protocol: entry.protocol,
|
||||||
|
activeConnections: 0,
|
||||||
|
totalConnections: 0,
|
||||||
|
connectErrors: 0,
|
||||||
|
handshakeErrors: 0,
|
||||||
|
requestErrors: 0,
|
||||||
|
avgConnectTimeMs: 0,
|
||||||
|
poolHitRate: 0,
|
||||||
|
h2Failures: 0,
|
||||||
|
h2Suppressed: entry.h2Suppressed,
|
||||||
|
h3Suppressed: entry.h3Suppressed,
|
||||||
|
h2CooldownRemainingSecs: entry.h2CooldownRemainingSecs,
|
||||||
|
h3CooldownRemainingSecs: entry.h3CooldownRemainingSecs,
|
||||||
|
h2ConsecutiveFailures: entry.h2ConsecutiveFailures,
|
||||||
|
h3ConsecutiveFailures: entry.h3ConsecutiveFailures,
|
||||||
|
h3Port: entry.h3Port,
|
||||||
|
cacheAgeSecs: entry.ageSecs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionsByIP,
|
connectionsByIP,
|
||||||
throughputRate,
|
throughputRate,
|
||||||
@@ -536,7 +704,151 @@ export class MetricsManager {
|
|||||||
throughputByIP,
|
throughputByIP,
|
||||||
requestsPerSecond,
|
requestsPerSecond,
|
||||||
requestsTotal,
|
requestsTotal,
|
||||||
|
backends,
|
||||||
};
|
};
|
||||||
}, 200); // Use 200ms cache for more frequent updates
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Time-series helpers ---
|
||||||
|
|
||||||
|
private static minuteKey(ts: number = Date.now()): number {
|
||||||
|
return Math.floor(ts / 60000) * 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
|
||||||
|
const key = MetricsManager.minuteKey();
|
||||||
|
let bucket = this.emailMinuteBuckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { sent: 0, received: 0, failed: 0 };
|
||||||
|
this.emailMinuteBuckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket[field]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementDnsBucket(): void {
|
||||||
|
const key = MetricsManager.minuteKey();
|
||||||
|
let bucket = this.dnsMinuteBuckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { queries: 0 };
|
||||||
|
this.dnsMinuteBuckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket.queries++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the per-second query counter in the ring buffer.
|
||||||
|
* Zeros any stale slots between the last write and the current second.
|
||||||
|
*/
|
||||||
|
private incrementQueryRing(): void {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
// First call — zero and anchor
|
||||||
|
ring.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length] = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) {
|
||||||
|
// Entire ring is stale — clear all
|
||||||
|
ring.fill(0);
|
||||||
|
} else if (gap > 0) {
|
||||||
|
// Zero slots from (last+1) to currentSecond (inclusive)
|
||||||
|
for (let s = last + 1; s <= currentSecond; s++) {
|
||||||
|
ring[s % ring.length] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum query counts from the ring buffer for the last N seconds.
|
||||||
|
*/
|
||||||
|
private getQueryRingSum(seconds: number): number {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) return 0;
|
||||||
|
|
||||||
|
// First, zero stale slots so reads are accurate even without writes
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) return 0; // all data is stale
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
const limit = Math.min(seconds, ring.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const sec = currentSecond - i;
|
||||||
|
if (sec < last - (ring.length - 1)) break; // slot is from older cycle
|
||||||
|
if (sec > last) continue; // no writes yet for this second
|
||||||
|
sum += ring[sec % ring.length];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneOldBuckets(): void {
|
||||||
|
const cutoff = Date.now() - 86400000; // 24h
|
||||||
|
for (const key of this.emailMinuteBuckets.keys()) {
|
||||||
|
if (key < cutoff) this.emailMinuteBuckets.delete(key);
|
||||||
|
}
|
||||||
|
for (const key of this.dnsMinuteBuckets.keys()) {
|
||||||
|
if (key < cutoff) this.dnsMinuteBuckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get email time-series data for the last N hours, aggregated per minute.
|
||||||
|
*/
|
||||||
|
public getEmailTimeSeries(hours: number = 24): {
|
||||||
|
sent: Array<{ timestamp: number; value: number }>;
|
||||||
|
received: Array<{ timestamp: number; value: number }>;
|
||||||
|
failed: Array<{ timestamp: number; value: number }>;
|
||||||
|
} {
|
||||||
|
this.pruneOldBuckets();
|
||||||
|
const cutoff = Date.now() - hours * 3600000;
|
||||||
|
const sent: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
const received: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
const failed: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
|
||||||
|
const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
|
||||||
|
.filter((k) => k >= cutoff)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const bucket = this.emailMinuteBuckets.get(key)!;
|
||||||
|
sent.push({ timestamp: key, value: bucket.sent });
|
||||||
|
received.push({ timestamp: key, value: bucket.received });
|
||||||
|
failed.push({ timestamp: key, value: bucket.failed });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, received, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS time-series data for the last N hours, aggregated per minute.
|
||||||
|
*/
|
||||||
|
public getDnsTimeSeries(hours: number = 24): {
|
||||||
|
queries: Array<{ timestamp: number; value: number }>;
|
||||||
|
} {
|
||||||
|
this.pruneOldBuckets();
|
||||||
|
const cutoff = Date.now() - hours * 3600000;
|
||||||
|
const queries: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
|
||||||
|
const sortedKeys = Array.from(this.dnsMinuteBuckets.keys())
|
||||||
|
.filter((k) => k >= cutoff)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const bucket = this.dnsMinuteBuckets.get(key)!;
|
||||||
|
queries.push({ timestamp: key, value: bucket.queries });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { queries };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,27 +2,39 @@ import type DcRouter from '../classes.dcrouter.js';
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
import * as paths from '../paths.js';
|
||||||
import * as handlers from './handlers/index.js';
|
import * as handlers from './handlers/index.js';
|
||||||
|
import * as interfaces from '../../ts_interfaces/index.js';
|
||||||
|
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
|
||||||
|
|
||||||
export class OpsServer {
|
export class OpsServer {
|
||||||
public dcRouterRef: DcRouter;
|
public dcRouterRef: DcRouter;
|
||||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||||
|
|
||||||
// TypedRouter for OpsServer-specific handlers
|
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// Auth-enforced routers — middleware validates identity before any handler runs
|
||||||
|
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||||
|
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||||
|
|
||||||
// Handler instances
|
// Handler instances
|
||||||
public adminHandler: handlers.AdminHandler;
|
public adminHandler!: handlers.AdminHandler;
|
||||||
private configHandler: handlers.ConfigHandler;
|
private configHandler!: handlers.ConfigHandler;
|
||||||
private logsHandler: handlers.LogsHandler;
|
private logsHandler!: handlers.LogsHandler;
|
||||||
private securityHandler: handlers.SecurityHandler;
|
private securityHandler!: handlers.SecurityHandler;
|
||||||
private statsHandler: handlers.StatsHandler;
|
private statsHandler!: handlers.StatsHandler;
|
||||||
private radiusHandler: handlers.RadiusHandler;
|
private radiusHandler!: handlers.RadiusHandler;
|
||||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
private emailOpsHandler!: handlers.EmailOpsHandler;
|
||||||
private certificateHandler: handlers.CertificateHandler;
|
private certificateHandler!: handlers.CertificateHandler;
|
||||||
|
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||||
|
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||||
|
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||||
|
private vpnHandler!: handlers.VpnHandler;
|
||||||
|
private securityProfileHandler!: handlers.SecurityProfileHandler;
|
||||||
|
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
|
|
||||||
// Add our typedrouter to the dcRouter's main typedrouter
|
// Add our typedrouter to the dcRouter's main typedrouter
|
||||||
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
}
|
}
|
||||||
@@ -30,7 +42,7 @@ export class OpsServer {
|
|||||||
public async start() {
|
public async start() {
|
||||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||||
domain: 'localhost',
|
domain: 'localhost',
|
||||||
feedMetadata: null,
|
feedMetadata: undefined,
|
||||||
serveDir: paths.distServe,
|
serveDir: paths.distServe,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,17 +53,32 @@ export class OpsServer {
|
|||||||
// Set up handlers
|
// Set up handlers
|
||||||
await this.setupHandlers();
|
await this.setupHandlers();
|
||||||
|
|
||||||
await this.server.start(3000);
|
await this.server.start(this.dcRouterRef.options.opsServerPort ?? 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up all TypedRequest handlers
|
* Set up all TypedRequest handlers
|
||||||
*/
|
*/
|
||||||
private async setupHandlers(): Promise<void> {
|
private async setupHandlers(): Promise<void> {
|
||||||
// Instantiate all handlers - they self-register with the typedrouter
|
// AdminHandler must be initialized first (JWT setup needed for guards)
|
||||||
this.adminHandler = new handlers.AdminHandler(this);
|
this.adminHandler = new handlers.AdminHandler(this);
|
||||||
await this.adminHandler.initialize(); // JWT needs async initialization
|
await this.adminHandler.initialize();
|
||||||
|
|
||||||
|
// viewRouter middleware: requires valid identity (any logged-in user)
|
||||||
|
this.viewRouter.addMiddleware(async (typedRequest) => {
|
||||||
|
await requireValidIdentity(this.adminHandler, typedRequest.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// adminRouter middleware: requires admin identity
|
||||||
|
this.adminRouter.addMiddleware(async (typedRequest) => {
|
||||||
|
await requireAdminIdentity(this.adminHandler, typedRequest.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect auth routers to the main typedrouter
|
||||||
|
this.typedrouter.addTypedRouter(this.viewRouter);
|
||||||
|
this.typedrouter.addTypedRouter(this.adminRouter);
|
||||||
|
|
||||||
|
// Instantiate all handlers — they self-register with the appropriate router
|
||||||
this.configHandler = new handlers.ConfigHandler(this);
|
this.configHandler = new handlers.ConfigHandler(this);
|
||||||
this.logsHandler = new handlers.LogsHandler(this);
|
this.logsHandler = new handlers.LogsHandler(this);
|
||||||
this.securityHandler = new handlers.SecurityHandler(this);
|
this.securityHandler = new handlers.SecurityHandler(this);
|
||||||
@@ -59,11 +86,21 @@ export class OpsServer {
|
|||||||
this.radiusHandler = new handlers.RadiusHandler(this);
|
this.radiusHandler = new handlers.RadiusHandler(this);
|
||||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||||
this.certificateHandler = new handlers.CertificateHandler(this);
|
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||||
|
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||||
|
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||||
|
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||||
|
this.vpnHandler = new handlers.VpnHandler(this);
|
||||||
|
this.securityProfileHandler = new handlers.SecurityProfileHandler(this);
|
||||||
|
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
|
// Clean up log handler streams and push destination before stopping the server
|
||||||
|
if (this.logsHandler) {
|
||||||
|
this.logsHandler.cleanup();
|
||||||
|
}
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
await this.server.stop();
|
await this.server.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class AdminHandler {
|
|||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
// JWT instance
|
// JWT instance
|
||||||
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
|
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||||
|
|
||||||
// Simple in-memory user storage (in production, use proper database)
|
// Simple in-memory user storage (in production, use proper database)
|
||||||
private users = new Map<string, {
|
private users = new Map<string, {
|
||||||
|
|||||||
97
ts/opsserver/handlers/api-token.handler.ts
Normal file
97
ts/opsserver/handlers/api-token.handler.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class ApiTokenHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// All token management endpoints register directly on adminRouter
|
||||||
|
// (middleware enforces admin JWT check, so no per-handler requireAdmin needed)
|
||||||
|
const router = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// Create API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
|
||||||
|
'createApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const result = await manager.createToken(
|
||||||
|
dataArg.name,
|
||||||
|
dataArg.scopes,
|
||||||
|
dataArg.expiresInDays ?? null,
|
||||||
|
dataArg.identity.userId,
|
||||||
|
);
|
||||||
|
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// List API tokens
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
|
||||||
|
'listApiTokens',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { tokens: [] };
|
||||||
|
}
|
||||||
|
return { tokens: manager.listTokens() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
|
||||||
|
'revokeApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.revokeToken(dataArg.id);
|
||||||
|
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Roll API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
|
||||||
|
'rollApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const result = await manager.rollToken(dataArg.id);
|
||||||
|
if (!result) {
|
||||||
|
return { success: false, message: 'Token not found' };
|
||||||
|
}
|
||||||
|
return { success: true, tokenValue: result.rawToken };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
|
||||||
|
'toggleApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.toggleToken(dataArg.id, dataArg.enabled);
|
||||||
|
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
||||||
|
|
||||||
export class CertificateHandler {
|
export class CertificateHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
// Get Certificate Overview
|
// Get Certificate Overview
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
||||||
'getCertificateOverview',
|
'getCertificateOverview',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -23,8 +26,10 @@ export class CertificateHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||||
|
|
||||||
// Legacy route-based reprovision (backward compat)
|
// Legacy route-based reprovision (backward compat)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||||
'reprovisionCertificate',
|
'reprovisionCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -34,7 +39,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Domain-based reprovision (preferred)
|
// Domain-based reprovision (preferred)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||||
'reprovisionCertificateDomain',
|
'reprovisionCertificateDomain',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -42,6 +47,36 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Delete certificate
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||||
|
'deleteCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.deleteCertificate(dataArg.domain);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export certificate
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||||
|
'exportCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.exportCertificate(dataArg.domain);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import certificate
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||||
|
'importCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.importCertificate(dataArg.cert);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,22 +188,28 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check persisted cert data from StorageManager
|
// Check persisted cert data from smartdata document classes
|
||||||
if (status === 'unknown') {
|
if (status === 'unknown') {
|
||||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
if (!certData) {
|
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
|
||||||
// Also check certStore path (proxy-certs)
|
|
||||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
if (acmeDoc?.validUntil) {
|
||||||
}
|
expiryDate = new Date(acmeDoc.validUntil).toISOString();
|
||||||
if (certData?.validUntil) {
|
if (acmeDoc.created) {
|
||||||
expiryDate = new Date(certData.validUntil).toISOString();
|
issuedAt = new Date(acmeDoc.created).toISOString();
|
||||||
if (certData.created) {
|
|
||||||
issuedAt = new Date(certData.created).toISOString();
|
|
||||||
}
|
}
|
||||||
issuer = 'smartacme-dns-01';
|
issuer = 'smartacme-dns-01';
|
||||||
} else if (certData) {
|
} else if (proxyDoc?.publicKey) {
|
||||||
// certStore has the cert (no expiry metadata) — it's loaded and serving
|
// certStore has the cert — parse PEM for expiry
|
||||||
|
try {
|
||||||
|
const x509 = new plugins.crypto.X509Certificate(proxyDoc.publicKey);
|
||||||
|
expiryDate = new Date(x509.validTo).toISOString();
|
||||||
|
issuedAt = new Date(x509.validFrom).toISOString();
|
||||||
|
} catch { /* PEM parsing failed */ }
|
||||||
|
status = 'valid';
|
||||||
|
issuer = 'cert-store';
|
||||||
|
} else if (acmeDoc || proxyDoc) {
|
||||||
status = 'valid';
|
status = 'valid';
|
||||||
issuer = 'cert-store';
|
issuer = 'cert-store';
|
||||||
}
|
}
|
||||||
@@ -269,8 +310,8 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,8 +339,8 @@ export class CertificateHandler {
|
|||||||
try {
|
try {
|
||||||
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
||||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,11 +350,164 @@ export class CertificateHandler {
|
|||||||
try {
|
try {
|
||||||
await smartProxy.provisionCertificate(routeNames[0]);
|
await smartProxy.provisionCertificate(routeNames[0]);
|
||||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete certificate data for a domain from storage
|
||||||
|
*/
|
||||||
|
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Delete from smartdata document classes
|
||||||
|
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
|
if (acmeDoc) {
|
||||||
|
await acmeDoc.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try both original domain and clean domain for proxy certs
|
||||||
|
for (const d of [domain, cleanDomain]) {
|
||||||
|
const proxyDoc = await ProxyCertDoc.findByDomain(d);
|
||||||
|
if (proxyDoc) {
|
||||||
|
await proxyDoc.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear from in-memory status map
|
||||||
|
dcRouter.certificateStatusMap.delete(domain);
|
||||||
|
|
||||||
|
// Clear backoff info
|
||||||
|
if (dcRouter.certProvisionScheduler) {
|
||||||
|
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: `Certificate data deleted for '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export certificate data for a domain as ICert-shaped JSON
|
||||||
|
*/
|
||||||
|
private async exportCertificate(domain: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
cert?: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Try AcmeCertDoc first (has full ICert fields)
|
||||||
|
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
|
if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cert: {
|
||||||
|
id: acmeDoc.id || plugins.crypto.randomUUID(),
|
||||||
|
domainName: acmeDoc.domainName || domain,
|
||||||
|
created: acmeDoc.created || Date.now(),
|
||||||
|
validUntil: acmeDoc.validUntil || 0,
|
||||||
|
privateKey: acmeDoc.privateKey,
|
||||||
|
publicKey: acmeDoc.publicKey,
|
||||||
|
csr: acmeDoc.csr || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try ProxyCertDoc with original domain, then clean domain
|
||||||
|
let proxyDoc = await ProxyCertDoc.findByDomain(domain);
|
||||||
|
if (!proxyDoc || !proxyDoc.publicKey) {
|
||||||
|
proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cert: {
|
||||||
|
id: plugins.crypto.randomUUID(),
|
||||||
|
domainName: domain,
|
||||||
|
created: proxyDoc.validFrom || Date.now(),
|
||||||
|
validUntil: proxyDoc.validUntil || 0,
|
||||||
|
privateKey: proxyDoc.privateKey,
|
||||||
|
publicKey: proxyDoc.publicKey,
|
||||||
|
csr: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: `No certificate data found for '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a certificate from ICert-shaped JSON
|
||||||
|
*/
|
||||||
|
private async importCertificate(cert: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
// Validate PEM content
|
||||||
|
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
|
||||||
|
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
|
||||||
|
}
|
||||||
|
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
|
||||||
|
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Save to AcmeCertDoc (SmartAcme-compatible)
|
||||||
|
let acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
|
if (!acmeDoc) {
|
||||||
|
acmeDoc = new AcmeCertDoc();
|
||||||
|
acmeDoc.domainName = cleanDomain;
|
||||||
|
}
|
||||||
|
acmeDoc.id = cert.id;
|
||||||
|
acmeDoc.created = cert.created;
|
||||||
|
acmeDoc.validUntil = cert.validUntil;
|
||||||
|
acmeDoc.privateKey = cert.privateKey;
|
||||||
|
acmeDoc.publicKey = cert.publicKey;
|
||||||
|
acmeDoc.csr = cert.csr || '';
|
||||||
|
await acmeDoc.save();
|
||||||
|
|
||||||
|
// Also save to ProxyCertDoc (proxy-cert format)
|
||||||
|
let proxyDoc = await ProxyCertDoc.findByDomain(cert.domainName);
|
||||||
|
if (!proxyDoc) {
|
||||||
|
proxyDoc = new ProxyCertDoc();
|
||||||
|
proxyDoc.domain = cert.domainName;
|
||||||
|
}
|
||||||
|
proxyDoc.publicKey = cert.publicKey;
|
||||||
|
proxyDoc.privateKey = cert.privateKey;
|
||||||
|
proxyDoc.ca = '';
|
||||||
|
proxyDoc.validUntil = cert.validUntil;
|
||||||
|
proxyDoc.validFrom = cert.created;
|
||||||
|
await proxyDoc.save();
|
||||||
|
|
||||||
|
// Update in-memory status map
|
||||||
|
dcRouter.certificateStatusMap.set(cert.domainName, {
|
||||||
|
status: 'valid',
|
||||||
|
source: 'static',
|
||||||
|
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
|
||||||
|
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
|
||||||
|
routeNames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
|
import * as paths from '../../paths.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class ConfigHandler {
|
export class ConfigHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Get Configuration Handler (read-only)
|
// Get Configuration Handler (read-only)
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'getConfiguration',
|
'getConfiguration',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
const config = await this.getConfiguration(dataArg.section);
|
const config = await this.getConfiguration();
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
section: dataArg.section,
|
section: dataArg.section,
|
||||||
@@ -26,83 +26,188 @@ export class ConfigHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getConfiguration(section?: string): Promise<{
|
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
|
||||||
email: {
|
|
||||||
enabled: boolean;
|
|
||||||
ports: number[];
|
|
||||||
maxMessageSize: number;
|
|
||||||
rateLimits: {
|
|
||||||
perMinute: number;
|
|
||||||
perHour: number;
|
|
||||||
perDay: number;
|
|
||||||
};
|
|
||||||
domains?: string[];
|
|
||||||
};
|
|
||||||
dns: {
|
|
||||||
enabled: boolean;
|
|
||||||
port: number;
|
|
||||||
nameservers: string[];
|
|
||||||
caching: boolean;
|
|
||||||
ttl: number;
|
|
||||||
};
|
|
||||||
proxy: {
|
|
||||||
enabled: boolean;
|
|
||||||
httpPort: number;
|
|
||||||
httpsPort: number;
|
|
||||||
maxConnections: number;
|
|
||||||
};
|
|
||||||
security: {
|
|
||||||
blockList: string[];
|
|
||||||
rateLimit: boolean;
|
|
||||||
spamDetection: boolean;
|
|
||||||
tlsRequired: boolean;
|
|
||||||
};
|
|
||||||
}> {
|
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const opts = dcRouter.options;
|
||||||
// Get email domains if email server is configured
|
const resolvedPaths = dcRouter.resolvedPaths;
|
||||||
|
|
||||||
|
// --- System ---
|
||||||
|
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.dbConfig?.mongoDbUrl
|
||||||
|
? 'custom'
|
||||||
|
: 'filesystem';
|
||||||
|
|
||||||
|
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||||
|
let proxyIps = opts.proxyIps || [];
|
||||||
|
if (proxyIps.length === 0 && dcRouter.smartProxy) {
|
||||||
|
const spSettings = (dcRouter.smartProxy as any).settings;
|
||||||
|
if (spSettings?.proxyIPs?.length > 0) {
|
||||||
|
proxyIps = spSettings.proxyIPs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const system: interfaces.requests.IConfigData['system'] = {
|
||||||
|
baseDir: resolvedPaths.dcrouterHomeDir,
|
||||||
|
dataDir: resolvedPaths.dataDir,
|
||||||
|
publicIp: opts.publicIp || dcRouter.detectedPublicIp || null,
|
||||||
|
proxyIps,
|
||||||
|
uptime: Math.floor(process.uptime()),
|
||||||
|
storageBackend,
|
||||||
|
storagePath: opts.dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SmartProxy ---
|
||||||
|
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
|
||||||
|
if (opts.smartProxyConfig?.acme) {
|
||||||
|
const acme = opts.smartProxyConfig.acme;
|
||||||
|
acmeInfo = {
|
||||||
|
enabled: acme.enabled !== false,
|
||||||
|
accountEmail: acme.accountEmail || '',
|
||||||
|
useProduction: acme.useProduction !== false,
|
||||||
|
autoRenew: acme.autoRenew !== false,
|
||||||
|
renewThresholdDays: acme.renewThresholdDays || 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let routeCount = 0;
|
||||||
|
if (dcRouter.routeConfigManager) {
|
||||||
|
try {
|
||||||
|
const merged = await dcRouter.routeConfigManager.getMergedRoutes();
|
||||||
|
routeCount = merged.routes.length;
|
||||||
|
} catch {
|
||||||
|
routeCount = opts.smartProxyConfig?.routes?.length || 0;
|
||||||
|
}
|
||||||
|
} else if (opts.smartProxyConfig?.routes) {
|
||||||
|
routeCount = opts.smartProxyConfig.routes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smartProxy: interfaces.requests.IConfigData['smartProxy'] = {
|
||||||
|
enabled: !!dcRouter.smartProxy,
|
||||||
|
routeCount,
|
||||||
|
acme: acmeInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Email ---
|
||||||
let emailDomains: string[] = [];
|
let emailDomains: string[] = [];
|
||||||
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) {
|
if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
|
||||||
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
|
emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
|
||||||
} else if (dcRouter.options.emailConfig?.domains) {
|
} else if (opts.emailConfig?.domains) {
|
||||||
// Fallback: get domains from email config options
|
emailDomains = opts.emailConfig.domains.map((d: any) =>
|
||||||
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
|
|
||||||
typeof d === 'string' ? d : d.domain
|
typeof d === 'string' ? d : d.domain
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let portMapping: Record<string, number> | null = null;
|
||||||
|
if (opts.emailPortConfig?.portMapping) {
|
||||||
|
portMapping = {};
|
||||||
|
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
|
||||||
|
portMapping[String(ext)] = int as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const email: interfaces.requests.IConfigData['email'] = {
|
||||||
|
enabled: !!dcRouter.emailServer,
|
||||||
|
ports: opts.emailConfig?.ports || [],
|
||||||
|
portMapping,
|
||||||
|
hostname: opts.emailConfig?.hostname || null,
|
||||||
|
domains: emailDomains,
|
||||||
|
emailRouteCount: opts.emailConfig?.routes?.length || 0,
|
||||||
|
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DNS ---
|
||||||
|
const dnsRecords = (opts.dnsRecords || []).map(r => ({
|
||||||
|
name: r.name,
|
||||||
|
type: r.type,
|
||||||
|
value: r.value,
|
||||||
|
ttl: r.ttl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||||
|
enabled: !!dcRouter.dnsServer,
|
||||||
|
port: 53,
|
||||||
|
nsDomains: opts.dnsNsDomains || [],
|
||||||
|
scopes: opts.dnsScopes || [],
|
||||||
|
recordCount: dnsRecords.length,
|
||||||
|
records: dnsRecords,
|
||||||
|
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- TLS ---
|
||||||
|
let tlsSource: 'acme' | 'static' | 'none' = 'none';
|
||||||
|
if (opts.tls?.certPath && opts.tls?.keyPath) {
|
||||||
|
tlsSource = 'static';
|
||||||
|
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
|
||||||
|
tlsSource = 'acme';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tls: interfaces.requests.IConfigData['tls'] = {
|
||||||
|
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
|
||||||
|
domain: opts.tls?.domain || null,
|
||||||
|
source: tlsSource,
|
||||||
|
certPath: opts.tls?.certPath || null,
|
||||||
|
keyPath: opts.tls?.keyPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Database ---
|
||||||
|
const dbConfig = opts.dbConfig;
|
||||||
|
const cache: interfaces.requests.IConfigData['cache'] = {
|
||||||
|
enabled: dbConfig?.enabled !== false,
|
||||||
|
storagePath: dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
|
dbName: dbConfig?.dbName || 'dcrouter',
|
||||||
|
defaultTTLDays: 30,
|
||||||
|
cleanupIntervalHours: dbConfig?.cleanupIntervalHours || 1,
|
||||||
|
ttlConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- RADIUS ---
|
||||||
|
const radiusCfg = opts.radiusConfig;
|
||||||
|
const radius: interfaces.requests.IConfigData['radius'] = {
|
||||||
|
enabled: !!dcRouter.radiusServer,
|
||||||
|
authPort: radiusCfg?.authPort || null,
|
||||||
|
acctPort: radiusCfg?.acctPort || null,
|
||||||
|
bindAddress: radiusCfg?.bindAddress || null,
|
||||||
|
clientCount: radiusCfg?.clients?.length || 0,
|
||||||
|
vlanDefaultVlan: radiusCfg?.vlanAssignment?.defaultVlan ?? null,
|
||||||
|
vlanAllowUnknownMacs: radiusCfg?.vlanAssignment?.allowUnknownMacs ?? null,
|
||||||
|
vlanMappingCount: radiusCfg?.vlanAssignment?.mappings?.length || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Remote Ingress ---
|
||||||
|
const riCfg = opts.remoteIngressConfig;
|
||||||
|
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
|
||||||
|
|
||||||
|
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
|
||||||
|
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
|
||||||
|
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
|
||||||
|
tlsMode = 'custom';
|
||||||
|
} else if (riCfg?.hubDomain) {
|
||||||
|
try {
|
||||||
|
const { ProxyCertDoc } = await import('../../db/index.js');
|
||||||
|
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
|
||||||
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
|
tlsMode = 'acme';
|
||||||
|
}
|
||||||
|
} catch { /* no stored cert */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
|
||||||
|
enabled: !!dcRouter.remoteIngressManager,
|
||||||
|
tunnelPort: riCfg?.tunnelPort || null,
|
||||||
|
hubDomain: riCfg?.hubDomain || null,
|
||||||
|
tlsMode,
|
||||||
|
connectedEdgeIps,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: {
|
system,
|
||||||
enabled: !!dcRouter.emailServer,
|
smartProxy,
|
||||||
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
|
email,
|
||||||
maxMessageSize: 10 * 1024 * 1024, // 10MB default
|
dns,
|
||||||
rateLimits: {
|
tls,
|
||||||
perMinute: 10,
|
cache,
|
||||||
perHour: 100,
|
radius,
|
||||||
perDay: 1000,
|
remoteIngress,
|
||||||
},
|
|
||||||
domains: emailDomains,
|
|
||||||
},
|
|
||||||
dns: {
|
|
||||||
enabled: !!dcRouter.dnsServer,
|
|
||||||
port: 53,
|
|
||||||
nameservers: dcRouter.options.dnsNsDomains || [],
|
|
||||||
caching: true,
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
enabled: !!dcRouter.smartProxy,
|
|
||||||
httpPort: 80,
|
|
||||||
httpsPort: 443,
|
|
||||||
maxConnections: 1000,
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
blockList: [],
|
|
||||||
rateLimit: true,
|
|
||||||
spamDetection: true,
|
|
||||||
tlsRequired: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,44 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { SecurityLogger } from '../../security/index.js';
|
|
||||||
|
|
||||||
export class EmailOpsHandler {
|
export class EmailOpsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
// Get Queued Emails Handler
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
this.typedrouter.addTypedHandler(
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
|
|
||||||
'getQueuedEmails',
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get All Emails Handler
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
|
||||||
|
'getAllEmails',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emails = this.getAllQueueEmails();
|
||||||
if (!emailServer?.deliveryQueue) {
|
return { emails };
|
||||||
return { items: [], total: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue = emailServer.deliveryQueue;
|
|
||||||
const stats = queue.getStats();
|
|
||||||
|
|
||||||
// Get all queue items and filter by status if provided
|
|
||||||
const items = this.getQueueItems(
|
|
||||||
dataArg.status,
|
|
||||||
dataArg.limit || 50,
|
|
||||||
dataArg.offset || 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: stats.queueSize,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Sent Emails Handler
|
// Get Email Detail Handler
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
||||||
'getSentEmails',
|
'getEmailDetail',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
const items = this.getQueueItems(
|
const email = this.getEmailDetail(dataArg.emailId);
|
||||||
'delivered',
|
return { email };
|
||||||
dataArg.limit || 50,
|
|
||||||
dataArg.offset || 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: items.length, // Note: total would ideally come from a counter
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Failed Emails Handler
|
// ---- Write endpoints (adminRouter) ----
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
|
|
||||||
'getFailedEmails',
|
|
||||||
async (dataArg) => {
|
|
||||||
const items = this.getQueueItems(
|
|
||||||
'failed',
|
|
||||||
dataArg.limit || 50,
|
|
||||||
dataArg.offset || 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: items.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resend Failed Email Handler
|
// Resend Failed Email Handler
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||||
'resendEmail',
|
'resendEmail',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -101,17 +59,12 @@ export class EmailOpsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Re-enqueue the failed email by creating a new queue entry
|
|
||||||
// with the same data but reset attempt count
|
|
||||||
const newQueueId = await queue.enqueue(
|
const newQueueId = await queue.enqueue(
|
||||||
item.processingResult,
|
item.processingResult,
|
||||||
item.processingMode,
|
item.processingMode,
|
||||||
item.route
|
item.route
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optionally remove the old failed entry
|
|
||||||
await queue.removeItem(dataArg.emailId);
|
await queue.removeItem(dataArg.emailId);
|
||||||
|
|
||||||
return { success: true, newQueueId };
|
return { success: true, newQueueId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -122,197 +75,199 @@ export class EmailOpsHandler {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Security Incidents Handler
|
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
|
|
||||||
'getSecurityIncidents',
|
|
||||||
async (dataArg) => {
|
|
||||||
const securityLogger = SecurityLogger.getInstance();
|
|
||||||
|
|
||||||
const filter: {
|
|
||||||
level?: any;
|
|
||||||
type?: any;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
if (dataArg.level) {
|
|
||||||
filter.level = dataArg.level;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataArg.type) {
|
|
||||||
filter.type = dataArg.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
const incidents = securityLogger.getRecentEvents(
|
|
||||||
dataArg.limit || 100,
|
|
||||||
Object.keys(filter).length > 0 ? filter : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
incidents: incidents.map(event => ({
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
level: event.level as interfaces.requests.TSecurityLogLevel,
|
|
||||||
type: event.type as interfaces.requests.TSecurityEventType,
|
|
||||||
message: event.message,
|
|
||||||
details: event.details,
|
|
||||||
ipAddress: event.ipAddress,
|
|
||||||
userId: event.userId,
|
|
||||||
sessionId: event.sessionId,
|
|
||||||
emailId: event.emailId,
|
|
||||||
domain: event.domain,
|
|
||||||
action: event.action,
|
|
||||||
result: event.result,
|
|
||||||
success: event.success,
|
|
||||||
})),
|
|
||||||
total: incidents.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get Bounce Records Handler
|
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
|
|
||||||
'getBounceRecords',
|
|
||||||
async (dataArg) => {
|
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
|
||||||
|
|
||||||
if (!emailServer) {
|
|
||||||
return { records: [], suppressionList: [], total: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use smartmta's public API for bounce/suppression data
|
|
||||||
const suppressionList = emailServer.getSuppressionList();
|
|
||||||
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
|
|
||||||
|
|
||||||
// Create bounce records from the available data
|
|
||||||
const records: interfaces.requests.IBounceRecord[] = [];
|
|
||||||
|
|
||||||
for (const email of hardBouncedAddresses) {
|
|
||||||
const bounceInfo = emailServer.getBounceHistory(email);
|
|
||||||
if (bounceInfo) {
|
|
||||||
records.push({
|
|
||||||
id: `bounce-${email}`,
|
|
||||||
recipient: email,
|
|
||||||
sender: '',
|
|
||||||
domain: email.split('@')[1] || '',
|
|
||||||
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
|
|
||||||
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
|
|
||||||
timestamp: (bounceInfo as any).lastBounce,
|
|
||||||
processed: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply limit and offset
|
|
||||||
const limit = dataArg.limit || 50;
|
|
||||||
const offset = dataArg.offset || 0;
|
|
||||||
const paginatedRecords = records.slice(offset, offset + limit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
records: paginatedRecords,
|
|
||||||
suppressionList,
|
|
||||||
total: records.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove from Suppression List Handler
|
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
|
|
||||||
'removeFromSuppressionList',
|
|
||||||
async (dataArg) => {
|
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
|
||||||
|
|
||||||
if (!emailServer) {
|
|
||||||
return { success: false, error: 'Email server not available' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
emailServer.removeFromSuppressionList(dataArg.email);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to get queue items with filtering and pagination
|
* Get all queue items mapped to catalog IEmail format
|
||||||
*/
|
*/
|
||||||
private getQueueItems(
|
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||||
status?: interfaces.requests.TEmailQueueStatus,
|
|
||||||
limit: number = 50,
|
|
||||||
offset: number = 0
|
|
||||||
): interfaces.requests.IEmailQueueItem[] {
|
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer?.deliveryQueue) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = emailServer.deliveryQueue;
|
const queue = emailServer.deliveryQueue;
|
||||||
const items: interfaces.requests.IEmailQueueItem[] = [];
|
|
||||||
|
|
||||||
// Access the internal queue map via reflection
|
|
||||||
// This is necessary because the queue doesn't expose iteration methods
|
|
||||||
const queueMap = (queue as any).queue as Map<string, any>;
|
const queueMap = (queue as any).queue as Map<string, any>;
|
||||||
|
|
||||||
if (!queueMap) {
|
if (!queueMap) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter and convert items
|
const emails: interfaces.requests.IEmail[] = [];
|
||||||
|
|
||||||
for (const [id, item] of queueMap.entries()) {
|
for (const [id, item] of queueMap.entries()) {
|
||||||
// Apply status filter if provided
|
emails.push(this.mapQueueItemToEmail(item));
|
||||||
if (status && item.status !== status) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract email details from processingResult if available
|
|
||||||
const processingResult = item.processingResult;
|
|
||||||
let from = '';
|
|
||||||
let to: string[] = [];
|
|
||||||
let subject = '';
|
|
||||||
|
|
||||||
if (processingResult) {
|
|
||||||
// Check if it's an Email object or raw email data
|
|
||||||
if (processingResult.email) {
|
|
||||||
from = processingResult.email.from || '';
|
|
||||||
to = processingResult.email.to || [];
|
|
||||||
subject = processingResult.email.subject || '';
|
|
||||||
} else if (processingResult.from) {
|
|
||||||
from = processingResult.from;
|
|
||||||
to = processingResult.to || [];
|
|
||||||
subject = processingResult.subject || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: item.id,
|
|
||||||
processingMode: item.processingMode,
|
|
||||||
status: item.status,
|
|
||||||
attempts: item.attempts,
|
|
||||||
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
|
|
||||||
lastError: item.lastError,
|
|
||||||
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
|
|
||||||
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
|
|
||||||
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by createdAt descending (newest first)
|
// Sort by createdAt descending (newest first)
|
||||||
items.sort((a, b) => b.createdAt - a.createdAt);
|
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
|
||||||
// Apply pagination
|
return emails;
|
||||||
return items.slice(offset, offset + limit);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single email detail by ID
|
||||||
|
*/
|
||||||
|
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||||
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
|
if (!emailServer?.deliveryQueue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = emailServer.deliveryQueue;
|
||||||
|
const item = queue.getItem(emailId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapQueueItemToEmailDetail(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a queue item to catalog IEmail format
|
||||||
|
*/
|
||||||
|
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
|
||||||
|
const processingResult = item.processingResult;
|
||||||
|
let from = '';
|
||||||
|
let to = '';
|
||||||
|
let subject = '';
|
||||||
|
let messageId = '';
|
||||||
|
let size = '0 B';
|
||||||
|
|
||||||
|
if (processingResult) {
|
||||||
|
if (processingResult.email) {
|
||||||
|
from = processingResult.email.from || '';
|
||||||
|
to = (processingResult.email.to || [])[0] || '';
|
||||||
|
subject = processingResult.email.subject || '';
|
||||||
|
} else if (processingResult.from) {
|
||||||
|
from = processingResult.from;
|
||||||
|
to = (processingResult.to || [])[0] || '';
|
||||||
|
subject = processingResult.subject || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get messageId
|
||||||
|
if (typeof processingResult.getMessageId === 'function') {
|
||||||
|
try {
|
||||||
|
messageId = processingResult.getMessageId() || '';
|
||||||
|
} catch {
|
||||||
|
messageId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute approximate size
|
||||||
|
const textLen = processingResult.text?.length || 0;
|
||||||
|
const htmlLen = processingResult.html?.length || 0;
|
||||||
|
let attachSize = 0;
|
||||||
|
if (typeof processingResult.getAttachmentsSize === 'function') {
|
||||||
|
try {
|
||||||
|
attachSize = processingResult.getAttachmentsSize() || 0;
|
||||||
|
} catch {
|
||||||
|
attachSize = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size = this.formatSize(textLen + htmlLen + attachSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map queue status to catalog TEmailStatus
|
||||||
|
const status = this.mapStatus(item.status);
|
||||||
|
|
||||||
|
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
direction: 'outbound' as interfaces.requests.TEmailDirection,
|
||||||
|
status,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
timestamp: new Date(createdAt).toISOString(),
|
||||||
|
messageId,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a queue item to catalog IEmailDetail format
|
||||||
|
*/
|
||||||
|
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
|
||||||
|
const base = this.mapQueueItemToEmail(item);
|
||||||
|
const processingResult = item.processingResult;
|
||||||
|
|
||||||
|
let toList: string[] = [];
|
||||||
|
let cc: string[] = [];
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
if (processingResult) {
|
||||||
|
if (processingResult.email) {
|
||||||
|
toList = processingResult.email.to || [];
|
||||||
|
cc = processingResult.email.cc || [];
|
||||||
|
} else {
|
||||||
|
toList = processingResult.to || [];
|
||||||
|
cc = processingResult.cc || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = processingResult.headers || {};
|
||||||
|
body = processingResult.html || processingResult.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toList,
|
||||||
|
cc,
|
||||||
|
smtpLog: [],
|
||||||
|
connectionInfo: {
|
||||||
|
sourceIp: '',
|
||||||
|
sourceHostname: '',
|
||||||
|
destinationIp: '',
|
||||||
|
destinationPort: 0,
|
||||||
|
tlsVersion: '',
|
||||||
|
tlsCipher: '',
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: '',
|
||||||
|
authUser: '',
|
||||||
|
},
|
||||||
|
authenticationResults: {
|
||||||
|
spf: 'none',
|
||||||
|
spfDomain: '',
|
||||||
|
dkim: 'none',
|
||||||
|
dkimDomain: '',
|
||||||
|
dmarc: 'none',
|
||||||
|
dmarcPolicy: '',
|
||||||
|
},
|
||||||
|
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
|
||||||
|
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map queue status to catalog TEmailStatus
|
||||||
|
*/
|
||||||
|
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
|
||||||
|
switch (queueStatus) {
|
||||||
|
case 'pending':
|
||||||
|
case 'processing':
|
||||||
|
return 'pending';
|
||||||
|
case 'delivered':
|
||||||
|
return 'delivered';
|
||||||
|
case 'failed':
|
||||||
|
return 'bounced';
|
||||||
|
case 'deferred':
|
||||||
|
return 'deferred';
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format byte size to human-readable string
|
||||||
|
*/
|
||||||
|
private formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ export * from './security.handler.js';
|
|||||||
export * from './stats.handler.js';
|
export * from './stats.handler.js';
|
||||||
export * from './radius.handler.js';
|
export * from './radius.handler.js';
|
||||||
export * from './email-ops.handler.js';
|
export * from './email-ops.handler.js';
|
||||||
export * from './certificate.handler.js';
|
export * from './certificate.handler.js';
|
||||||
|
export * from './remoteingress.handler.js';
|
||||||
|
export * from './route-management.handler.js';
|
||||||
|
export * from './api-token.handler.js';
|
||||||
|
export * from './vpn.handler.js';
|
||||||
|
export * from './security-profile.handler.js';
|
||||||
|
export * from './network-target.handler.js';
|
||||||
@@ -1,19 +1,42 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
import { logBuffer, baseLogger } from '../../logger.js';
|
||||||
|
|
||||||
|
// Module-level singleton: the log push destination is added once and reuses
|
||||||
|
// the current OpsServer reference so it survives OpsServer restarts without
|
||||||
|
// accumulating duplicate destinations.
|
||||||
|
let logPushDestinationInstalled = false;
|
||||||
|
let currentOpsServerRef: OpsServer | null = null;
|
||||||
|
|
||||||
export class LogsHandler {
|
export class LogsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
private activeStreamStops: Set<() => void> = new Set();
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
|
this.setupLogPushDestination();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all active log streams and deactivate the push destination.
|
||||||
|
* Called when OpsServer stops.
|
||||||
|
*/
|
||||||
|
public cleanup(): void {
|
||||||
|
// Stop all active follow-mode log streams
|
||||||
|
for (const stop of this.activeStreamStops) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
this.activeStreamStops.clear();
|
||||||
|
// Deactivate the push destination (it stays registered but becomes a no-op)
|
||||||
|
currentOpsServerRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All log endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Get Recent Logs Handler
|
// Get Recent Logs Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
||||||
'getRecentLogs',
|
'getRecentLogs',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -25,24 +48,24 @@ export class LogsHandler {
|
|||||||
dataArg.search,
|
dataArg.search,
|
||||||
dataArg.timeRange
|
dataArg.timeRange
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logs,
|
logs,
|
||||||
total: logs.length, // TODO: Implement proper total count
|
total: logs.length,
|
||||||
hasMore: false, // TODO: Implement proper pagination
|
hasMore: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Log Stream Handler
|
// Get Log Stream Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
||||||
'getLogStream',
|
'getLogStream',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
// Create a virtual stream for log streaming
|
// Create a virtual stream for log streaming
|
||||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||||
|
|
||||||
// Set up log streaming
|
// Set up log streaming
|
||||||
const streamLogs = this.setupLogStream(
|
const streamLogs = this.setupLogStream(
|
||||||
virtualStream,
|
virtualStream,
|
||||||
@@ -50,20 +73,47 @@ export class LogsHandler {
|
|||||||
dataArg.filters?.category,
|
dataArg.filters?.category,
|
||||||
dataArg.follow
|
dataArg.follow
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start streaming
|
// Start streaming
|
||||||
streamLogs.start();
|
streamLogs.start();
|
||||||
|
|
||||||
// VirtualStream handles cleanup automatically
|
// Track the stop function so we can clean up on shutdown
|
||||||
|
this.activeStreamStops.add(streamLogs.stop);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logStream: virtualStream as any, // Cast to IVirtualStream interface
|
logStream: virtualStream as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
|
||||||
|
switch (smartlogLevel) {
|
||||||
|
case 'silly':
|
||||||
|
case 'debug':
|
||||||
|
return 'debug';
|
||||||
|
case 'warn':
|
||||||
|
return 'warn';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deriveCategory(
|
||||||
|
zone?: string,
|
||||||
|
message?: string
|
||||||
|
): 'smtp' | 'dns' | 'security' | 'system' | 'email' {
|
||||||
|
const msg = (message || '').toLowerCase();
|
||||||
|
if (msg.includes('[security:') || msg.includes('security')) return 'security';
|
||||||
|
if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email';
|
||||||
|
if (zone === 'dns' || msg.includes('dns')) return 'dns';
|
||||||
|
if (msg.includes('smtp')) return 'smtp';
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
private async getRecentLogs(
|
private async getRecentLogs(
|
||||||
level?: 'error' | 'warn' | 'info' | 'debug',
|
level?: 'error' | 'warn' | 'info' | 'debug',
|
||||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
||||||
@@ -78,44 +128,122 @@ export class LogsHandler {
|
|||||||
message: string;
|
message: string;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
}>> {
|
}>> {
|
||||||
// TODO: Implement actual log retrieval from storage or logger
|
// Compute a timestamp cutoff from timeRange
|
||||||
// For now, return mock data
|
let since: number | undefined;
|
||||||
const mockLogs: Array<{
|
if (timeRange) {
|
||||||
|
const rangeMs: Record<string, number> = {
|
||||||
|
'1h': 3600000,
|
||||||
|
'6h': 21600000,
|
||||||
|
'24h': 86400000,
|
||||||
|
'7d': 604800000,
|
||||||
|
'30d': 2592000000,
|
||||||
|
};
|
||||||
|
since = Date.now() - (rangeMs[timeRange] || 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the UI level to smartlog levels for filtering
|
||||||
|
const smartlogLevels: string[] | undefined = level
|
||||||
|
? level === 'debug'
|
||||||
|
? ['debug', 'silly']
|
||||||
|
: level === 'info'
|
||||||
|
? ['info', 'ok', 'success', 'note', 'lifecycle']
|
||||||
|
: [level]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch a larger batch from buffer, then apply category filter client-side
|
||||||
|
const rawEntries = logBuffer.getEntries({
|
||||||
|
level: smartlogLevels as any,
|
||||||
|
search,
|
||||||
|
since,
|
||||||
|
limit: limit * 3, // over-fetch to compensate for category filtering
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map ILogPackage → UI log format and apply category filter
|
||||||
|
const mapped: Array<{
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
level: 'debug' | 'info' | 'warn' | 'error';
|
level: 'debug' | 'info' | 'warn' | 'error';
|
||||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
message: string;
|
message: string;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
for (const pkg of rawEntries) {
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const uiLevel = LogsHandler.mapLogLevel(pkg.level);
|
||||||
const now = Date.now();
|
const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message);
|
||||||
|
|
||||||
// Generate some mock log entries
|
if (category && uiCategory !== category) continue;
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
mapped.push({
|
||||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
timestamp: pkg.timestamp,
|
||||||
|
level: uiLevel,
|
||||||
// Filter by requested criteria
|
category: uiCategory,
|
||||||
if (level && mockLevel !== level) continue;
|
message: pkg.message,
|
||||||
if (category && mockCategory !== category) continue;
|
metadata: pkg.data,
|
||||||
|
|
||||||
mockLogs.push({
|
|
||||||
timestamp: now - (i * 60000), // 1 minute apart
|
|
||||||
level: mockLevel,
|
|
||||||
category: mockCategory,
|
|
||||||
message: `Sample log message ${i} from ${mockCategory}`,
|
|
||||||
metadata: {
|
|
||||||
requestId: plugins.uuid.v4(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (mapped.length >= limit) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pagination
|
return mapped;
|
||||||
return mockLogs.slice(offset, offset + limit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a log destination to the base logger that pushes entries
|
||||||
|
* to all connected ops_dashboard TypedSocket clients.
|
||||||
|
*
|
||||||
|
* Uses a module-level singleton so the destination is added only once,
|
||||||
|
* even across OpsServer restart cycles. The destination reads
|
||||||
|
* `currentOpsServerRef` dynamically so it always uses the active server.
|
||||||
|
*/
|
||||||
|
private setupLogPushDestination(): void {
|
||||||
|
// Update the module-level reference so the existing destination uses the new server
|
||||||
|
currentOpsServerRef = this.opsServerRef;
|
||||||
|
|
||||||
|
if (logPushDestinationInstalled) {
|
||||||
|
return; // destination already registered — just updated the ref
|
||||||
|
}
|
||||||
|
logPushDestinationInstalled = true;
|
||||||
|
|
||||||
|
baseLogger.addLogDestination({
|
||||||
|
async handleLog(logPackage: any) {
|
||||||
|
const opsServer = currentOpsServerRef;
|
||||||
|
if (!opsServer) return;
|
||||||
|
|
||||||
|
const typedsocket = opsServer.server?.typedserver?.typedsocket;
|
||||||
|
if (!typedsocket) return;
|
||||||
|
|
||||||
|
let connections: any[];
|
||||||
|
try {
|
||||||
|
connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connections.length === 0) return;
|
||||||
|
|
||||||
|
const entry: interfaces.data.ILogEntry = {
|
||||||
|
timestamp: logPackage.timestamp || Date.now(),
|
||||||
|
level: LogsHandler.mapLogLevel(logPackage.level),
|
||||||
|
category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message),
|
||||||
|
message: logPackage.message,
|
||||||
|
metadata: logPackage.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
try {
|
||||||
|
const push = typedsocket.createTypedRequest<interfaces.requests.IReq_PushLogEntry>(
|
||||||
|
'pushLogEntry',
|
||||||
|
conn,
|
||||||
|
);
|
||||||
|
push.fire({ entry }).catch(() => {}); // fire-and-forget
|
||||||
|
} catch {
|
||||||
|
// connection may have closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private setupLogStream(
|
private setupLogStream(
|
||||||
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
|
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
|
||||||
levelFilter?: string[],
|
levelFilter?: string[],
|
||||||
@@ -126,8 +254,18 @@ export class LogsHandler {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
} {
|
} {
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
|
let stopped = false;
|
||||||
let logIndex = 0;
|
let logIndex = 0;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
this.activeStreamStops.delete(stop);
|
||||||
|
};
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
if (!follow) {
|
if (!follow) {
|
||||||
// Send existing logs and close
|
// Send existing logs and close
|
||||||
@@ -142,23 +280,29 @@ export class LogsHandler {
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
virtualStream.sendData(encoder.encode(logData));
|
virtualStream.sendData(encoder.encode(logData));
|
||||||
});
|
});
|
||||||
// VirtualStream doesn't have end() method - it closes automatically
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow mode, simulate real-time log streaming
|
// For follow mode, simulate real-time log streaming
|
||||||
intervalId = setInterval(() => {
|
intervalId = setInterval(async () => {
|
||||||
|
if (stopped) {
|
||||||
|
// Guard: clear interval if stop() was called between ticks
|
||||||
|
clearInterval(intervalId!);
|
||||||
|
intervalId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||||
|
|
||||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||||
|
|
||||||
// Filter by requested criteria
|
// Filter by requested criteria
|
||||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
||||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
||||||
|
|
||||||
const logEntry = {
|
const logEntry = {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
level: mockLevel,
|
level: mockLevel,
|
||||||
@@ -168,28 +312,29 @@ export class LogsHandler {
|
|||||||
requestId: plugins.uuid.v4(),
|
requestId: plugins.uuid.v4(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const logData = JSON.stringify(logEntry);
|
const logData = JSON.stringify(logEntry);
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
virtualStream.sendData(encoder.encode(logData));
|
try {
|
||||||
}, 2000); // Send a log every 2 seconds
|
// Use a timeout to detect hung streams (sendData can hang if the
|
||||||
|
// VirtualStream's keepAlive loop has ended)
|
||||||
// TODO: Hook into actual logger events
|
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||||
// logger.on('log', (logEntry) => {
|
await Promise.race([
|
||||||
// if (matchesCriteria(logEntry, level, service)) {
|
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||||
// virtualStream.sendData(formatLogEntry(logEntry));
|
clearTimeout(timeoutHandle);
|
||||||
// }
|
return result;
|
||||||
// });
|
}),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Stream closed, errored, or timed out — clean up
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
intervalId = null;
|
|
||||||
}
|
|
||||||
// TODO: Unhook from logger events
|
|
||||||
};
|
|
||||||
|
|
||||||
return { start, stop };
|
return { start, stop };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class NetworkTargetHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all network targets
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
'getNetworkTargets',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { targets: [] };
|
||||||
|
}
|
||||||
|
return { targets: resolver.listTargets() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
'getNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { target: null };
|
||||||
|
}
|
||||||
|
return { target: resolver.getTarget(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
'createNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { success: false, message: 'Reference resolver not initialized' };
|
||||||
|
}
|
||||||
|
const id = await resolver.createTarget({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateNetworkTarget>(
|
||||||
|
'updateNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { affectedRouteIds } = await resolver.updateTarget(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await manager.reResolveRoutes(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, affectedRouteCount: affectedRouteIds.length };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteNetworkTarget>(
|
||||||
|
'deleteNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolver.deleteTarget(
|
||||||
|
dataArg.id,
|
||||||
|
dataArg.force ?? false,
|
||||||
|
manager.getStoredRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && dataArg.force) {
|
||||||
|
await manager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get routes using a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
'getNetworkTargetUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||||
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,21 +3,19 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class RadiusHandler {
|
export class RadiusHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// RADIUS Client Management
|
// RADIUS Client Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get all RADIUS clients
|
// Get all RADIUS clients (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||||
'getRadiusClients',
|
'getRadiusClients',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -40,8 +38,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add or update a RADIUS client
|
// Add or update a RADIUS client (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||||
'setRadiusClient',
|
'setRadiusClient',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -54,15 +52,15 @@ export class RadiusHandler {
|
|||||||
try {
|
try {
|
||||||
await radiusServer.addClient(dataArg.client);
|
await radiusServer.addClient(dataArg.client);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: (error as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a RADIUS client
|
// Remove a RADIUS client (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||||
'removeRadiusClient',
|
'removeRadiusClient',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -85,8 +83,8 @@ export class RadiusHandler {
|
|||||||
// VLAN Mapping Management
|
// VLAN Mapping Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get all VLAN mappings
|
// Get all VLAN mappings (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||||
'getVlanMappings',
|
'getVlanMappings',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -121,8 +119,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add or update a VLAN mapping
|
// Add or update a VLAN mapping (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||||
'setVlanMapping',
|
'setVlanMapping',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -146,15 +144,15 @@ export class RadiusHandler {
|
|||||||
updatedAt: mapping.updatedAt,
|
updatedAt: mapping.updatedAt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: (error as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a VLAN mapping
|
// Remove a VLAN mapping (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||||
'removeVlanMapping',
|
'removeVlanMapping',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -174,8 +172,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update VLAN configuration
|
// Update VLAN configuration (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||||
'updateVlanConfig',
|
'updateVlanConfig',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -206,8 +204,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test VLAN assignment
|
// Test VLAN assignment (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||||
'testVlanAssignment',
|
'testVlanAssignment',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -240,8 +238,8 @@ export class RadiusHandler {
|
|||||||
// Accounting / Session Management
|
// Accounting / Session Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get active sessions
|
// Get active sessions (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||||
'getRadiusSessions',
|
'getRadiusSessions',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -289,8 +287,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Disconnect a session
|
// Disconnect a session (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||||
'disconnectRadiusSession',
|
'disconnectRadiusSession',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -314,8 +312,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get accounting summary
|
// Get accounting summary (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||||
'getRadiusAccountingSummary',
|
'getRadiusAccountingSummary',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -351,8 +349,8 @@ export class RadiusHandler {
|
|||||||
// Statistics
|
// Statistics
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get RADIUS statistics
|
// Get RADIUS statistics (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||||
'getRadiusStatistics',
|
'getRadiusStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
226
ts/opsserver/handlers/remoteingress.handler.ts
Normal file
226
ts/opsserver/handlers/remoteingress.handler.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class RemoteIngressHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get all remote ingress edges
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||||
|
'getRemoteIngresses',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { edges: [] };
|
||||||
|
}
|
||||||
|
// Return edges without secrets, enriched with effective listen ports and breakdown
|
||||||
|
const edges = manager.getAllEdges().map((e) => {
|
||||||
|
const breakdown = manager.getPortBreakdown(e);
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
secret: '********', // Never expose secrets via API
|
||||||
|
effectiveListenPorts: manager.getEffectiveListenPorts(e),
|
||||||
|
manualPorts: breakdown.manual,
|
||||||
|
derivedPorts: breakdown.derived,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { edges };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter) ----
|
||||||
|
|
||||||
|
// Create a new remote ingress edge
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||||
|
'createRemoteIngress',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||||
|
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
edge: null as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge = await manager.createEdge(
|
||||||
|
dataArg.name,
|
||||||
|
dataArg.listenPorts || [],
|
||||||
|
dataArg.tags,
|
||||||
|
dataArg.autoDerivePorts ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync allowed edges with the hub
|
||||||
|
if (tunnelManager) {
|
||||||
|
await tunnelManager.syncAllowedEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, edge };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a remote ingress edge
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||||
|
'deleteRemoteIngress',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||||
|
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'RemoteIngress not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await manager.deleteEdge(dataArg.id);
|
||||||
|
if (deleted && tunnelManager) {
|
||||||
|
await tunnelManager.syncAllowedEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: deleted,
|
||||||
|
message: deleted ? undefined : 'Edge not found',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a remote ingress edge
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||||
|
'updateRemoteIngress',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||||
|
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, edge: null as any };
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge = await manager.updateEdge(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
listenPorts: dataArg.listenPorts,
|
||||||
|
autoDerivePorts: dataArg.autoDerivePorts,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
tags: dataArg.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!edge) {
|
||||||
|
return { success: false, edge: null as any };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync allowed edges — ports, tags, or enabled may have changed
|
||||||
|
if (tunnelManager) {
|
||||||
|
await tunnelManager.syncAllowedEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdown = manager.getPortBreakdown(edge);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
edge: {
|
||||||
|
...edge,
|
||||||
|
secret: '********',
|
||||||
|
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
|
||||||
|
manualPorts: breakdown.manual,
|
||||||
|
derivedPorts: breakdown.derived,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regenerate secret for an edge
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||||
|
'regenerateRemoteIngressSecret',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||||
|
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, secret: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = await manager.regenerateSecret(dataArg.id);
|
||||||
|
if (!secret) {
|
||||||
|
return { success: false, secret: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync allowed edges since secret changed
|
||||||
|
if (tunnelManager) {
|
||||||
|
await tunnelManager.syncAllowedEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, secret };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get runtime status of all edges (read)
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||||
|
'getRemoteIngressStatus',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
|
||||||
|
if (!tunnelManager) {
|
||||||
|
return { statuses: [] };
|
||||||
|
}
|
||||||
|
return { statuses: tunnelManager.getEdgeStatuses() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a connection token for an edge (write — exposes secret)
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||||
|
'getRemoteIngressConnectionToken',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'RemoteIngress not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge = manager.getEdge(dataArg.edgeId);
|
||||||
|
if (!edge) {
|
||||||
|
return { success: false, message: 'Edge not found' };
|
||||||
|
}
|
||||||
|
if (!edge.enabled) {
|
||||||
|
return { success: false, message: 'Edge is disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubHost = dataArg.hubHost
|
||||||
|
|| this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.hubDomain;
|
||||||
|
if (!hubHost) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'No hub hostname configured. Set hubDomain in remoteIngressConfig or provide hubHost.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubPort = this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.tunnelPort ?? 8443;
|
||||||
|
|
||||||
|
const token = plugins.remoteingress.encodeConnectionToken({
|
||||||
|
hubHost,
|
||||||
|
hubPort,
|
||||||
|
edgeId: edge.id,
|
||||||
|
secret: edge.secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, token };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
ts/opsserver/handlers/route-management.handler.ts
Normal file
164
ts/opsserver/handlers/route-management.handler.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class RouteManagementHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate auth: JWT identity OR API token with required scope.
|
||||||
|
* Returns a userId string on success, throws on failure.
|
||||||
|
*/
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
// Try JWT identity first
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try API token
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get merged routes
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMergedRoutes>(
|
||||||
|
'getMergedRoutes',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'routes:read');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { routes: [], warnings: [] };
|
||||||
|
}
|
||||||
|
return manager.getMergedRoutes();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create route
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
|
||||||
|
'createRoute',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Route management not initialized' };
|
||||||
|
}
|
||||||
|
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
||||||
|
return { success: true, storedRouteId: id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update route
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRoute>(
|
||||||
|
'updateRoute',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'routes:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Route management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.updateRoute(dataArg.id, {
|
||||||
|
route: dataArg.route as any,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
metadata: dataArg.metadata,
|
||||||
|
});
|
||||||
|
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete route
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRoute>(
|
||||||
|
'deleteRoute',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'routes:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Route management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.deleteRoute(dataArg.id);
|
||||||
|
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set override on a hardcoded route
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
|
||||||
|
'setRouteOverride',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Route management not initialized' };
|
||||||
|
}
|
||||||
|
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove override from a hardcoded route
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||||
|
'removeRouteOverride',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'routes:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Route management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.removeOverride(dataArg.routeName);
|
||||||
|
return { success: ok, message: ok ? undefined : 'Override not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle programmatic route
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||||
|
'toggleRoute',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'routes:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Route management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
|
||||||
|
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
ts/opsserver/handlers/security-profile.handler.ts
Normal file
169
ts/opsserver/handlers/security-profile.handler.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class SecurityProfileHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all security profiles
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||||
|
'getSecurityProfiles',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { profiles: [] };
|
||||||
|
}
|
||||||
|
return { profiles: resolver.listProfiles() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>(
|
||||||
|
'getSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { profile: null };
|
||||||
|
}
|
||||||
|
return { profile: resolver.getProfile(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||||
|
'createSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { success: false, message: 'Reference resolver not initialized' };
|
||||||
|
}
|
||||||
|
const id = await resolver.createProfile({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>(
|
||||||
|
'updateSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { affectedRouteIds } = await resolver.updateProfile(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Propagate to affected routes
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await manager.reResolveRoutes(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, affectedRouteCount: affectedRouteIds.length };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>(
|
||||||
|
'deleteSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolver.deleteProfile(
|
||||||
|
dataArg.id,
|
||||||
|
dataArg.force ?? false,
|
||||||
|
manager.getStoredRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If force-deleted with affected routes, re-apply
|
||||||
|
if (result.success && dataArg.force) {
|
||||||
|
await manager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get routes using a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||||
|
'getSecurityProfileUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||||
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,16 @@ import * as interfaces from '../../../ts_interfaces/index.js';
|
|||||||
import { MetricsManager } from '../../monitoring/index.js';
|
import { MetricsManager } from '../../monitoring/index.js';
|
||||||
|
|
||||||
export class SecurityHandler {
|
export class SecurityHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All security endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Security Metrics Handler
|
// Security Metrics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||||
'getSecurityMetrics',
|
'getSecurityMetrics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -40,7 +39,7 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Active Connections Handler
|
// Active Connections Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||||
'getActiveConnections',
|
'getActiveConnections',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -77,8 +76,8 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Network Stats Handler - provides comprehensive network metrics
|
// Network Stats Handler - provides comprehensive network metrics
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
|
||||||
'getNetworkStats',
|
'getNetworkStats',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
// Get network stats from MetricsManager if available
|
// Get network stats from MetricsManager if available
|
||||||
@@ -102,6 +101,7 @@ export class SecurityHandler {
|
|||||||
throughputByIP,
|
throughputByIP,
|
||||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||||
requestsTotal: networkStats.requestsTotal || 0,
|
requestsTotal: networkStats.requestsTotal || 0,
|
||||||
|
backends: networkStats.backends || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,13 +115,14 @@ export class SecurityHandler {
|
|||||||
throughputByIP: [],
|
throughputByIP: [],
|
||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
|
backends: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rate Limit Status Handler
|
// Rate Limit Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||||
'getRateLimitStatus',
|
'getRateLimitStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import * as plugins from '../../plugins.js';
|
|||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { MetricsManager } from '../../monitoring/index.js';
|
import { MetricsManager } from '../../monitoring/index.js';
|
||||||
|
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||||
|
|
||||||
export class StatsHandler {
|
export class StatsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All stats endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Server Statistics Handler
|
// Server Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
||||||
'getServerStatistics',
|
'getServerStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -37,7 +37,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Email Statistics Handler
|
// Email Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
||||||
'getEmailStatistics',
|
'getEmailStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -76,7 +76,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// DNS Statistics Handler
|
// DNS Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
||||||
'getDnsStatistics',
|
'getDnsStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -113,7 +113,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Queue Status Handler
|
// Queue Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
||||||
'getQueueStatus',
|
'getQueueStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -141,7 +141,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Health Status Handler
|
// Health Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'getHealthStatus',
|
'getHealthStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -166,7 +166,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Combined Metrics Handler - More efficient for frontend polling
|
// Combined Metrics Handler - More efficient for frontend polling
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||||
'getCombinedMetrics',
|
'getCombinedMetrics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -203,6 +203,11 @@ export class StatsHandler {
|
|||||||
if (sections.email) {
|
if (sections.email) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.collectEmailStats().then(stats => {
|
this.collectEmailStats().then(stats => {
|
||||||
|
// Get time-series data from MetricsManager
|
||||||
|
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||||
|
? this.opsServerRef.dcRouterRef.metricsManager.getEmailTimeSeries(24)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
metrics.email = {
|
metrics.email = {
|
||||||
sent: stats.sentToday,
|
sent: stats.sentToday,
|
||||||
received: stats.receivedToday,
|
received: stats.receivedToday,
|
||||||
@@ -212,6 +217,7 @@ export class StatsHandler {
|
|||||||
averageDeliveryTime: 0,
|
averageDeliveryTime: 0,
|
||||||
deliveryRate: stats.deliveryRate,
|
deliveryRate: stats.deliveryRate,
|
||||||
bounceRate: stats.bounceRate,
|
bounceRate: stats.bounceRate,
|
||||||
|
timeSeries,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -220,6 +226,11 @@ export class StatsHandler {
|
|||||||
if (sections.dns) {
|
if (sections.dns) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.collectDnsStats().then(stats => {
|
this.collectDnsStats().then(stats => {
|
||||||
|
// Get time-series data from MetricsManager
|
||||||
|
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||||
|
? this.opsServerRef.dcRouterRef.metricsManager.getDnsTimeSeries(24)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
metrics.dns = {
|
metrics.dns = {
|
||||||
totalQueries: stats.totalQueries,
|
totalQueries: stats.totalQueries,
|
||||||
cacheHits: stats.cacheHits,
|
cacheHits: stats.cacheHits,
|
||||||
@@ -228,6 +239,8 @@ export class StatsHandler {
|
|||||||
activeDomains: stats.topDomains.length,
|
activeDomains: stats.topDomains.length,
|
||||||
averageResponseTime: 0,
|
averageResponseTime: 0,
|
||||||
queryTypes: stats.queryTypes,
|
queryTypes: stats.queryTypes,
|
||||||
|
timeSeries,
|
||||||
|
recentQueries: stats.recentQueries,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -236,6 +249,19 @@ export class StatsHandler {
|
|||||||
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
||||||
|
// Get recent events from the SecurityLogger singleton
|
||||||
|
const securityLogger = SecurityLogger.getInstance();
|
||||||
|
const recentEvents = securityLogger.getRecentEvents(50).map((evt) => ({
|
||||||
|
timestamp: evt.timestamp,
|
||||||
|
level: evt.level,
|
||||||
|
type: evt.type,
|
||||||
|
message: evt.message,
|
||||||
|
details: evt.details,
|
||||||
|
ipAddress: evt.ipAddress,
|
||||||
|
domain: evt.domain,
|
||||||
|
success: evt.success,
|
||||||
|
}));
|
||||||
|
|
||||||
metrics.security = {
|
metrics.security = {
|
||||||
blockedIPs: stats.blockedIPs,
|
blockedIPs: stats.blockedIPs,
|
||||||
reputationScores: {},
|
reputationScores: {},
|
||||||
@@ -244,6 +270,7 @@ export class StatsHandler {
|
|||||||
phishingDetected: stats.phishingDetected,
|
phishingDetected: stats.phishingDetected,
|
||||||
authenticationFailures: stats.authFailures,
|
authenticationFailures: stats.authFailures,
|
||||||
suspiciousActivities: stats.totalThreatsBlocked,
|
suspiciousActivities: stats.totalThreatsBlocked,
|
||||||
|
recentEvents,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -252,7 +279,7 @@ export class StatsHandler {
|
|||||||
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||||
promises.push(
|
promises.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
|
||||||
const serverStats = await this.collectServerStats();
|
const serverStats = await this.collectServerStats();
|
||||||
|
|
||||||
// Build per-IP bandwidth lookup from throughputByIP
|
// Build per-IP bandwidth lookup from throughputByIP
|
||||||
@@ -282,6 +309,7 @@ export class StatsHandler {
|
|||||||
throughputHistory: stats.throughputHistory || [],
|
throughputHistory: stats.throughputHistory || [],
|
||||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||||
requestsTotal: stats.requestsTotal || 0,
|
requestsTotal: stats.requestsTotal || 0,
|
||||||
|
backends: stats.backends || [],
|
||||||
};
|
};
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
@@ -395,6 +423,7 @@ export class StatsHandler {
|
|||||||
count: number;
|
count: number;
|
||||||
}>;
|
}>;
|
||||||
queryTypes: { [key: string]: number };
|
queryTypes: { [key: string]: number };
|
||||||
|
recentQueries?: Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>;
|
||||||
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
||||||
}> {
|
}> {
|
||||||
// Get metrics from MetricsManager if available
|
// Get metrics from MetricsManager if available
|
||||||
@@ -408,9 +437,10 @@ export class StatsHandler {
|
|||||||
cacheHitRate: dnsStats.cacheHitRate,
|
cacheHitRate: dnsStats.cacheHitRate,
|
||||||
topDomains: dnsStats.topDomains,
|
topDomains: dnsStats.topDomains,
|
||||||
queryTypes: dnsStats.queryTypes,
|
queryTypes: dnsStats.queryTypes,
|
||||||
|
recentQueries: dnsStats.recentQueries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if MetricsManager not available
|
// Fallback if MetricsManager not available
|
||||||
return {
|
return {
|
||||||
queriesPerSecond: 0,
|
queriesPerSecond: 0,
|
||||||
@@ -460,44 +490,41 @@ export class StatsHandler {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
const services: Array<{
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
name: string;
|
const health = dcRouter.serviceManager.getHealth();
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
||||||
message?: string;
|
const services = health.services.map((svc) => {
|
||||||
}> = [];
|
let status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
|
switch (svc.state) {
|
||||||
// Check HTTP Proxy
|
case 'running':
|
||||||
if (this.opsServerRef.dcRouterRef.smartProxy) {
|
status = 'healthy';
|
||||||
services.push({
|
break;
|
||||||
name: 'HTTP/HTTPS Proxy',
|
case 'starting':
|
||||||
status: 'healthy',
|
case 'degraded':
|
||||||
});
|
status = 'degraded';
|
||||||
}
|
break;
|
||||||
|
case 'failed':
|
||||||
// Check Email Server
|
status = svc.criticality === 'critical' ? 'unhealthy' : 'degraded';
|
||||||
if (this.opsServerRef.dcRouterRef.emailServer) {
|
break;
|
||||||
services.push({
|
case 'stopped':
|
||||||
name: 'Email Server',
|
case 'stopping':
|
||||||
status: 'healthy',
|
default:
|
||||||
});
|
status = 'degraded';
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
// Check DNS Server
|
|
||||||
if (this.opsServerRef.dcRouterRef.dnsServer) {
|
let message: string | undefined;
|
||||||
services.push({
|
if (svc.state === 'failed' && svc.lastError) {
|
||||||
name: 'DNS Server',
|
message = svc.lastError;
|
||||||
status: 'healthy',
|
} else if (svc.retryCount > 0 && svc.state !== 'running') {
|
||||||
});
|
message = `Retry attempt ${svc.retryCount}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check OpsServer
|
return { name: svc.name, status, message };
|
||||||
services.push({
|
|
||||||
name: 'OpsServer',
|
|
||||||
status: 'healthy',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const healthy = services.every(s => s.status === 'healthy');
|
const healthy = health.overall === 'healthy';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
healthy,
|
healthy,
|
||||||
services,
|
services,
|
||||||
|
|||||||
340
ts/opsserver/handlers/vpn.handler.ts
Normal file
340
ts/opsserver/handlers/vpn.handler.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class VpnHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get all registered VPN clients
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
|
||||||
|
'getVpnClients',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { clients: [] };
|
||||||
|
}
|
||||||
|
const clients = manager.listClients().map((c) => ({
|
||||||
|
clientId: c.clientId,
|
||||||
|
enabled: c.enabled,
|
||||||
|
serverDefinedClientTags: c.serverDefinedClientTags,
|
||||||
|
description: c.description,
|
||||||
|
assignedIp: c.assignedIp,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
expiresAt: c.expiresAt,
|
||||||
|
forceDestinationSmartproxy: c.forceDestinationSmartproxy ?? true,
|
||||||
|
destinationAllowList: c.destinationAllowList,
|
||||||
|
destinationBlockList: c.destinationBlockList,
|
||||||
|
useHostIp: c.useHostIp,
|
||||||
|
useDhcp: c.useDhcp,
|
||||||
|
staticIp: c.staticIp,
|
||||||
|
forceVlan: c.forceVlan,
|
||||||
|
vlanId: c.vlanId,
|
||||||
|
}));
|
||||||
|
return { clients };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get VPN server status
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
|
||||||
|
'getVpnStatus',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
|
||||||
|
if (!manager) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
running: false,
|
||||||
|
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||||
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
|
serverPublicKeys: null,
|
||||||
|
registeredClients: 0,
|
||||||
|
connectedClients: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await manager.getConnectedClients();
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
running: manager.running,
|
||||||
|
subnet: manager.getSubnet(),
|
||||||
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
|
serverPublicKeys: manager.getServerPublicKeys(),
|
||||||
|
registeredClients: manager.listClients().length,
|
||||||
|
connectedClients: connected.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get currently connected VPN clients
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnConnectedClients>(
|
||||||
|
'getVpnConnectedClients',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { connectedClients: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await manager.getConnectedClients();
|
||||||
|
return {
|
||||||
|
connectedClients: connected.map((c) => ({
|
||||||
|
clientId: c.registeredClientId || c.clientId,
|
||||||
|
assignedIp: c.assignedIp,
|
||||||
|
connectedSince: c.connectedSince,
|
||||||
|
bytesSent: c.bytesSent,
|
||||||
|
bytesReceived: c.bytesReceived,
|
||||||
|
transport: c.transportType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||||
|
|
||||||
|
// Create a new VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
|
||||||
|
'createVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundle = await manager.createClient({
|
||||||
|
clientId: dataArg.clientId,
|
||||||
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
|
description: dataArg.description,
|
||||||
|
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||||
|
destinationAllowList: dataArg.destinationAllowList,
|
||||||
|
destinationBlockList: dataArg.destinationBlockList,
|
||||||
|
useHostIp: dataArg.useHostIp,
|
||||||
|
useDhcp: dataArg.useDhcp,
|
||||||
|
staticIp: dataArg.staticIp,
|
||||||
|
forceVlan: dataArg.forceVlan,
|
||||||
|
vlanId: dataArg.vlanId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retrieve the persisted doc to get dcrouter-level fields
|
||||||
|
const persistedClient = manager.listClients().find(
|
||||||
|
(c) => c.clientId === bundle.entry.clientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
client: {
|
||||||
|
clientId: bundle.entry.clientId,
|
||||||
|
enabled: bundle.entry.enabled ?? true,
|
||||||
|
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||||
|
description: bundle.entry.description,
|
||||||
|
assignedIp: bundle.entry.assignedIp,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
expiresAt: bundle.entry.expiresAt,
|
||||||
|
forceDestinationSmartproxy: persistedClient?.forceDestinationSmartproxy ?? true,
|
||||||
|
destinationAllowList: persistedClient?.destinationAllowList,
|
||||||
|
destinationBlockList: persistedClient?.destinationBlockList,
|
||||||
|
useHostIp: persistedClient?.useHostIp,
|
||||||
|
useDhcp: persistedClient?.useDhcp,
|
||||||
|
staticIp: persistedClient?.staticIp,
|
||||||
|
forceVlan: persistedClient?.forceVlan,
|
||||||
|
vlanId: persistedClient?.vlanId,
|
||||||
|
},
|
||||||
|
wireguardConfig: bundle.wireguardConfig,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a VPN client's metadata
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVpnClient>(
|
||||||
|
'updateVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.updateClient(dataArg.clientId, {
|
||||||
|
description: dataArg.description,
|
||||||
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
|
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||||
|
destinationAllowList: dataArg.destinationAllowList,
|
||||||
|
destinationBlockList: dataArg.destinationBlockList,
|
||||||
|
useHostIp: dataArg.useHostIp,
|
||||||
|
useDhcp: dataArg.useDhcp,
|
||||||
|
staticIp: dataArg.staticIp,
|
||||||
|
forceVlan: dataArg.forceVlan,
|
||||||
|
vlanId: dataArg.vlanId,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
|
||||||
|
'deleteVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.removeClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
|
||||||
|
'enableVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.enableClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disable a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
|
||||||
|
'disableVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.disableClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rotate a VPN client's keys
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
|
||||||
|
'rotateVpnClientKey',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundle = await manager.rotateClientKey(dataArg.clientId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
wireguardConfig: bundle.wireguardConfig,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export a VPN client config
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
|
||||||
|
'exportVpnClientConfig',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format);
|
||||||
|
return { success: true, config };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get telemetry for a specific VPN client
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
|
||||||
|
'getVpnClientTelemetry',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const telemetry = await manager.getClientTelemetry(dataArg.clientId);
|
||||||
|
if (!telemetry) {
|
||||||
|
return { success: false, message: 'Client not found or not connected' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
telemetry: {
|
||||||
|
clientId: telemetry.clientId,
|
||||||
|
assignedIp: telemetry.assignedIp,
|
||||||
|
bytesSent: telemetry.bytesSent,
|
||||||
|
bytesReceived: telemetry.bytesReceived,
|
||||||
|
packetsDropped: telemetry.packetsDropped,
|
||||||
|
bytesDropped: telemetry.bytesDropped,
|
||||||
|
lastKeepaliveAt: telemetry.lastKeepaliveAt,
|
||||||
|
keepalivesReceived: telemetry.keepalivesReceived,
|
||||||
|
rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec,
|
||||||
|
burstBytes: telemetry.burstBytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,16 +22,17 @@ export async function passGuards<T extends { identity?: any }>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check admin identity in handlers
|
* Helper to check admin identity in handlers and middleware.
|
||||||
|
* Accepts both optional and required identity for flexibility.
|
||||||
*/
|
*/
|
||||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
export async function requireAdminIdentity(
|
||||||
adminHandler: AdminHandler,
|
adminHandler: AdminHandler,
|
||||||
dataArg: T
|
dataArg: { identity?: interfaces.data.IIdentity }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!dataArg.identity) {
|
if (!dataArg.identity) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
@@ -39,16 +40,17 @@ export async function requireAdminIdentity<T extends { identity?: interfaces.dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check valid identity in handlers
|
* Helper to check valid identity in handlers and middleware.
|
||||||
|
* Accepts both optional and required identity for flexibility.
|
||||||
*/
|
*/
|
||||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
export async function requireValidIdentity(
|
||||||
adminHandler: AdminHandler,
|
adminHandler: AdminHandler,
|
||||||
dataArg: T
|
dataArg: { identity?: interfaces.data.IIdentity }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!dataArg.identity) {
|
if (!dataArg.identity) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||||
|
|||||||
58
ts/paths.ts
58
ts/paths.ts
@@ -1,7 +1,6 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
// Base directories
|
// Code/asset paths (not affected by baseDir)
|
||||||
export const baseDir = process.cwd();
|
|
||||||
export const packageDir = plugins.path.join(
|
export const packageDir = plugins.path.join(
|
||||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
'../'
|
'../'
|
||||||
@@ -20,35 +19,36 @@ export const dataDir = process.env.DATA_DIR
|
|||||||
// Default TsmDB path for CacheDb
|
// Default TsmDB path for CacheDb
|
||||||
export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb');
|
export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb');
|
||||||
|
|
||||||
// MTA directories
|
// DNS records directory (only surviving MTA directory reference)
|
||||||
export const keysDir = plugins.path.join(dataDir, 'keys');
|
|
||||||
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
|
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
|
||||||
export const sentEmailsDir = plugins.path.join(dataDir, 'emails', 'sent');
|
|
||||||
export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received');
|
|
||||||
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
|
|
||||||
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
|
|
||||||
|
|
||||||
// Email template directories
|
/**
|
||||||
export const emailTemplatesDir = plugins.path.join(dataDir, 'templates', 'email');
|
* Resolve all data paths from a given baseDir.
|
||||||
export const MtaAttachmentsDir = plugins.path.join(dataDir, 'attachments'); // For email attachments
|
* When no baseDir is provided, falls back to ~/.serve.zone/dcrouter.
|
||||||
|
* Specific overrides (e.g. DATA_DIR env) take precedence.
|
||||||
|
*/
|
||||||
|
export function resolvePaths(baseDir?: string) {
|
||||||
|
const root = baseDir ?? plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
|
||||||
|
const resolvedDataDir = process.env.DATA_DIR ?? plugins.path.join(root, 'data');
|
||||||
|
return {
|
||||||
|
dcrouterHomeDir: root,
|
||||||
|
dataDir: resolvedDataDir,
|
||||||
|
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
|
||||||
|
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration path
|
/**
|
||||||
export const configPath = process.env.CONFIG_PATH
|
* Ensure only the data directories that are actually used exist.
|
||||||
? process.env.CONFIG_PATH
|
*/
|
||||||
: plugins.path.join(baseDir, 'config.json');
|
export function ensureDataDirectories(resolvedPaths: ReturnType<typeof resolvePaths>) {
|
||||||
|
plugins.fsUtils.ensureDirSync(resolvedPaths.dataDir);
|
||||||
|
plugins.fsUtils.ensureDirSync(resolvedPaths.dnsRecordsDir);
|
||||||
|
}
|
||||||
|
|
||||||
// Create directories if they don't exist
|
/**
|
||||||
|
* Legacy wrapper — delegates to ensureDataDirectories with module-level defaults.
|
||||||
|
*/
|
||||||
export function ensureDirectories() {
|
export function ensureDirectories() {
|
||||||
// Ensure data directories
|
ensureDataDirectories(resolvePaths());
|
||||||
plugins.fsUtils.ensureDirSync(dataDir);
|
}
|
||||||
plugins.fsUtils.ensureDirSync(keysDir);
|
|
||||||
plugins.fsUtils.ensureDirSync(dnsRecordsDir);
|
|
||||||
plugins.fsUtils.ensureDirSync(sentEmailsDir);
|
|
||||||
plugins.fsUtils.ensureDirSync(receivedEmailsDir);
|
|
||||||
plugins.fsUtils.ensureDirSync(failedEmailsDir);
|
|
||||||
plugins.fsUtils.ensureDirSync(logsDir);
|
|
||||||
|
|
||||||
// Ensure email template directories
|
|
||||||
plugins.fsUtils.ensureDirSync(emailTemplatesDir);
|
|
||||||
plugins.fsUtils.ensureDirSync(MtaAttachmentsDir);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export {
|
|||||||
|
|
||||||
// @serve.zone scope
|
// @serve.zone scope
|
||||||
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
||||||
|
import * as remoteingress from '@serve.zone/remoteingress';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
servezoneInterfaces
|
servezoneInterfaces,
|
||||||
|
remoteingress,
|
||||||
}
|
}
|
||||||
|
|
||||||
// @api.global scope
|
// @api.global scope
|
||||||
@@ -45,23 +47,25 @@ import * as qenv from '@push.rocks/qenv';
|
|||||||
import * as smartacme from '@push.rocks/smartacme';
|
import * as smartacme from '@push.rocks/smartacme';
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
import * as smartdns from '@push.rocks/smartdns';
|
import * as smartdns from '@push.rocks/smartdns';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfs from '@push.rocks/smartfs';
|
||||||
import * as smartguard from '@push.rocks/smartguard';
|
import * as smartguard from '@push.rocks/smartguard';
|
||||||
import * as smartjwt from '@push.rocks/smartjwt';
|
import * as smartjwt from '@push.rocks/smartjwt';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||||
import * as smartmta from '@push.rocks/smartmta';
|
import * as smartmta from '@push.rocks/smartmta';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartdb from '@push.rocks/smartdb';
|
||||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartproxy from '@push.rocks/smartproxy';
|
import * as smartproxy from '@push.rocks/smartproxy';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
import * as smartvpn from '@push.rocks/smartvpn';
|
||||||
import * as smartradius from '@push.rocks/smartradius';
|
import * as smartradius from '@push.rocks/smartradius';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique };
|
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
|
||||||
|
|
||||||
// Define SmartLog types for use in error handling
|
// Define SmartLog types for use in error handling
|
||||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||||
@@ -87,7 +91,7 @@ export {
|
|||||||
uuid,
|
uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filesystem utilities (compatibility helpers for smartfile v13+)
|
// Filesystem utilities
|
||||||
export const fsUtils = {
|
export const fsUtils = {
|
||||||
/**
|
/**
|
||||||
* Ensure a directory exists, creating it recursively if needed (sync)
|
* Ensure a directory exists, creating it recursively if needed (sync)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { AccountingSessionDoc } from '../db/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RADIUS accounting session
|
* RADIUS accounting session
|
||||||
@@ -84,14 +84,14 @@ export interface IAccountingSummary {
|
|||||||
* Accounting manager configuration
|
* Accounting manager configuration
|
||||||
*/
|
*/
|
||||||
export interface IAccountingManagerConfig {
|
export interface IAccountingManagerConfig {
|
||||||
/** Storage key prefix */
|
|
||||||
storagePrefix?: string;
|
|
||||||
/** Session retention period in days (default: 30) */
|
/** Session retention period in days (default: 30) */
|
||||||
retentionDays?: number;
|
retentionDays?: number;
|
||||||
/** Enable detailed session logging */
|
/** Enable detailed session logging */
|
||||||
detailedLogging?: boolean;
|
detailedLogging?: boolean;
|
||||||
/** Maximum active sessions to track in memory */
|
/** Maximum active sessions to track in memory */
|
||||||
maxActiveSessions?: number;
|
maxActiveSessions?: number;
|
||||||
|
/** Stale session timeout in hours — sessions with no update for this long are evicted (default: 24) */
|
||||||
|
staleSessionTimeoutHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,7 +104,7 @@ export interface IAccountingManagerConfig {
|
|||||||
export class AccountingManager {
|
export class AccountingManager {
|
||||||
private activeSessions: Map<string, IAccountingSession> = new Map();
|
private activeSessions: Map<string, IAccountingSession> = new Map();
|
||||||
private config: Required<IAccountingManagerConfig>;
|
private config: Required<IAccountingManagerConfig>;
|
||||||
private storageManager?: StorageManager;
|
private staleSessionSweepTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
// Counters for statistics
|
// Counters for statistics
|
||||||
private stats = {
|
private stats = {
|
||||||
@@ -115,26 +115,72 @@ export class AccountingManager {
|
|||||||
interimUpdatesReceived: 0,
|
interimUpdatesReceived: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) {
|
constructor(config?: IAccountingManagerConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
|
|
||||||
retentionDays: config?.retentionDays ?? 30,
|
retentionDays: config?.retentionDays ?? 30,
|
||||||
detailedLogging: config?.detailedLogging ?? false,
|
detailedLogging: config?.detailedLogging ?? false,
|
||||||
maxActiveSessions: config?.maxActiveSessions ?? 10000,
|
maxActiveSessions: config?.maxActiveSessions ?? 10000,
|
||||||
|
staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24,
|
||||||
};
|
};
|
||||||
this.storageManager = storageManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the accounting manager
|
* Initialize the accounting manager
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.storageManager) {
|
await this.loadActiveSessions();
|
||||||
await this.loadActiveSessions();
|
|
||||||
|
// Start periodic sweep to evict stale sessions (every 15 minutes)
|
||||||
|
this.staleSessionSweepTimer = setInterval(() => {
|
||||||
|
this.sweepStaleSessions();
|
||||||
|
}, 15 * 60 * 1000);
|
||||||
|
// Allow the process to exit even if the timer is pending
|
||||||
|
if (this.staleSessionSweepTimer.unref) {
|
||||||
|
this.staleSessionSweepTimer.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
|
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the accounting manager and clean up timers
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (this.staleSessionSweepTimer) {
|
||||||
|
clearInterval(this.staleSessionSweepTimer);
|
||||||
|
this.staleSessionSweepTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep stale active sessions that have not received any update
|
||||||
|
* within the configured timeout. These are orphaned sessions where
|
||||||
|
* the Stop packet was never received.
|
||||||
|
*/
|
||||||
|
private sweepStaleSessions(): void {
|
||||||
|
const timeoutMs = this.config.staleSessionTimeoutHours * 60 * 60 * 1000;
|
||||||
|
const cutoff = Date.now() - timeoutMs;
|
||||||
|
let swept = 0;
|
||||||
|
|
||||||
|
for (const [sessionId, session] of this.activeSessions) {
|
||||||
|
if (session.lastUpdateTime < cutoff) {
|
||||||
|
session.status = 'terminated';
|
||||||
|
session.terminateCause = 'StaleSessionTimeout';
|
||||||
|
session.endTime = Date.now();
|
||||||
|
session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000);
|
||||||
|
|
||||||
|
this.persistSession(session).catch(() => {});
|
||||||
|
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
swept++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swept > 0) {
|
||||||
|
logger.log('info', `Swept ${swept} stale RADIUS sessions (no update for ${this.config.staleSessionTimeoutHours}h)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle accounting start request
|
* Handle accounting start request
|
||||||
*/
|
*/
|
||||||
@@ -195,9 +241,7 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist session
|
// Persist session
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.persistSession(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,9 +287,7 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update persisted session
|
// Update persisted session
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.persistSession(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,10 +340,8 @@ export class AccountingManager {
|
|||||||
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
|
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive the session
|
// Update status in the database (single collection, no active->archive move needed)
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.archiveSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from active sessions
|
// Remove from active sessions
|
||||||
this.activeSessions.delete(data.sessionId);
|
this.activeSessions.delete(data.sessionId);
|
||||||
@@ -438,23 +478,16 @@ export class AccountingManager {
|
|||||||
* Clean up old archived sessions based on retention policy
|
* Clean up old archived sessions based on retention policy
|
||||||
*/
|
*/
|
||||||
async cleanupOldSessions(): Promise<number> {
|
async cleanupOldSessions(): Promise<number> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
const oldDocs = await AccountingSessionDoc.findStoppedBefore(cutoffTime);
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const doc of oldDocs) {
|
||||||
try {
|
try {
|
||||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
await doc.delete();
|
||||||
if (session && session.endTime > 0 && session.endTime < cutoffTime) {
|
deletedCount++;
|
||||||
await this.storageManager.delete(key);
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore individual errors
|
// Ignore individual errors
|
||||||
}
|
}
|
||||||
@@ -463,8 +496,8 @@ export class AccountingManager {
|
|||||||
if (deletedCount > 0) {
|
if (deletedCount > 0) {
|
||||||
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
|
logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
@@ -497,9 +530,7 @@ export class AccountingManager {
|
|||||||
session.terminateCause = 'SessionEvicted';
|
session.terminateCause = 'SessionEvicted';
|
||||||
session.endTime = Date.now();
|
session.endTime = Date.now();
|
||||||
|
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.archiveSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeSessions.delete(sessionId);
|
this.activeSessions.delete(sessionId);
|
||||||
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
|
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
|
||||||
@@ -507,99 +538,101 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load active sessions from storage
|
* Load active sessions from database
|
||||||
*/
|
*/
|
||||||
private async loadActiveSessions(): Promise<void> {
|
private async loadActiveSessions(): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`);
|
const docs = await AccountingSessionDoc.findActive();
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
try {
|
const session: IAccountingSession = {
|
||||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
sessionId: doc.sessionId,
|
||||||
if (session && session.status === 'active') {
|
username: doc.username,
|
||||||
this.activeSessions.set(session.sessionId, session);
|
macAddress: doc.macAddress,
|
||||||
}
|
nasIpAddress: doc.nasIpAddress,
|
||||||
} catch (error) {
|
nasPort: doc.nasPort,
|
||||||
// Ignore individual errors
|
nasPortType: doc.nasPortType,
|
||||||
}
|
nasIdentifier: doc.nasIdentifier,
|
||||||
|
vlanId: doc.vlanId,
|
||||||
|
framedIpAddress: doc.framedIpAddress,
|
||||||
|
calledStationId: doc.calledStationId,
|
||||||
|
callingStationId: doc.callingStationId,
|
||||||
|
startTime: doc.startTime,
|
||||||
|
endTime: doc.endTime,
|
||||||
|
lastUpdateTime: doc.lastUpdateTime,
|
||||||
|
status: doc.status,
|
||||||
|
terminateCause: doc.terminateCause,
|
||||||
|
inputOctets: doc.inputOctets,
|
||||||
|
outputOctets: doc.outputOctets,
|
||||||
|
inputPackets: doc.inputPackets,
|
||||||
|
outputPackets: doc.outputPackets,
|
||||||
|
sessionTime: doc.sessionTime,
|
||||||
|
serviceType: doc.serviceType,
|
||||||
|
};
|
||||||
|
this.activeSessions.set(session.sessionId, session);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to load active sessions: ${error.message}`);
|
logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a session to storage
|
* Persist a session to the database (create or update)
|
||||||
*/
|
*/
|
||||||
private async persistSession(session: IAccountingSession): Promise<void> {
|
private async persistSession(session: IAccountingSession): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
|
||||||
try {
|
try {
|
||||||
await this.storageManager.setJSON(key, session);
|
let doc = await AccountingSessionDoc.findBySessionId(session.sessionId);
|
||||||
} catch (error) {
|
if (!doc) {
|
||||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`);
|
doc = new AccountingSessionDoc();
|
||||||
|
}
|
||||||
|
Object.assign(doc, session);
|
||||||
|
await doc.save();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive a completed session
|
* Get archived (stopped/terminated) sessions for a time period
|
||||||
*/
|
|
||||||
private async archiveSession(session: IAccountingSession): Promise<void> {
|
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Remove from active
|
|
||||||
const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
|
||||||
await this.storageManager.delete(activeKey);
|
|
||||||
|
|
||||||
// Add to archive with date-based path
|
|
||||||
const date = new Date(session.endTime);
|
|
||||||
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
|
|
||||||
await this.storageManager.setJSON(archiveKey, session);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get archived sessions for a time period
|
|
||||||
*/
|
*/
|
||||||
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
|
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessions: IAccountingSession[] = [];
|
const sessions: IAccountingSession[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
const docs = await AccountingSessionDoc.getInstances({
|
||||||
|
status: { $in: ['stopped', 'terminated'] } as any,
|
||||||
|
endTime: { $gt: 0, $gte: startTime } as any,
|
||||||
|
startTime: { $lte: endTime } as any,
|
||||||
|
});
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
try {
|
sessions.push({
|
||||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
sessionId: doc.sessionId,
|
||||||
if (
|
username: doc.username,
|
||||||
session &&
|
macAddress: doc.macAddress,
|
||||||
session.endTime > 0 &&
|
nasIpAddress: doc.nasIpAddress,
|
||||||
session.startTime <= endTime &&
|
nasPort: doc.nasPort,
|
||||||
session.endTime >= startTime
|
nasPortType: doc.nasPortType,
|
||||||
) {
|
nasIdentifier: doc.nasIdentifier,
|
||||||
sessions.push(session);
|
vlanId: doc.vlanId,
|
||||||
}
|
framedIpAddress: doc.framedIpAddress,
|
||||||
} catch (error) {
|
calledStationId: doc.calledStationId,
|
||||||
// Ignore individual errors
|
callingStationId: doc.callingStationId,
|
||||||
}
|
startTime: doc.startTime,
|
||||||
|
endTime: doc.endTime,
|
||||||
|
lastUpdateTime: doc.lastUpdateTime,
|
||||||
|
status: doc.status,
|
||||||
|
terminateCause: doc.terminateCause,
|
||||||
|
inputOctets: doc.inputOctets,
|
||||||
|
outputOctets: doc.outputOctets,
|
||||||
|
inputPackets: doc.inputPackets,
|
||||||
|
outputPackets: doc.outputPackets,
|
||||||
|
sessionTime: doc.sessionTime,
|
||||||
|
serviceType: doc.serviceType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
|
logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
return sessions;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
|
||||||
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
|
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
|
||||||
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
||||||
|
|
||||||
@@ -92,7 +91,6 @@ export class RadiusServer {
|
|||||||
private vlanManager: VlanManager;
|
private vlanManager: VlanManager;
|
||||||
private accountingManager: AccountingManager;
|
private accountingManager: AccountingManager;
|
||||||
private config: IRadiusServerConfig;
|
private config: IRadiusServerConfig;
|
||||||
private storageManager?: StorageManager;
|
|
||||||
private clientSecrets: Map<string, string> = new Map();
|
private clientSecrets: Map<string, string> = new Map();
|
||||||
private running: boolean = false;
|
private running: boolean = false;
|
||||||
|
|
||||||
@@ -105,20 +103,19 @@ export class RadiusServer {
|
|||||||
startTime: 0,
|
startTime: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) {
|
constructor(config: IRadiusServerConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
authPort: config.authPort ?? 1812,
|
authPort: config.authPort ?? 1812,
|
||||||
acctPort: config.acctPort ?? 1813,
|
acctPort: config.acctPort ?? 1813,
|
||||||
bindAddress: config.bindAddress ?? '0.0.0.0',
|
bindAddress: config.bindAddress ?? '0.0.0.0',
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
this.storageManager = storageManager;
|
|
||||||
|
|
||||||
// Initialize VLAN manager
|
// Initialize VLAN manager
|
||||||
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager);
|
this.vlanManager = new VlanManager(config.vlanAssignment);
|
||||||
|
|
||||||
// Initialize accounting manager
|
// Initialize accounting manager
|
||||||
this.accountingManager = new AccountingManager(config.accounting, storageManager);
|
this.accountingManager = new AccountingManager(config.accounting);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,6 +180,8 @@ export class RadiusServer {
|
|||||||
this.radiusServer = undefined;
|
this.radiusServer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.accountingManager.stop();
|
||||||
|
|
||||||
this.running = false;
|
this.running = false;
|
||||||
logger.log('info', 'RADIUS server stopped');
|
logger.log('info', 'RADIUS server stopped');
|
||||||
}
|
}
|
||||||
@@ -308,8 +307,8 @@ export class RadiusServer {
|
|||||||
default:
|
default:
|
||||||
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `RADIUS accounting error: ${error.message}`);
|
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { VlanMappingsDoc } from '../db/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MAC address to VLAN mapping
|
* MAC address to VLAN mapping
|
||||||
@@ -42,8 +42,6 @@ export interface IVlanManagerConfig {
|
|||||||
defaultVlan?: number;
|
defaultVlan?: number;
|
||||||
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
||||||
allowUnknownMacs?: boolean;
|
allowUnknownMacs?: boolean;
|
||||||
/** Storage key prefix for persistence */
|
|
||||||
storagePrefix?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,27 +54,22 @@ export interface IVlanManagerConfig {
|
|||||||
export class VlanManager {
|
export class VlanManager {
|
||||||
private mappings: Map<string, IMacVlanMapping> = new Map();
|
private mappings: Map<string, IMacVlanMapping> = new Map();
|
||||||
private config: Required<IVlanManagerConfig>;
|
private config: Required<IVlanManagerConfig>;
|
||||||
private storageManager?: StorageManager;
|
|
||||||
|
|
||||||
// Cache for normalized MAC lookups
|
// Cache for normalized MAC lookups
|
||||||
private normalizedMacCache: Map<string, string> = new Map();
|
private normalizedMacCache: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) {
|
constructor(config?: IVlanManagerConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
defaultVlan: config?.defaultVlan ?? 1,
|
defaultVlan: config?.defaultVlan ?? 1,
|
||||||
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
||||||
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
|
|
||||||
};
|
};
|
||||||
this.storageManager = storageManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the VLAN manager and load persisted mappings
|
* Initialize the VLAN manager and load persisted mappings
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.storageManager) {
|
await this.loadMappings();
|
||||||
await this.loadMappings();
|
|
||||||
}
|
|
||||||
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
|
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +93,14 @@ export class VlanManager {
|
|||||||
// Cache the result
|
// Cache the result
|
||||||
this.normalizedMacCache.set(mac, normalized);
|
this.normalizedMacCache.set(mac, normalized);
|
||||||
|
|
||||||
|
// Prevent unbounded cache growth
|
||||||
|
if (this.normalizedMacCache.size > 10000) {
|
||||||
|
const iterator = this.normalizedMacCache.keys();
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
this.normalizedMacCache.delete(iterator.next().value!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,10 +150,8 @@ export class VlanManager {
|
|||||||
|
|
||||||
this.mappings.set(normalizedMac, fullMapping);
|
this.mappings.set(normalizedMac, fullMapping);
|
||||||
|
|
||||||
// Persist to storage
|
// Persist to database
|
||||||
if (this.storageManager) {
|
await this.saveMappings();
|
||||||
await this.saveMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
||||||
return fullMapping;
|
return fullMapping;
|
||||||
@@ -165,7 +164,7 @@ export class VlanManager {
|
|||||||
const normalizedMac = this.normalizeMac(mac);
|
const normalizedMac = this.normalizeMac(mac);
|
||||||
const removed = this.mappings.delete(normalizedMac);
|
const removed = this.mappings.delete(normalizedMac);
|
||||||
|
|
||||||
if (removed && this.storageManager) {
|
if (removed) {
|
||||||
await this.saveMappings();
|
await this.saveMappings();
|
||||||
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
||||||
}
|
}
|
||||||
@@ -325,39 +324,36 @@ export class VlanManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load mappings from storage
|
* Load mappings from database
|
||||||
*/
|
*/
|
||||||
private async loadMappings(): Promise<void> {
|
private async loadMappings(): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix);
|
const doc = await VlanMappingsDoc.load();
|
||||||
if (data && Array.isArray(data)) {
|
if (doc && Array.isArray(doc.mappings)) {
|
||||||
for (const mapping of data) {
|
for (const mapping of doc.mappings) {
|
||||||
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
|
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
|
||||||
}
|
}
|
||||||
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
|
logger.log('info', `Loaded ${doc.mappings.length} VLAN mappings from database`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`);
|
logger.log('warn', `Failed to load VLAN mappings from database: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save mappings to storage
|
* Save mappings to database
|
||||||
*/
|
*/
|
||||||
private async saveMappings(): Promise<void> {
|
private async saveMappings(): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mappings = Array.from(this.mappings.values());
|
const mappings = Array.from(this.mappings.values());
|
||||||
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
|
let doc = await VlanMappingsDoc.load();
|
||||||
} catch (error) {
|
if (!doc) {
|
||||||
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
|
doc = new VlanMappingsDoc();
|
||||||
|
}
|
||||||
|
doc.mappings = mappings;
|
||||||
|
await doc.save();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Failed to save VLAN mappings to database: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* - VLAN assignment based on MAC addresses
|
* - VLAN assignment based on MAC addresses
|
||||||
* - OUI (vendor prefix) pattern matching for device categorization
|
* - OUI (vendor prefix) pattern matching for device categorization
|
||||||
* - RADIUS accounting for session tracking and billing
|
* - RADIUS accounting for session tracking and billing
|
||||||
* - Integration with StorageManager for persistence
|
* - Integration with smartdata document classes for persistence
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './classes.radius.server.js';
|
export * from './classes.radius.server.js';
|
||||||
|
|||||||
156
ts/readme.md
Normal file
156
ts/readme.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# @serve.zone/dcrouter
|
||||||
|
|
||||||
|
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀
|
||||||
|
|
||||||
|
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @serve.zone/dcrouter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DcRouter } from '@serve.zone/dcrouter';
|
||||||
|
|
||||||
|
const router = new DcRouter({
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'web-app',
|
||||||
|
match: { domains: ['example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.start();
|
||||||
|
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
await router.stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ts/
|
||||||
|
├── index.ts # Main exports (DcRouter, re-exported smartmta types)
|
||||||
|
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
|
||||||
|
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
|
||||||
|
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
|
||||||
|
├── logger.ts # Structured logging utility
|
||||||
|
├── paths.ts # Centralized data directory paths
|
||||||
|
├── plugins.ts # All dependency imports
|
||||||
|
├── cache/ # Cache database (smartdata + LocalTsmDb)
|
||||||
|
│ ├── classes.cachedb.ts # CacheDb singleton
|
||||||
|
│ ├── classes.cachecleaner.ts # TTL-based cleanup
|
||||||
|
│ └── documents/ # Cached document models
|
||||||
|
├── config/ # Configuration utilities
|
||||||
|
├── errors/ # Error classes and retry logic
|
||||||
|
├── http3/ # HTTP/3 (QUIC) route augmentation
|
||||||
|
│ ├── index.ts # Barrel export
|
||||||
|
│ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
|
||||||
|
├── monitoring/ # MetricsManager (SmartMetrics integration)
|
||||||
|
├── opsserver/ # OpsServer dashboard + API handlers
|
||||||
|
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
||||||
|
│ └── handlers/ # TypedRequest handlers by domain
|
||||||
|
│ ├── admin.handler.ts # Auth (login/logout/verify)
|
||||||
|
│ ├── stats.handler.ts # Statistics + health
|
||||||
|
│ ├── config.handler.ts # Configuration (read-only)
|
||||||
|
│ ├── logs.handler.ts # Log retrieval
|
||||||
|
│ ├── email.handler.ts # Email operations
|
||||||
|
│ ├── certificate.handler.ts # Certificate management
|
||||||
|
│ ├── radius.handler.ts # RADIUS management
|
||||||
|
│ ├── remoteingress.handler.ts # Remote ingress edge + token management
|
||||||
|
│ ├── route-management.handler.ts # Programmatic route CRUD
|
||||||
|
│ ├── api-token.handler.ts # API token management
|
||||||
|
│ └── security.handler.ts # Security metrics + connections
|
||||||
|
├── radius/ # RADIUS server integration
|
||||||
|
├── remoteingress/ # Remote ingress hub integration
|
||||||
|
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
||||||
|
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
|
||||||
|
├── security/ # Security utilities
|
||||||
|
├── sms/ # SMS integration
|
||||||
|
└── storage/ # StorageManager (filesystem/custom/memory)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Main class
|
||||||
|
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
|
||||||
|
|
||||||
|
// Re-exported from smartmta
|
||||||
|
export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||||
|
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
// RADIUS
|
||||||
|
export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
|
||||||
|
|
||||||
|
// Remote Ingress
|
||||||
|
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||||
|
|
||||||
|
// HTTP/3
|
||||||
|
export type { IHttp3Config } from './http3/index.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Classes
|
||||||
|
|
||||||
|
### `DcRouter`
|
||||||
|
|
||||||
|
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
|
||||||
|
|
||||||
|
| Config Section | Service Started | Package |
|
||||||
|
|----------------|----------------|---------|
|
||||||
|
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
|
||||||
|
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
|
||||||
|
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
|
||||||
|
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
||||||
|
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
||||||
|
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
|
||||||
|
| `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
|
||||||
|
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
|
||||||
|
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
||||||
|
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
||||||
|
|
||||||
|
### `RemoteIngressManager`
|
||||||
|
|
||||||
|
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
|
||||||
|
|
||||||
|
### `TunnelManager`
|
||||||
|
|
||||||
|
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
323
ts/remoteingress/classes.remoteingress-manager.ts
Normal file
323
ts/remoteingress/classes.remoteingress-manager.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
|
import { RemoteIngressEdgeDoc } from '../db/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
||||||
|
*/
|
||||||
|
function extractPorts(portRange: number | Array<number | { from: number; to: number }>): number[] {
|
||||||
|
const ports = new Set<number>();
|
||||||
|
if (typeof portRange === 'number') {
|
||||||
|
ports.add(portRange);
|
||||||
|
} else if (Array.isArray(portRange)) {
|
||||||
|
for (const entry of portRange) {
|
||||||
|
if (typeof entry === 'number') {
|
||||||
|
ports.add(entry);
|
||||||
|
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
|
||||||
|
for (let p = entry.from; p <= entry.to; p++) {
|
||||||
|
ports.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ports].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages CRUD for remote ingress edge registrations.
|
||||||
|
* Persists edge configs via smartdata document classes and provides
|
||||||
|
* the allowed edges list for the Rust hub.
|
||||||
|
*/
|
||||||
|
export class RemoteIngressManager {
|
||||||
|
private edges: Map<string, IRemoteIngress> = new Map();
|
||||||
|
private routes: IDcRouterRouteConfig[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all edge registrations from the database into memory.
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
const docs = await RemoteIngressEdgeDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
// Migration: old edges without autoDerivePorts default to true
|
||||||
|
if ((doc as any).autoDerivePorts === undefined) {
|
||||||
|
doc.autoDerivePorts = true;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
const edge: IRemoteIngress = {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
secret: doc.secret,
|
||||||
|
listenPorts: doc.listenPorts,
|
||||||
|
listenPortsUdp: doc.listenPortsUdp,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
autoDerivePorts: doc.autoDerivePorts,
|
||||||
|
tags: doc.tags,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
};
|
||||||
|
this.edges.set(edge.id, edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the current route configs for port derivation.
|
||||||
|
*/
|
||||||
|
public setRoutes(routes: IDcRouterRouteConfig[]): void {
|
||||||
|
this.routes = routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
|
||||||
|
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
|
||||||
|
* When edgeFilter is absent, the route applies to all edges.
|
||||||
|
*/
|
||||||
|
public derivePortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
|
||||||
|
const ports = new Set<number>();
|
||||||
|
|
||||||
|
for (const route of this.routes) {
|
||||||
|
if (!route.remoteIngress?.enabled) continue;
|
||||||
|
|
||||||
|
// Apply edge filter if present
|
||||||
|
const filter = route.remoteIngress.edgeFilter;
|
||||||
|
if (filter && filter.length > 0) {
|
||||||
|
const idMatch = filter.includes(edgeId);
|
||||||
|
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
|
||||||
|
if (!idMatch && !tagMatch) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ports from the route match
|
||||||
|
if (route.match?.ports) {
|
||||||
|
for (const p of extractPorts(route.match.ports)) {
|
||||||
|
ports.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ports].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive UDP listen ports for an edge from routes with transport 'udp' or 'all'.
|
||||||
|
* These ports need UDP listeners on the edge (e.g. for QUIC/HTTP3).
|
||||||
|
*/
|
||||||
|
public deriveUdpPortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
|
||||||
|
const ports = new Set<number>();
|
||||||
|
|
||||||
|
for (const route of this.routes) {
|
||||||
|
if (!route.remoteIngress?.enabled) continue;
|
||||||
|
|
||||||
|
// Apply edge filter if present
|
||||||
|
const filter = route.remoteIngress.edgeFilter;
|
||||||
|
if (filter && filter.length > 0) {
|
||||||
|
const idMatch = filter.includes(edgeId);
|
||||||
|
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
|
||||||
|
if (!idMatch && !tagMatch) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include ports from routes that listen on UDP
|
||||||
|
const transport = route.match?.transport;
|
||||||
|
if (transport === 'udp' || transport === 'all') {
|
||||||
|
if (route.match?.ports) {
|
||||||
|
for (const p of extractPorts(route.match.ports)) {
|
||||||
|
ports.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ports].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective listen ports for an edge.
|
||||||
|
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
|
||||||
|
*/
|
||||||
|
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
|
||||||
|
const manualPorts = edge.listenPorts || [];
|
||||||
|
const shouldDerive = edge.autoDerivePorts !== false;
|
||||||
|
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
|
||||||
|
const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags);
|
||||||
|
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective UDP listen ports for an edge.
|
||||||
|
* Manual UDP ports are always included. Auto-derived UDP ports are added when autoDerivePorts is true.
|
||||||
|
*/
|
||||||
|
public getEffectiveListenPortsUdp(edge: IRemoteIngress): number[] {
|
||||||
|
const manualPorts = edge.listenPortsUdp || [];
|
||||||
|
const shouldDerive = edge.autoDerivePorts !== false;
|
||||||
|
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
|
||||||
|
const derivedPorts = this.deriveUdpPortsForEdge(edge.id, edge.tags);
|
||||||
|
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get manual and derived port breakdown for an edge (used in API responses).
|
||||||
|
* Derived ports exclude any ports already present in the manual list.
|
||||||
|
*/
|
||||||
|
public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } {
|
||||||
|
const manual = edge.listenPorts || [];
|
||||||
|
const shouldDerive = edge.autoDerivePorts !== false;
|
||||||
|
if (!shouldDerive) return { manual, derived: [] };
|
||||||
|
const manualSet = new Set(manual);
|
||||||
|
const allDerived = this.derivePortsForEdge(edge.id, edge.tags);
|
||||||
|
const derived = allDerived.filter((p) => !manualSet.has(p));
|
||||||
|
return { manual, derived };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new edge registration.
|
||||||
|
*/
|
||||||
|
public async createEdge(
|
||||||
|
name: string,
|
||||||
|
listenPorts: number[] = [],
|
||||||
|
tags?: string[],
|
||||||
|
autoDerivePorts: boolean = true,
|
||||||
|
): Promise<IRemoteIngress> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const secret = plugins.crypto.randomBytes(32).toString('hex');
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const edge: IRemoteIngress = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
secret,
|
||||||
|
listenPorts,
|
||||||
|
enabled: true,
|
||||||
|
autoDerivePorts,
|
||||||
|
tags: tags || [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const doc = new RemoteIngressEdgeDoc();
|
||||||
|
Object.assign(doc, edge);
|
||||||
|
await doc.save();
|
||||||
|
this.edges.set(id, edge);
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an edge by ID.
|
||||||
|
*/
|
||||||
|
public getEdge(id: string): IRemoteIngress | undefined {
|
||||||
|
return this.edges.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all edge registrations.
|
||||||
|
*/
|
||||||
|
public getAllEdges(): IRemoteIngress[] {
|
||||||
|
return Array.from(this.edges.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an edge registration.
|
||||||
|
*/
|
||||||
|
public async updateEdge(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
listenPorts?: number[];
|
||||||
|
autoDerivePorts?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
},
|
||||||
|
): Promise<IRemoteIngress | null> {
|
||||||
|
const edge = this.edges.get(id);
|
||||||
|
if (!edge) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.name !== undefined) edge.name = updates.name;
|
||||||
|
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
|
||||||
|
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
|
||||||
|
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
|
||||||
|
if (updates.tags !== undefined) edge.tags = updates.tags;
|
||||||
|
edge.updatedAt = Date.now();
|
||||||
|
|
||||||
|
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||||
|
if (doc) {
|
||||||
|
Object.assign(doc, edge);
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
this.edges.set(id, edge);
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an edge registration.
|
||||||
|
*/
|
||||||
|
public async deleteEdge(id: string): Promise<boolean> {
|
||||||
|
if (!this.edges.has(id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
|
this.edges.delete(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate the secret for an edge.
|
||||||
|
*/
|
||||||
|
public async regenerateSecret(id: string): Promise<string | null> {
|
||||||
|
const edge = this.edges.get(id);
|
||||||
|
if (!edge) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
|
||||||
|
edge.updatedAt = Date.now();
|
||||||
|
|
||||||
|
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||||
|
if (doc) {
|
||||||
|
Object.assign(doc, edge);
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
this.edges.set(id, edge);
|
||||||
|
return edge.secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an edge's secret using constant-time comparison.
|
||||||
|
*/
|
||||||
|
public verifySecret(id: string, secret: string): boolean {
|
||||||
|
const edge = this.edges.get(id);
|
||||||
|
if (!edge) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expected = Buffer.from(edge.secret);
|
||||||
|
const provided = Buffer.from(secret);
|
||||||
|
if (expected.length !== provided.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return plugins.crypto.timingSafeEqual(expected, provided);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of allowed edges (enabled only) for the Rust hub.
|
||||||
|
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
|
||||||
|
*/
|
||||||
|
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
|
||||||
|
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
|
||||||
|
for (const edge of this.edges.values()) {
|
||||||
|
if (edge.enabled) {
|
||||||
|
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
|
||||||
|
result.push({
|
||||||
|
id: edge.id,
|
||||||
|
secret: edge.secret,
|
||||||
|
listenPorts: this.getEffectiveListenPorts(edge),
|
||||||
|
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
ts/remoteingress/classes.tunnel-manager.ts
Normal file
192
ts/remoteingress/classes.tunnel-manager.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
|
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
|
||||||
|
|
||||||
|
export interface ITunnelManagerConfig {
|
||||||
|
tunnelPort?: number;
|
||||||
|
targetHost?: string;
|
||||||
|
tls?: {
|
||||||
|
certPem?: string;
|
||||||
|
keyPem?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the RemoteIngressHub instance and tracks connected edge statuses.
|
||||||
|
*/
|
||||||
|
export class TunnelManager {
|
||||||
|
private hub: InstanceType<typeof plugins.remoteingress.RemoteIngressHub>;
|
||||||
|
private manager: RemoteIngressManager;
|
||||||
|
private config: ITunnelManagerConfig;
|
||||||
|
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
||||||
|
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
||||||
|
this.manager = manager;
|
||||||
|
this.config = config;
|
||||||
|
this.hub = new plugins.remoteingress.RemoteIngressHub();
|
||||||
|
|
||||||
|
// Listen for edge connect/disconnect events
|
||||||
|
this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
|
||||||
|
this.edgeStatuses.set(data.edgeId, {
|
||||||
|
edgeId: data.edgeId,
|
||||||
|
connected: true,
|
||||||
|
publicIp: data.peerAddr || null,
|
||||||
|
activeTunnels: 0,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
|
||||||
|
this.edgeStatuses.delete(data.edgeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
|
||||||
|
const existing = this.edgeStatuses.get(data.edgeId);
|
||||||
|
if (existing) {
|
||||||
|
existing.activeTunnels++;
|
||||||
|
existing.lastHeartbeat = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
|
||||||
|
const existing = this.edgeStatuses.get(data.edgeId);
|
||||||
|
if (existing && existing.activeTunnels > 0) {
|
||||||
|
existing.activeTunnels--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the tunnel hub and load allowed edges.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
await this.hub.start({
|
||||||
|
tunnelPort: this.config.tunnelPort ?? 8443,
|
||||||
|
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||||
|
tls: this.config.tls,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send allowed edges to the hub
|
||||||
|
await this.syncAllowedEdges();
|
||||||
|
|
||||||
|
// Periodically reconcile with authoritative Rust hub status
|
||||||
|
this.reconcileInterval = setInterval(() => {
|
||||||
|
this.reconcile().catch(() => {});
|
||||||
|
}, 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the tunnel hub.
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.reconcileInterval) {
|
||||||
|
clearInterval(this.reconcileInterval);
|
||||||
|
this.reconcileInterval = null;
|
||||||
|
}
|
||||||
|
// Remove event listeners before stopping to prevent leaks
|
||||||
|
this.hub.removeAllListeners();
|
||||||
|
await this.hub.stop();
|
||||||
|
this.edgeStatuses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile TS-side edge statuses with the authoritative Rust hub status.
|
||||||
|
* Overwrites event-derived activeTunnels with the real activeStreams count.
|
||||||
|
*/
|
||||||
|
private async reconcile(): Promise<void> {
|
||||||
|
const hubStatus = await this.hub.getStatus();
|
||||||
|
if (!hubStatus || !hubStatus.connectedEdges) return;
|
||||||
|
|
||||||
|
const rustEdgeIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const rustEdge of hubStatus.connectedEdges) {
|
||||||
|
rustEdgeIds.add(rustEdge.edgeId);
|
||||||
|
const existing = this.edgeStatuses.get(rustEdge.edgeId);
|
||||||
|
if (existing) {
|
||||||
|
existing.activeTunnels = rustEdge.activeStreams;
|
||||||
|
existing.lastHeartbeat = Date.now();
|
||||||
|
// Update peer address if available from Rust hub
|
||||||
|
if (rustEdge.peerAddr) {
|
||||||
|
existing.publicIp = rustEdge.peerAddr;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Missed edgeConnected event — add entry
|
||||||
|
this.edgeStatuses.set(rustEdge.edgeId, {
|
||||||
|
edgeId: rustEdge.edgeId,
|
||||||
|
connected: true,
|
||||||
|
publicIp: rustEdge.peerAddr || null,
|
||||||
|
activeTunnels: rustEdge.activeStreams,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
connectedAt: rustEdge.connectedAt * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove entries for edges no longer connected in Rust (missed edgeDisconnected)
|
||||||
|
for (const edgeId of this.edgeStatuses.keys()) {
|
||||||
|
if (!rustEdgeIds.has(edgeId)) {
|
||||||
|
this.edgeStatuses.delete(edgeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync allowed edges from the manager to the hub.
|
||||||
|
* Call this after creating/deleting/updating edges.
|
||||||
|
*/
|
||||||
|
public async syncAllowedEdges(): Promise<void> {
|
||||||
|
const edges = this.manager.getAllowedEdges();
|
||||||
|
await this.hub.updateAllowedEdges(edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get runtime statuses for all known edges.
|
||||||
|
*/
|
||||||
|
public getEdgeStatuses(): IRemoteIngressStatus[] {
|
||||||
|
return Array.from(this.edgeStatuses.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status for a specific edge.
|
||||||
|
*/
|
||||||
|
public getEdgeStatus(edgeId: string): IRemoteIngressStatus | undefined {
|
||||||
|
return this.edgeStatuses.get(edgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of connected edges.
|
||||||
|
*/
|
||||||
|
public getConnectedCount(): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const status of this.edgeStatuses.values()) {
|
||||||
|
if (status.connected) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public IPs of all connected edges.
|
||||||
|
*/
|
||||||
|
public getConnectedEdgeIps(): string[] {
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (const status of this.edgeStatuses.values()) {
|
||||||
|
if (status.connected && status.publicIp) {
|
||||||
|
ips.push(status.publicIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total number of active tunnels across all edges.
|
||||||
|
*/
|
||||||
|
public getTotalActiveTunnels(): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const status of this.edgeStatuses.values()) {
|
||||||
|
total += status.activeTunnels;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
ts/remoteingress/index.ts
Normal file
2
ts/remoteingress/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './classes.remoteingress-manager.js';
|
||||||
|
export * from './classes.tunnel-manager.js';
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { Email, type Core } from '@push.rocks/smartmta';
|
import { Email, type Core } from '@push.rocks/smartmta';
|
||||||
type IAttachment = Core.IAttachment;
|
type IAttachment = Core.IAttachment;
|
||||||
@@ -61,7 +60,7 @@ export enum ThreatCategory {
|
|||||||
* Content Scanner for detecting malicious email content
|
* Content Scanner for detecting malicious email content
|
||||||
*/
|
*/
|
||||||
export class ContentScanner {
|
export class ContentScanner {
|
||||||
private static instance: ContentScanner;
|
private static instance: ContentScanner | undefined;
|
||||||
private scanCache: LRUCache<string, IScanResult>;
|
private scanCache: LRUCache<string, IScanResult>;
|
||||||
private options: Required<IContentScannerOptions>;
|
private options: Required<IContentScannerOptions>;
|
||||||
|
|
||||||
@@ -183,7 +182,14 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
return ContentScanner.instance;
|
return ContentScanner.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
ContentScanner.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan an email for malicious content
|
* Scan an email for malicious content
|
||||||
* @param email The email to scan
|
* @param email The email to scan
|
||||||
@@ -252,12 +258,12 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
logger.log('error', `Error scanning email: ${(error as Error).message}`, {
|
||||||
messageId: email.getMessageId(),
|
messageId: email.getMessageId(),
|
||||||
error: error.stack
|
error: (error as Error).stack
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return a safe default with error indication
|
// Return a safe default with error indication
|
||||||
return {
|
return {
|
||||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||||
@@ -265,7 +271,7 @@ export class ContentScanner {
|
|||||||
scannedElements: ['error'],
|
scannedElements: ['error'],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
threatType: 'scan_error',
|
threatType: 'scan_error',
|
||||||
threatDetails: `Scan error: ${error.message}`
|
threatDetails: `Scan error: ${(error as Error).message}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,8 +625,8 @@ export class ContentScanner {
|
|||||||
return sample.toString('utf8')
|
return sample.toString('utf8')
|
||||||
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
||||||
.replace(/\uFFFD/g, ''); // Remove replacement char
|
.replace(/\uFFFD/g, ''); // Remove replacement char
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Error extracting text from buffer: ${error.message}`);
|
logger.log('warn', `Error extracting text from buffer: ${(error as Error).message}`);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,10 +699,10 @@ export class ContentScanner {
|
|||||||
subject: email.subject
|
subject: email.subject
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
domain: email.getFromDomain()
|
domain: email.getFromDomain() ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a threat finding to the security logger
|
* Log a threat finding to the security logger
|
||||||
* @param email The email containing the threat
|
* @param email The email containing the threat
|
||||||
@@ -716,10 +722,10 @@ export class ContentScanner {
|
|||||||
subject: email.subject
|
subject: email.subject
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
domain: email.getFromDomain()
|
domain: email.getFromDomain() ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get threat level description based on score
|
* Get threat level description based on score
|
||||||
* @param score Threat score
|
* @param score Threat score
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import { CachedIPReputation } from '../db/documents/classes.cached.ip.reputation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reputation check result information
|
* Reputation check result information
|
||||||
@@ -52,7 +52,7 @@ export interface IIPReputationOptions {
|
|||||||
highRiskThreshold?: number; // Score below this is high risk
|
highRiskThreshold?: number; // Score below this is high risk
|
||||||
mediumRiskThreshold?: number; // Score below this is medium risk
|
mediumRiskThreshold?: number; // Score below this is medium risk
|
||||||
lowRiskThreshold?: number; // Score below this is low risk
|
lowRiskThreshold?: number; // Score below this is low risk
|
||||||
enableLocalCache?: boolean; // Whether to persist cache to disk (default: true)
|
enableLocalCache?: boolean; // Whether to persist cache to database (default: true)
|
||||||
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
|
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
|
||||||
enableIPInfo?: boolean; // Whether to use IP info service (default: true)
|
enableIPInfo?: boolean; // Whether to use IP info service (default: true)
|
||||||
}
|
}
|
||||||
@@ -61,11 +61,10 @@ export interface IIPReputationOptions {
|
|||||||
* Class for checking IP reputation of inbound email senders
|
* Class for checking IP reputation of inbound email senders
|
||||||
*/
|
*/
|
||||||
export class IPReputationChecker {
|
export class IPReputationChecker {
|
||||||
private static instance: IPReputationChecker;
|
private static instance: IPReputationChecker | undefined;
|
||||||
private reputationCache: LRUCache<string, IReputationResult>;
|
private reputationCache: LRUCache<string, IReputationResult>;
|
||||||
private options: Required<IIPReputationOptions>;
|
private options: Required<IIPReputationOptions>;
|
||||||
private storageManager?: any; // StorageManager instance
|
|
||||||
|
|
||||||
// Default DNSBL servers
|
// Default DNSBL servers
|
||||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||||
'zen.spamhaus.org', // Spamhaus
|
'zen.spamhaus.org', // Spamhaus
|
||||||
@@ -73,13 +72,13 @@ export class IPReputationChecker {
|
|||||||
'b.barracudacentral.org', // Barracuda
|
'b.barracudacentral.org', // Barracuda
|
||||||
'spam.dnsbl.sorbs.net', // SORBS
|
'spam.dnsbl.sorbs.net', // SORBS
|
||||||
'dnsbl.sorbs.net', // SORBS (expanded)
|
'dnsbl.sorbs.net', // SORBS (expanded)
|
||||||
'cbl.abuseat.org', // Composite Blocking List
|
'cbl.abuseat.org', // Composite Blocking List
|
||||||
'xbl.spamhaus.org', // Spamhaus XBL
|
'xbl.spamhaus.org', // Spamhaus XBL
|
||||||
'pbl.spamhaus.org', // Spamhaus PBL
|
'pbl.spamhaus.org', // Spamhaus PBL
|
||||||
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
||||||
'psbl.surriel.com' // PSBL
|
'psbl.surriel.com' // PSBL
|
||||||
];
|
];
|
||||||
|
|
||||||
// Default options
|
// Default options
|
||||||
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||||||
maxCacheSize: 10000,
|
maxCacheSize: 10000,
|
||||||
@@ -92,58 +91,51 @@ export class IPReputationChecker {
|
|||||||
enableDNSBL: true,
|
enableDNSBL: true,
|
||||||
enableIPInfo: true
|
enableIPInfo: true
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for IPReputationChecker
|
* Constructor for IPReputationChecker
|
||||||
* @param options Configuration options
|
* @param options Configuration options
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
*/
|
*/
|
||||||
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
constructor(options: IIPReputationOptions = {}) {
|
||||||
// Merge with default options
|
// Merge with default options
|
||||||
this.options = {
|
this.options = {
|
||||||
...IPReputationChecker.DEFAULT_OPTIONS,
|
...IPReputationChecker.DEFAULT_OPTIONS,
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
this.storageManager = storageManager;
|
|
||||||
|
|
||||||
// If no storage manager provided, log warning
|
|
||||||
if (!storageManager && this.options.enableLocalCache) {
|
|
||||||
logger.log('warn',
|
|
||||||
'⚠️ WARNING: IPReputationChecker initialized without StorageManager.\n' +
|
|
||||||
' IP reputation cache will only be stored to filesystem.\n' +
|
|
||||||
' Consider passing a StorageManager instance for better storage flexibility.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize reputation cache
|
// Initialize reputation cache
|
||||||
this.reputationCache = new LRUCache<string, IReputationResult>({
|
this.reputationCache = new LRUCache<string, IReputationResult>({
|
||||||
max: this.options.maxCacheSize,
|
max: this.options.maxCacheSize,
|
||||||
ttl: this.options.cacheTTL, // Cache TTL
|
ttl: this.options.cacheTTL, // Cache TTL
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load cache from disk if enabled
|
// Load persisted reputations into in-memory cache
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
// Fire and forget the load operation
|
this.loadCacheFromDb().catch((error: unknown) => {
|
||||||
this.loadCache().catch(error => {
|
logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
|
||||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the singleton instance of the checker
|
* Get the singleton instance of the checker
|
||||||
* @param options Configuration options
|
* @param options Configuration options
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
* @returns Singleton instance
|
* @returns Singleton instance
|
||||||
*/
|
*/
|
||||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
public static getInstance(options: IIPReputationOptions = {}): IPReputationChecker {
|
||||||
if (!IPReputationChecker.instance) {
|
if (!IPReputationChecker.instance) {
|
||||||
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
IPReputationChecker.instance = new IPReputationChecker(options);
|
||||||
}
|
}
|
||||||
return IPReputationChecker.instance;
|
return IPReputationChecker.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
IPReputationChecker.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check an IP address's reputation
|
* Check an IP address's reputation
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
@@ -156,8 +148,8 @@ export class IPReputationChecker {
|
|||||||
logger.log('warn', `Invalid IP address format: ${ip}`);
|
logger.log('warn', `Invalid IP address format: ${ip}`);
|
||||||
return this.createErrorResult(ip, 'Invalid IP address format');
|
return this.createErrorResult(ip, 'Invalid IP address format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
// Check in-memory LRU cache first (fast path)
|
||||||
const cachedResult = this.reputationCache.get(ip);
|
const cachedResult = this.reputationCache.get(ip);
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
logger.log('info', `Using cached reputation data for IP ${ip}`, {
|
logger.log('info', `Using cached reputation data for IP ${ip}`, {
|
||||||
@@ -166,7 +158,7 @@ export class IPReputationChecker {
|
|||||||
});
|
});
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize empty result
|
// Initialize empty result
|
||||||
const result: IReputationResult = {
|
const result: IReputationResult = {
|
||||||
score: 100, // Start with perfect score
|
score: 100, // Start with perfect score
|
||||||
@@ -176,65 +168,64 @@ export class IPReputationChecker {
|
|||||||
isVPN: false,
|
isVPN: false,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check IP against DNS blacklists if enabled
|
// Check IP against DNS blacklists if enabled
|
||||||
if (this.options.enableDNSBL) {
|
if (this.options.enableDNSBL) {
|
||||||
const dnsblResult = await this.checkDNSBL(ip);
|
const dnsblResult = await this.checkDNSBL(ip);
|
||||||
|
|
||||||
// Update result with DNSBL information
|
// Update result with DNSBL information
|
||||||
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
||||||
result.isSpam = dnsblResult.listCount > 0;
|
result.isSpam = dnsblResult.listCount > 0;
|
||||||
result.blacklists = dnsblResult.lists;
|
result.blacklists = dnsblResult.lists;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get additional IP information if enabled
|
// Get additional IP information if enabled
|
||||||
if (this.options.enableIPInfo) {
|
if (this.options.enableIPInfo) {
|
||||||
const ipInfo = await this.getIPInfo(ip);
|
const ipInfo = await this.getIPInfo(ip);
|
||||||
|
|
||||||
// Update result with IP info
|
// Update result with IP info
|
||||||
result.country = ipInfo.country;
|
result.country = ipInfo.country;
|
||||||
result.asn = ipInfo.asn;
|
result.asn = ipInfo.asn;
|
||||||
result.org = ipInfo.org;
|
result.org = ipInfo.org;
|
||||||
|
|
||||||
// Adjust score based on IP type
|
// Adjust score based on IP type
|
||||||
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
||||||
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
||||||
|
|
||||||
// Set proxy flags
|
// Set proxy flags
|
||||||
result.isProxy = ipInfo.type === IPType.PROXY;
|
result.isProxy = ipInfo.type === IPType.PROXY;
|
||||||
result.isTor = ipInfo.type === IPType.TOR;
|
result.isTor = ipInfo.type === IPType.TOR;
|
||||||
result.isVPN = ipInfo.type === IPType.VPN;
|
result.isVPN = ipInfo.type === IPType.VPN;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure score is between 0 and 100
|
// Ensure score is between 0 and 100
|
||||||
result.score = Math.max(0, Math.min(100, result.score));
|
result.score = Math.max(0, Math.min(100, result.score));
|
||||||
|
|
||||||
// Update cache with result
|
// Update in-memory LRU cache
|
||||||
this.reputationCache.set(ip, result);
|
this.reputationCache.set(ip, result);
|
||||||
|
|
||||||
// Save cache if enabled
|
// Persist to database if enabled (fire and forget)
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
// Fire and forget the save operation
|
this.persistReputationToDb(ip, result).catch((error: unknown) => {
|
||||||
this.saveCache().catch(error => {
|
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the reputation check
|
// Log the reputation check
|
||||||
this.logReputationCheck(ip, result);
|
this.logReputationCheck(ip, result);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
|
||||||
ip,
|
ip,
|
||||||
stack: error.stack
|
stack: (error as Error).stack
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.createErrorResult(ip, error.message);
|
return this.createErrorResult(ip, (error as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check an IP against DNS blacklists
|
* Check an IP against DNS blacklists
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
@@ -247,42 +238,42 @@ export class IPReputationChecker {
|
|||||||
try {
|
try {
|
||||||
// Reverse the IP for DNSBL queries
|
// Reverse the IP for DNSBL queries
|
||||||
const reversedIP = this.reverseIP(ip);
|
const reversedIP = this.reverseIP(ip);
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
this.options.dnsblServers.map(async (server) => {
|
this.options.dnsblServers.map(async (server) => {
|
||||||
try {
|
try {
|
||||||
const lookupDomain = `${reversedIP}.${server}`;
|
const lookupDomain = `${reversedIP}.${server}`;
|
||||||
await plugins.dns.promises.resolve(lookupDomain);
|
await plugins.dns.promises.resolve(lookupDomain);
|
||||||
return server; // IP is listed in this DNSBL
|
return server; // IP is listed in this DNSBL
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
if (error.code === 'ENOTFOUND') {
|
if ((error as any).code === 'ENOTFOUND') {
|
||||||
return null; // IP is not listed in this DNSBL
|
return null; // IP is not listed in this DNSBL
|
||||||
}
|
}
|
||||||
throw error; // Other error
|
throw error; // Other error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract successful lookups (listed in DNSBL)
|
// Extract successful lookups (listed in DNSBL)
|
||||||
const lists = results
|
const lists = results
|
||||||
.filter((result): result is PromiseFulfilledResult<string> =>
|
.filter((result): result is PromiseFulfilledResult<string> =>
|
||||||
result.status === 'fulfilled' && result.value !== null
|
result.status === 'fulfilled' && result.value !== null
|
||||||
)
|
)
|
||||||
.map(result => result.value);
|
.map(result => result.value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listCount: lists.length,
|
listCount: lists.length,
|
||||||
lists
|
lists
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
logger.log('error', `Error checking DNSBL for ${ip}: ${(error as Error).message}`);
|
||||||
return {
|
return {
|
||||||
listCount: 0,
|
listCount: 0,
|
||||||
lists: []
|
lists: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get information about an IP address
|
* Get information about an IP address
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
@@ -297,16 +288,16 @@ export class IPReputationChecker {
|
|||||||
try {
|
try {
|
||||||
// In a real implementation, this would use an IP data service API
|
// In a real implementation, this would use an IP data service API
|
||||||
// For this implementation, we'll use a simplified approach
|
// For this implementation, we'll use a simplified approach
|
||||||
|
|
||||||
// Check if it's a known Tor exit node (simplified)
|
// Check if it's a known Tor exit node (simplified)
|
||||||
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
||||||
|
|
||||||
// Check if it's a known VPN (simplified)
|
// Check if it's a known VPN (simplified)
|
||||||
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
||||||
|
|
||||||
// Check if it's a known proxy (simplified)
|
// Check if it's a known proxy (simplified)
|
||||||
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
||||||
|
|
||||||
// Determine IP type
|
// Determine IP type
|
||||||
let type = IPType.UNKNOWN;
|
let type = IPType.UNKNOWN;
|
||||||
if (isTor) {
|
if (isTor) {
|
||||||
@@ -329,7 +320,7 @@ export class IPReputationChecker {
|
|||||||
type = IPType.RESIDENTIAL;
|
type = IPType.RESIDENTIAL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the information
|
// Return the information
|
||||||
return {
|
return {
|
||||||
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
||||||
@@ -337,14 +328,14 @@ export class IPReputationChecker {
|
|||||||
org: this.determineOrg(ip), // Simplified, would use real org data
|
org: this.determineOrg(ip), // Simplified, would use real org data
|
||||||
type
|
type
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
logger.log('error', `Error getting IP info for ${ip}: ${(error as Error).message}`);
|
||||||
return {
|
return {
|
||||||
type: IPType.UNKNOWN
|
type: IPType.UNKNOWN
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified method to determine country from IP
|
* Simplified method to determine country from IP
|
||||||
* In a real implementation, this would use a geolocation database or service
|
* In a real implementation, this would use a geolocation database or service
|
||||||
@@ -359,7 +350,7 @@ export class IPReputationChecker {
|
|||||||
if (ip.startsWith('171.')) return 'DE';
|
if (ip.startsWith('171.')) return 'DE';
|
||||||
return 'XX'; // Unknown
|
return 'XX'; // Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified method to determine organization from IP
|
* Simplified method to determine organization from IP
|
||||||
* In a real implementation, this would use an IP-to-org database or service
|
* In a real implementation, this would use an IP-to-org database or service
|
||||||
@@ -375,7 +366,7 @@ export class IPReputationChecker {
|
|||||||
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
||||||
* @param ip IP address to reverse
|
* @param ip IP address to reverse
|
||||||
@@ -384,7 +375,7 @@ export class IPReputationChecker {
|
|||||||
private reverseIP(ip: string): string {
|
private reverseIP(ip: string): string {
|
||||||
return ip.split('.').reverse().join('.');
|
return ip.split('.').reverse().join('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an error result for when reputation check fails
|
* Create an error result for when reputation check fails
|
||||||
* @param ip IP address
|
* @param ip IP address
|
||||||
@@ -402,7 +393,7 @@ export class IPReputationChecker {
|
|||||||
error: errorMessage
|
error: errorMessage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate IP address format
|
* Validate IP address format
|
||||||
* @param ip IP address to validate
|
* @param ip IP address to validate
|
||||||
@@ -413,7 +404,7 @@ export class IPReputationChecker {
|
|||||||
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
return ipv4Pattern.test(ip);
|
return ipv4Pattern.test(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log reputation check to security logger
|
* Log reputation check to security logger
|
||||||
* @param ip IP address
|
* @param ip IP address
|
||||||
@@ -427,7 +418,7 @@ export class IPReputationChecker {
|
|||||||
} else if (result.score < this.options.mediumRiskThreshold) {
|
} else if (result.score < this.options.mediumRiskThreshold) {
|
||||||
logLevel = SecurityLogLevel.INFO;
|
logLevel = SecurityLogLevel.INFO;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the check
|
// Log the check
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: logLevel,
|
level: logLevel,
|
||||||
@@ -446,116 +437,76 @@ export class IPReputationChecker {
|
|||||||
success: !result.isSpam
|
success: !result.isSpam
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save cache to disk or storage manager
|
* Persist a single IP reputation result to the database via CachedIPReputation
|
||||||
*/
|
*/
|
||||||
private async saveCache(): Promise<void> {
|
private async persistReputationToDb(ip: string, result: IReputationResult): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Convert cache entries to serializable array
|
const data = {
|
||||||
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
score: result.score,
|
||||||
ip,
|
isSpam: result.isSpam,
|
||||||
data
|
isProxy: result.isProxy,
|
||||||
}));
|
isTor: result.isTor,
|
||||||
|
isVPN: result.isVPN,
|
||||||
// Only save if we have entries
|
country: result.country,
|
||||||
if (entries.length === 0) {
|
asn: result.asn,
|
||||||
return;
|
org: result.org,
|
||||||
}
|
blacklists: result.blacklists,
|
||||||
|
};
|
||||||
const cacheData = JSON.stringify(entries);
|
|
||||||
|
const existing = await CachedIPReputation.findByIP(ip);
|
||||||
// Save to storage manager if available
|
if (existing) {
|
||||||
if (this.storageManager) {
|
existing.updateReputation(data);
|
||||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
await existing.save();
|
||||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
|
||||||
} else {
|
} else {
|
||||||
// Fall back to filesystem
|
const doc = CachedIPReputation.fromReputationData(ip, data);
|
||||||
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
await doc.save();
|
||||||
plugins.fsUtils.ensureDirSync(cacheDir);
|
|
||||||
|
|
||||||
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
|
|
||||||
plugins.fsUtils.toFsSync(cacheData, cacheFile);
|
|
||||||
|
|
||||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load cache from disk or storage manager
|
* Load persisted reputations from CachedIPReputation documents into the in-memory LRU cache
|
||||||
*/
|
*/
|
||||||
private async loadCache(): Promise<void> {
|
private async loadCacheFromDb(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let cacheData: string | null = null;
|
const docs = await CachedIPReputation.getInstances({});
|
||||||
let fromFilesystem = false;
|
let loadedCount = 0;
|
||||||
|
|
||||||
// Try to load from storage manager first
|
for (const doc of docs) {
|
||||||
if (this.storageManager) {
|
// Skip expired documents
|
||||||
try {
|
if (doc.isExpired()) {
|
||||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
continue;
|
||||||
|
|
||||||
if (!cacheData) {
|
|
||||||
// Check if data exists in filesystem and migrate it
|
|
||||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
|
||||||
|
|
||||||
if (plugins.fs.existsSync(cacheFile)) {
|
|
||||||
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
|
|
||||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
|
||||||
fromFilesystem = true;
|
|
||||||
|
|
||||||
// Migrate to storage manager
|
|
||||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
|
||||||
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
|
|
||||||
|
|
||||||
// Optionally delete the old file after successful migration
|
|
||||||
try {
|
|
||||||
plugins.fs.unlinkSync(cacheFile);
|
|
||||||
logger.log('info', 'Old cache file removed after migration');
|
|
||||||
} catch (deleteError) {
|
|
||||||
logger.log('warn', `Could not delete old cache file: ${deleteError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error loading from StorageManager: ${error.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No storage manager, load from filesystem
|
|
||||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
|
||||||
|
|
||||||
if (plugins.fs.existsSync(cacheFile)) {
|
|
||||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
|
||||||
fromFilesystem = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result: IReputationResult = {
|
||||||
|
score: doc.score,
|
||||||
|
isSpam: doc.isSpam,
|
||||||
|
isProxy: doc.isProxy,
|
||||||
|
isTor: doc.isTor,
|
||||||
|
isVPN: doc.isVPN,
|
||||||
|
country: doc.country || undefined,
|
||||||
|
asn: doc.asn || undefined,
|
||||||
|
org: doc.org || undefined,
|
||||||
|
blacklists: doc.blacklists || [],
|
||||||
|
timestamp: doc.lastAccessedAt?.getTime() ?? doc.createdAt?.getTime() ?? Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.reputationCache.set(doc.ipAddress, result);
|
||||||
|
loadedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and restore cache if data was found
|
if (loadedCount > 0) {
|
||||||
if (cacheData) {
|
logger.log('info', `Loaded ${loadedCount} IP reputation cache entries from database`);
|
||||||
const entries = JSON.parse(cacheData);
|
|
||||||
|
|
||||||
// Validate and filter entries
|
|
||||||
const now = Date.now();
|
|
||||||
const validEntries = entries.filter(entry => {
|
|
||||||
const age = now - entry.data.timestamp;
|
|
||||||
return age < this.options.cacheTTL; // Only load entries that haven't expired
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore cache
|
|
||||||
for (const entry of validEntries) {
|
|
||||||
this.reputationCache.set(entry.ip, entry.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = fromFilesystem ? 'disk' : 'StorageManager';
|
|
||||||
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
|
logger.log('error', `Failed to load IP reputation cache from database: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the risk level for a reputation score
|
* Get the risk level for a reputation score
|
||||||
* @param score Reputation score (0-100)
|
* @param score Reputation score (0-100)
|
||||||
@@ -572,21 +523,4 @@ export class IPReputationChecker {
|
|||||||
return 'trusted';
|
return 'trusted';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Update the storage manager after instantiation
|
|
||||||
* This is useful when the storage manager is not available at construction time
|
|
||||||
* @param storageManager The StorageManager instance to use
|
|
||||||
*/
|
|
||||||
public updateStorageManager(storageManager: any): void {
|
|
||||||
this.storageManager = storageManager;
|
|
||||||
logger.log('info', 'IPReputationChecker storage manager updated');
|
|
||||||
|
|
||||||
// If cache is enabled and we have entries, save them to the new storage manager
|
|
||||||
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
|
|
||||||
this.saveCache().catch(error => {
|
|
||||||
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface ISecurityEvent {
|
|||||||
* Security logger for enhanced security monitoring
|
* Security logger for enhanced security monitoring
|
||||||
*/
|
*/
|
||||||
export class SecurityLogger {
|
export class SecurityLogger {
|
||||||
private static instance: SecurityLogger;
|
private static instance: SecurityLogger | undefined;
|
||||||
private securityEvents: ISecurityEvent[] = [];
|
private securityEvents: ISecurityEvent[] = [];
|
||||||
private maxEventHistory: number;
|
private maxEventHistory: number;
|
||||||
private enableNotifications: boolean;
|
private enableNotifications: boolean;
|
||||||
@@ -83,7 +83,14 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
return SecurityLogger.instance;
|
return SecurityLogger.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
SecurityLogger.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a security event
|
* Log a security event
|
||||||
* @param event The security event to log
|
* @param event The security event to log
|
||||||
@@ -147,16 +154,19 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.fromTimestamp) {
|
if (filter.fromTimestamp) {
|
||||||
filteredEvents = filteredEvents.filter(event => event.timestamp >= filter.fromTimestamp);
|
const fromTs = filter.fromTimestamp;
|
||||||
|
filteredEvents = filteredEvents.filter(event => event.timestamp >= fromTs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.toTimestamp) {
|
if (filter.toTimestamp) {
|
||||||
filteredEvents = filteredEvents.filter(event => event.timestamp <= filter.toTimestamp);
|
const toTs = filter.toTimestamp;
|
||||||
|
filteredEvents = filteredEvents.filter(event => event.timestamp <= toTs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return most recent events up to limit
|
// Return most recent events up to limit (slice first to avoid mutating source)
|
||||||
return filteredEvents
|
return filteredEvents
|
||||||
|
.slice()
|
||||||
.sort((a, b) => b.timestamp - a.timestamp)
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
@@ -242,58 +252,46 @@ export class SecurityLogger {
|
|||||||
topIPs: Array<{ ip: string; count: number }>;
|
topIPs: Array<{ ip: string; count: number }>;
|
||||||
topDomains: Array<{ domain: string; count: number }>;
|
topDomains: Array<{ domain: string; count: number }>;
|
||||||
} {
|
} {
|
||||||
// Filter by time window if provided
|
const cutoff = timeWindow ? Date.now() - timeWindow : 0;
|
||||||
let events = this.securityEvents;
|
|
||||||
if (timeWindow) {
|
// Initialize counters
|
||||||
const cutoff = Date.now() - timeWindow;
|
const byLevel = {} as Record<SecurityLogLevel, number>;
|
||||||
events = events.filter(e => e.timestamp >= cutoff);
|
for (const level of Object.values(SecurityLogLevel)) {
|
||||||
|
byLevel[level] = 0;
|
||||||
|
}
|
||||||
|
const byType = {} as Record<SecurityEventType, number>;
|
||||||
|
for (const type of Object.values(SecurityEventType)) {
|
||||||
|
byType[type] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count by level
|
|
||||||
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
|
|
||||||
acc[level] = events.filter(e => e.level === level).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityLogLevel, number>);
|
|
||||||
|
|
||||||
// Count by type
|
|
||||||
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
|
|
||||||
acc[type] = events.filter(e => e.type === type).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityEventType, number>);
|
|
||||||
|
|
||||||
// Count by IP
|
|
||||||
const ipCounts = new Map<string, number>();
|
const ipCounts = new Map<string, number>();
|
||||||
events.forEach(e => {
|
const domainCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
// Single pass over all events
|
||||||
|
let total = 0;
|
||||||
|
for (const e of this.securityEvents) {
|
||||||
|
if (cutoff && e.timestamp < cutoff) continue;
|
||||||
|
total++;
|
||||||
|
byLevel[e.level]++;
|
||||||
|
byType[e.type]++;
|
||||||
if (e.ipAddress) {
|
if (e.ipAddress) {
|
||||||
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Count by domain
|
|
||||||
const domainCounts = new Map<string, number>();
|
|
||||||
events.forEach(e => {
|
|
||||||
if (e.domain) {
|
if (e.domain) {
|
||||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Sort and limit top entries
|
// Sort and limit top entries
|
||||||
const topIPs = Array.from(ipCounts.entries())
|
const topIPs = Array.from(ipCounts.entries())
|
||||||
.map(([ip, count]) => ({ ip, count }))
|
.map(([ip, count]) => ({ ip, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
const topDomains = Array.from(domainCounts.entries())
|
const topDomains = Array.from(domainCounts.entries())
|
||||||
.map(([domain, count]) => ({ domain, count }))
|
.map(([domain, count]) => ({ domain, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
return {
|
return { total, byLevel, byType, topIPs, topDomains };
|
||||||
total: events.length,
|
|
||||||
byLevel,
|
|
||||||
byType,
|
|
||||||
topIPs,
|
|
||||||
topDomains
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { smsConfigSchema } from './config/sms.schema.js';
|
|||||||
import { ConfigValidator } from '../config/validator.js';
|
import { ConfigValidator } from '../config/validator.js';
|
||||||
|
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
public projectinfo!: plugins.projectinfo.ProjectInfo;
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
public config: ISmsConfig;
|
public config: ISmsConfig;
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export class SmsService {
|
|||||||
const validationResult = ConfigValidator.validate(options, smsConfigSchema);
|
const validationResult = ConfigValidator.validate(options, smsConfigSchema);
|
||||||
|
|
||||||
if (!validationResult.valid) {
|
if (!validationResult.valid) {
|
||||||
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors.join(', ')}`);
|
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors!.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set configuration with defaults
|
// Set configuration with defaults
|
||||||
@@ -30,7 +30,7 @@ export class SmsService {
|
|||||||
*/
|
*/
|
||||||
public async start() {
|
public async start() {
|
||||||
logger.log('info', `starting sms service`);
|
logger.log('info', `starting sms service`);
|
||||||
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
this.projectinfo = await plugins.projectinfo.ProjectInfo.create(paths.packageDir);
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>(
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>(
|
||||||
'sendSms',
|
'sendSms',
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
// Promisify filesystem operations
|
|
||||||
const readFile = plugins.util.promisify(plugins.fs.readFile);
|
|
||||||
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
|
||||||
const unlink = plugins.util.promisify(plugins.fs.unlink);
|
|
||||||
const rename = plugins.util.promisify(plugins.fs.rename);
|
|
||||||
const readdir = plugins.util.promisify(plugins.fs.readdir);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage configuration interface
|
|
||||||
*/
|
|
||||||
export interface IStorageConfig {
|
|
||||||
/** Filesystem path for storage */
|
|
||||||
fsPath?: string;
|
|
||||||
/** Custom read function */
|
|
||||||
readFunction?: (key: string) => Promise<string>;
|
|
||||||
/** Custom write function */
|
|
||||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage backend type
|
|
||||||
*/
|
|
||||||
export type StorageBackend = 'filesystem' | 'custom' | 'memory';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Central storage manager for DcRouter
|
|
||||||
* Provides unified key-value storage with multiple backend support
|
|
||||||
*/
|
|
||||||
export class StorageManager {
|
|
||||||
private backend: StorageBackend;
|
|
||||||
private memoryStore: Map<string, string> = new Map();
|
|
||||||
private config: IStorageConfig;
|
|
||||||
private fsBasePath?: string;
|
|
||||||
|
|
||||||
constructor(config?: IStorageConfig) {
|
|
||||||
this.config = config || {};
|
|
||||||
|
|
||||||
// Check if both fsPath and custom functions are provided
|
|
||||||
if (config?.fsPath && (config?.readFunction || config?.writeFunction)) {
|
|
||||||
console.warn(
|
|
||||||
'⚠️ WARNING: Both fsPath and custom read/write functions are configured.\n' +
|
|
||||||
' Using custom read/write functions. fsPath will be ignored.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine backend based on configuration
|
|
||||||
if (config?.readFunction && config?.writeFunction) {
|
|
||||||
this.backend = 'custom';
|
|
||||||
} else if (config?.fsPath) {
|
|
||||||
// Set up internal read/write functions for filesystem
|
|
||||||
this.backend = 'custom'; // Use custom backend with internal functions
|
|
||||||
this.fsBasePath = plugins.path.resolve(config.fsPath);
|
|
||||||
this.ensureDirectory(this.fsBasePath);
|
|
||||||
|
|
||||||
// Set up internal filesystem read/write functions
|
|
||||||
this.config.readFunction = async (key: string) => {
|
|
||||||
return this.fsRead(key);
|
|
||||||
};
|
|
||||||
this.config.writeFunction = async (key: string, value: string) => {
|
|
||||||
await this.fsWrite(key, value);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.backend = 'memory';
|
|
||||||
this.showMemoryWarning();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', `StorageManager initialized with ${this.backend} backend`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show warning when using memory backend
|
|
||||||
*/
|
|
||||||
private showMemoryWarning(): void {
|
|
||||||
console.warn(
|
|
||||||
'⚠️ WARNING: StorageManager is using in-memory storage.\n' +
|
|
||||||
' Data will be lost when the process restarts.\n' +
|
|
||||||
' Configure storage.fsPath or storage functions for persistence.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure directory exists for filesystem backend
|
|
||||||
*/
|
|
||||||
private async ensureDirectory(dirPath: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await plugins.fsUtils.ensureDir(dirPath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to create storage directory: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and sanitize storage key
|
|
||||||
*/
|
|
||||||
private validateKey(key: string): string {
|
|
||||||
if (!key || typeof key !== 'string') {
|
|
||||||
throw new Error('Storage key must be a non-empty string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure key starts with /
|
|
||||||
if (!key.startsWith('/')) {
|
|
||||||
key = '/' + key;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any dangerous path elements
|
|
||||||
key = key.replace(/\.\./g, '').replace(/\/+/g, '/');
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert key to filesystem path
|
|
||||||
*/
|
|
||||||
private keyToPath(key: string): string {
|
|
||||||
if (!this.fsBasePath) {
|
|
||||||
throw new Error('Filesystem base path not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove leading slash and convert to path
|
|
||||||
const relativePath = key.substring(1);
|
|
||||||
return plugins.path.join(this.fsBasePath, relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal filesystem read function
|
|
||||||
*/
|
|
||||||
private async fsRead(key: string): Promise<string> {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
try {
|
|
||||||
const content = await readFile(filePath, 'utf8');
|
|
||||||
return content;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal filesystem write function
|
|
||||||
*/
|
|
||||||
private async fsWrite(key: string, value: string): Promise<void> {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
const dir = plugins.path.dirname(filePath);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
await plugins.fsUtils.ensureDir(dir);
|
|
||||||
|
|
||||||
// Write atomically with temp file
|
|
||||||
const tempPath = `${filePath}.tmp`;
|
|
||||||
await writeFile(tempPath, value, 'utf8');
|
|
||||||
await rename(tempPath, filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get value by key
|
|
||||||
*/
|
|
||||||
async get(key: string): Promise<string | null> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
|
|
||||||
case 'custom': {
|
|
||||||
if (!this.config.readFunction) {
|
|
||||||
throw new Error('Read function not configured');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await this.config.readFunction(key);
|
|
||||||
} catch (error) {
|
|
||||||
// Assume null if read fails (key doesn't exist)
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
return this.memoryStore.get(key) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Storage get error for key ${key}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set value by key
|
|
||||||
*/
|
|
||||||
async set(key: string, value: string): Promise<void> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new Error('Storage value must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
case 'filesystem': {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
const dirPath = plugins.path.dirname(filePath);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
await plugins.fsUtils.ensureDir(dirPath);
|
|
||||||
|
|
||||||
// Write atomically
|
|
||||||
const tempPath = filePath + '.tmp';
|
|
||||||
await writeFile(tempPath, value, 'utf8');
|
|
||||||
await rename(tempPath, filePath);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'custom': {
|
|
||||||
if (!this.config.writeFunction) {
|
|
||||||
throw new Error('Write function not configured');
|
|
||||||
}
|
|
||||||
await this.config.writeFunction(key, value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
this.memoryStore.set(key, value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Storage set error for key ${key}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete value by key
|
|
||||||
*/
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
case 'filesystem': {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
try {
|
|
||||||
await unlink(filePath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'custom': {
|
|
||||||
// Try to delete by setting empty value
|
|
||||||
if (this.config.writeFunction) {
|
|
||||||
await this.config.writeFunction(key, '');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
this.memoryStore.delete(key);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Storage delete error for key ${key}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List keys by prefix
|
|
||||||
*/
|
|
||||||
async list(prefix?: string): Promise<string[]> {
|
|
||||||
prefix = prefix ? this.validateKey(prefix) : '/';
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
case 'custom': {
|
|
||||||
// If we have fsBasePath, this is actually filesystem backend
|
|
||||||
if (this.fsBasePath) {
|
|
||||||
const basePath = this.keyToPath(prefix);
|
|
||||||
const keys: string[] = [];
|
|
||||||
|
|
||||||
const walkDir = async (dir: string, baseDir: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = plugins.path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await walkDir(fullPath, baseDir);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
// Convert path back to key
|
|
||||||
const relativePath = plugins.path.relative(this.fsBasePath!, fullPath);
|
|
||||||
const key = '/' + relativePath.replace(/\\/g, '/');
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
keys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await walkDir(basePath, basePath);
|
|
||||||
return keys.sort();
|
|
||||||
} else {
|
|
||||||
// True custom backends need to implement their own listing
|
|
||||||
logger.log('warn', 'List operation not supported for custom backend');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (const key of this.memoryStore.keys()) {
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
keys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Storage list error for prefix ${prefix}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if key exists
|
|
||||||
*/
|
|
||||||
async exists(key: string): Promise<boolean> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = await this.get(key);
|
|
||||||
return value !== null;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get storage backend type
|
|
||||||
*/
|
|
||||||
getBackend(): StorageBackend {
|
|
||||||
// If we're using custom backend with fsBasePath, report it as filesystem
|
|
||||||
if (this.backend === 'custom' && this.fsBasePath) {
|
|
||||||
return 'filesystem' as StorageBackend;
|
|
||||||
}
|
|
||||||
return this.backend;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON helper: Get and parse JSON value
|
|
||||||
*/
|
|
||||||
async getJSON<T = any>(key: string): Promise<T | null> {
|
|
||||||
const value = await this.get(key);
|
|
||||||
if (value === null || value.trim() === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(value) as T;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to parse JSON for key ${key}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON helper: Set value as JSON
|
|
||||||
*/
|
|
||||||
async setJSON(key: string, value: any): Promise<void> {
|
|
||||||
const jsonString = JSON.stringify(value, null, 2);
|
|
||||||
await this.set(key, jsonString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Storage module exports
|
|
||||||
export * from './classes.storagemanager.js';
|
|
||||||
567
ts/vpn/classes.vpn-manager.ts
Normal file
567
ts/vpn/classes.vpn-manager.ts
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { VpnServerKeysDoc, VpnClientDoc } from '../db/index.js';
|
||||||
|
|
||||||
|
export interface IVpnManagerConfig {
|
||||||
|
/** VPN subnet CIDR (default: '10.8.0.0/24') */
|
||||||
|
subnet?: string;
|
||||||
|
/** WireGuard UDP listen port (default: 51820) */
|
||||||
|
wgListenPort?: number;
|
||||||
|
/** DNS servers pushed to VPN clients */
|
||||||
|
dns?: string[];
|
||||||
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
|
serverEndpoint?: string;
|
||||||
|
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||||
|
initialClients?: Array<{
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||||
|
onClientChanged?: () => void;
|
||||||
|
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
|
||||||
|
destinationPolicy?: {
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string;
|
||||||
|
allowList?: string[];
|
||||||
|
blockList?: string[];
|
||||||
|
};
|
||||||
|
/** Compute per-client AllowedIPs based on the client's server-defined tags.
|
||||||
|
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||||
|
* When not set, defaults to [subnet]. */
|
||||||
|
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
|
||||||
|
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
|
||||||
|
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
|
||||||
|
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
/** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
|
||||||
|
bridgeLanSubnet?: string;
|
||||||
|
/** Physical network interface for bridge mode (auto-detected if omitted) */
|
||||||
|
bridgePhysicalInterface?: string;
|
||||||
|
/** Start of VPN client IP range in LAN subnet (host offset, default: 200) */
|
||||||
|
bridgeIpRangeStart?: number;
|
||||||
|
/** End of VPN client IP range in LAN subnet (host offset, default: 250) */
|
||||||
|
bridgeIpRangeEnd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the SmartVPN server lifecycle and VPN client CRUD.
|
||||||
|
* Persists server keys and client registrations via smartdata document classes.
|
||||||
|
*/
|
||||||
|
export class VpnManager {
|
||||||
|
private config: IVpnManagerConfig;
|
||||||
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
|
private clients: Map<string, VpnClientDoc> = new Map();
|
||||||
|
private serverKeys?: VpnServerKeysDoc;
|
||||||
|
|
||||||
|
constructor(config: IVpnManagerConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The VPN subnet CIDR. */
|
||||||
|
public getSubnet(): string {
|
||||||
|
return this.config.subnet || '10.8.0.0/24';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the VPN server is running. */
|
||||||
|
public get running(): boolean {
|
||||||
|
return this.vpnServer?.running ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the VPN server.
|
||||||
|
* Loads or generates server keys, loads persisted clients, starts VpnServer.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
// Load or generate server keys
|
||||||
|
this.serverKeys = await this.loadOrGenerateServerKeys();
|
||||||
|
|
||||||
|
// Load persisted clients
|
||||||
|
await this.loadPersistedClients();
|
||||||
|
|
||||||
|
// Build client entries for the daemon
|
||||||
|
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
|
||||||
|
let anyClientUsesHostIp = false;
|
||||||
|
for (const client of this.clients.values()) {
|
||||||
|
if (client.useHostIp) {
|
||||||
|
anyClientUsesHostIp = true;
|
||||||
|
}
|
||||||
|
const entry: plugins.smartvpn.IClientEntry = {
|
||||||
|
clientId: client.clientId,
|
||||||
|
publicKey: client.noisePublicKey,
|
||||||
|
wgPublicKey: client.wgPublicKey,
|
||||||
|
enabled: client.enabled,
|
||||||
|
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||||
|
description: client.description,
|
||||||
|
assignedIp: client.assignedIp,
|
||||||
|
expiresAt: client.expiresAt,
|
||||||
|
security: this.buildClientSecurity(client),
|
||||||
|
};
|
||||||
|
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
||||||
|
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
||||||
|
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
||||||
|
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
||||||
|
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
||||||
|
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
||||||
|
clientEntries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnet = this.getSubnet();
|
||||||
|
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||||
|
|
||||||
|
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||||
|
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||||
|
let configuredMode = this.config.forwardingMode ?? 'socket';
|
||||||
|
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||||
|
configuredMode = 'hybrid';
|
||||||
|
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||||
|
}
|
||||||
|
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||||
|
const isBridge = forwardingMode === 'bridge';
|
||||||
|
|
||||||
|
// Create and start VpnServer
|
||||||
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default destination policy: bridge mode allows traffic through directly,
|
||||||
|
// socket mode forces traffic to SmartProxy on 127.0.0.1
|
||||||
|
const defaultDestinationPolicy: plugins.smartvpn.IDestinationPolicy = isBridge
|
||||||
|
? { default: 'allow' as const }
|
||||||
|
: { default: 'forceTarget' as const, target: '127.0.0.1' };
|
||||||
|
|
||||||
|
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
||||||
|
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
||||||
|
privateKey: this.serverKeys.noisePrivateKey,
|
||||||
|
publicKey: this.serverKeys.noisePublicKey,
|
||||||
|
subnet,
|
||||||
|
dns: this.config.dns,
|
||||||
|
forwardingMode: forwardingMode as any,
|
||||||
|
transportMode: 'all',
|
||||||
|
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||||
|
wgListenPort,
|
||||||
|
clients: clientEntries,
|
||||||
|
socketForwardProxyProtocol: !isBridge,
|
||||||
|
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||||
|
serverEndpoint: this.config.serverEndpoint
|
||||||
|
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||||
|
: undefined,
|
||||||
|
clientAllowedIPs: [subnet],
|
||||||
|
// Bridge-specific config
|
||||||
|
...(isBridge ? {
|
||||||
|
bridgeLanSubnet: this.config.bridgeLanSubnet,
|
||||||
|
bridgePhysicalInterface: this.config.bridgePhysicalInterface,
|
||||||
|
bridgeIpRangeStart: this.config.bridgeIpRangeStart,
|
||||||
|
bridgeIpRangeEnd: this.config.bridgeIpRangeEnd,
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.vpnServer.start(serverConfig);
|
||||||
|
|
||||||
|
// Create initial clients from config (idempotent — skip already-persisted)
|
||||||
|
if (this.config.initialClients) {
|
||||||
|
for (const initial of this.config.initialClients) {
|
||||||
|
if (!this.clients.has(initial.clientId)) {
|
||||||
|
const bundle = await this.createClient({
|
||||||
|
clientId: initial.clientId,
|
||||||
|
serverDefinedClientTags: initial.serverDefinedClientTags,
|
||||||
|
description: initial.description,
|
||||||
|
});
|
||||||
|
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the VPN server.
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.vpnServer) {
|
||||||
|
try {
|
||||||
|
await this.vpnServer.stopServer();
|
||||||
|
} catch {
|
||||||
|
// Ignore stop errors
|
||||||
|
}
|
||||||
|
this.vpnServer.stop();
|
||||||
|
this.vpnServer = undefined;
|
||||||
|
}
|
||||||
|
logger.log('info', 'VPN server stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new VPN client. Returns the config bundle (secrets only shown once).
|
||||||
|
*/
|
||||||
|
public async createClient(opts: {
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
forceDestinationSmartproxy?: boolean;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
|
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
|
if (!this.vpnServer) {
|
||||||
|
throw new Error('VPN server not running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = await this.vpnServer.createClient({
|
||||||
|
clientId: opts.clientId,
|
||||||
|
serverDefinedClientTags: opts.serverDefinedClientTags,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||||
|
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||||
|
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
|
||||||
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
|
/AllowedIPs\s*=\s*.+/,
|
||||||
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist client entry (including WG private key for export/QR)
|
||||||
|
const doc = new VpnClientDoc();
|
||||||
|
doc.clientId = bundle.entry.clientId;
|
||||||
|
doc.enabled = bundle.entry.enabled ?? true;
|
||||||
|
doc.serverDefinedClientTags = bundle.entry.serverDefinedClientTags;
|
||||||
|
doc.description = bundle.entry.description;
|
||||||
|
doc.assignedIp = bundle.entry.assignedIp;
|
||||||
|
doc.noisePublicKey = bundle.entry.publicKey;
|
||||||
|
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
|
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
|
doc.createdAt = Date.now();
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
doc.expiresAt = bundle.entry.expiresAt;
|
||||||
|
if (opts.forceDestinationSmartproxy !== undefined) {
|
||||||
|
doc.forceDestinationSmartproxy = opts.forceDestinationSmartproxy;
|
||||||
|
}
|
||||||
|
if (opts.destinationAllowList !== undefined) {
|
||||||
|
doc.destinationAllowList = opts.destinationAllowList;
|
||||||
|
}
|
||||||
|
if (opts.destinationBlockList !== undefined) {
|
||||||
|
doc.destinationBlockList = opts.destinationBlockList;
|
||||||
|
}
|
||||||
|
if (opts.useHostIp !== undefined) {
|
||||||
|
doc.useHostIp = opts.useHostIp;
|
||||||
|
}
|
||||||
|
if (opts.useDhcp !== undefined) {
|
||||||
|
doc.useDhcp = opts.useDhcp;
|
||||||
|
}
|
||||||
|
if (opts.staticIp !== undefined) {
|
||||||
|
doc.staticIp = opts.staticIp;
|
||||||
|
}
|
||||||
|
if (opts.forceVlan !== undefined) {
|
||||||
|
doc.forceVlan = opts.forceVlan;
|
||||||
|
}
|
||||||
|
if (opts.vlanId !== undefined) {
|
||||||
|
doc.vlanId = opts.vlanId;
|
||||||
|
}
|
||||||
|
this.clients.set(doc.clientId, doc);
|
||||||
|
await this.persistClient(doc);
|
||||||
|
|
||||||
|
// Sync per-client security to the running daemon
|
||||||
|
const security = this.buildClientSecurity(doc);
|
||||||
|
if (security.destinationPolicy) {
|
||||||
|
await this.vpnServer!.updateClient(doc.clientId, { security });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a VPN client.
|
||||||
|
*/
|
||||||
|
public async removeClient(clientId: string): Promise<void> {
|
||||||
|
if (!this.vpnServer) {
|
||||||
|
throw new Error('VPN server not running');
|
||||||
|
}
|
||||||
|
await this.vpnServer.removeClient(clientId);
|
||||||
|
const doc = this.clients.get(clientId);
|
||||||
|
this.clients.delete(clientId);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all registered clients (without secrets).
|
||||||
|
*/
|
||||||
|
public listClients(): VpnClientDoc[] {
|
||||||
|
return [...this.clients.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a client.
|
||||||
|
*/
|
||||||
|
public async enableClient(clientId: string): Promise<void> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
await this.vpnServer.enableClient(clientId);
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
client.enabled = true;
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a client.
|
||||||
|
*/
|
||||||
|
public async disableClient(clientId: string): Promise<void> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
await this.vpnServer.disableClient(clientId);
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
client.enabled = false;
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a client's metadata (description, tags) without rotating keys.
|
||||||
|
*/
|
||||||
|
public async updateClient(clientId: string, update: {
|
||||||
|
description?: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
forceDestinationSmartproxy?: boolean;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (!client) throw new Error(`Client not found: ${clientId}`);
|
||||||
|
if (update.description !== undefined) client.description = update.description;
|
||||||
|
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
|
||||||
|
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
|
||||||
|
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
|
||||||
|
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
|
||||||
|
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
|
||||||
|
if (update.useDhcp !== undefined) client.useDhcp = update.useDhcp;
|
||||||
|
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||||
|
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||||
|
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
|
||||||
|
// Sync per-client security to the running daemon
|
||||||
|
if (this.vpnServer) {
|
||||||
|
const security = this.buildClientSecurity(client);
|
||||||
|
await this.vpnServer.updateClient(clientId, { security });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate a client's keys. Returns the new config bundle.
|
||||||
|
*/
|
||||||
|
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
||||||
|
|
||||||
|
// Update persisted entry with new keys (including private key for export/QR)
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
client.noisePublicKey = bundle.entry.publicKey;
|
||||||
|
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
|
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
|
||||||
|
*/
|
||||||
|
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
||||||
|
|
||||||
|
if (format === 'wireguard') {
|
||||||
|
const persisted = this.clients.get(clientId);
|
||||||
|
|
||||||
|
// Inject stored WG private key so exports produce valid, scannable configs
|
||||||
|
if (persisted?.wgPrivateKey) {
|
||||||
|
config = config.replace(
|
||||||
|
'[Interface]\n',
|
||||||
|
`[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||||
|
if (this.config.getClientAllowedIPs) {
|
||||||
|
const clientTags = persisted?.serverDefinedClientTags || [];
|
||||||
|
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
|
||||||
|
config = config.replace(
|
||||||
|
/AllowedIPs\s*=\s*.+/,
|
||||||
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag-based access control ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
|
||||||
|
*/
|
||||||
|
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (const client of this.clients.values()) {
|
||||||
|
if (!client.enabled || !client.assignedIp) continue;
|
||||||
|
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
|
||||||
|
ips.push(client.assignedIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status and telemetry ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server status.
|
||||||
|
*/
|
||||||
|
public async getStatus(): Promise<plugins.smartvpn.IVpnStatus | null> {
|
||||||
|
if (!this.vpnServer) return null;
|
||||||
|
return this.vpnServer.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server statistics.
|
||||||
|
*/
|
||||||
|
public async getStatistics(): Promise<plugins.smartvpn.IVpnServerStatistics | null> {
|
||||||
|
if (!this.vpnServer) return null;
|
||||||
|
return this.vpnServer.getStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List currently connected clients.
|
||||||
|
*/
|
||||||
|
public async getConnectedClients(): Promise<plugins.smartvpn.IVpnClientInfo[]> {
|
||||||
|
if (!this.vpnServer) return [];
|
||||||
|
return this.vpnServer.listClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get telemetry for a specific client.
|
||||||
|
*/
|
||||||
|
public async getClientTelemetry(clientId: string): Promise<plugins.smartvpn.IVpnClientTelemetry | null> {
|
||||||
|
if (!this.vpnServer) return null;
|
||||||
|
return this.vpnServer.getClientTelemetry(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server public keys (for display/info).
|
||||||
|
*/
|
||||||
|
public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null {
|
||||||
|
if (!this.serverKeys) return null;
|
||||||
|
return {
|
||||||
|
noisePublicKey: this.serverKeys.noisePublicKey,
|
||||||
|
wgPublicKey: this.serverKeys.wgPublicKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-client security ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build per-client security settings for the smartvpn daemon.
|
||||||
|
* Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists)
|
||||||
|
* to smartvpn's IClientSecurity with a destinationPolicy.
|
||||||
|
*/
|
||||||
|
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||||
|
const security: plugins.smartvpn.IClientSecurity = {};
|
||||||
|
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
||||||
|
|
||||||
|
if (!forceSmartproxy) {
|
||||||
|
// Client traffic goes directly — not forced to SmartProxy
|
||||||
|
security.destinationPolicy = {
|
||||||
|
default: 'allow' as const,
|
||||||
|
blockList: client.destinationBlockList,
|
||||||
|
};
|
||||||
|
} else if (client.destinationAllowList?.length || client.destinationBlockList?.length) {
|
||||||
|
// Client is forced to SmartProxy, but with per-client allow/block overrides
|
||||||
|
security.destinationPolicy = {
|
||||||
|
default: 'forceTarget' as const,
|
||||||
|
target: '127.0.0.1',
|
||||||
|
allowList: client.destinationAllowList,
|
||||||
|
blockList: client.destinationBlockList,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// else: no per-client policy, server-wide applies
|
||||||
|
|
||||||
|
return security;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||||
|
const stored = await VpnServerKeysDoc.load();
|
||||||
|
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
|
||||||
|
logger.log('info', 'Loaded VPN server keys from storage');
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new keys via the daemon
|
||||||
|
const tempServer = new plugins.smartvpn.VpnServer({
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
});
|
||||||
|
await tempServer.start();
|
||||||
|
|
||||||
|
const noiseKeys = await tempServer.generateKeypair();
|
||||||
|
const wgKeys = await tempServer.generateWgKeypair();
|
||||||
|
tempServer.stop();
|
||||||
|
|
||||||
|
const doc = stored || new VpnServerKeysDoc();
|
||||||
|
doc.noisePrivateKey = noiseKeys.privateKey;
|
||||||
|
doc.noisePublicKey = noiseKeys.publicKey;
|
||||||
|
doc.wgPrivateKey = wgKeys.privateKey;
|
||||||
|
doc.wgPublicKey = wgKeys.publicKey;
|
||||||
|
await doc.save();
|
||||||
|
|
||||||
|
logger.log('info', 'Generated and persisted new VPN server keys');
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPersistedClients(): Promise<void> {
|
||||||
|
const docs = await VpnClientDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||||
|
if (!doc.serverDefinedClientTags && (doc as any).tags) {
|
||||||
|
doc.serverDefinedClientTags = (doc as any).tags;
|
||||||
|
(doc as any).tags = undefined;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
this.clients.set(doc.clientId, doc);
|
||||||
|
}
|
||||||
|
if (this.clients.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||||
|
await client.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/vpn/index.ts
Normal file
1
ts/vpn/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './classes.vpn-manager.js';
|
||||||
157
ts_apiclient/classes.apitoken.ts
Normal file
157
ts_apiclient/classes.apitoken.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||||
|
|
||||||
|
export class ApiToken {
|
||||||
|
private clientRef: DcRouterApiClient;
|
||||||
|
|
||||||
|
// Data from IApiTokenInfo
|
||||||
|
public id: string;
|
||||||
|
public name: string;
|
||||||
|
public scopes: interfaces.data.TApiTokenScope[];
|
||||||
|
public createdAt: number;
|
||||||
|
public expiresAt: number | null;
|
||||||
|
public lastUsedAt: number | null;
|
||||||
|
public enabled: boolean;
|
||||||
|
|
||||||
|
/** Only set on creation or roll. Not persisted on server side. */
|
||||||
|
public tokenValue?: string;
|
||||||
|
|
||||||
|
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IApiTokenInfo, tokenValue?: string) {
|
||||||
|
this.clientRef = clientRef;
|
||||||
|
this.id = data.id;
|
||||||
|
this.name = data.name;
|
||||||
|
this.scopes = data.scopes;
|
||||||
|
this.createdAt = data.createdAt;
|
||||||
|
this.expiresAt = data.expiresAt;
|
||||||
|
this.lastUsedAt = data.lastUsedAt;
|
||||||
|
this.enabled = data.enabled;
|
||||||
|
this.tokenValue = tokenValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revoke(): Promise<void> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_RevokeApiToken>(
|
||||||
|
'revokeApiToken',
|
||||||
|
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to revoke token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async roll(): Promise<string> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_RollApiToken>(
|
||||||
|
'rollApiToken',
|
||||||
|
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to roll token');
|
||||||
|
}
|
||||||
|
this.tokenValue = response.tokenValue;
|
||||||
|
return response.tokenValue!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggle(enabled: boolean): Promise<void> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleApiToken>(
|
||||||
|
'toggleApiToken',
|
||||||
|
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to toggle token');
|
||||||
|
}
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiTokenBuilder {
|
||||||
|
private clientRef: DcRouterApiClient;
|
||||||
|
private tokenName: string = '';
|
||||||
|
private tokenScopes: interfaces.data.TApiTokenScope[] = [];
|
||||||
|
private tokenExpiresInDays?: number | null;
|
||||||
|
|
||||||
|
constructor(clientRef: DcRouterApiClient) {
|
||||||
|
this.clientRef = clientRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setName(name: string): this {
|
||||||
|
this.tokenName = name;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setScopes(scopes: interfaces.data.TApiTokenScope[]): this {
|
||||||
|
this.tokenScopes = scopes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addScope(scope: interfaces.data.TApiTokenScope): this {
|
||||||
|
if (!this.tokenScopes.includes(scope)) {
|
||||||
|
this.tokenScopes.push(scope);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setExpiresInDays(days: number | null): this {
|
||||||
|
this.tokenExpiresInDays = days;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(): Promise<ApiToken> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_CreateApiToken>(
|
||||||
|
'createApiToken',
|
||||||
|
this.clientRef.buildRequestPayload({
|
||||||
|
name: this.tokenName,
|
||||||
|
scopes: this.tokenScopes,
|
||||||
|
expiresInDays: this.tokenExpiresInDays,
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to create API token');
|
||||||
|
}
|
||||||
|
return new ApiToken(
|
||||||
|
this.clientRef,
|
||||||
|
{
|
||||||
|
id: response.tokenId!,
|
||||||
|
name: this.tokenName,
|
||||||
|
scopes: this.tokenScopes,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: this.tokenExpiresInDays
|
||||||
|
? Date.now() + this.tokenExpiresInDays * 24 * 60 * 60 * 1000
|
||||||
|
: null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
response.tokenValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiTokenManager {
|
||||||
|
private clientRef: DcRouterApiClient;
|
||||||
|
|
||||||
|
constructor(clientRef: DcRouterApiClient) {
|
||||||
|
this.clientRef = clientRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(): Promise<ApiToken[]> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_ListApiTokens>(
|
||||||
|
'listApiTokens',
|
||||||
|
this.clientRef.buildRequestPayload() as any,
|
||||||
|
);
|
||||||
|
return response.tokens.map((t) => new ApiToken(this.clientRef, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(options: {
|
||||||
|
name: string;
|
||||||
|
scopes: interfaces.data.TApiTokenScope[];
|
||||||
|
expiresInDays?: number | null;
|
||||||
|
}): Promise<ApiToken> {
|
||||||
|
return this.build()
|
||||||
|
.setName(options.name)
|
||||||
|
.setScopes(options.scopes)
|
||||||
|
.setExpiresInDays(options.expiresInDays ?? null)
|
||||||
|
.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): ApiTokenBuilder {
|
||||||
|
return new ApiTokenBuilder(this.clientRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
ts_apiclient/classes.certificate.ts
Normal file
123
ts_apiclient/classes.certificate.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||||
|
|
||||||
|
export class Certificate {
|
||||||
|
private clientRef: DcRouterApiClient;
|
||||||
|
|
||||||
|
// Data from ICertificateInfo
|
||||||
|
public domain: string;
|
||||||
|
public routeNames: string[];
|
||||||
|
public status: interfaces.requests.TCertificateStatus;
|
||||||
|
public source: interfaces.requests.TCertificateSource;
|
||||||
|
public tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||||
|
public expiryDate?: string;
|
||||||
|
public issuer?: string;
|
||||||
|
public issuedAt?: string;
|
||||||
|
public error?: string;
|
||||||
|
public canReprovision: boolean;
|
||||||
|
public backoffInfo?: {
|
||||||
|
failures: number;
|
||||||
|
retryAfter?: string;
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(clientRef: DcRouterApiClient, data: interfaces.requests.ICertificateInfo) {
|
||||||
|
this.clientRef = clientRef;
|
||||||
|
this.domain = data.domain;
|
||||||
|
this.routeNames = data.routeNames;
|
||||||
|
this.status = data.status;
|
||||||
|
this.source = data.source;
|
||||||
|
this.tlsMode = data.tlsMode;
|
||||||
|
this.expiryDate = data.expiryDate;
|
||||||
|
this.issuer = data.issuer;
|
||||||
|
this.issuedAt = data.issuedAt;
|
||||||
|
this.error = data.error;
|
||||||
|
this.canReprovision = data.canReprovision;
|
||||||
|
this.backoffInfo = data.backoffInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reprovision(): Promise<void> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||||
|
'reprovisionCertificateDomain',
|
||||||
|
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to reprovision certificate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(): Promise<void> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteCertificate>(
|
||||||
|
'deleteCertificate',
|
||||||
|
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to delete certificate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async export(): Promise<{
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
} | undefined> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_ExportCertificate>(
|
||||||
|
'exportCertificate',
|
||||||
|
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to export certificate');
|
||||||
|
}
|
||||||
|
return response.cert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICertificateSummary {
|
||||||
|
total: number;
|
||||||
|
valid: number;
|
||||||
|
expiring: number;
|
||||||
|
expired: number;
|
||||||
|
failed: number;
|
||||||
|
unknown: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CertificateManager {
|
||||||
|
private clientRef: DcRouterApiClient;
|
||||||
|
|
||||||
|
constructor(clientRef: DcRouterApiClient) {
|
||||||
|
this.clientRef = clientRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(): Promise<{ certificates: Certificate[]; summary: ICertificateSummary }> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_GetCertificateOverview>(
|
||||||
|
'getCertificateOverview',
|
||||||
|
this.clientRef.buildRequestPayload() as any,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
certificates: response.certificates.map((c) => new Certificate(this.clientRef, c)),
|
||||||
|
summary: response.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async import(cert: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const response = await this.clientRef.request<interfaces.requests.IReq_ImportCertificate>(
|
||||||
|
'importCertificate',
|
||||||
|
this.clientRef.buildRequestPayload({ cert }) as any,
|
||||||
|
);
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to import certificate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user