Compare commits
391 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -6,7 +6,7 @@ on:
|
||||
- '**'
|
||||
|
||||
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_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- '*'
|
||||
|
||||
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_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
|
||||
image: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -82,15 +82,13 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @git.zone/tsdocker
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
npmci docker login
|
||||
npmci docker build
|
||||
npmci docker test
|
||||
# npmci docker push gitea.lossless.digital
|
||||
npmci docker push dockerregistry.lossless.digital
|
||||
tsdocker login
|
||||
tsdocker build
|
||||
tsdocker push
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ dist_*/
|
||||
**/.claude/settings.local.json
|
||||
.nogit/data/
|
||||
readme.plan.md
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
"production": true,
|
||||
"includeFiles": ["./html/**/*.html"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -71,9 +72,14 @@
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
54
Dockerfile
54
Dockerfile
@@ -1,46 +1,34 @@
|
||||
# gitzone dockerfile_service
|
||||
## 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
|
||||
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 rm -rf node_modules && pnpm install
|
||||
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
|
||||
COPY --from=node1 /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
|
||||
COPY --from=build /app /app
|
||||
|
||||
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
|
||||
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
|
||||
CMD ["npm", "start"]
|
||||
LABEL org.opencontainers.image.title="dcrouter" \
|
||||
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"]
|
||||
|
||||
1221
changelog.md
1221
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.
|
||||
67
package.json
67
package.json
@@ -1,64 +1,73 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "7.4.3",
|
||||
"version": "12.0.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./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",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
"release:docker": "tsdocker push --verbose",
|
||||
"bundle": "(tsbundle)",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@types/node": "^25.3.0"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.2.6",
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.43.2",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@design.estate/dees-catalog": "^3.49.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.1.3",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartacme": "^9.3.1",
|
||||
"@push.rocks/smartdata": "^7.1.3",
|
||||
"@push.rocks/smartdb": "^2.0.0",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartmetrics": "^3.0.1",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmta": "^5.2.2",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^25.7.9",
|
||||
"@push.rocks/smartproxy": "^27.1.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@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/smartvpn": "1.17.1",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.9.0",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.0.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -98,13 +107,15 @@
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_apiclient/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"dist_ts_apiclient/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
|
||||
5669
pnpm-lock.yaml
generated
5669
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
|
||||
pnpm run watch
|
||||
```
|
||||
Configuration in `npmextra.json`:
|
||||
Configuration in `.smartconfig.json`:
|
||||
```json
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
|
||||
512
readme.md
512
readme.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
||||
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -18,23 +18,28 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- [Architecture](#architecture)
|
||||
- [Configuration Reference](#configuration-reference)
|
||||
- [HTTP/HTTPS & TCP/SNI Routing](#httphttps--tcpsni-routing)
|
||||
- [HTTP/3 (QUIC) Support](#http3-quic-support)
|
||||
- [Email System](#email-system)
|
||||
- [DNS Server](#dns-server)
|
||||
- [RADIUS Server](#radius-server)
|
||||
- [Remote Ingress](#remote-ingress)
|
||||
- [VPN Access Control](#vpn-access-control)
|
||||
- [Certificate Management](#certificate-management)
|
||||
- [Storage & Caching](#storage--caching)
|
||||
- [Security Features](#security-features)
|
||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||
- [API Client](#api-client)
|
||||
- [API Reference](#api-reference)
|
||||
- [Sub-Modules](#sub-modules)
|
||||
- [Testing](#testing)
|
||||
- [Docker / OCI Container Deployment](#docker--oci-container-deployment)
|
||||
- [License and Legal Information](#license-and-legal-information)
|
||||
|
||||
## Features
|
||||
|
||||
### 🌐 Universal Traffic Router
|
||||
- **HTTP/HTTPS routing** with domain matching, path-based forwarding, and automatic TLS
|
||||
- **HTTP/3 (QUIC) enabled by default** — qualifying HTTPS routes automatically get QUIC/H3 support with zero configuration
|
||||
- **TCP/SNI proxy** for any protocol with TLS termination or passthrough
|
||||
- **DNS server** (Rust-powered via [SmartDNS](https://code.foss.global/push.rocks/smartdns)) with authoritative zones, dynamic record management, and DNS-over-HTTPS
|
||||
- **Multi-protocol support** on the same infrastructure via [SmartProxy](https://code.foss.global/push.rocks/smartproxy)
|
||||
@@ -69,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
||||
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
||||
|
||||
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
|
||||
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
|
||||
- **Route-level VPN gating** — mark any route with `vpn: { enabled: true }` to restrict access to VPN clients only, or `vpn: { enabled: true, mandatory: false }` to add VPN clients alongside existing access rules
|
||||
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
|
||||
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
|
||||
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
|
||||
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
|
||||
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
|
||||
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||
|
||||
### ⚡ High Performance
|
||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||
@@ -79,16 +95,24 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
### 💾 Persistent Storage & Caching
|
||||
- **Multiple storage backends**: filesystem, custom functions, or in-memory
|
||||
- **Embedded cache database** via smartdata + LocalTsmDb (MongoDB-compatible)
|
||||
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
|
||||
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
||||
|
||||
### 🖥️ OpsServer Dashboard
|
||||
- **Web-based management interface** with real-time monitoring
|
||||
- **JWT authentication** with session persistence
|
||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
|
||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||
- **Remote ingress management** with connection token generation and one-click copy
|
||||
- **Read-only configuration display** — DcRouter is configured through code
|
||||
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
||||
|
||||
### 🔧 Programmatic API Client
|
||||
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
|
||||
- **Builder pattern** — fluent `.setName().setMatch().save()` chains for creating routes, tokens, and edges
|
||||
- **Auto-injected auth** — JWT identity and API tokens included automatically in every request
|
||||
- **Dual auth modes** — login with credentials (JWT) or pass an API token for programmatic access
|
||||
- **Full coverage** — wraps every OpsServer endpoint with typed request/response pairs
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -236,6 +260,15 @@ const router = new DcRouter({
|
||||
hubDomain: 'hub.example.com',
|
||||
},
|
||||
|
||||
// VPN — restrict sensitive routes to VPN clients
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
clients: [
|
||||
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||
],
|
||||
},
|
||||
|
||||
// Persistent storage
|
||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||
|
||||
@@ -264,6 +297,7 @@ graph TB
|
||||
DNS[DNS Queries]
|
||||
RAD[RADIUS Clients]
|
||||
EDGE[Edge Nodes]
|
||||
VPN[VPN Clients]
|
||||
end
|
||||
|
||||
subgraph "DcRouter Core"
|
||||
@@ -273,6 +307,7 @@ graph TB
|
||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||
RS[SmartRadius Server]
|
||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
||||
VS[SmartVPN Server<br/><i>Rust data plane</i>]
|
||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||
OS[OpsServer Dashboard]
|
||||
MM[Metrics Manager]
|
||||
@@ -293,12 +328,14 @@ graph TB
|
||||
DNS --> DS
|
||||
RAD --> RS
|
||||
EDGE --> RI
|
||||
VPN --> VS
|
||||
|
||||
DC --> SP
|
||||
DC --> ES
|
||||
DC --> DS
|
||||
DC --> RS
|
||||
DC --> RI
|
||||
DC --> VS
|
||||
DC --> CM
|
||||
DC --> OS
|
||||
DC --> MM
|
||||
@@ -329,14 +366,14 @@ graph TB
|
||||
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
|
||||
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
|
||||
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
|
||||
| **CacheDb** | `@push.rocks/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) for persistent caching |
|
||||
| **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
|
||||
|
||||
### How It Works
|
||||
|
||||
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
||||
|
||||
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
|
||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and SmartVPN based on which configs are provided. Services start in dependency order via `ServiceManager`.
|
||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartVPN runs a Rust data plane for WireGuard and custom transports. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
||||
|
||||
### Rust-Powered Architecture
|
||||
@@ -349,6 +386,7 @@ DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-c
|
||||
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
||||
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
||||
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
||||
| **SmartVPN** | `smartvpn_daemon` | WireGuard (boringtun), Noise IK handshake, QUIC/WS transports, userspace NAT (smoltcp) |
|
||||
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
||||
|
||||
## Configuration Reference
|
||||
@@ -416,6 +454,52 @@ interface IDcRouterOptions {
|
||||
};
|
||||
};
|
||||
|
||||
// ── VPN ───────────────────────────────────────────────────────
|
||||
/** VPN server for route-level access control */
|
||||
vpnConfig?: {
|
||||
enabled?: boolean; // default: false
|
||||
subnet?: string; // default: '10.8.0.0/24'
|
||||
wgListenPort?: number; // default: 51820
|
||||
dns?: string[]; // DNS servers pushed to VPN clients
|
||||
serverEndpoint?: string; // Hostname in generated client configs
|
||||
clients?: Array<{ // Pre-defined VPN clients
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
description?: string;
|
||||
}>;
|
||||
destinationPolicy?: { // Traffic routing policy
|
||||
default: 'forceTarget' | 'block' | 'allow';
|
||||
target?: string; // IP for forceTarget (default: '127.0.0.1')
|
||||
allowList?: string[]; // Pass through directly
|
||||
blockList?: string[]; // Always block (overrides allowList)
|
||||
};
|
||||
};
|
||||
|
||||
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
||||
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
||||
http3?: {
|
||||
enabled?: boolean; // default: true
|
||||
quicSettings?: {
|
||||
maxIdleTimeout?: number; // default: 30000ms
|
||||
maxConcurrentBidiStreams?: number; // default: 100
|
||||
maxConcurrentUniStreams?: number; // default: 100
|
||||
initialCongestionWindow?: number;
|
||||
};
|
||||
altSvc?: {
|
||||
port?: number; // default: listening port
|
||||
maxAge?: number; // default: 86400s
|
||||
};
|
||||
udpSettings?: {
|
||||
sessionTimeout?: number; // default: 60000ms
|
||||
maxSessionsPerIP?: number; // default: 1000
|
||||
maxDatagramSize?: number; // default: 65535
|
||||
};
|
||||
};
|
||||
|
||||
// ── OpsServer ────────────────────────────────────────────────
|
||||
/** Port for the OpsServer web dashboard (default: 3000) */
|
||||
opsServerPort?: number;
|
||||
|
||||
// ── TLS & Certificates ────────────────────────────────────────
|
||||
tls?: {
|
||||
contactEmail: string;
|
||||
@@ -503,6 +587,102 @@ DcRouter uses [SmartProxy](https://code.foss.global/push.rocks/smartproxy) for a
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP/3 (QUIC) Support
|
||||
|
||||
DcRouter ships with **HTTP/3 enabled by default** 🚀. All qualifying HTTPS routes on port 443 are automatically augmented with QUIC/H3 configuration — no extra setup needed. Under the hood, SmartProxy's native HTTP/3 support (via `IRouteQuic`) handles QUIC transport, Alt-Svc advertisement, and HTTP/3 negotiation.
|
||||
|
||||
### How It Works
|
||||
|
||||
When DcRouter assembles routes in `setupSmartProxy()`, it automatically augments qualifying routes with:
|
||||
- `match.transport: 'all'` — listen on both TCP (HTTP/1.1 + HTTP/2) and UDP (QUIC/HTTP/3) on the same port
|
||||
- `action.udp.quic` — QUIC configuration with `enableHttp3: true` and `altSvcMaxAge: 86400`
|
||||
|
||||
Browsers that support HTTP/3 will discover it via the `Alt-Svc` header on initial TCP responses, then upgrade to QUIC for subsequent requests.
|
||||
|
||||
### What Gets Augmented
|
||||
|
||||
A route qualifies for HTTP/3 augmentation when **all** of these are true:
|
||||
- Port includes **443** (single number, array, or range)
|
||||
- Action type is **`forward`** (not `socket-handler`)
|
||||
- **TLS is enabled** (passthrough, terminate, or terminate-and-reencrypt)
|
||||
- Route is **not** an email route (ports 25/587/465)
|
||||
- Route doesn't already have `transport: 'all'` or existing `udp.quic` config
|
||||
|
||||
### Zero-Config (Default Behavior)
|
||||
|
||||
```typescript
|
||||
// HTTP/3 is ON by default — this route automatically gets QUIC/H3:
|
||||
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' }
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Per-Route Opt-Out
|
||||
|
||||
Disable HTTP/3 on a specific route using `action.options.http3`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'legacy-app',
|
||||
match: { domains: ['legacy.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
options: { http3: false } // ← This route stays TCP-only
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Global Opt-Out
|
||||
|
||||
Disable HTTP/3 across all routes:
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
http3: { enabled: false },
|
||||
smartProxyConfig: { routes: [/* ... */] }
|
||||
});
|
||||
```
|
||||
|
||||
### Custom QUIC Settings
|
||||
|
||||
Fine-tune QUIC parameters globally:
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
http3: {
|
||||
quicSettings: {
|
||||
maxIdleTimeout: 60000, // 60s idle timeout
|
||||
maxConcurrentBidiStreams: 200, // More parallel streams
|
||||
maxConcurrentUniStreams: 50,
|
||||
},
|
||||
altSvc: {
|
||||
maxAge: 3600, // 1 hour Alt-Svc cache
|
||||
},
|
||||
udpSettings: {
|
||||
sessionTimeout: 120000, // 2 min UDP session timeout
|
||||
maxSessionsPerIP: 500,
|
||||
}
|
||||
},
|
||||
smartProxyConfig: { routes: [/* ... */] }
|
||||
});
|
||||
```
|
||||
|
||||
### Programmatic Routes
|
||||
|
||||
Routes added at runtime via the Route Management API also get HTTP/3 augmentation automatically — the `RouteConfigManager` applies the same augmentation logic when merging programmatic routes.
|
||||
|
||||
## Email System
|
||||
|
||||
The email system is powered by [`@push.rocks/smartmta`](https://code.foss.global/push.rocks/smartmta), a TypeScript + Rust hybrid MTA. DcRouter configures and orchestrates smartmta's **UnifiedEmailServer**, which handles SMTP sessions, route matching, delivery queuing, DKIM signing, and all email processing.
|
||||
@@ -842,6 +1022,129 @@ The OpsServer Remote Ingress view provides:
|
||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||
| **Delete** | Remove the edge registration |
|
||||
|
||||
## VPN Access Control
|
||||
|
||||
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
|
||||
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
|
||||
3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.enabled` are resolved at config generation time, so clients route only the necessary traffic through the tunnel
|
||||
4. Routes with `vpn: { enabled: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change). With `mandatory: true` (default), the allowlist is replaced; with `mandatory: false`, VPN IPs are appended to existing rules
|
||||
5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
|
||||
6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
|
||||
7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
|
||||
|
||||
### Destination Policy
|
||||
|
||||
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|
||||
|
||||
```typescript
|
||||
// Default: all traffic → SmartProxy
|
||||
destinationPolicy: { default: 'forceTarget', target: '127.0.0.1' }
|
||||
|
||||
// Allow direct access to a backend subnet
|
||||
destinationPolicy: {
|
||||
default: 'forceTarget',
|
||||
target: '127.0.0.1',
|
||||
allowList: ['192.168.190.*'], // direct access to this subnet
|
||||
blockList: ['192.168.190.1'], // except the gateway
|
||||
}
|
||||
|
||||
// Block everything except specific IPs
|
||||
destinationPolicy: {
|
||||
default: 'block',
|
||||
allowList: ['10.0.0.*', '192.168.1.*'],
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
||||
wgListenPort: 51820, // WireGuard UDP port (default)
|
||||
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
||||
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
||||
|
||||
// Pre-define VPN clients with server-defined tags
|
||||
clients: [
|
||||
{ clientId: 'alice-laptop', serverDefinedClientTags: ['engineering'], description: 'Dev laptop' },
|
||||
{ clientId: 'bob-phone', serverDefinedClientTags: ['engineering', 'mobile'] },
|
||||
{ clientId: 'carol-desktop', serverDefinedClientTags: ['finance'] },
|
||||
],
|
||||
|
||||
// Optional: customize destination policy (default: forceTarget → localhost)
|
||||
// destinationPolicy: { default: 'forceTarget', target: '127.0.0.1', allowList: ['192.168.1.*'] },
|
||||
},
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
// 🔐 VPN-only: any VPN client can access
|
||||
{
|
||||
name: 'internal-app',
|
||||
match: { domains: ['internal.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
vpn: { enabled: true },
|
||||
},
|
||||
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients
|
||||
{
|
||||
name: 'eng-dashboard',
|
||||
match: { domains: ['eng.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.51', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||
// → alice + bob can access, carol cannot
|
||||
},
|
||||
// 🌐 Public: no VPN
|
||||
{
|
||||
name: 'public-site',
|
||||
match: { domains: ['example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.10', port: 80 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Client Tags
|
||||
|
||||
SmartVPN distinguishes between two types of client tags:
|
||||
|
||||
| Tag Type | Set By | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `serverDefinedClientTags` | Admin (via config or API) | **Trusted** — used for route access control |
|
||||
| `clientDefinedClientTags` | Connecting client | **Informational** — displayed in dashboard, never used for security |
|
||||
|
||||
Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin-assigned tags match. Clients cannot influence their own server-defined tags.
|
||||
|
||||
### Client Management via OpsServer
|
||||
|
||||
The OpsServer dashboard and API provide full VPN client lifecycle management:
|
||||
|
||||
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
|
||||
- **QR code** — scan with the WireGuard mobile app (iOS/Android) for instant setup
|
||||
- **Enable / Disable** — toggle client access without deleting
|
||||
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
||||
- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code
|
||||
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
|
||||
- **Delete** — remove a client and revoke access
|
||||
|
||||
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or by scanning the QR code — no custom VPN software needed.
|
||||
|
||||
## Certificate Management
|
||||
|
||||
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
||||
@@ -934,7 +1237,7 @@ Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, I
|
||||
|
||||
### Cache Database
|
||||
|
||||
An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persistent caching with automatic TTL cleanup:
|
||||
An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
|
||||
|
||||
```typescript
|
||||
cacheConfig: {
|
||||
@@ -1007,7 +1310,7 @@ action: {
|
||||
|
||||
## OpsServer Dashboard
|
||||
|
||||
The OpsServer provides a web-based management interface served on port 3000. It's built with modern web components using [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog).
|
||||
The OpsServer provides a web-based management interface served on port 3000 by default (configurable via `opsServerPort`). It's built with modern web components using [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog).
|
||||
|
||||
### Dashboard Views
|
||||
|
||||
@@ -1016,8 +1319,12 @@ The OpsServer provides a web-based management interface served on port 3000. It'
|
||||
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
||||
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
||||
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
||||
| 🛣️ **Routes** | Merged route list (hardcoded + programmatic), create/edit/toggle/override routes |
|
||||
| 🔑 **API Tokens** | Token management with scopes, create/revoke/roll/toggle |
|
||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
||||
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
||||
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
|
||||
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
|
||||
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
||||
@@ -1038,12 +1345,9 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'getCombinedMetrics' // All metrics in one call
|
||||
|
||||
// Email Operations
|
||||
'getQueuedEmails' // Emails pending delivery
|
||||
'getSentEmails' // Successfully delivered emails
|
||||
'getFailedEmails' // Failed emails
|
||||
'getAllEmails' // List all emails (queued/sent/failed)
|
||||
'getEmailDetail' // Full detail for a specific email
|
||||
'resendEmail' // Re-queue a failed email
|
||||
'getBounceRecords' // Bounce records
|
||||
'removeFromSuppressionList' // Unsuppress an address
|
||||
|
||||
// Certificates
|
||||
'getCertificateOverview' // Domain-centric certificate status
|
||||
@@ -1062,11 +1366,39 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'getRemoteIngressStatus' // Runtime status of all edges
|
||||
'getRemoteIngressConnectionToken' // Generate a connection token for an edge
|
||||
|
||||
// Route Management (JWT or API token auth)
|
||||
'getMergedRoutes' // List all routes (hardcoded + programmatic)
|
||||
'createRoute' // Create a new programmatic route
|
||||
'updateRoute' // Update a programmatic route
|
||||
'deleteRoute' // Delete a programmatic route
|
||||
'toggleRoute' // Enable/disable a programmatic route
|
||||
'setRouteOverride' // Override a hardcoded route
|
||||
'removeRouteOverride' // Remove a hardcoded route override
|
||||
|
||||
// API Token Management (admin JWT only)
|
||||
'createApiToken' // Create API token → returns raw value once
|
||||
'listApiTokens' // List all tokens (without secrets)
|
||||
'revokeApiToken' // Delete an API token
|
||||
'rollApiToken' // Regenerate token secret
|
||||
'toggleApiToken' // Enable/disable a token
|
||||
|
||||
// Configuration (read-only)
|
||||
'getConfiguration' // Current system config
|
||||
|
||||
// Logs
|
||||
'getLogs' // Retrieve system logs
|
||||
'getRecentLogs' // Retrieve system logs with filtering
|
||||
'getLogStream' // Stream live logs
|
||||
|
||||
// VPN
|
||||
'getVpnClients' // List all registered VPN clients
|
||||
'getVpnStatus' // VPN server status (running, subnet, port, keys)
|
||||
'createVpnClient' // Create client → returns WireGuard config (shown once)
|
||||
'deleteVpnClient' // Remove a VPN client
|
||||
'enableVpnClient' // Enable a disabled client
|
||||
'disableVpnClient' // Disable a client
|
||||
'rotateVpnClientKey' // Generate new keys (invalidates old ones)
|
||||
'exportVpnClientConfig' // Export WireGuard (.conf) or SmartVPN (.json) config
|
||||
'getVpnClientTelemetry' // Per-client bytes sent/received, keepalives
|
||||
|
||||
// RADIUS
|
||||
'getRadiusSessions' // Active RADIUS sessions
|
||||
@@ -1080,6 +1412,77 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'testVlanAssignment' // Test what VLAN a MAC gets
|
||||
```
|
||||
|
||||
## API Client
|
||||
|
||||
DcRouter ships with a typed, object-oriented API client for programmatic management of a running instance. Install it separately or import from the main package:
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
# or import from the main package:
|
||||
# import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
```
|
||||
|
||||
### Quick Example
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
|
||||
await client.login('admin', 'password');
|
||||
|
||||
// OO resource instances with methods
|
||||
const { routes } = await client.routes.list();
|
||||
await routes[0].toggle(false);
|
||||
|
||||
// Builder pattern for creation
|
||||
const newRoute = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||
.setTls({ mode: 'terminate', certificate: 'auto' })
|
||||
.save();
|
||||
|
||||
// Manage certificates
|
||||
const { certificates, summary } = await client.certificates.list();
|
||||
await certificates[0].reprovision();
|
||||
|
||||
// Create API tokens with builder
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('ci-token')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.setExpiresInDays(90)
|
||||
.save();
|
||||
console.log(token.tokenValue); // only available at creation
|
||||
|
||||
// Remote ingress edges
|
||||
const edge = await client.remoteIngress.build()
|
||||
.setName('edge-nyc-01')
|
||||
.setListenPorts([80, 443])
|
||||
.save();
|
||||
const connToken = await edge.getConnectionToken();
|
||||
|
||||
// Read-only managers
|
||||
const health = await client.stats.getHealth();
|
||||
const config = await client.config.get();
|
||||
const { logs } = await client.logs.getRecent({ level: 'error', limit: 50 });
|
||||
```
|
||||
|
||||
### Resource Managers
|
||||
|
||||
| Manager | Operations |
|
||||
|---------|-----------|
|
||||
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
|
||||
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
|
||||
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
|
||||
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
|
||||
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
|
||||
| `client.config` | `get(section?)` |
|
||||
| `client.logs` | `getRecent()`, `getStream()` |
|
||||
| `client.emails` | `list()` → Email: `getDetail()`, `resend()` |
|
||||
| `client.radius` | `.clients`, `.vlans`, `.sessions` sub-managers + `getStatistics()`, `getAccountingSummary()` |
|
||||
|
||||
See the [full API client documentation](./ts_apiclient/readme.md) for detailed usage of every manager, builder, and resource class.
|
||||
|
||||
## API Reference
|
||||
|
||||
### DcRouter Class
|
||||
@@ -1114,6 +1517,7 @@ const router = new DcRouter(options: IDcRouterOptions);
|
||||
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
||||
| `storageManager` | `StorageManager` | Storage backend |
|
||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||
@@ -1123,7 +1527,7 @@ const router = new DcRouter(options: IDcRouterOptions);
|
||||
|
||||
### Re-exported Types
|
||||
|
||||
DcRouter re-exports key types from smartmta for convenience:
|
||||
DcRouter re-exports key types for convenience:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
@@ -1133,6 +1537,7 @@ import {
|
||||
type IUnifiedEmailServerOptions,
|
||||
type IEmailRoute,
|
||||
type IEmailDomainConfig,
|
||||
type IHttp3Config,
|
||||
} from '@serve.zone/dcrouter';
|
||||
```
|
||||
|
||||
@@ -1144,12 +1549,14 @@ DcRouter is published as a monorepo with separately-installable interface and we
|
||||
|---------|-------------|---------|
|
||||
| [`@serve.zone/dcrouter`](https://www.npmjs.com/package/@serve.zone/dcrouter) | Main package — the full router | `pnpm add @serve.zone/dcrouter` |
|
||||
| [`@serve.zone/dcrouter-interfaces`](https://www.npmjs.com/package/@serve.zone/dcrouter-interfaces) | TypedRequest interfaces for the OpsServer API | `pnpm add @serve.zone/dcrouter-interfaces` |
|
||||
| [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) | OO API client with builder pattern | `pnpm add @serve.zone/dcrouter-apiclient` |
|
||||
| [`@serve.zone/dcrouter-web`](https://www.npmjs.com/package/@serve.zone/dcrouter-web) | Web dashboard components | `pnpm add @serve.zone/dcrouter-web` |
|
||||
|
||||
You can also import interfaces directly from the main package:
|
||||
You can also import directly from the main package:
|
||||
|
||||
```typescript
|
||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -1171,20 +1578,87 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
|
||||
|
||||
| Test File | Area | Tests |
|
||||
|-----------|------|-------|
|
||||
| `test.apiclient.ts` | API client instantiation, builders, resource hydration, exports | 18 |
|
||||
| `test.contentscanner.ts` | Content scanning (spam, phishing, malware, attachments) | 13 |
|
||||
| `test.dcrouter.email.ts` | Email config, domain and route setup | 4 |
|
||||
| `test.dns-server-config.ts` | DNS record parsing, grouping, extraction | 5 |
|
||||
| `test.dns-socket-handler.ts` | DNS socket handler and route generation | 6 |
|
||||
| `test.errors.ts` | Error classes, handler, retry utilities | 5 |
|
||||
| `test.http3-augmentation.ts` | HTTP/3 route augmentation, qualification, opt-in/out, QUIC settings | 20 |
|
||||
| `test.ipreputationchecker.ts` | IP reputation, DNSBL, caching, risk classification | 10 |
|
||||
| `test.jwt-auth.ts` | JWT login, verification, logout, invalid credentials | 8 |
|
||||
| `test.opsserver-api.ts` | Health, statistics, configuration, log APIs | 6 |
|
||||
| `test.opsserver-api.ts` | Health, statistics, configuration, log APIs | 8 |
|
||||
| `test.protected-endpoint.ts` | Admin auth, identity verification, public endpoints | 8 |
|
||||
| `test.storagemanager.ts` | Memory, filesystem, custom backends, concurrency | 8 |
|
||||
|
||||
## Docker / OCI Container Deployment
|
||||
|
||||
DcRouter ships with a production-ready `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. The container image includes tini as PID 1 (via the base image), proper health checks, and configurable resource limits. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
|
||||
|
||||
### Running with Docker
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--ulimit nofile=65536:65536 \
|
||||
-e DCROUTER_TLS_EMAIL=admin@example.com \
|
||||
-e DCROUTER_PUBLIC_IP=203.0.113.1 \
|
||||
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
|
||||
-e DCROUTER_DNS_SCOPES=example.com \
|
||||
-p 80:80 -p 443:443 -p 25:25 -p 587:587 -p 465:465 \
|
||||
-p 53:53/udp -p 3000:3000 -p 8443:8443 \
|
||||
code.foss.global/serve.zone/dcrouter:latest
|
||||
```
|
||||
|
||||
> ⚡ **Production tip:** Always set `--ulimit nofile=65536:65536` for production deployments. DcRouter will log a warning at startup if the file descriptor limit is below 65536.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default | Example |
|
||||
|----------|-------------|---------|---------|
|
||||
| `DCROUTER_MODE` | Container mode (set automatically in image) | `OCI_CONTAINER` | — |
|
||||
| `DCROUTER_CONFIG_PATH` | Path to JSON config file (env vars override) | — | `/config/dcrouter.json` |
|
||||
| `DCROUTER_BASE_DIR` | Base data directory | `~/.serve.zone/dcrouter` | `/data/dcrouter` |
|
||||
| `DCROUTER_TLS_EMAIL` | ACME contact email | — | `admin@example.com` |
|
||||
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | — | `example.com` |
|
||||
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | — | `203.0.113.1` |
|
||||
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | — | `198.51.100.1,198.51.100.2` |
|
||||
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | — | `ns1.example.com,ns2.example.com` |
|
||||
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | — | `example.com,other.com` |
|
||||
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | — | `mail.example.com` |
|
||||
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | — | `25,587,465` |
|
||||
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | `false` |
|
||||
| `DCROUTER_HEAP_SIZE` | Node.js V8 heap size in MB | `512` | `1024` |
|
||||
| `DCROUTER_MAX_CONNECTIONS` | Global max concurrent connections | `50000` | `100000` |
|
||||
| `DCROUTER_MAX_CONNECTIONS_PER_IP` | Max connections per source IP | `100` | `200` |
|
||||
| `DCROUTER_CONNECTION_RATE_LIMIT` | Max new connections/min per IP | `600` | `1200` |
|
||||
|
||||
### Exposed Ports
|
||||
|
||||
The container exposes all service ports:
|
||||
|
||||
| Port(s) | Protocol | Service |
|
||||
|---------|----------|---------|
|
||||
| 80, 443 | TCP | HTTP/HTTPS (SmartProxy) |
|
||||
| 25, 587, 465 | TCP | SMTP, Submission, SMTPS |
|
||||
| 53 | TCP/UDP | DNS |
|
||||
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||
| 3000 | TCP | OpsServer dashboard |
|
||||
| 8443 | TCP | Remote ingress tunnels |
|
||||
| 51820 | UDP | WireGuard VPN |
|
||||
| 29000–30000 | TCP | Dynamic port range |
|
||||
|
||||
### Building the Image
|
||||
|
||||
```bash
|
||||
pnpm run build:docker # Build the container image
|
||||
pnpm run release:docker # Push to registry
|
||||
```
|
||||
|
||||
The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsdocker](https://code.foss.global/git.zone/tsdocker).
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
@@ -1196,7 +1670,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
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.
|
||||
|
||||
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: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
cacheConfig: {
|
||||
opsServerPort: 3104,
|
||||
dbConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,8 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
},
|
||||
cacheConfig: { enabled: false }
|
||||
opsServerPort: 3100,
|
||||
dbConfig: { enabled: false }
|
||||
});
|
||||
|
||||
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 () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
opsServerPort: 3102,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
@@ -41,7 +42,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
|
||||
tap.test('should verify valid JWT identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -57,7 +58,7 @@ tap.test('should verify valid JWT identity', async () => {
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -74,7 +75,7 @@ tap.test('should reject invalid JWT', async () => {
|
||||
|
||||
tap.test('should verify JWT matches identity data', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -91,7 +92,7 @@ tap.test('should verify JWT matches identity data', async () => {
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLogout'
|
||||
);
|
||||
|
||||
@@ -105,7 +106,7 @@ tap.test('should handle logout', async () => {
|
||||
|
||||
tap.test('should reject wrong credentials', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
|
||||
@@ -4,27 +4,45 @@ import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
opsServerPort: 3101,
|
||||
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>(
|
||||
'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 () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
|
||||
const response = await healthRequest.fire({
|
||||
detailed: false
|
||||
identity: adminIdentity,
|
||||
detailed: false,
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
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 () => {
|
||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
|
||||
const response = await statsRequest.fire({
|
||||
includeHistory: false
|
||||
identity: adminIdentity,
|
||||
includeHistory: false,
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('stats');
|
||||
expect(response.stats).toHaveProperty('uptime');
|
||||
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 () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({});
|
||||
|
||||
|
||||
const response = await configRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('system');
|
||||
expect(response.config).toHaveProperty('smartProxy');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
expect(response.config).toHaveProperty('tls');
|
||||
expect(response.config).toHaveProperty('cache');
|
||||
expect(response.config).toHaveProperty('radius');
|
||||
expect(response.config).toHaveProperty('remoteIngress');
|
||||
});
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getRecentLogs'
|
||||
);
|
||||
|
||||
|
||||
const response = await logsRequest.fire({
|
||||
limit: 10
|
||||
identity: adminIdentity,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('logs');
|
||||
expect(response).toHaveProperty('total');
|
||||
expect(response).toHaveProperty('hasMore');
|
||||
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 () => {
|
||||
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 () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
opsServerPort: 3103,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
@@ -34,7 +35,7 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
tap.test('should allow admin to verify identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -49,7 +50,7 @@ tap.test('should allow admin to verify identity', async () => {
|
||||
|
||||
tap.test('should reject verify identity without identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -64,7 +65,7 @@ tap.test('should reject verify identity without identity', async () => {
|
||||
|
||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'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>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
// No identity provided
|
||||
const response = await healthRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
console.log('Public endpoint accessible without auth');
|
||||
try {
|
||||
// No identity provided — should be rejected
|
||||
await healthRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
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>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'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.config).toHaveProperty('system');
|
||||
expect(response.config).toHaveProperty('smartProxy');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
console.log('Configuration read successfully');
|
||||
expect(response.config).toHaveProperty('tls');
|
||||
expect(response.config).toHaveProperty('cache');
|
||||
expect(response.config).toHaveProperty('radius');
|
||||
expect(response.config).toHaveProperty('remoteIngress');
|
||||
console.log('Authenticated access to config successful');
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
|
||||
@@ -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,56 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Configure services as needed for development
|
||||
// OpsServer always starts on port 3000
|
||||
|
||||
// Example: Add SmartProxy routes
|
||||
// smartProxyConfig: {
|
||||
// routes: [...]
|
||||
// },
|
||||
|
||||
// Example: Add email configuration
|
||||
// emailConfig: {
|
||||
// ports: [2525],
|
||||
// hostname: 'localhost',
|
||||
// domains: [],
|
||||
// routes: []
|
||||
// },
|
||||
// Server public IP (used for VPN AllowedIPs)
|
||||
publicIp: '203.0.113.1',
|
||||
// SmartProxy routes for development/demo
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-traffic',
|
||||
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
},
|
||||
{
|
||||
name: 'api-gateway',
|
||||
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||
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' },
|
||||
],
|
||||
},
|
||||
// Disable db/mongo for dev
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '7.4.3',
|
||||
version: '12.0.0',
|
||||
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 type { StorageManager } from './storage/index.js';
|
||||
import { CertBackoffDoc } from './db/index.js';
|
||||
|
||||
interface IBackoffEntry {
|
||||
failures: number;
|
||||
@@ -10,65 +10,86 @@ interface IBackoffEntry {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* concurrency, per-domain dedup, and rate limiting internally.
|
||||
*/
|
||||
export class CertProvisionScheduler {
|
||||
private storageManager: StorageManager;
|
||||
private maxBackoffHours: number;
|
||||
|
||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||
private backoffCache = new Map<string, IBackoffEntry>();
|
||||
|
||||
constructor(
|
||||
storageManager: StorageManager,
|
||||
options?: { maxBackoffHours?: number }
|
||||
) {
|
||||
this.storageManager = storageManager;
|
||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a domain's backoff entry
|
||||
* Sanitized domain key for storage lookups
|
||||
*/
|
||||
private backoffKey(domain: string): string {
|
||||
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return `/cert-backoff/${clean}`;
|
||||
private sanitizeDomain(domain: string): string {
|
||||
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const cached = this.backoffCache.get(domain);
|
||||
if (cached) return cached;
|
||||
|
||||
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
||||
if (entry) {
|
||||
const sanitized = this.sanitizeDomain(domain);
|
||||
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);
|
||||
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> {
|
||||
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> {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return false;
|
||||
|
||||
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,9 +121,13 @@ export class CertProvisionScheduler {
|
||||
async clearBackoff(domain: string): Promise<void> {
|
||||
this.backoffCache.delete(domain);
|
||||
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 {
|
||||
// Ignore delete errors (key may not exist)
|
||||
// Ignore delete errors (doc may not exist)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +149,12 @@ export class CertProvisionScheduler {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
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);
|
||||
if (retryAfter.getTime() <= Date.now()) return null;
|
||||
if (retryAfter.getTime() <= Date.now()) {
|
||||
this.backoffCache.delete(domain);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
failures: entry.failures,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,58 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { StorageManager } from './storage/index.js';
|
||||
import { AcmeCertDoc } from './db/index.js';
|
||||
|
||||
/**
|
||||
* ICertManager implementation backed by StorageManager.
|
||||
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
||||
* ICertManager implementation backed by smartdata document classes.
|
||||
* Persists SmartAcme certificates via AcmeCertDoc so they
|
||||
* survive process restarts without re-hitting ACME.
|
||||
*/
|
||||
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||
private keyPrefix = '/certs/';
|
||||
|
||||
constructor(private storageManager: StorageManager) {}
|
||||
constructor() {}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
||||
if (!data) return null;
|
||||
return new plugins.smartacme.Cert(data);
|
||||
}
|
||||
|
||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr,
|
||||
validUntil: cert.validUntil,
|
||||
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||
if (!doc) return null;
|
||||
return new plugins.smartacme.Cert({
|
||||
id: doc.id,
|
||||
domainName: doc.domainName,
|
||||
created: doc.created,
|
||||
privateKey: doc.privateKey,
|
||||
publicKey: doc.publicKey,
|
||||
csr: doc.csr,
|
||||
validUntil: doc.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> {
|
||||
await this.storageManager.delete(this.keyPrefix + domainName);
|
||||
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async wipe(): Promise<void> {
|
||||
const keys = await this.storageManager.list(this.keyPrefix);
|
||||
for (const key of keys) {
|
||||
await this.storageManager.delete(key);
|
||||
const docs = await AcmeCertDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
335
ts/config/classes.route-config-manager.ts
Normal file
335
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
||||
import type {
|
||||
IStoredRoute,
|
||||
IRouteOverride,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.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[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
|
||||
return { routes: merged, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Programmatic route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
): 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)}`;
|
||||
}
|
||||
|
||||
const stored: IStoredRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
};
|
||||
|
||||
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 },
|
||||
): 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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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);
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// 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';
|
||||
@@ -170,7 +170,7 @@ export class ConfigValidator {
|
||||
} else if (rules.items.schema && itemType === 'object') {
|
||||
const itemResult = this.validate(value[i], rules.items.schema);
|
||||
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) {
|
||||
const nestedResult = this.validate(value, rules.schema);
|
||||
if (!nestedResult.valid) {
|
||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
||||
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||
}
|
||||
validatedConfig[key] = nestedResult.config;
|
||||
}
|
||||
@@ -233,8 +233,8 @@ export class ConfigValidator {
|
||||
|
||||
// Apply defaults to array items
|
||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export class ConfigValidator {
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ValidationError(
|
||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
||||
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||
'CONFIG_VALIDATION_ERROR',
|
||||
{ data: { errors: result.errors } }
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.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 { CachedEmail } from './documents/classes.cached.email.js';
|
||||
@@ -26,10 +26,10 @@ export class CacheCleaner {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private options: Required<ICacheCleanerOptions>;
|
||||
private cacheDb: CacheDb;
|
||||
private dcRouterDb: DcRouterDb;
|
||||
|
||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||
this.cacheDb = cacheDb;
|
||||
constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
|
||||
this.dcRouterDb = dcRouterDb;
|
||||
this.options = {
|
||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||
verbose: options.verbose || false,
|
||||
@@ -48,14 +48,14 @@ export class CacheCleaner {
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||
this.runCleanup().catch((error: unknown) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
|
||||
});
|
||||
|
||||
// Schedule periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||
this.runCleanup().catch((error: unknown) => {
|
||||
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
@@ -86,8 +86,8 @@ export class CacheCleaner {
|
||||
* Run a single cleanup cycle
|
||||
*/
|
||||
public async runCleanup(): Promise<void> {
|
||||
if (!this.cacheDb.isReady()) {
|
||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||
if (!this.dcRouterDb.isReady()) {
|
||||
logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ export class CacheCleaner {
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -138,14 +138,14 @@ export class CacheCleaner {
|
||||
try {
|
||||
await doc.delete();
|
||||
deletedCount++;
|
||||
} catch (deleteError) {
|
||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
||||
} catch (deleteError: unknown) {
|
||||
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
|
||||
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
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public expiresAt: Date;
|
||||
public expiresAt!: Date;
|
||||
|
||||
/**
|
||||
* 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 { 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
|
||||
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
|
||||
/**
|
||||
* 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
|
||||
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
public id!: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId: string;
|
||||
public messageId!: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from: string;
|
||||
public from!: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to: string[];
|
||||
public to!: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc: string[];
|
||||
public cc!: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc: string[];
|
||||
public bcc!: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject: string;
|
||||
public subject!: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent: string;
|
||||
public rawContent!: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TCachedEmailStatus;
|
||||
public status!: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt: Date;
|
||||
public nextAttempt!: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError: string;
|
||||
public lastError!: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt: Date;
|
||||
public deliveredAt!: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain: string;
|
||||
public senderDomain!: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData: string;
|
||||
public routeData!: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as plugins from '../../plugins.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
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* IP reputation result data
|
||||
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string;
|
||||
public ipAddress!: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score: number;
|
||||
public score!: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam: boolean;
|
||||
public isSpam!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy: boolean;
|
||||
public isProxy!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor: boolean;
|
||||
public isTor!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN: boolean;
|
||||
public isVPN!: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string;
|
||||
public country!: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: string;
|
||||
public asn!: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org: string;
|
||||
public org!: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists: string[];
|
||||
public blacklists!: string[];
|
||||
|
||||
/**
|
||||
* 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({});
|
||||
}
|
||||
}
|
||||
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({});
|
||||
}
|
||||
}
|
||||
38
ts/db/documents/classes.stored-route.doc.ts
Normal file
38
ts/db/documents/classes.stored-route.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 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;
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
57
ts/db/documents/classes.vpn-client.doc.ts
Normal file
57
ts/db/documents/classes.vpn-client.doc.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
24
ts/db/documents/index.ts
Normal file
24
ts/db/documents/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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';
|
||||
|
||||
// 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
|
||||
export * from './classes.cachedb.js';
|
||||
// Unified database manager
|
||||
export * from './classes.dcrouter-db.js';
|
||||
|
||||
// TTL base class and constants
|
||||
export * from './classes.cached.document.js';
|
||||
|
||||
// Cache cleaner
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
// Document classes
|
||||
@@ -227,7 +227,7 @@ export class PlatformError extends Error {
|
||||
const { retry } = this.context;
|
||||
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';
|
||||
26
ts/index.ts
26
ts/index.ts
@@ -5,6 +5,7 @@ export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
|
||||
// DcRouter
|
||||
import { DcRouter } from './classes.dcrouter.js';
|
||||
export * from './classes.dcrouter.js';
|
||||
|
||||
// RADIUS module
|
||||
@@ -13,4 +14,27 @@ export * from './radius/index.js';
|
||||
// Remote Ingress module
|
||||
export * from './remoteingress/index.js';
|
||||
|
||||
export const runCli = async () => {};
|
||||
// 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);
|
||||
};
|
||||
|
||||
@@ -35,7 +35,9 @@ export class MetricsManager {
|
||||
queryTypes: {} as Record<string, number>,
|
||||
topDomains: new Map<string, number>(),
|
||||
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
|
||||
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||
};
|
||||
@@ -95,12 +97,13 @@ export class MetricsManager {
|
||||
this.dnsMetrics.cacheMisses = 0;
|
||||
this.dnsMetrics.queryTypes = {};
|
||||
this.dnsMetrics.topDomains.clear();
|
||||
this.dnsMetrics.queryTimestamps = [];
|
||||
this.dnsMetrics.queryRing.fill(0);
|
||||
this.dnsMetrics.queryRingLastSecond = 0;
|
||||
this.dnsMetrics.responseTimes = [];
|
||||
this.dnsMetrics.recentQueries = [];
|
||||
this.dnsMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
|
||||
if (currentDate !== this.securityMetrics.lastResetDate) {
|
||||
this.securityMetrics.blockedIPs = 0;
|
||||
this.securityMetrics.authFailures = 0;
|
||||
@@ -141,16 +144,16 @@ export class MetricsManager {
|
||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
|
||||
|
||||
return {
|
||||
uptime: process.uptime(),
|
||||
startTime: Date.now() - (process.uptime() * 1000),
|
||||
memoryUsage: {
|
||||
heapUsed: process.memoryUsage().heapUsed,
|
||||
heapTotal: process.memoryUsage().heapTotal,
|
||||
external: process.memoryUsage().external,
|
||||
rss: process.memoryUsage().rss,
|
||||
// Add SmartMetrics memory data
|
||||
heapUsed,
|
||||
heapTotal,
|
||||
external,
|
||||
rss,
|
||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||
@@ -219,11 +222,8 @@ export class MetricsManager {
|
||||
.slice(0, 10)
|
||||
.map(([domain, count]) => ({ domain, count }));
|
||||
|
||||
// Calculate queries per second from recent timestamps
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60000;
|
||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
||||
const queriesPerSecond = recentQueries.length / 60;
|
||||
// Calculate queries per second from ring buffer (sum last 60 seconds)
|
||||
const queriesPerSecond = this.getQueryRingSum(60) / 60;
|
||||
|
||||
// Calculate average response time
|
||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||
@@ -296,11 +296,11 @@ export class MetricsManager {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
return [];
|
||||
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
|
||||
}
|
||||
|
||||
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const connectionInfo = [];
|
||||
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
|
||||
|
||||
for (const [routeName, count] of connectionsByRoute) {
|
||||
connectionInfo.push({
|
||||
@@ -427,12 +427,8 @@ export class MetricsManager {
|
||||
this.dnsMetrics.cacheMisses++;
|
||||
}
|
||||
|
||||
// Track query timestamp
|
||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
||||
|
||||
// Keep only timestamps from last 5 minutes
|
||||
const fiveMinutesAgo = Date.now() - 300000;
|
||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
|
||||
// Increment per-second query counter in ring buffer
|
||||
this.incrementQueryRing();
|
||||
|
||||
// Track response time if provided
|
||||
if (responseTimeMs) {
|
||||
@@ -562,6 +558,7 @@ export class MetricsManager {
|
||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [] as Array<any>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -594,6 +591,110 @@ export class MetricsManager {
|
||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||
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 {
|
||||
connectionsByIP,
|
||||
throughputRate,
|
||||
@@ -603,8 +704,9 @@ export class MetricsManager {
|
||||
throughputByIP,
|
||||
requestsPerSecond,
|
||||
requestsTotal,
|
||||
backends,
|
||||
};
|
||||
}, 200); // Use 200ms cache for more frequent updates
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
|
||||
// --- Time-series helpers ---
|
||||
@@ -633,6 +735,63 @@ export class MetricsManager {
|
||||
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()) {
|
||||
|
||||
@@ -2,28 +2,37 @@ import type DcRouter from '../classes.dcrouter.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.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 {
|
||||
public dcRouterRef: DcRouter;
|
||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||
|
||||
// TypedRouter for OpsServer-specific handlers
|
||||
public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||
|
||||
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||
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
|
||||
public adminHandler: handlers.AdminHandler;
|
||||
private configHandler: handlers.ConfigHandler;
|
||||
private logsHandler: handlers.LogsHandler;
|
||||
private securityHandler: handlers.SecurityHandler;
|
||||
private statsHandler: handlers.StatsHandler;
|
||||
private radiusHandler: handlers.RadiusHandler;
|
||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||
private certificateHandler: handlers.CertificateHandler;
|
||||
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
||||
public adminHandler!: handlers.AdminHandler;
|
||||
private configHandler!: handlers.ConfigHandler;
|
||||
private logsHandler!: handlers.LogsHandler;
|
||||
private securityHandler!: handlers.SecurityHandler;
|
||||
private statsHandler!: handlers.StatsHandler;
|
||||
private radiusHandler!: handlers.RadiusHandler;
|
||||
private emailOpsHandler!: handlers.EmailOpsHandler;
|
||||
private certificateHandler!: handlers.CertificateHandler;
|
||||
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||
private vpnHandler!: handlers.VpnHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
|
||||
|
||||
// Add our typedrouter to the dcRouter's main typedrouter
|
||||
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
}
|
||||
@@ -31,7 +40,7 @@ export class OpsServer {
|
||||
public async start() {
|
||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||
domain: 'localhost',
|
||||
feedMetadata: null,
|
||||
feedMetadata: undefined,
|
||||
serveDir: paths.distServe,
|
||||
});
|
||||
|
||||
@@ -42,17 +51,32 @@ export class OpsServer {
|
||||
// Set up handlers
|
||||
await this.setupHandlers();
|
||||
|
||||
await this.server.start(3000);
|
||||
await this.server.start(this.dcRouterRef.options.opsServerPort ?? 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all TypedRequest handlers
|
||||
*/
|
||||
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);
|
||||
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.logsHandler = new handlers.LogsHandler(this);
|
||||
this.securityHandler = new handlers.SecurityHandler(this);
|
||||
@@ -61,11 +85,18 @@ export class OpsServer {
|
||||
this.emailOpsHandler = new handlers.EmailOpsHandler(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);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
// Clean up log handler streams and push destination before stopping the server
|
||||
if (this.logsHandler) {
|
||||
this.logsHandler.cleanup();
|
||||
}
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class AdminHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// JWT instance
|
||||
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
|
||||
// Simple in-memory user storage (in production, use proper database)
|
||||
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 type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
||||
|
||||
export class CertificateHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
const viewRouter = this.opsServerRef.viewRouter;
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
|
||||
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||
|
||||
// Get Certificate Overview
|
||||
this.typedrouter.addTypedHandler(
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
||||
'getCertificateOverview',
|
||||
async (dataArg) => {
|
||||
@@ -23,8 +26,10 @@ export class CertificateHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||
|
||||
// Legacy route-based reprovision (backward compat)
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||
'reprovisionCertificate',
|
||||
async (dataArg) => {
|
||||
@@ -34,7 +39,7 @@ export class CertificateHandler {
|
||||
);
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
async (dataArg) => {
|
||||
@@ -44,7 +49,7 @@ export class CertificateHandler {
|
||||
);
|
||||
|
||||
// Delete certificate
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||
'deleteCertificate',
|
||||
async (dataArg) => {
|
||||
@@ -54,7 +59,7 @@ export class CertificateHandler {
|
||||
);
|
||||
|
||||
// Export certificate
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||
'exportCertificate',
|
||||
async (dataArg) => {
|
||||
@@ -64,7 +69,7 @@ export class CertificateHandler {
|
||||
);
|
||||
|
||||
// Import certificate
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||
'importCertificate',
|
||||
async (dataArg) => {
|
||||
@@ -183,30 +188,28 @@ export class CertificateHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Check persisted cert data from StorageManager
|
||||
// Check persisted cert data from smartdata document classes
|
||||
if (status === 'unknown') {
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
||||
if (!certData) {
|
||||
// Also check certStore path (proxy-certs)
|
||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
||||
}
|
||||
if (certData?.validUntil) {
|
||||
expiryDate = new Date(certData.validUntil).toISOString();
|
||||
if (certData.created) {
|
||||
issuedAt = new Date(certData.created).toISOString();
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
|
||||
|
||||
if (acmeDoc?.validUntil) {
|
||||
expiryDate = new Date(acmeDoc.validUntil).toISOString();
|
||||
if (acmeDoc.created) {
|
||||
issuedAt = new Date(acmeDoc.created).toISOString();
|
||||
}
|
||||
issuer = 'smartacme-dns-01';
|
||||
} else if (certData?.publicKey) {
|
||||
} else if (proxyDoc?.publicKey) {
|
||||
// certStore has the cert — parse PEM for expiry
|
||||
try {
|
||||
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
|
||||
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 (certData) {
|
||||
} else if (acmeDoc || proxyDoc) {
|
||||
status = 'valid';
|
||||
issuer = 'cert-store';
|
||||
}
|
||||
@@ -307,8 +310,8 @@ export class CertificateHandler {
|
||||
}
|
||||
}
|
||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,8 +339,8 @@ export class CertificateHandler {
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,8 +350,8 @@ export class CertificateHandler {
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeNames[0]);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,18 +365,17 @@ export class CertificateHandler {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
|
||||
// Delete from all known storage paths
|
||||
const paths = [
|
||||
`/proxy-certs/${domain}`,
|
||||
`/proxy-certs/${cleanDomain}`,
|
||||
`/certs/${cleanDomain}`,
|
||||
];
|
||||
// Delete from smartdata document classes
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
if (acmeDoc) {
|
||||
await acmeDoc.delete();
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
await dcRouter.storageManager.delete(path);
|
||||
} catch {
|
||||
// Path may not exist — ignore
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,43 +406,41 @@ export class CertificateHandler {
|
||||
};
|
||||
message?: string;
|
||||
}> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
|
||||
// Try SmartAcme /certs/ path first (has full ICert fields)
|
||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
||||
if (certData && certData.publicKey && certData.privateKey) {
|
||||
// Try AcmeCertDoc first (has full ICert fields)
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
|
||||
return {
|
||||
success: true,
|
||||
cert: {
|
||||
id: certData.id || plugins.crypto.randomUUID(),
|
||||
domainName: certData.domainName || domain,
|
||||
created: certData.created || Date.now(),
|
||||
validUntil: certData.validUntil || 0,
|
||||
privateKey: certData.privateKey,
|
||||
publicKey: certData.publicKey,
|
||||
csr: certData.csr || '',
|
||||
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 /proxy-certs/ with original domain
|
||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
||||
if (!certData || !certData.publicKey) {
|
||||
// Try with clean domain
|
||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
|
||||
// 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 (certData && certData.publicKey && certData.privateKey) {
|
||||
if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
|
||||
return {
|
||||
success: true,
|
||||
cert: {
|
||||
id: plugins.crypto.randomUUID(),
|
||||
domainName: domain,
|
||||
created: certData.validFrom || Date.now(),
|
||||
validUntil: certData.validUntil || 0,
|
||||
privateKey: certData.privateKey,
|
||||
publicKey: certData.publicKey,
|
||||
created: proxyDoc.validFrom || Date.now(),
|
||||
validUntil: proxyDoc.validUntil || 0,
|
||||
privateKey: proxyDoc.privateKey,
|
||||
publicKey: proxyDoc.publicKey,
|
||||
csr: '',
|
||||
},
|
||||
};
|
||||
@@ -472,26 +472,32 @@ export class CertificateHandler {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
||||
|
||||
// Save to /certs/ (SmartAcme-compatible path)
|
||||
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
validUntil: cert.validUntil,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr || '',
|
||||
});
|
||||
// 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 /proxy-certs/ (proxy-cert format)
|
||||
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
|
||||
domain: cert.domainName,
|
||||
publicKey: cert.publicKey,
|
||||
privateKey: cert.privateKey,
|
||||
ca: undefined,
|
||||
validUntil: cert.validUntil,
|
||||
validFrom: cert.created,
|
||||
});
|
||||
// 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, {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class ConfigHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Get Configuration Handler (read-only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
async (dataArg, toolsArg) => {
|
||||
const config = await this.getConfiguration(dataArg.section);
|
||||
const config = await this.getConfiguration();
|
||||
return {
|
||||
config,
|
||||
section: dataArg.section,
|
||||
@@ -26,83 +26,188 @@ export class ConfigHandler {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfiguration(section?: string): Promise<{
|
||||
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;
|
||||
};
|
||||
}> {
|
||||
|
||||
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
|
||||
// Get email domains if email server is configured
|
||||
const opts = dcRouter.options;
|
||||
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[] = [];
|
||||
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) {
|
||||
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
|
||||
} else if (dcRouter.options.emailConfig?.domains) {
|
||||
// Fallback: get domains from email config options
|
||||
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
|
||||
if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
|
||||
emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
|
||||
} else if (opts.emailConfig?.domains) {
|
||||
emailDomains = opts.emailConfig.domains.map((d: any) =>
|
||||
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 {
|
||||
email: {
|
||||
enabled: !!dcRouter.emailServer,
|
||||
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
|
||||
maxMessageSize: 10 * 1024 * 1024, // 10MB default
|
||||
rateLimits: {
|
||||
perMinute: 10,
|
||||
perHour: 100,
|
||||
perDay: 1000,
|
||||
},
|
||||
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,
|
||||
},
|
||||
system,
|
||||
smartProxy,
|
||||
email,
|
||||
dns,
|
||||
tls,
|
||||
cache,
|
||||
radius,
|
||||
remoteIngress,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,44 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { SecurityLogger } from '../../security/index.js';
|
||||
|
||||
export class EmailOpsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Queued Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
|
||||
'getQueuedEmails',
|
||||
const viewRouter = this.opsServerRef.viewRouter;
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
|
||||
// ---- 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) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
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,
|
||||
};
|
||||
const emails = this.getAllQueueEmails();
|
||||
return { emails };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Sent Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
||||
'getSentEmails',
|
||||
// Get Email Detail Handler
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
||||
'getEmailDetail',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'delivered',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length, // Note: total would ideally come from a counter
|
||||
};
|
||||
const email = this.getEmailDetail(dataArg.emailId);
|
||||
return { email };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Failed Emails Handler
|
||||
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,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
// ---- Write endpoints (adminRouter) ----
|
||||
|
||||
// Resend Failed Email Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
async (dataArg) => {
|
||||
@@ -101,17 +59,12 @@ export class EmailOpsHandler {
|
||||
}
|
||||
|
||||
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(
|
||||
item.processingResult,
|
||||
item.processingMode,
|
||||
item.route
|
||||
);
|
||||
|
||||
// Optionally remove the old failed entry
|
||||
await queue.removeItem(dataArg.emailId);
|
||||
|
||||
return { success: true, newQueueId };
|
||||
} catch (error) {
|
||||
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(
|
||||
status?: interfaces.requests.TEmailQueueStatus,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): interfaces.requests.IEmailQueueItem[] {
|
||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter and convert items
|
||||
const emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
// Apply status filter if provided
|
||||
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,
|
||||
});
|
||||
emails.push(this.mapQueueItemToEmail(item));
|
||||
}
|
||||
|
||||
// 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 items.slice(offset, offset + limit);
|
||||
return emails;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,7 @@ export * from './stats.handler.js';
|
||||
export * from './radius.handler.js';
|
||||
export * from './email-ops.handler.js';
|
||||
export * from './certificate.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './route-management.handler.js';
|
||||
export * from './api-token.handler.js';
|
||||
export * from './vpn.handler.js';
|
||||
@@ -3,19 +3,40 @@ import type { OpsServer } from '../classes.opsserver.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 {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private activeStreamStops: Set<() => void> = new Set();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
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 {
|
||||
// All log endpoints register directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Get Recent Logs Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'getRecentLogs',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -27,24 +48,24 @@ export class LogsHandler {
|
||||
dataArg.search,
|
||||
dataArg.timeRange
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: logs.length, // TODO: Implement proper total count
|
||||
hasMore: false, // TODO: Implement proper pagination
|
||||
total: logs.length,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
// Get Log Stream Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
||||
'getLogStream',
|
||||
async (dataArg, toolsArg) => {
|
||||
// Create a virtual stream for log streaming
|
||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||
|
||||
|
||||
// Set up log streaming
|
||||
const streamLogs = this.setupLogStream(
|
||||
virtualStream,
|
||||
@@ -52,20 +73,21 @@ export class LogsHandler {
|
||||
dataArg.filters?.category,
|
||||
dataArg.follow
|
||||
);
|
||||
|
||||
|
||||
// Start streaming
|
||||
streamLogs.start();
|
||||
|
||||
// VirtualStream handles cleanup automatically
|
||||
|
||||
|
||||
// Track the stop function so we can clean up on shutdown
|
||||
this.activeStreamStops.add(streamLogs.stop);
|
||||
|
||||
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':
|
||||
@@ -165,18 +187,30 @@ export class LogsHandler {
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const opsServerRef = this.opsServerRef;
|
||||
// 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) {
|
||||
// Access the TypedSocket server instance from OpsServer
|
||||
const typedsocket = opsServerRef.server?.typedserver?.typedsocket;
|
||||
const opsServer = currentOpsServerRef;
|
||||
if (!opsServer) return;
|
||||
|
||||
const typedsocket = opsServer.server?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
|
||||
let connections: any[];
|
||||
@@ -220,8 +254,18 @@ export class LogsHandler {
|
||||
stop: () => void;
|
||||
} {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
let logIndex = 0;
|
||||
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
this.activeStreamStops.delete(stop);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (!follow) {
|
||||
// Send existing logs and close
|
||||
@@ -236,13 +280,19 @@ export class LogsHandler {
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
});
|
||||
// VirtualStream doesn't have end() method - it closes automatically
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// For follow mode, simulate real-time log streaming
|
||||
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 levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
|
||||
@@ -266,30 +316,25 @@ export class LogsHandler {
|
||||
const logData = JSON.stringify(logEntry);
|
||||
const encoder = new TextEncoder();
|
||||
try {
|
||||
await virtualStream.sendData(encoder.encode(logData));
|
||||
// Use a timeout to detect hung streams (sendData can hang if the
|
||||
// VirtualStream's keepAlive loop has ended)
|
||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
await Promise.race([
|
||||
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
return result;
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Stream closed or errored — clean up to prevent interval leak
|
||||
clearInterval(intervalId!);
|
||||
intervalId = null;
|
||||
// Stream closed, errored, or timed out — clean up
|
||||
stop();
|
||||
}
|
||||
}, 2000); // Send a log every 2 seconds
|
||||
|
||||
// TODO: Hook into actual logger events
|
||||
// logger.on('log', (logEntry) => {
|
||||
// if (matchesCriteria(logEntry, level, service)) {
|
||||
// virtualStream.sendData(formatLogEntry(logEntry));
|
||||
// }
|
||||
// });
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
// TODO: Unhook from logger events
|
||||
};
|
||||
|
||||
|
||||
return { start, stop };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,19 @@ import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RadiusHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
const viewRouter = this.opsServerRef.viewRouter;
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
// ========================================================================
|
||||
// RADIUS Client Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all RADIUS clients
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get all RADIUS clients (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -40,8 +38,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Add or update a RADIUS client (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -54,15 +52,15 @@ export class RadiusHandler {
|
||||
try {
|
||||
await radiusServer.addClient(dataArg.client);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Remove a RADIUS client (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -85,8 +83,8 @@ export class RadiusHandler {
|
||||
// VLAN Mapping Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all VLAN mappings
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get all VLAN mappings (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -121,8 +119,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Add or update a VLAN mapping (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -146,15 +144,15 @@ export class RadiusHandler {
|
||||
updatedAt: mapping.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Remove a VLAN mapping (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -174,8 +172,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Update VLAN configuration
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Update VLAN configuration (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -206,8 +204,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Test VLAN assignment
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Test VLAN assignment (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -240,8 +238,8 @@ export class RadiusHandler {
|
||||
// Accounting / Session Management
|
||||
// ========================================================================
|
||||
|
||||
// Get active sessions
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get active sessions (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -289,8 +287,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Disconnect a session
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Disconnect a session (write)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -314,8 +312,8 @@ export class RadiusHandler {
|
||||
)
|
||||
);
|
||||
|
||||
// Get accounting summary
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get accounting summary (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -351,8 +349,8 @@ export class RadiusHandler {
|
||||
// Statistics
|
||||
// ========================================================================
|
||||
|
||||
// Get RADIUS statistics
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get RADIUS statistics (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
|
||||
@@ -3,16 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RemoteIngressHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
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
|
||||
this.typedrouter.addTypedHandler(
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||
'getRemoteIngresses',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -36,8 +38,10 @@ export class RemoteIngressHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// ---- Write endpoints (adminRouter) ----
|
||||
|
||||
// Create a new remote ingress edge
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||
'createRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -69,7 +73,7 @@ export class RemoteIngressHandler {
|
||||
);
|
||||
|
||||
// Delete a remote ingress edge
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||
'deleteRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -94,7 +98,7 @@ export class RemoteIngressHandler {
|
||||
);
|
||||
|
||||
// Update a remote ingress edge
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||
'updateRemoteIngress',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -138,7 +142,7 @@ export class RemoteIngressHandler {
|
||||
);
|
||||
|
||||
// Regenerate secret for an edge
|
||||
this.typedrouter.addTypedHandler(
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||
'regenerateRemoteIngressSecret',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -164,8 +168,8 @@ export class RemoteIngressHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Get runtime status of all edges
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get runtime status of all edges (read)
|
||||
viewRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||
'getRemoteIngressStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -178,8 +182,8 @@ export class RemoteIngressHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Get a connection token for an edge
|
||||
this.typedrouter.addTypedHandler(
|
||||
// Get a connection token for an edge (write — exposes secret)
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'getRemoteIngressConnectionToken',
|
||||
async (dataArg, toolsArg) => {
|
||||
|
||||
163
ts/opsserver/handlers/route-management.handler.ts
Normal file
163
ts/opsserver/handlers/route-management.handler.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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);
|
||||
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,
|
||||
});
|
||||
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' };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,16 @@ import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
|
||||
export class SecurityHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
|
||||
private registerHandlers(): void {
|
||||
// All security endpoints register directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Security Metrics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||
'getSecurityMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -40,7 +39,7 @@ export class SecurityHandler {
|
||||
);
|
||||
|
||||
// Active Connections Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||
'getActiveConnections',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -77,8 +76,8 @@ export class SecurityHandler {
|
||||
);
|
||||
|
||||
// Network Stats Handler - provides comprehensive network metrics
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
|
||||
'getNetworkStats',
|
||||
async (dataArg, toolsArg) => {
|
||||
// Get network stats from MetricsManager if available
|
||||
@@ -102,6 +101,7 @@ export class SecurityHandler {
|
||||
throughputByIP,
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
backends: networkStats.backends || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,13 +115,14 @@ export class SecurityHandler {
|
||||
throughputByIP: [],
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Rate Limit Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||
'getRateLimitStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
|
||||
@@ -5,17 +5,16 @@ import { MetricsManager } from '../../monitoring/index.js';
|
||||
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||
|
||||
export class StatsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
|
||||
private registerHandlers(): void {
|
||||
// All stats endpoints register directly on viewRouter (valid identity required via middleware)
|
||||
const router = this.opsServerRef.viewRouter;
|
||||
|
||||
// Server Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'getServerStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -38,7 +37,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Email Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
||||
'getEmailStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -77,7 +76,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// DNS Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
||||
'getDnsStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -114,7 +113,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Queue Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
||||
'getQueueStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -142,7 +141,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Health Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'getHealthStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -167,7 +166,7 @@ export class StatsHandler {
|
||||
);
|
||||
|
||||
// Combined Metrics Handler - More efficient for frontend polling
|
||||
this.typedrouter.addTypedHandler(
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||
'getCombinedMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
@@ -280,7 +279,7 @@ export class StatsHandler {
|
||||
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
|
||||
const serverStats = await this.collectServerStats();
|
||||
|
||||
// Build per-IP bandwidth lookup from throughputByIP
|
||||
@@ -310,6 +309,7 @@ export class StatsHandler {
|
||||
throughputHistory: stats.throughputHistory || [],
|
||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||
requestsTotal: stats.requestsTotal || 0,
|
||||
backends: stats.backends || [],
|
||||
};
|
||||
})()
|
||||
);
|
||||
@@ -490,44 +490,41 @@ export class StatsHandler {
|
||||
message?: string;
|
||||
}>;
|
||||
}> {
|
||||
const services: Array<{
|
||||
name: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
message?: string;
|
||||
}> = [];
|
||||
|
||||
// Check HTTP Proxy
|
||||
if (this.opsServerRef.dcRouterRef.smartProxy) {
|
||||
services.push({
|
||||
name: 'HTTP/HTTPS Proxy',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check Email Server
|
||||
if (this.opsServerRef.dcRouterRef.emailServer) {
|
||||
services.push({
|
||||
name: 'Email Server',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check DNS Server
|
||||
if (this.opsServerRef.dcRouterRef.dnsServer) {
|
||||
services.push({
|
||||
name: 'DNS Server',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check OpsServer
|
||||
services.push({
|
||||
name: 'OpsServer',
|
||||
status: 'healthy',
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const health = dcRouter.serviceManager.getHealth();
|
||||
|
||||
const services = health.services.map((svc) => {
|
||||
let status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
switch (svc.state) {
|
||||
case 'running':
|
||||
status = 'healthy';
|
||||
break;
|
||||
case 'starting':
|
||||
case 'degraded':
|
||||
status = 'degraded';
|
||||
break;
|
||||
case 'failed':
|
||||
status = svc.criticality === 'critical' ? 'unhealthy' : 'degraded';
|
||||
break;
|
||||
case 'stopped':
|
||||
case 'stopping':
|
||||
default:
|
||||
status = 'degraded';
|
||||
break;
|
||||
}
|
||||
|
||||
let message: string | undefined;
|
||||
if (svc.state === 'failed' && svc.lastError) {
|
||||
message = svc.lastError;
|
||||
} else if (svc.retryCount > 0 && svc.state !== 'running') {
|
||||
message = `Retry attempt ${svc.retryCount}`;
|
||||
}
|
||||
|
||||
return { name: svc.name, status, message };
|
||||
});
|
||||
|
||||
const healthy = services.every(s => s.status === 'healthy');
|
||||
|
||||
|
||||
const healthy = health.overall === 'healthy';
|
||||
|
||||
return {
|
||||
healthy,
|
||||
services,
|
||||
|
||||
303
ts/opsserver/handlers/vpn.handler.ts
Normal file
303
ts/opsserver/handlers/vpn.handler.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
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,
|
||||
});
|
||||
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,
|
||||
dataArg: T
|
||||
dataArg: { identity?: interfaces.data.IIdentity }
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
|
||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
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,
|
||||
dataArg: T
|
||||
dataArg: { identity?: interfaces.data.IIdentity }
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
|
||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
|
||||
@@ -34,7 +34,6 @@ export function resolvePaths(baseDir?: string) {
|
||||
dcrouterHomeDir: root,
|
||||
dataDir: resolvedDataDir,
|
||||
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
|
||||
defaultStoragePath: plugins.path.join(root, 'storage'),
|
||||
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,23 +47,25 @@ import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartacme from '@push.rocks/smartacme';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
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 smartjwt from '@push.rocks/smartjwt';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||
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 smartpath from '@push.rocks/smartpath';
|
||||
import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartvpn from '@push.rocks/smartvpn';
|
||||
import * as smartradius from '@push.rocks/smartradius';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
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
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
@@ -89,7 +91,7 @@ export {
|
||||
uuid,
|
||||
}
|
||||
|
||||
// Filesystem utilities (compatibility helpers for smartfile v13+)
|
||||
// Filesystem utilities
|
||||
export const fsUtils = {
|
||||
/**
|
||||
* Ensure a directory exists, creating it recursively if needed (sync)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import { AccountingSessionDoc } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* RADIUS accounting session
|
||||
@@ -84,14 +84,14 @@ export interface IAccountingSummary {
|
||||
* Accounting manager configuration
|
||||
*/
|
||||
export interface IAccountingManagerConfig {
|
||||
/** Storage key prefix */
|
||||
storagePrefix?: string;
|
||||
/** Session retention period in days (default: 30) */
|
||||
retentionDays?: number;
|
||||
/** Enable detailed session logging */
|
||||
detailedLogging?: boolean;
|
||||
/** Maximum active sessions to track in memory */
|
||||
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 {
|
||||
private activeSessions: Map<string, IAccountingSession> = new Map();
|
||||
private config: Required<IAccountingManagerConfig>;
|
||||
private storageManager?: StorageManager;
|
||||
private staleSessionSweepTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
// Counters for statistics
|
||||
private stats = {
|
||||
@@ -115,26 +115,72 @@ export class AccountingManager {
|
||||
interimUpdatesReceived: 0,
|
||||
};
|
||||
|
||||
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) {
|
||||
constructor(config?: IAccountingManagerConfig) {
|
||||
this.config = {
|
||||
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
|
||||
retentionDays: config?.retentionDays ?? 30,
|
||||
detailedLogging: config?.detailedLogging ?? false,
|
||||
maxActiveSessions: config?.maxActiveSessions ?? 10000,
|
||||
staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24,
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the accounting manager
|
||||
*/
|
||||
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`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -195,9 +241,7 @@ export class AccountingManager {
|
||||
}
|
||||
|
||||
// Persist session
|
||||
if (this.storageManager) {
|
||||
await this.persistSession(session);
|
||||
}
|
||||
await this.persistSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,9 +287,7 @@ export class AccountingManager {
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
// Archive the session
|
||||
if (this.storageManager) {
|
||||
await this.archiveSession(session);
|
||||
}
|
||||
// Update status in the database (single collection, no active->archive move needed)
|
||||
await this.persistSession(session);
|
||||
|
||||
// Remove from active sessions
|
||||
this.activeSessions.delete(data.sessionId);
|
||||
@@ -438,23 +478,16 @@ export class AccountingManager {
|
||||
* Clean up old archived sessions based on retention policy
|
||||
*/
|
||||
async cleanupOldSessions(): Promise<number> {
|
||||
if (!this.storageManager) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
||||
let deletedCount = 0;
|
||||
|
||||
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 {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (session && session.endTime > 0 && session.endTime < cutoffTime) {
|
||||
await this.storageManager.delete(key);
|
||||
deletedCount++;
|
||||
}
|
||||
await doc.delete();
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
@@ -463,8 +496,8 @@ export class AccountingManager {
|
||||
if (deletedCount > 0) {
|
||||
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
@@ -497,9 +530,7 @@ export class AccountingManager {
|
||||
session.terminateCause = 'SessionEvicted';
|
||||
session.endTime = Date.now();
|
||||
|
||||
if (this.storageManager) {
|
||||
await this.archiveSession(session);
|
||||
}
|
||||
await this.persistSession(session);
|
||||
|
||||
this.activeSessions.delete(sessionId);
|
||||
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> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`);
|
||||
const docs = await AccountingSessionDoc.findActive();
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (session && session.status === 'active') {
|
||||
this.activeSessions.set(session.sessionId, session);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
for (const doc of docs) {
|
||||
const session: IAccountingSession = {
|
||||
sessionId: doc.sessionId,
|
||||
username: doc.username,
|
||||
macAddress: doc.macAddress,
|
||||
nasIpAddress: doc.nasIpAddress,
|
||||
nasPort: doc.nasPort,
|
||||
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) {
|
||||
logger.log('warn', `Failed to load active sessions: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
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> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
||||
try {
|
||||
await this.storageManager.setJSON(key, session);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`);
|
||||
let doc = await AccountingSessionDoc.findBySessionId(session.sessionId);
|
||||
if (!doc) {
|
||||
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
|
||||
*/
|
||||
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
|
||||
* Get archived (stopped/terminated) sessions for a time period
|
||||
*/
|
||||
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
|
||||
if (!this.storageManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions: IAccountingSession[] = [];
|
||||
|
||||
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) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (
|
||||
session &&
|
||||
session.endTime > 0 &&
|
||||
session.startTime <= endTime &&
|
||||
session.endTime >= startTime
|
||||
) {
|
||||
sessions.push(session);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
for (const doc of docs) {
|
||||
sessions.push({
|
||||
sessionId: doc.sessionId,
|
||||
username: doc.username,
|
||||
macAddress: doc.macAddress,
|
||||
nasIpAddress: doc.nasIpAddress,
|
||||
nasPort: doc.nasPort,
|
||||
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,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as plugins from '../plugins.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 { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
||||
|
||||
@@ -92,7 +91,6 @@ export class RadiusServer {
|
||||
private vlanManager: VlanManager;
|
||||
private accountingManager: AccountingManager;
|
||||
private config: IRadiusServerConfig;
|
||||
private storageManager?: StorageManager;
|
||||
private clientSecrets: Map<string, string> = new Map();
|
||||
private running: boolean = false;
|
||||
|
||||
@@ -105,20 +103,19 @@ export class RadiusServer {
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) {
|
||||
constructor(config: IRadiusServerConfig) {
|
||||
this.config = {
|
||||
authPort: config.authPort ?? 1812,
|
||||
acctPort: config.acctPort ?? 1813,
|
||||
bindAddress: config.bindAddress ?? '0.0.0.0',
|
||||
...config,
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
|
||||
// Initialize VLAN manager
|
||||
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager);
|
||||
this.vlanManager = new VlanManager(config.vlanAssignment);
|
||||
|
||||
// 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.accountingManager.stop();
|
||||
|
||||
this.running = false;
|
||||
logger.log('info', 'RADIUS server stopped');
|
||||
}
|
||||
@@ -308,8 +307,8 @@ export class RadiusServer {
|
||||
default:
|
||||
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `RADIUS accounting error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import { VlanMappingsDoc } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* MAC address to VLAN mapping
|
||||
@@ -42,8 +42,6 @@ export interface IVlanManagerConfig {
|
||||
defaultVlan?: number;
|
||||
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
||||
allowUnknownMacs?: boolean;
|
||||
/** Storage key prefix for persistence */
|
||||
storagePrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,27 +54,22 @@ export interface IVlanManagerConfig {
|
||||
export class VlanManager {
|
||||
private mappings: Map<string, IMacVlanMapping> = new Map();
|
||||
private config: Required<IVlanManagerConfig>;
|
||||
private storageManager?: StorageManager;
|
||||
|
||||
// Cache for normalized MAC lookups
|
||||
private normalizedMacCache: Map<string, string> = new Map();
|
||||
|
||||
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) {
|
||||
constructor(config?: IVlanManagerConfig) {
|
||||
this.config = {
|
||||
defaultVlan: config?.defaultVlan ?? 1,
|
||||
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
||||
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VLAN manager and load persisted mappings
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -104,7 +97,7 @@ export class VlanManager {
|
||||
if (this.normalizedMacCache.size > 10000) {
|
||||
const iterator = this.normalizedMacCache.keys();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
this.normalizedMacCache.delete(iterator.next().value);
|
||||
this.normalizedMacCache.delete(iterator.next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,10 +150,8 @@ export class VlanManager {
|
||||
|
||||
this.mappings.set(normalizedMac, fullMapping);
|
||||
|
||||
// Persist to storage
|
||||
if (this.storageManager) {
|
||||
await this.saveMappings();
|
||||
}
|
||||
// Persist to database
|
||||
await this.saveMappings();
|
||||
|
||||
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
||||
return fullMapping;
|
||||
@@ -173,7 +164,7 @@ export class VlanManager {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
const removed = this.mappings.delete(normalizedMac);
|
||||
|
||||
if (removed && this.storageManager) {
|
||||
if (removed) {
|
||||
await this.saveMappings();
|
||||
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
||||
}
|
||||
@@ -333,39 +324,36 @@ export class VlanManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mappings from storage
|
||||
* Load mappings from database
|
||||
*/
|
||||
private async loadMappings(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix);
|
||||
if (data && Array.isArray(data)) {
|
||||
for (const mapping of data) {
|
||||
const doc = await VlanMappingsDoc.load();
|
||||
if (doc && Array.isArray(doc.mappings)) {
|
||||
for (const mapping of doc.mappings) {
|
||||
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) {
|
||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
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> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mappings = Array.from(this.mappings.values());
|
||||
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
|
||||
let doc = await VlanMappingsDoc.load();
|
||||
if (!doc) {
|
||||
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
|
||||
* - OUI (vendor prefix) pattern matching for device categorization
|
||||
* - 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';
|
||||
|
||||
16
ts/readme.md
16
ts/readme.md
@@ -37,7 +37,7 @@ const router = new DcRouter({
|
||||
});
|
||||
|
||||
await router.start();
|
||||
// OpsServer dashboard at http://localhost:3000
|
||||
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
|
||||
|
||||
// Graceful shutdown
|
||||
await router.stop();
|
||||
@@ -60,6 +60,9 @@ ts/
|
||||
│ └── 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
|
||||
@@ -71,7 +74,10 @@ ts/
|
||||
│ ├── email.handler.ts # Email operations
|
||||
│ ├── certificate.handler.ts # Certificate management
|
||||
│ ├── radius.handler.ts # RADIUS management
|
||||
│ └── remoteingress.handler.ts # Remote ingress edge + token 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
|
||||
@@ -96,6 +102,9 @@ 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
|
||||
@@ -112,6 +121,7 @@ The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle o
|
||||
| `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` |
|
||||
@@ -126,7 +136,7 @@ Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks c
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
||||
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const STORAGE_PREFIX = '/remote-ingress/';
|
||||
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 | number[] | Array<{ from: number; to: number }>): number[] {
|
||||
function extractPorts(portRange: number | Array<number | { from: number; to: number }>): number[] {
|
||||
const ports = new Set<number>();
|
||||
if (typeof portRange === 'number') {
|
||||
ports.add(portRange);
|
||||
@@ -27,33 +25,40 @@ function extractPorts(portRange: number | number[] | Array<{ from: number; to: n
|
||||
|
||||
/**
|
||||
* Manages CRUD for remote ingress edge registrations.
|
||||
* Persists edge configs via StorageManager and provides
|
||||
* Persists edge configs via smartdata document classes and provides
|
||||
* the allowed edges list for the Rust hub.
|
||||
*/
|
||||
export class RemoteIngressManager {
|
||||
private storageManager: StorageManager;
|
||||
private edges: Map<string, IRemoteIngress> = new Map();
|
||||
private routes: IDcRouterRouteConfig[] = [];
|
||||
|
||||
constructor(storageManager: StorageManager) {
|
||||
this.storageManager = storageManager;
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all edge registrations from storage into memory.
|
||||
* Load all edge registrations from the database into memory.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
const keys = await this.storageManager.list(STORAGE_PREFIX);
|
||||
for (const key of keys) {
|
||||
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
|
||||
if (edge) {
|
||||
// Migration: old edges without autoDerivePorts default to true
|
||||
if ((edge as any).autoDerivePorts === undefined) {
|
||||
edge.autoDerivePorts = true;
|
||||
await this.storageManager.setJSON(key, edge);
|
||||
}
|
||||
this.edges.set(edge.id, edge);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +99,38 @@ export class RemoteIngressManager {
|
||||
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.
|
||||
@@ -106,6 +143,18 @@ export class RemoteIngressManager {
|
||||
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.
|
||||
@@ -145,7 +194,9 @@ export class RemoteIngressManager {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
||||
const doc = new RemoteIngressEdgeDoc();
|
||||
Object.assign(doc, edge);
|
||||
await doc.save();
|
||||
this.edges.set(id, edge);
|
||||
return edge;
|
||||
}
|
||||
@@ -189,7 +240,11 @@ export class RemoteIngressManager {
|
||||
if (updates.tags !== undefined) edge.tags = updates.tags;
|
||||
edge.updatedAt = Date.now();
|
||||
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
||||
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||
if (doc) {
|
||||
Object.assign(doc, edge);
|
||||
await doc.save();
|
||||
}
|
||||
this.edges.set(id, edge);
|
||||
return edge;
|
||||
}
|
||||
@@ -201,7 +256,10 @@ export class RemoteIngressManager {
|
||||
if (!this.edges.has(id)) {
|
||||
return false;
|
||||
}
|
||||
await this.storageManager.delete(`${STORAGE_PREFIX}${id}`);
|
||||
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
this.edges.delete(id);
|
||||
return true;
|
||||
}
|
||||
@@ -218,7 +276,11 @@ export class RemoteIngressManager {
|
||||
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
|
||||
edge.updatedAt = Date.now();
|
||||
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
||||
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||
if (doc) {
|
||||
Object.assign(doc, edge);
|
||||
await doc.save();
|
||||
}
|
||||
this.edges.set(id, edge);
|
||||
return edge.secret;
|
||||
}
|
||||
@@ -241,15 +303,18 @@ export class RemoteIngressManager {
|
||||
|
||||
/**
|
||||
* 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[] }> {
|
||||
const result: Array<{ id: string; secret: string; listenPorts: number[] }> = [];
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
|
||||
export interface ITunnelManagerConfig {
|
||||
tunnelPort?: number;
|
||||
targetHost?: string;
|
||||
tls?: {
|
||||
certPem?: string;
|
||||
keyPem?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,6 +19,7 @@ export class TunnelManager {
|
||||
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;
|
||||
@@ -22,12 +27,11 @@ export class TunnelManager {
|
||||
this.hub = new plugins.remoteingress.RemoteIngressHub();
|
||||
|
||||
// Listen for edge connect/disconnect events
|
||||
this.hub.on('edgeConnected', (data: { edgeId: string }) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
|
||||
this.edgeStatuses.set(data.edgeId, {
|
||||
edgeId: data.edgeId,
|
||||
connected: true,
|
||||
publicIp: existing?.publicIp ?? null,
|
||||
publicIp: data.peerAddr || null,
|
||||
activeTunnels: 0,
|
||||
lastHeartbeat: Date.now(),
|
||||
connectedAt: Date.now(),
|
||||
@@ -61,20 +65,73 @@ export class TunnelManager {
|
||||
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.
|
||||
@@ -109,6 +166,19 @@ export class TunnelManager {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -60,7 +60,7 @@ export enum ThreatCategory {
|
||||
* Content Scanner for detecting malicious email content
|
||||
*/
|
||||
export class ContentScanner {
|
||||
private static instance: ContentScanner;
|
||||
private static instance: ContentScanner | undefined;
|
||||
private scanCache: LRUCache<string, IScanResult>;
|
||||
private options: Required<IContentScannerOptions>;
|
||||
|
||||
@@ -182,7 +182,14 @@ export class ContentScanner {
|
||||
}
|
||||
return ContentScanner.instance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for shutdown/testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
ContentScanner.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an email for malicious content
|
||||
* @param email The email to scan
|
||||
@@ -251,12 +258,12 @@ export class ContentScanner {
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error scanning email: ${(error as Error).message}`, {
|
||||
messageId: email.getMessageId(),
|
||||
error: error.stack
|
||||
error: (error as Error).stack
|
||||
});
|
||||
|
||||
|
||||
// Return a safe default with error indication
|
||||
return {
|
||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||
@@ -264,7 +271,7 @@ export class ContentScanner {
|
||||
scannedElements: ['error'],
|
||||
timestamp: Date.now(),
|
||||
threatType: 'scan_error',
|
||||
threatDetails: `Scan error: ${error.message}`
|
||||
threatDetails: `Scan error: ${(error as Error).message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -618,8 +625,8 @@ export class ContentScanner {
|
||||
return sample.toString('utf8')
|
||||
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
||||
.replace(/\uFFFD/g, ''); // Remove replacement char
|
||||
} catch (error) {
|
||||
logger.log('warn', `Error extracting text from buffer: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('warn', `Error extracting text from buffer: ${(error as Error).message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -692,10 +699,10 @@ export class ContentScanner {
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
domain: email.getFromDomain() ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Log a threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
@@ -715,10 +722,10 @@ export class ContentScanner {
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
domain: email.getFromDomain() ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get threat level description based on score
|
||||
* @param score Threat score
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { CachedIPReputation } from '../db/documents/classes.cached.ip.reputation.js';
|
||||
|
||||
/**
|
||||
* Reputation check result information
|
||||
@@ -52,7 +52,7 @@ export interface IIPReputationOptions {
|
||||
highRiskThreshold?: number; // Score below this is high risk
|
||||
mediumRiskThreshold?: number; // Score below this is medium 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)
|
||||
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
|
||||
*/
|
||||
export class IPReputationChecker {
|
||||
private static instance: IPReputationChecker;
|
||||
private static instance: IPReputationChecker | undefined;
|
||||
private reputationCache: LRUCache<string, IReputationResult>;
|
||||
private options: Required<IIPReputationOptions>;
|
||||
private storageManager?: any; // StorageManager instance
|
||||
|
||||
|
||||
// Default DNSBL servers
|
||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||
'zen.spamhaus.org', // Spamhaus
|
||||
@@ -73,13 +72,13 @@ export class IPReputationChecker {
|
||||
'b.barracudacentral.org', // Barracuda
|
||||
'spam.dnsbl.sorbs.net', // SORBS
|
||||
'dnsbl.sorbs.net', // SORBS (expanded)
|
||||
'cbl.abuseat.org', // Composite Blocking List
|
||||
'cbl.abuseat.org', // Composite Blocking List
|
||||
'xbl.spamhaus.org', // Spamhaus XBL
|
||||
'pbl.spamhaus.org', // Spamhaus PBL
|
||||
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
||||
'psbl.surriel.com' // PSBL
|
||||
];
|
||||
|
||||
|
||||
// Default options
|
||||
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||||
maxCacheSize: 10000,
|
||||
@@ -92,58 +91,51 @@ export class IPReputationChecker {
|
||||
enableDNSBL: true,
|
||||
enableIPInfo: true
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Constructor for IPReputationChecker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
*/
|
||||
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
||||
constructor(options: IIPReputationOptions = {}) {
|
||||
// Merge with default options
|
||||
this.options = {
|
||||
...IPReputationChecker.DEFAULT_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
|
||||
this.reputationCache = new LRUCache<string, IReputationResult>({
|
||||
max: this.options.maxCacheSize,
|
||||
ttl: this.options.cacheTTL, // Cache TTL
|
||||
});
|
||||
|
||||
// Load cache from disk if enabled
|
||||
|
||||
// Load persisted reputations into in-memory cache
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the load operation
|
||||
this.loadCache().catch(error => {
|
||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
|
||||
this.loadCacheFromDb().catch((error: unknown) => {
|
||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the checker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
* @returns Singleton instance
|
||||
*/
|
||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
||||
public static getInstance(options: IIPReputationOptions = {}): IPReputationChecker {
|
||||
if (!IPReputationChecker.instance) {
|
||||
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
||||
IPReputationChecker.instance = new IPReputationChecker(options);
|
||||
}
|
||||
return IPReputationChecker.instance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for shutdown/testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
IPReputationChecker.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an IP address's reputation
|
||||
* @param ip IP address to check
|
||||
@@ -156,8 +148,8 @@ export class IPReputationChecker {
|
||||
logger.log('warn', `Invalid IP address format: ${ip}`);
|
||||
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);
|
||||
if (cachedResult) {
|
||||
logger.log('info', `Using cached reputation data for IP ${ip}`, {
|
||||
@@ -166,7 +158,7 @@ export class IPReputationChecker {
|
||||
});
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
|
||||
// Initialize empty result
|
||||
const result: IReputationResult = {
|
||||
score: 100, // Start with perfect score
|
||||
@@ -176,65 +168,64 @@ export class IPReputationChecker {
|
||||
isVPN: false,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
|
||||
// Check IP against DNS blacklists if enabled
|
||||
if (this.options.enableDNSBL) {
|
||||
const dnsblResult = await this.checkDNSBL(ip);
|
||||
|
||||
|
||||
// Update result with DNSBL information
|
||||
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
||||
result.isSpam = dnsblResult.listCount > 0;
|
||||
result.blacklists = dnsblResult.lists;
|
||||
}
|
||||
|
||||
|
||||
// Get additional IP information if enabled
|
||||
if (this.options.enableIPInfo) {
|
||||
const ipInfo = await this.getIPInfo(ip);
|
||||
|
||||
|
||||
// Update result with IP info
|
||||
result.country = ipInfo.country;
|
||||
result.asn = ipInfo.asn;
|
||||
result.org = ipInfo.org;
|
||||
|
||||
|
||||
// Adjust score based on IP type
|
||||
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
||||
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
||||
|
||||
|
||||
// Set proxy flags
|
||||
result.isProxy = ipInfo.type === IPType.PROXY;
|
||||
result.isTor = ipInfo.type === IPType.TOR;
|
||||
result.isVPN = ipInfo.type === IPType.VPN;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Ensure score is between 0 and 100
|
||||
result.score = Math.max(0, Math.min(100, result.score));
|
||||
|
||||
// Update cache with result
|
||||
|
||||
// Update in-memory LRU cache
|
||||
this.reputationCache.set(ip, result);
|
||||
|
||||
// Save cache if enabled
|
||||
|
||||
// Persist to database if enabled (fire and forget)
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the save operation
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
this.persistReputationToDb(ip, result).catch((error: unknown) => {
|
||||
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Log the reputation check
|
||||
this.logReputationCheck(ip, result);
|
||||
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
|
||||
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
|
||||
* @param ip IP address to check
|
||||
@@ -247,42 +238,42 @@ export class IPReputationChecker {
|
||||
try {
|
||||
// Reverse the IP for DNSBL queries
|
||||
const reversedIP = this.reverseIP(ip);
|
||||
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
this.options.dnsblServers.map(async (server) => {
|
||||
try {
|
||||
const lookupDomain = `${reversedIP}.${server}`;
|
||||
await plugins.dns.promises.resolve(lookupDomain);
|
||||
return server; // IP is listed in this DNSBL
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
} catch (error: unknown) {
|
||||
if ((error as any).code === 'ENOTFOUND') {
|
||||
return null; // IP is not listed in this DNSBL
|
||||
}
|
||||
throw error; // Other error
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Extract successful lookups (listed in DNSBL)
|
||||
const lists = results
|
||||
.filter((result): result is PromiseFulfilledResult<string> =>
|
||||
.filter((result): result is PromiseFulfilledResult<string> =>
|
||||
result.status === 'fulfilled' && result.value !== null
|
||||
)
|
||||
.map(result => result.value);
|
||||
|
||||
|
||||
return {
|
||||
listCount: lists.length,
|
||||
lists
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error checking DNSBL for ${ip}: ${(error as Error).message}`);
|
||||
return {
|
||||
listCount: 0,
|
||||
lists: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get information about an IP address
|
||||
* @param ip IP address to check
|
||||
@@ -297,16 +288,16 @@ export class IPReputationChecker {
|
||||
try {
|
||||
// In a real implementation, this would use an IP data service API
|
||||
// For this implementation, we'll use a simplified approach
|
||||
|
||||
|
||||
// Check if it's a known Tor exit node (simplified)
|
||||
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
||||
|
||||
|
||||
// Check if it's a known VPN (simplified)
|
||||
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
||||
|
||||
|
||||
// Check if it's a known proxy (simplified)
|
||||
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
||||
|
||||
|
||||
// Determine IP type
|
||||
let type = IPType.UNKNOWN;
|
||||
if (isTor) {
|
||||
@@ -329,7 +320,7 @@ export class IPReputationChecker {
|
||||
type = IPType.RESIDENTIAL;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Return the information
|
||||
return {
|
||||
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
|
||||
type
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error getting IP info for ${ip}: ${(error as Error).message}`);
|
||||
return {
|
||||
type: IPType.UNKNOWN
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Simplified method to determine country from IP
|
||||
* 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';
|
||||
return 'XX'; // Unknown
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Simplified method to determine organization from IP
|
||||
* 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';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
||||
* @param ip IP address to reverse
|
||||
@@ -384,7 +375,7 @@ export class IPReputationChecker {
|
||||
private reverseIP(ip: string): string {
|
||||
return ip.split('.').reverse().join('.');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create an error result for when reputation check fails
|
||||
* @param ip IP address
|
||||
@@ -402,7 +393,7 @@ export class IPReputationChecker {
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate IP address format
|
||||
* @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]?)$/;
|
||||
return ipv4Pattern.test(ip);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Log reputation check to security logger
|
||||
* @param ip IP address
|
||||
@@ -427,7 +418,7 @@ export class IPReputationChecker {
|
||||
} else if (result.score < this.options.mediumRiskThreshold) {
|
||||
logLevel = SecurityLogLevel.INFO;
|
||||
}
|
||||
|
||||
|
||||
// Log the check
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: logLevel,
|
||||
@@ -446,116 +437,76 @@ export class IPReputationChecker {
|
||||
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 {
|
||||
// Convert cache entries to serializable array
|
||||
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
||||
ip,
|
||||
data
|
||||
}));
|
||||
|
||||
// Only save if we have entries
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheData = JSON.stringify(entries);
|
||||
|
||||
// Save to storage manager if available
|
||||
if (this.storageManager) {
|
||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
||||
const data = {
|
||||
score: result.score,
|
||||
isSpam: result.isSpam,
|
||||
isProxy: result.isProxy,
|
||||
isTor: result.isTor,
|
||||
isVPN: result.isVPN,
|
||||
country: result.country,
|
||||
asn: result.asn,
|
||||
org: result.org,
|
||||
blacklists: result.blacklists,
|
||||
};
|
||||
|
||||
const existing = await CachedIPReputation.findByIP(ip);
|
||||
if (existing) {
|
||||
existing.updateReputation(data);
|
||||
await existing.save();
|
||||
} else {
|
||||
// Fall back to filesystem
|
||||
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
||||
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`);
|
||||
const doc = CachedIPReputation.fromReputationData(ip, data);
|
||||
await doc.save();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
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 {
|
||||
let cacheData: string | null = null;
|
||||
let fromFilesystem = false;
|
||||
|
||||
// Try to load from storage manager first
|
||||
if (this.storageManager) {
|
||||
try {
|
||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
||||
|
||||
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 docs = await CachedIPReputation.getInstances({});
|
||||
let loadedCount = 0;
|
||||
|
||||
for (const doc of docs) {
|
||||
// Skip expired documents
|
||||
if (doc.isExpired()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 (cacheData) {
|
||||
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}`);
|
||||
|
||||
if (loadedCount > 0) {
|
||||
logger.log('info', `Loaded ${loadedCount} IP reputation cache entries from database`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load IP reputation cache from database: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the risk level for a reputation score
|
||||
* @param score Reputation score (0-100)
|
||||
@@ -572,21 +523,4 @@ export class IPReputationChecker {
|
||||
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
|
||||
*/
|
||||
export class SecurityLogger {
|
||||
private static instance: SecurityLogger;
|
||||
private static instance: SecurityLogger | undefined;
|
||||
private securityEvents: ISecurityEvent[] = [];
|
||||
private maxEventHistory: number;
|
||||
private enableNotifications: boolean;
|
||||
@@ -83,7 +83,14 @@ export class SecurityLogger {
|
||||
}
|
||||
return SecurityLogger.instance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for shutdown/testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
SecurityLogger.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
* @param event The security event to log
|
||||
@@ -147,16 +154,19 @@ export class SecurityLogger {
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
.slice()
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -242,58 +252,46 @@ export class SecurityLogger {
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topDomains: Array<{ domain: string; count: number }>;
|
||||
} {
|
||||
// Filter by time window if provided
|
||||
let events = this.securityEvents;
|
||||
if (timeWindow) {
|
||||
const cutoff = Date.now() - timeWindow;
|
||||
events = events.filter(e => e.timestamp >= cutoff);
|
||||
const cutoff = timeWindow ? Date.now() - timeWindow : 0;
|
||||
|
||||
// Initialize counters
|
||||
const byLevel = {} as Record<SecurityLogLevel, number>;
|
||||
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>();
|
||||
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) {
|
||||
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) {
|
||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Sort and limit top entries
|
||||
const topIPs = Array.from(ipCounts.entries())
|
||||
.map(([ip, count]) => ({ ip, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
|
||||
const topDomains = Array.from(domainCounts.entries())
|
||||
.map(([domain, count]) => ({ domain, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
total: events.length,
|
||||
byLevel,
|
||||
byType,
|
||||
topIPs,
|
||||
topDomains
|
||||
};
|
||||
|
||||
return { total, byLevel, byType, topIPs, topDomains };
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { smsConfigSchema } from './config/sms.schema.js';
|
||||
import { ConfigValidator } from '../config/validator.js';
|
||||
|
||||
export class SmsService {
|
||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||
public projectinfo!: plugins.projectinfo.ProjectInfo;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
public config: ISmsConfig;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class SmsService {
|
||||
const validationResult = ConfigValidator.validate(options, smsConfigSchema);
|
||||
|
||||
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
|
||||
@@ -30,7 +30,7 @@ export class SmsService {
|
||||
*/
|
||||
public async start() {
|
||||
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(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_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';
|
||||
430
ts/vpn/classes.vpn-manager.ts
Normal file
430
ts/vpn/classes.vpn-manager.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
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[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = [];
|
||||
for (const client of this.clients.values()) {
|
||||
clientEntries.push({
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
wgPublicKey: client.wgPublicKey,
|
||||
enabled: client.enabled,
|
||||
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||
description: client.description,
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
const subnet = this.getSubnet();
|
||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
transport: { transport: 'stdio' },
|
||||
});
|
||||
|
||||
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: 'socket',
|
||||
transportMode: 'all',
|
||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: true,
|
||||
destinationPolicy: this.config.destinationPolicy
|
||||
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
clientAllowedIPs: [subnet],
|
||||
};
|
||||
|
||||
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;
|
||||
}): 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;
|
||||
this.clients.set(doc.clientId, doc);
|
||||
await this.persistClient(doc);
|
||||
|
||||
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[];
|
||||
}): 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;
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
17
ts_apiclient/classes.config.ts
Normal file
17
ts_apiclient/classes.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class ConfigManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async get(section?: string): Promise<interfaces.requests.IReq_GetConfiguration['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
this.clientRef.buildRequestPayload({ section }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
112
ts_apiclient/classes.dcrouterapiclient.ts
Normal file
112
ts_apiclient/classes.dcrouterapiclient.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
import { RouteManager } from './classes.route.js';
|
||||
import { CertificateManager } from './classes.certificate.js';
|
||||
import { ApiTokenManager } from './classes.apitoken.js';
|
||||
import { RemoteIngressManager } from './classes.remoteingress.js';
|
||||
import { StatsManager } from './classes.stats.js';
|
||||
import { ConfigManager } from './classes.config.js';
|
||||
import { LogManager } from './classes.logs.js';
|
||||
import { EmailManager } from './classes.email.js';
|
||||
import { RadiusManager } from './classes.radius.js';
|
||||
|
||||
export interface IDcRouterApiClientOptions {
|
||||
baseUrl: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export class DcRouterApiClient {
|
||||
public baseUrl: string;
|
||||
public apiToken?: string;
|
||||
public identity?: interfaces.data.IIdentity;
|
||||
|
||||
// Resource managers
|
||||
public routes: RouteManager;
|
||||
public certificates: CertificateManager;
|
||||
public apiTokens: ApiTokenManager;
|
||||
public remoteIngress: RemoteIngressManager;
|
||||
public stats: StatsManager;
|
||||
public config: ConfigManager;
|
||||
public logs: LogManager;
|
||||
public emails: EmailManager;
|
||||
public radius: RadiusManager;
|
||||
|
||||
constructor(options: IDcRouterApiClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.apiToken = options.apiToken;
|
||||
|
||||
this.routes = new RouteManager(this);
|
||||
this.certificates = new CertificateManager(this);
|
||||
this.apiTokens = new ApiTokenManager(this);
|
||||
this.remoteIngress = new RemoteIngressManager(this);
|
||||
this.stats = new StatsManager(this);
|
||||
this.config = new ConfigManager(this);
|
||||
this.logs = new LogManager(this);
|
||||
this.emails = new EmailManager(this);
|
||||
this.radius = new RadiusManager(this);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Auth
|
||||
// =====================
|
||||
|
||||
public async login(username: string, password: string): Promise<interfaces.data.IIdentity> {
|
||||
const response = await this.request<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
{ username, password },
|
||||
);
|
||||
if (response.identity) {
|
||||
this.identity = response.identity;
|
||||
}
|
||||
return response.identity!;
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
await this.request<interfaces.requests.IReq_AdminLogout>(
|
||||
'adminLogout',
|
||||
{ identity: this.identity! },
|
||||
);
|
||||
this.identity = undefined;
|
||||
}
|
||||
|
||||
public async verifyIdentity(): Promise<{ valid: boolean; identity?: interfaces.data.IIdentity }> {
|
||||
const response = await this.request<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'verifyIdentity',
|
||||
{ identity: this.identity! },
|
||||
);
|
||||
if (response.identity) {
|
||||
this.identity = response.identity;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Internal request helper
|
||||
// =====================
|
||||
|
||||
public async request<T extends plugins.typedrequestInterfaces.ITypedRequest>(
|
||||
method: string,
|
||||
requestData: T['request'],
|
||||
): Promise<T['response']> {
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<T>(
|
||||
`${this.baseUrl}/typedrequest`,
|
||||
method,
|
||||
);
|
||||
return typedRequest.fire(requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a request payload with identity and optional API token auto-injected.
|
||||
*/
|
||||
public buildRequestPayload(extra: Record<string, any> = {}): Record<string, any> {
|
||||
const payload: Record<string, any> = { ...extra };
|
||||
if (this.identity) {
|
||||
payload.identity = this.identity;
|
||||
}
|
||||
if (this.apiToken) {
|
||||
payload.apiToken = this.apiToken;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
77
ts_apiclient/classes.email.ts
Normal file
77
ts_apiclient/classes.email.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class Email {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from IEmail
|
||||
public id: string;
|
||||
public direction: interfaces.requests.TEmailDirection;
|
||||
public status: interfaces.requests.TEmailStatus;
|
||||
public from: string;
|
||||
public to: string;
|
||||
public subject: string;
|
||||
public timestamp: string;
|
||||
public messageId: string;
|
||||
public size: string;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.requests.IEmail) {
|
||||
this.clientRef = clientRef;
|
||||
this.id = data.id;
|
||||
this.direction = data.direction;
|
||||
this.status = data.status;
|
||||
this.from = data.from;
|
||||
this.to = data.to;
|
||||
this.subject = data.subject;
|
||||
this.timestamp = data.timestamp;
|
||||
this.messageId = data.messageId;
|
||||
this.size = data.size;
|
||||
}
|
||||
|
||||
public async getDetail(): Promise<interfaces.requests.IEmailDetail | null> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetEmailDetail>(
|
||||
'getEmailDetail',
|
||||
this.clientRef.buildRequestPayload({ emailId: this.id }) as any,
|
||||
);
|
||||
return response.email;
|
||||
}
|
||||
|
||||
public async resend(): Promise<{ success: boolean; newQueueId?: string }> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
this.clientRef.buildRequestPayload({ emailId: this.id }) as any,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<Email[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetAllEmails>(
|
||||
'getAllEmails',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.emails.map((e) => new Email(this.clientRef, e));
|
||||
}
|
||||
|
||||
public async getDetail(emailId: string): Promise<interfaces.requests.IEmailDetail | null> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetEmailDetail>(
|
||||
'getEmailDetail',
|
||||
this.clientRef.buildRequestPayload({ emailId }) as any,
|
||||
);
|
||||
return response.email;
|
||||
}
|
||||
|
||||
public async resend(emailId: string): Promise<{ success: boolean; newQueueId?: string }> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
this.clientRef.buildRequestPayload({ emailId }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
ts_apiclient/classes.logs.ts
Normal file
37
ts_apiclient/classes.logs.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class LogManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async getRecent(options?: {
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
timeRange?: string;
|
||||
}): Promise<interfaces.requests.IReq_GetRecentLogs['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'getRecentLogs',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getStream(options?: {
|
||||
follow?: boolean;
|
||||
filters?: {
|
||||
level?: string[];
|
||||
category?: string[];
|
||||
};
|
||||
}): Promise<interfaces.requests.IReq_GetLogStream['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetLogStream>(
|
||||
'getLogStream',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
180
ts_apiclient/classes.radius.ts
Normal file
180
ts_apiclient/classes.radius.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
// =====================
|
||||
// Sub-managers
|
||||
// =====================
|
||||
|
||||
export class RadiusClientManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<Array<{
|
||||
name: string;
|
||||
ipRange: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}>> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.clients;
|
||||
}
|
||||
|
||||
public async set(client: {
|
||||
name: string;
|
||||
ipRange: string;
|
||||
secret: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
this.clientRef.buildRequestPayload({ client }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set RADIUS client');
|
||||
}
|
||||
}
|
||||
|
||||
public async remove(name: string): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
this.clientRef.buildRequestPayload({ name }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove RADIUS client');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RadiusVlanManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<interfaces.requests.IReq_GetVlanMappings['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async set(mapping: {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
this.clientRef.buildRequestPayload({ mapping }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set VLAN mapping');
|
||||
}
|
||||
}
|
||||
|
||||
public async remove(mac: string): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
this.clientRef.buildRequestPayload({ mac }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove VLAN mapping');
|
||||
}
|
||||
}
|
||||
|
||||
public async updateConfig(options: {
|
||||
defaultVlan?: number;
|
||||
allowUnknownMacs?: boolean;
|
||||
}): Promise<{ defaultVlan: number; allowUnknownMacs: boolean }> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
this.clientRef.buildRequestPayload(options) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to update VLAN config');
|
||||
}
|
||||
return response.config;
|
||||
}
|
||||
|
||||
public async testAssignment(mac: string): Promise<interfaces.requests.IReq_TestVlanAssignment['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
this.clientRef.buildRequestPayload({ mac }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class RadiusSessionManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(filter?: {
|
||||
username?: string;
|
||||
nasIpAddress?: string;
|
||||
vlanId?: number;
|
||||
}): Promise<interfaces.requests.IReq_GetRadiusSessions['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
this.clientRef.buildRequestPayload({ filter }) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async disconnect(sessionId: string, reason?: string): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
this.clientRef.buildRequestPayload({ sessionId, reason }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to disconnect session');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Main RADIUS Manager
|
||||
// =====================
|
||||
|
||||
export class RadiusManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
public clients: RadiusClientManager;
|
||||
public vlans: RadiusVlanManager;
|
||||
public sessions: RadiusSessionManager;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
this.clients = new RadiusClientManager(clientRef);
|
||||
this.vlans = new RadiusVlanManager(clientRef);
|
||||
this.sessions = new RadiusSessionManager(clientRef);
|
||||
}
|
||||
|
||||
public async getAccountingSummary(
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<interfaces.requests.IReq_GetRadiusAccountingSummary['response']['summary']> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
this.clientRef.buildRequestPayload({ startTime, endTime }) as any,
|
||||
);
|
||||
return response.summary;
|
||||
}
|
||||
|
||||
public async getStatistics(): Promise<interfaces.requests.IReq_GetRadiusStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
185
ts_apiclient/classes.remoteingress.ts
Normal file
185
ts_apiclient/classes.remoteingress.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class RemoteIngress {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from IRemoteIngress
|
||||
public id: string;
|
||||
public name: string;
|
||||
public secret: string;
|
||||
public listenPorts: number[];
|
||||
public enabled: boolean;
|
||||
public autoDerivePorts: boolean;
|
||||
public tags?: string[];
|
||||
public createdAt: number;
|
||||
public updatedAt: number;
|
||||
public effectiveListenPorts?: number[];
|
||||
public manualPorts?: number[];
|
||||
public derivedPorts?: number[];
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IRemoteIngress) {
|
||||
this.clientRef = clientRef;
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.secret = data.secret;
|
||||
this.listenPorts = data.listenPorts;
|
||||
this.enabled = data.enabled;
|
||||
this.autoDerivePorts = data.autoDerivePorts;
|
||||
this.tags = data.tags;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.effectiveListenPorts = data.effectiveListenPorts;
|
||||
this.manualPorts = data.manualPorts;
|
||||
this.derivedPorts = data.derivedPorts;
|
||||
}
|
||||
|
||||
public async update(changes: {
|
||||
name?: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
enabled?: boolean;
|
||||
tags?: string[];
|
||||
}): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||
'updateRemoteIngress',
|
||||
this.clientRef.buildRequestPayload({ id: this.id, ...changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to update remote ingress');
|
||||
}
|
||||
// Update local state from response
|
||||
const edge = response.edge;
|
||||
this.name = edge.name;
|
||||
this.listenPorts = edge.listenPorts;
|
||||
this.enabled = edge.enabled;
|
||||
this.autoDerivePorts = edge.autoDerivePorts;
|
||||
this.tags = edge.tags;
|
||||
this.updatedAt = edge.updatedAt;
|
||||
this.effectiveListenPorts = edge.effectiveListenPorts;
|
||||
this.manualPorts = edge.manualPorts;
|
||||
this.derivedPorts = edge.derivedPorts;
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||
'deleteRemoteIngress',
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete remote ingress');
|
||||
}
|
||||
}
|
||||
|
||||
public async regenerateSecret(): Promise<string> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||
'regenerateRemoteIngressSecret',
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to regenerate secret');
|
||||
}
|
||||
this.secret = response.secret;
|
||||
return response.secret;
|
||||
}
|
||||
|
||||
public async getConnectionToken(hubHost?: string): Promise<string | undefined> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'getRemoteIngressConnectionToken',
|
||||
this.clientRef.buildRequestPayload({ edgeId: this.id, hubHost }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to get connection token');
|
||||
}
|
||||
return response.token;
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteIngressBuilder {
|
||||
private clientRef: DcRouterApiClient;
|
||||
private edgeName: string = '';
|
||||
private edgeListenPorts?: number[];
|
||||
private edgeAutoDerivePorts?: boolean;
|
||||
private edgeTags?: string[];
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public setName(name: string): this {
|
||||
this.edgeName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setListenPorts(ports: number[]): this {
|
||||
this.edgeListenPorts = ports;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setAutoDerivePorts(auto: boolean): this {
|
||||
this.edgeAutoDerivePorts = auto;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setTags(tags: string[]): this {
|
||||
this.edgeTags = tags;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async save(): Promise<RemoteIngress> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||
'createRemoteIngress',
|
||||
this.clientRef.buildRequestPayload({
|
||||
name: this.edgeName,
|
||||
listenPorts: this.edgeListenPorts,
|
||||
autoDerivePorts: this.edgeAutoDerivePorts,
|
||||
tags: this.edgeTags,
|
||||
}) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to create remote ingress');
|
||||
}
|
||||
return new RemoteIngress(this.clientRef, response.edge);
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteIngressManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<RemoteIngress[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||
'getRemoteIngresses',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.edges.map((e) => new RemoteIngress(this.clientRef, e));
|
||||
}
|
||||
|
||||
public async getStatuses(): Promise<interfaces.data.IRemoteIngressStatus[]> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||
'getRemoteIngressStatus',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return response.statuses;
|
||||
}
|
||||
|
||||
public async create(options: {
|
||||
name: string;
|
||||
listenPorts?: number[];
|
||||
autoDerivePorts?: boolean;
|
||||
tags?: string[];
|
||||
}): Promise<RemoteIngress> {
|
||||
const builder = this.build().setName(options.name);
|
||||
if (options.listenPorts) builder.setListenPorts(options.listenPorts);
|
||||
if (options.autoDerivePorts !== undefined) builder.setAutoDerivePorts(options.autoDerivePorts);
|
||||
if (options.tags) builder.setTags(options.tags);
|
||||
return builder.save();
|
||||
}
|
||||
|
||||
public build(): RemoteIngressBuilder {
|
||||
return new RemoteIngressBuilder(this.clientRef);
|
||||
}
|
||||
}
|
||||
203
ts_apiclient/classes.route.ts
Normal file
203
ts_apiclient/classes.route.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
export class Route {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
// Data from IMergedRoute
|
||||
public routeConfig: IRouteConfig;
|
||||
public source: 'hardcoded' | 'programmatic';
|
||||
public enabled: boolean;
|
||||
public overridden: boolean;
|
||||
public storedRouteId?: string;
|
||||
public createdAt?: number;
|
||||
public updatedAt?: number;
|
||||
|
||||
// Convenience accessors
|
||||
public get name(): string {
|
||||
return this.routeConfig.name || '';
|
||||
}
|
||||
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
||||
this.clientRef = clientRef;
|
||||
this.routeConfig = data.route;
|
||||
this.source = data.source;
|
||||
this.enabled = data.enabled;
|
||||
this.overridden = data.overridden;
|
||||
this.storedRouteId = data.storedRouteId;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
public async update(changes: Partial<IRouteConfig>): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
|
||||
'updateRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to update route');
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
|
||||
'deleteRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete route');
|
||||
}
|
||||
}
|
||||
|
||||
public async toggle(enabled: boolean): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to toggle route');
|
||||
}
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async setOverride(enabled: boolean): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set route override');
|
||||
}
|
||||
this.overridden = true;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async removeOverride(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove route override');
|
||||
}
|
||||
this.overridden = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteBuilder {
|
||||
private clientRef: DcRouterApiClient;
|
||||
private routeConfig: Partial<IRouteConfig> = {};
|
||||
private isEnabled: boolean = true;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public setName(name: string): this {
|
||||
this.routeConfig.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setMatch(match: IRouteConfig['match']): this {
|
||||
this.routeConfig.match = match;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setAction(action: IRouteConfig['action']): this {
|
||||
this.routeConfig.action = action;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setTls(tls: IRouteConfig['action']['tls']): this {
|
||||
if (!this.routeConfig.action) {
|
||||
this.routeConfig.action = { type: 'forward' } as IRouteConfig['action'];
|
||||
}
|
||||
this.routeConfig.action!.tls = tls;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setEnabled(enabled: boolean): this {
|
||||
this.isEnabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async save(): Promise<Route> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRoute>(
|
||||
'createRoute',
|
||||
this.clientRef.buildRequestPayload({
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
enabled: this.isEnabled,
|
||||
}) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to create route');
|
||||
}
|
||||
|
||||
// Return a Route instance by re-fetching the list
|
||||
// The created route is programmatic, so we find it by storedRouteId
|
||||
const { routes } = await new RouteManager(this.clientRef).list();
|
||||
const created = routes.find((r) => r.storedRouteId === response.storedRouteId);
|
||||
if (created) {
|
||||
return created;
|
||||
}
|
||||
|
||||
// Fallback: construct from known data
|
||||
return new Route(this.clientRef, {
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
source: 'programmatic',
|
||||
enabled: this.isEnabled,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async list(): Promise<{ routes: Route[]; warnings: interfaces.data.IRouteWarning[] }> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_GetMergedRoutes>(
|
||||
'getMergedRoutes',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
return {
|
||||
routes: response.routes.map((r) => new Route(this.clientRef, r)),
|
||||
warnings: response.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
public async create(routeConfig: IRouteConfig, enabled?: boolean): Promise<Route> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRoute>(
|
||||
'createRoute',
|
||||
this.clientRef.buildRequestPayload({ route: routeConfig, enabled: enabled ?? true }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to create route');
|
||||
}
|
||||
return new Route(this.clientRef, {
|
||||
route: routeConfig,
|
||||
source: 'programmatic',
|
||||
enabled: enabled ?? true,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
});
|
||||
}
|
||||
|
||||
public build(): RouteBuilder {
|
||||
return new RouteBuilder(this.clientRef);
|
||||
}
|
||||
}
|
||||
111
ts_apiclient/classes.stats.ts
Normal file
111
ts_apiclient/classes.stats.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
|
||||
|
||||
type TTimeRange = '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
|
||||
export class StatsManager {
|
||||
private clientRef: DcRouterApiClient;
|
||||
|
||||
constructor(clientRef: DcRouterApiClient) {
|
||||
this.clientRef = clientRef;
|
||||
}
|
||||
|
||||
public async getServer(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
includeHistory?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetServerStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'getServerStatistics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getEmail(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
domain?: string;
|
||||
includeDetails?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetEmailStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetEmailStatistics>(
|
||||
'getEmailStatistics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getDns(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
domain?: string;
|
||||
includeQueryTypes?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetDnsStatistics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetDnsStatistics>(
|
||||
'getDnsStatistics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getRateLimits(options?: {
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
includeBlocked?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetRateLimitStatus['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||
'getRateLimitStatus',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getSecurity(options?: {
|
||||
timeRange?: TTimeRange;
|
||||
includeDetails?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetSecurityMetrics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||
'getSecurityMetrics',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getConnections(options?: {
|
||||
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
||||
state?: string;
|
||||
}): Promise<interfaces.requests.IReq_GetActiveConnections['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetActiveConnections>(
|
||||
'getActiveConnections',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getQueues(options?: {
|
||||
queueName?: string;
|
||||
}): Promise<interfaces.requests.IReq_GetQueueStatus['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetQueueStatus>(
|
||||
'getQueueStatus',
|
||||
this.clientRef.buildRequestPayload(options || {}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getHealth(detailed?: boolean): Promise<interfaces.requests.IReq_GetHealthStatus['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'getHealthStatus',
|
||||
this.clientRef.buildRequestPayload({ detailed }) as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getNetwork(): Promise<interfaces.requests.IReq_GetNetworkStats['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetNetworkStats>(
|
||||
'getNetworkStats',
|
||||
this.clientRef.buildRequestPayload() as any,
|
||||
);
|
||||
}
|
||||
|
||||
public async getCombined(sections?: {
|
||||
server?: boolean;
|
||||
email?: boolean;
|
||||
dns?: boolean;
|
||||
security?: boolean;
|
||||
network?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_GetCombinedMetrics['response']> {
|
||||
return this.clientRef.request<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||
'getCombinedMetrics',
|
||||
this.clientRef.buildRequestPayload({ sections }) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
ts_apiclient/index.ts
Normal file
15
ts_apiclient/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Main client
|
||||
export { DcRouterApiClient, type IDcRouterApiClientOptions } from './classes.dcrouterapiclient.js';
|
||||
|
||||
// Resource classes
|
||||
export { Route, RouteBuilder, RouteManager } from './classes.route.js';
|
||||
export { Certificate, CertificateManager, type ICertificateSummary } from './classes.certificate.js';
|
||||
export { ApiToken, ApiTokenBuilder, ApiTokenManager } from './classes.apitoken.js';
|
||||
export { RemoteIngress, RemoteIngressBuilder, RemoteIngressManager } from './classes.remoteingress.js';
|
||||
export { Email, EmailManager } from './classes.email.js';
|
||||
|
||||
// Read-only managers
|
||||
export { StatsManager } from './classes.stats.js';
|
||||
export { ConfigManager } from './classes.config.js';
|
||||
export { LogManager } from './classes.logs.js';
|
||||
export { RadiusManager, RadiusClientManager, RadiusVlanManager, RadiusSessionManager } from './classes.radius.js';
|
||||
8
ts_apiclient/plugins.ts
Normal file
8
ts_apiclient/plugins.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
|
||||
export {
|
||||
typedrequest,
|
||||
typedrequestInterfaces,
|
||||
};
|
||||
279
ts_apiclient/readme.md
Normal file
279
ts_apiclient/readme.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# @serve.zone/dcrouter-apiclient
|
||||
|
||||
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧
|
||||
|
||||
Programmatically manage your DcRouter instance — routes, certificates, API tokens, remote ingress edges, RADIUS, email operations, and more — all with full TypeScript type safety and an intuitive OO interface.
|
||||
|
||||
## 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-apiclient
|
||||
```
|
||||
|
||||
Or import directly from the main package:
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
|
||||
|
||||
// Authenticate
|
||||
await client.login('admin', 'password');
|
||||
|
||||
// List routes
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
console.log(`${routes.length} routes, ${warnings.length} warnings`);
|
||||
|
||||
// Check health
|
||||
const { health } = await client.stats.getHealth();
|
||||
console.log(`Healthy: ${health.healthy}`);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 🔐 Authentication
|
||||
|
||||
```typescript
|
||||
// Login with credentials — identity is stored and auto-injected into all subsequent requests
|
||||
const identity = await client.login('admin', 'password');
|
||||
|
||||
// Verify current session
|
||||
const { valid } = await client.verifyIdentity();
|
||||
|
||||
// Logout
|
||||
await client.logout();
|
||||
|
||||
// Or use an API token for programmatic access (route management only)
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
apiToken: 'dcr_your_token_here',
|
||||
});
|
||||
```
|
||||
|
||||
### 🌐 Routes — OO Resources + Builder
|
||||
|
||||
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides:
|
||||
|
||||
```typescript
|
||||
// List all routes (hardcoded + programmatic)
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
|
||||
// Inspect a route
|
||||
const route = routes[0];
|
||||
console.log(route.name, route.source, route.enabled);
|
||||
|
||||
// Modify a programmatic route
|
||||
await route.update({ name: 'renamed-route' });
|
||||
await route.toggle(false);
|
||||
await route.delete();
|
||||
|
||||
// Override a hardcoded route (disable it)
|
||||
const hardcodedRoute = routes.find(r => r.source === 'hardcoded');
|
||||
await hardcodedRoute.setOverride(false);
|
||||
await hardcodedRoute.removeOverride();
|
||||
```
|
||||
|
||||
**Builder pattern** for creating new routes:
|
||||
|
||||
```typescript
|
||||
const newRoute = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||
.setTls({ mode: 'terminate', certificate: 'auto' })
|
||||
.setEnabled(true)
|
||||
.save();
|
||||
|
||||
// Or use quick creation
|
||||
const route = await client.routes.create(routeConfig);
|
||||
```
|
||||
|
||||
### 🔑 API Tokens
|
||||
|
||||
```typescript
|
||||
// List existing tokens
|
||||
const tokens = await client.apiTokens.list();
|
||||
|
||||
// Create with builder
|
||||
const token = await client.apiTokens.build()
|
||||
.setName('ci-pipeline')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.addScope('config:read')
|
||||
.setExpiresInDays(90)
|
||||
.save();
|
||||
|
||||
console.log(token.tokenValue); // Only available at creation time!
|
||||
|
||||
// Manage tokens
|
||||
await token.toggle(false); // Disable
|
||||
const newValue = await token.roll(); // Regenerate secret
|
||||
await token.revoke(); // Delete
|
||||
```
|
||||
|
||||
### 🔐 Certificates
|
||||
|
||||
```typescript
|
||||
const { certificates, summary } = await client.certificates.list();
|
||||
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} failed`);
|
||||
|
||||
// Operate on individual certificates
|
||||
const cert = certificates[0];
|
||||
await cert.reprovision();
|
||||
const exported = await cert.export();
|
||||
await cert.delete();
|
||||
|
||||
// Import a certificate
|
||||
await client.certificates.import({
|
||||
id: 'cert-id',
|
||||
domainName: 'example.com',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 3600 * 1000,
|
||||
privateKey: '...',
|
||||
publicKey: '...',
|
||||
csr: '...',
|
||||
});
|
||||
```
|
||||
|
||||
### 🌍 Remote Ingress
|
||||
|
||||
```typescript
|
||||
// List edges and their statuses
|
||||
const edges = await client.remoteIngress.list();
|
||||
const statuses = await client.remoteIngress.getStatuses();
|
||||
|
||||
// Create with builder
|
||||
const edge = await client.remoteIngress.build()
|
||||
.setName('edge-nyc-01')
|
||||
.setListenPorts([80, 443])
|
||||
.setAutoDerivePorts(true)
|
||||
.setTags(['us-east'])
|
||||
.save();
|
||||
|
||||
// Manage an edge
|
||||
await edge.update({ name: 'edge-nyc-02' });
|
||||
const newSecret = await edge.regenerateSecret();
|
||||
const token = await edge.getConnectionToken();
|
||||
await edge.delete();
|
||||
```
|
||||
|
||||
### 📊 Statistics (Read-Only)
|
||||
|
||||
```typescript
|
||||
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true });
|
||||
const emailStats = await client.stats.getEmail({ domain: 'example.com' });
|
||||
const dnsStats = await client.stats.getDns();
|
||||
const security = await client.stats.getSecurity({ includeDetails: true });
|
||||
const connections = await client.stats.getConnections({ protocol: 'https' });
|
||||
const queues = await client.stats.getQueues();
|
||||
const health = await client.stats.getHealth(true);
|
||||
const network = await client.stats.getNetwork();
|
||||
const combined = await client.stats.getCombined({ server: true, email: true });
|
||||
```
|
||||
|
||||
### ⚙️ Configuration & Logs
|
||||
|
||||
```typescript
|
||||
// Read-only configuration
|
||||
const config = await client.config.get();
|
||||
const emailSection = await client.config.get('email');
|
||||
|
||||
// Logs
|
||||
const { logs, total, hasMore } = await client.logs.getRecent({
|
||||
level: 'error',
|
||||
category: 'smtp',
|
||||
limit: 50,
|
||||
});
|
||||
```
|
||||
|
||||
### 📧 Email Operations
|
||||
|
||||
```typescript
|
||||
const emails = await client.emails.list();
|
||||
const email = emails[0];
|
||||
const detail = await email.getDetail();
|
||||
await email.resend();
|
||||
|
||||
// Or use the manager directly
|
||||
const detail2 = await client.emails.getDetail('email-id');
|
||||
await client.emails.resend('email-id');
|
||||
```
|
||||
|
||||
### 📡 RADIUS
|
||||
|
||||
```typescript
|
||||
// Client management
|
||||
const clients = await client.radius.clients.list();
|
||||
await client.radius.clients.set({
|
||||
name: 'switch-1',
|
||||
ipRange: '192.168.1.0/24',
|
||||
secret: 'shared-secret',
|
||||
enabled: true,
|
||||
});
|
||||
await client.radius.clients.remove('switch-1');
|
||||
|
||||
// VLAN management
|
||||
const { mappings, config: vlanConfig } = await client.radius.vlans.list();
|
||||
await client.radius.vlans.set({ mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true });
|
||||
const result = await client.radius.vlans.testAssignment('aa:bb:cc:dd:ee:ff');
|
||||
await client.radius.vlans.updateConfig({ defaultVlan: 200 });
|
||||
|
||||
// Sessions
|
||||
const { sessions } = await client.radius.sessions.list({ vlanId: 10 });
|
||||
await client.radius.sessions.disconnect('session-id', 'Admin disconnect');
|
||||
|
||||
// Statistics & Accounting
|
||||
const stats = await client.radius.getStatistics();
|
||||
const summary = await client.radius.getAccountingSummary(startTime, endTime);
|
||||
```
|
||||
|
||||
## API Surface
|
||||
|
||||
| Manager | Methods |
|
||||
|---------|---------|
|
||||
| `client.login()` / `logout()` / `verifyIdentity()` | Authentication |
|
||||
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
|
||||
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
|
||||
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
|
||||
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
|
||||
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getRateLimits()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
|
||||
| `client.config` | `get(section?)` |
|
||||
| `client.logs` | `getRecent()`, `getStream()` |
|
||||
| `client.emails` | `list()`, `getDetail()`, `resend()` → Email: `getDetail()`, `resend()` |
|
||||
| `client.radius` | `.clients.list/set/remove()`, `.vlans.list/set/remove/updateConfig/testAssignment()`, `.sessions.list/disconnect()`, `getStatistics()`, `getAccountingSummary()` |
|
||||
|
||||
## Architecture
|
||||
|
||||
The client uses HTTP-based [TypedRequest](https://code.foss.global/api.global/typedrequest) for transport. All requests are sent as POST to `{baseUrl}/typedrequest`. Authentication (JWT identity and/or API token) is automatically injected into every request payload via `buildRequestPayload()`.
|
||||
|
||||
Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) hold a reference to the client and provide instance methods that fire the appropriate TypedRequest operations. Builder classes (`RouteBuilder`, `ApiTokenBuilder`, `RemoteIngressBuilder`) use fluent chaining and a terminal `.save()` method.
|
||||
|
||||
## 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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user