Compare commits
315 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c453423e | |||
| c44b7d513a | |||
| 2487f77b8a | |||
| ea80ef005c | |||
| dd45b7fbe7 | |||
| ca73da7b9b | |||
| f6e1951aa2 | |||
| 76fd563e21 | |||
| ee831ea057 | |||
| a65c2ec096 | |||
| 65822278d5 | |||
| aa3955fc67 | |||
| d4605062bb | |||
| cd3f08d55f | |||
| 6d447f0086 | |||
| c7de3873d8 | |||
| 6d4e30e8a9 | |||
| 0e308b692b | |||
| 9f74b6e063 | |||
| 1d0f47f256 | |||
| 4e9301ae2a | |||
| 7e2142ce53 | |||
| 67190605a6 | |||
| 9479a07ddf | |||
| fbed56092f | |||
| 547b82b35b | |||
| 3dc63fa02e | |||
| e0154f5b70 | |||
| b268409897 | |||
| f3a9fd12c5 | |||
| ef741d84fb | |||
| b0ea97b922 | |||
| d1560811f5 | |||
| 5e872c4e6a | |||
| 3620e4549a | |||
| b32865e790 | |||
| ebe71a2a94 | |||
| 877a2ad0ee | |||
| 7be1aaedb3 | |||
| 05eb8e9723 | |||
| d95d89ea6f | |||
| 5d1b988579 | |||
| bae85eea9e | |||
| 2be7974991 | |||
| ac03b1f081 | |||
| 5ca209dd5a | |||
| 867e93b246 | |||
| aa9c4c1c28 | |||
| 207f21cb77 | |||
| 96a47ef588 | |||
| 3bac80eb41 | |||
| 19d67a644c | |||
| 341e4113bd | |||
| 81eb19a9ab | |||
| e0221c0d05 | |||
| e6a1f23c84 | |||
| f77772e1c0 | |||
| 0a706c03c4 | |||
| df5547fda0 | |||
| a677ee081c | |||
| 7339a91513 | |||
| cd27645489 | |||
| 71f2d53e45 | |||
| f74fcc68de | |||
| abcaf9c858 | |||
| c9f185dd82 | |||
| 5e3186d311 | |||
| db35c01a09 | |||
| 3cf6c84a60 | |||
| d570d5c916 | |||
| 4f6afb62f3 | |||
| e5edfdc052 | |||
| 55cbb92a00 | |||
| 928be6a5c6 | |||
| dff3e3c80d | |||
| 8db2118a78 | |||
| dc6da4ce34 | |||
| 63eb99ae5d | |||
| 6b533fba9f | |||
| d3e102b6d2 | |||
| 2bc9761d21 | |||
| d60b9fb8e2 | |||
| b2705f3e88 | |||
| 4dea263cb0 | |||
| 54593d0a9b | |||
| 225a75a81b | |||
| 72ae4e8a9b | |||
| b171590179 | |||
| c46f79914f | |||
| 914223972a | |||
| 559435d13c | |||
| 47300e4ced | |||
| a35a35f302 | |||
| 3b09587ca9 | |||
| 1cb2de2c3e | |||
| f972c38784 | |||
| 90736c3668 | |||
| f27ef5c768 | |||
| 68ecd18646 | |||
| e3d7c91705 | |||
| d3c2fa436c | |||
| 53b7da7e3f | |||
| 8e312d80c0 | |||
| c52d0ca759 | |||
| ca024345f6 | |||
| 89ea875ca8 | |||
| f15414e509 | |||
| cfaafab057 | |||
| 0aed608578 | |||
| 00a24051c9 | |||
| 310b43d1a6 | |||
| e7f56fb870 | |||
| 1f6eee20d3 | |||
| 7db7f92abd | |||
| fdf995cf61 | |||
| fa5adbbf61 | |||
| 1bdda9f501 | |||
| 39a5d6aaa6 | |||
| 1d46ec709b | |||
| 2b4bf42812 | |||
| 6faccc643b | |||
| 9f63908f7d | |||
| 5889eb5210 | |||
| 1de40c0d4e | |||
| e201efe0b4 | |||
| f8c582ee9b | |||
| bf9e85f518 | |||
| 0366ec8160 | |||
| 58bd4a0d33 | |||
| bbff76814e | |||
| 292486c33b | |||
| 2df8937d86 | |||
| 14aa1fa1d4 | |||
| 7a4214a7b8 | |||
| bd6130013c | |||
| 0f814bbcdd | |||
| 8ec94b7dae | |||
| d5dfe439c7 | |||
| aaf3c9cb1c | |||
| abde872ab2 | |||
| ca2d2b09ad | |||
| fb7d4d988b | |||
| 26e6eea5d5 | |||
| 2458dd08d8 | |||
| dee648b3bc | |||
| f4ed32cee4 | |||
| e9c72952ab | |||
| 1bd485c43e | |||
| 421a0390ba | |||
| c7f87a7c22 | |||
| 390d5c648f | |||
| ec651c1cdb | |||
| 6f82c393e7 | |||
| afdb48367b | |||
| 53526ca3ba | |||
| 07e8f4489b | |||
| 14101a09d3 | |||
| 5344d53806 | |||
| 971535926c | |||
| c13a4ae4be | |||
| e7a03c48ae | |||
| a682329a3f | |||
| c4580f9874 | |||
| b331065b8c | |||
| 4675ca3e89 | |||
| 70e2c8e17d | |||
| db53d87cc5 | |||
| ff6244d3d1 | |||
| f0aafe9027 | |||
| 487f2acac8 | |||
| 0a5e35c58e | |||
| 34c0cab5dc | |||
| 3a666e9300 | |||
| cbe1b5d37d | |||
| 30f2044d9f | |||
| 593b000ca3 | |||
| 60c298c396 | |||
| d7f1c16454 | |||
| 4290d4be86 | |||
| bc34cb5eab | |||
| eda12f3ce3 | |||
| 65f19aac72 | |||
| 29a992a695 | |||
| dbb2166a8f | |||
| 22691329a5 | |||
| e098e1a2ad | |||
| 16d64ec988 | |||
| cb1332ff76 | |||
| 3e52060788 | |||
| f041891a3f | |||
| f902c2c1db | |||
| e1a9e1f997 | |||
| d7b39a3017 | |||
| 0f41b0d8c7 | |||
| 2d33c037ba | |||
| dca7b37eb8 | |||
| b56598ba00 | |||
| bbf550b183 | |||
| f4fc5eb1fd | |||
| d9e88cf5f9 | |||
| eccb9706f2 | |||
| 285e681413 | |||
| 4f3958d94d | |||
| d19f22255d | |||
| 87ec55619a | |||
| b91dab0f85 | |||
| df573d498e | |||
| da2b838019 | |||
| 107adeee1d | |||
| 45f933b473 | |||
| ad16bc44f1 | |||
| 96d5b7e01a | |||
| 93ffcf86b3 | |||
| de98b070db | |||
| d3d2bde440 | |||
| 0840b2b571 | |||
| fa2e784eaa | |||
| 64f2854023 | |||
| 03e3261755 | |||
| c724e68b8c | |||
| f8f66d1392 | |||
| c66bdc9f88 | |||
| 8d57547ace | |||
| 54eaf23298 | |||
| 7148306381 | |||
| d3aefef78d | |||
| ecd0cc0066 | |||
| eac490297a | |||
| de65641f6f | |||
| ffddc1a5f5 | |||
| 26152e0520 | |||
| f79ad07a57 | |||
| 76d5b9bf7c | |||
| 670b67eecf | |||
| 174af5cf86 | |||
| a1f5e45e94 | |||
| d06165bd0c | |||
| 8f3c6fdf23 | |||
| 106ef2919e | |||
| 3d7fd233cf | |||
| 34d40f7370 | |||
| 89b9d01628 | |||
| ed3964e892 | |||
| baab152fd3 | |||
| 9baf09ff61 | |||
| 71f23302d3 | |||
| ecbaab3000 | |||
| 8cb1f3c12d | |||
| c7d7f92759 | |||
| 02e1b9231f | |||
| 4ec4dd2bdb | |||
| aa543160e2 | |||
| 94fa0f04d8 | |||
| 17deb481e0 | |||
| e452ffd38e | |||
| 865b4a53e6 | |||
| c07f3975e9 | |||
| 476505537a | |||
| 74ad5cec90 | |||
| 59a3f7978e | |||
| 7dc976b59e | |||
| 345effee13 | |||
| dee6897931 | |||
| 56f41d70b3 | |||
| 8f570ae8a0 | |||
| e58e24a92d | |||
| 12070bc7b5 | |||
| 37d62c51f3 | |||
| ea9427d46b | |||
| bc77321752 | |||
| 65aa546c1c | |||
| 54484518dc | |||
| 6fe1247d4d | |||
| e59d80a3b3 | |||
| 6c4feba711 | |||
| 006a9af20c | |||
| dfb3b0ac37 | |||
| 44c1a3a928 | |||
| 0c4e28455e | |||
| cfc4cf378f | |||
| a09e69a28b | |||
| 82dd19e274 | |||
| c1d8afdbf7 | |||
| 9b7426f1e6 | |||
| 3c9c865841 | |||
| 8421c9fe46 | |||
| 907e3df156 | |||
| aaa0956148 | |||
| 118019fcf5 | |||
| deb80f4fd0 | |||
| 7d28cea937 | |||
| 2bd5e5c7c5 | |||
| 4d6ac81c59 | |||
| 2ebe0de92d | |||
| f5028ffb60 | |||
| 90016d1217 | |||
| 48d3d1218f | |||
| 4759c4f011 | |||
| 0fbd8d1cdd | |||
| 447cf44d68 | |||
| 82ce17a941 | |||
| 15da996e70 | |||
| 582e19e6a6 | |||
| 79765d6729 | |||
| ffc93eb9d3 | |||
| 1337a4905a | |||
| c7418d9e1a | |||
| 2a94ffd4c9 | |||
| b2fe6caf33 | |||
| 822bbc1957 | |||
| eacddc7ce1 | |||
| dc6ce341bd | |||
| 1aadc93f92 | |||
| 8fdcd479d6 | |||
| d24dde8eff |
@@ -6,7 +6,7 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
|
image: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -82,15 +82,13 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @git.zone/tsdocker
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
run: |
|
||||||
npmci docker login
|
tsdocker login
|
||||||
npmci docker build
|
tsdocker build
|
||||||
npmci docker test
|
tsdocker push
|
||||||
# npmci docker push gitea.lossless.digital
|
|
||||||
npmci docker push dockerregistry.lossless.digital
|
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ dist_*/
|
|||||||
**/.claude/settings.local.json
|
**/.claude/settings.local.json
|
||||||
.nogit/data/
|
.nogit/data/
|
||||||
readme.plan.md
|
readme.plan.md
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
40
Dockerfile
40
Dockerfile
@@ -1,44 +1,24 @@
|
|||||||
# gitzone dockerfile_service
|
# gitzone dockerfile_service
|
||||||
## STAGE 1 // BUILD
|
## STAGE 1 // BUILD
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
|
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||||
COPY ./ /app
|
COPY ./ /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
RUN pnpm config set store-dir .pnpm-store
|
||||||
RUN rm -rf node_modules && pnpm install
|
RUN rm -rf node_modules && pnpm install
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
|
||||||
|
|
||||||
|
## STAGE 2 // PRODUCTION
|
||||||
|
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
|
||||||
|
|
||||||
|
# gcompat + libstdc++ for glibc-linked Rust binaries (smartproxy, smartmta, remoteingress)
|
||||||
|
RUN apk add --no-cache gcompat libstdc++
|
||||||
|
|
||||||
# gitzone dockerfile_service
|
|
||||||
## STAGE 2 // install production
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=node1 /app /app
|
COPY --from=build /app /app
|
||||||
RUN rm -rf .pnpm-store
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
RUN rm -rf node_modules/ && pnpm install --prod
|
|
||||||
|
|
||||||
|
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||||
|
|
||||||
## STAGE 3 // rebuild dependencies for alpine
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node2 /app /app
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
RUN pnpm rebuild -r
|
|
||||||
|
|
||||||
## STAGE 4 // the final production image with all dependencies in place
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node3 /app /app
|
|
||||||
|
|
||||||
### Healthchecks
|
|
||||||
RUN pnpm install -g @servezone/healthy
|
RUN pnpm install -g @servezone/healthy
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
||||||
|
|
||||||
|
|||||||
997
changelog.md
997
changelog.md
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,8 @@
|
|||||||
"to": "./dist_serve/bundle.js",
|
"to": "./dist_serve/bundle.js",
|
||||||
"outputMode": "bundle",
|
"outputMode": "bundle",
|
||||||
"bundler": "esbuild",
|
"bundler": "esbuild",
|
||||||
"production": true
|
"production": true,
|
||||||
|
"includeFiles": ["./html/**/*.html"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -71,9 +72,14 @@
|
|||||||
"dockerRegistryRepoMap": {
|
"dockerRegistryRepoMap": {
|
||||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
|
||||||
},
|
|
||||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||||
|
},
|
||||||
|
"@git.zone/tsdocker": {
|
||||||
|
"registries": ["code.foss.global"],
|
||||||
|
"registryRepoMap": {
|
||||||
|
"code.foss.global": "serve.zone/dcrouter",
|
||||||
|
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
|
||||||
|
},
|
||||||
|
"platforms": ["linux/amd64", "linux/arm64"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
53
package.json
53
package.json
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "6.13.2",
|
"version": "11.8.4",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist_ts/index.js",
|
".": "./dist_ts/index.js",
|
||||||
"./interfaces": "./dist_ts_interfaces/index.js"
|
"./interfaces": "./dist_ts_interfaces/index.js",
|
||||||
|
"./apiclient": "./dist_ts_apiclient/index.js"
|
||||||
},
|
},
|
||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -15,50 +16,54 @@
|
|||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
|
"build:docker": "tsdocker build --verbose",
|
||||||
|
"release:docker": "tsdocker push --verbose",
|
||||||
"bundle": "(tsbundle)",
|
"bundle": "(tsbundle)",
|
||||||
"watch": "tswatch"
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.1.2",
|
"@git.zone/tsbuild": "^4.3.0",
|
||||||
"@git.zone/tsbundle": "^2.8.3",
|
"@git.zone/tsbundle": "^2.9.1",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tstest": "^3.1.8",
|
"@git.zone/tstest": "^3.5.0",
|
||||||
"@git.zone/tswatch": "^3.1.0",
|
"@git.zone/tswatch": "^3.3.0",
|
||||||
"@types/node": "^25.2.3"
|
"@types/node": "^25.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.2.6",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedserver": "^8.3.0",
|
"@api.global/typedserver": "^8.4.2",
|
||||||
"@api.global/typedsocket": "^4.1.0",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@design.estate/dees-catalog": "^3.43.0",
|
"@design.estate/dees-catalog": "^3.48.5",
|
||||||
"@design.estate/dees-element": "^2.1.6",
|
"@design.estate/dees-element": "^2.2.3",
|
||||||
|
"@push.rocks/lik": "^6.3.1",
|
||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^9.1.3",
|
"@push.rocks/smartacme": "^9.3.0",
|
||||||
"@push.rocks/smartdata": "^7.0.15",
|
"@push.rocks/smartdata": "^7.1.0",
|
||||||
"@push.rocks/smartdns": "^7.8.1",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartlog": "^3.1.11",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
"@push.rocks/smartmetrics": "^2.0.10",
|
"@push.rocks/smartmetrics": "^3.0.2",
|
||||||
"@push.rocks/smartmongo": "^5.1.0",
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
"@push.rocks/smartmta": "^5.2.2",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^25.7.3",
|
"@push.rocks/smartproxy": "^25.17.0",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.0.30",
|
"@push.rocks/smartstate": "^2.2.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"@serve.zone/catalog": "^2.9.0",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^3.3.0",
|
"@serve.zone/remoteingress": "^4.14.0",
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
"@tsclass/tsclass": "^9.4.0",
|
||||||
"lru-cache": "^11.2.6",
|
"lru-cache": "^11.2.7",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -98,10 +103,12 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
"ts_web/**/*",
|
"ts_web/**/*",
|
||||||
|
"ts_apiclient/**/*",
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist_*/**/*",
|
"dist_*/**/*",
|
||||||
"dist_ts/**/*",
|
"dist_ts/**/*",
|
||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
|
"dist_ts_apiclient/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
"npmextra.json",
|
||||||
|
|||||||
4843
pnpm-lock.yaml
generated
4843
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
289
readme.md
289
readme.md
@@ -18,6 +18,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Configuration Reference](#configuration-reference)
|
- [Configuration Reference](#configuration-reference)
|
||||||
- [HTTP/HTTPS & TCP/SNI Routing](#httphttps--tcpsni-routing)
|
- [HTTP/HTTPS & TCP/SNI Routing](#httphttps--tcpsni-routing)
|
||||||
|
- [HTTP/3 (QUIC) Support](#http3-quic-support)
|
||||||
- [Email System](#email-system)
|
- [Email System](#email-system)
|
||||||
- [DNS Server](#dns-server)
|
- [DNS Server](#dns-server)
|
||||||
- [RADIUS Server](#radius-server)
|
- [RADIUS Server](#radius-server)
|
||||||
@@ -26,15 +27,18 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [Storage & Caching](#storage--caching)
|
- [Storage & Caching](#storage--caching)
|
||||||
- [Security Features](#security-features)
|
- [Security Features](#security-features)
|
||||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||||
|
- [API Client](#api-client)
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
- [Sub-Modules](#sub-modules)
|
- [Sub-Modules](#sub-modules)
|
||||||
- [Testing](#testing)
|
- [Testing](#testing)
|
||||||
|
- [Docker / OCI Container Deployment](#docker--oci-container-deployment)
|
||||||
- [License and Legal Information](#license-and-legal-information)
|
- [License and Legal Information](#license-and-legal-information)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### 🌐 Universal Traffic Router
|
### 🌐 Universal Traffic Router
|
||||||
- **HTTP/HTTPS routing** with domain matching, path-based forwarding, and automatic TLS
|
- **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
|
- **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
|
- **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)
|
- **Multi-protocol support** on the same infrastructure via [SmartProxy](https://code.foss.global/push.rocks/smartproxy)
|
||||||
@@ -90,6 +94,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Remote ingress management** with connection token generation and one-click copy
|
- **Remote ingress management** with connection token generation and one-click copy
|
||||||
- **Read-only configuration display** — DcRouter is configured through code
|
- **Read-only configuration display** — DcRouter is configured through code
|
||||||
|
|
||||||
|
### 🔧 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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -335,7 +346,7 @@ graph TB
|
|||||||
|
|
||||||
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
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.
|
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), 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.
|
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.
|
||||||
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
||||||
|
|
||||||
@@ -416,6 +427,31 @@ interface IDcRouterOptions {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 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 & Certificates ────────────────────────────────────────
|
||||||
tls?: {
|
tls?: {
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
@@ -503,6 +539,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
|
## 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.
|
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.
|
||||||
@@ -1007,7 +1139,7 @@ action: {
|
|||||||
|
|
||||||
## OpsServer Dashboard
|
## 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
|
### Dashboard Views
|
||||||
|
|
||||||
@@ -1038,12 +1170,9 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
|||||||
'getCombinedMetrics' // All metrics in one call
|
'getCombinedMetrics' // All metrics in one call
|
||||||
|
|
||||||
// Email Operations
|
// Email Operations
|
||||||
'getQueuedEmails' // Emails pending delivery
|
'getAllEmails' // List all emails (queued/sent/failed)
|
||||||
'getSentEmails' // Successfully delivered emails
|
'getEmailDetail' // Full detail for a specific email
|
||||||
'getFailedEmails' // Failed emails
|
|
||||||
'resendEmail' // Re-queue a failed email
|
'resendEmail' // Re-queue a failed email
|
||||||
'getBounceRecords' // Bounce records
|
|
||||||
'removeFromSuppressionList' // Unsuppress an address
|
|
||||||
|
|
||||||
// Certificates
|
// Certificates
|
||||||
'getCertificateOverview' // Domain-centric certificate status
|
'getCertificateOverview' // Domain-centric certificate status
|
||||||
@@ -1062,11 +1191,28 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
|||||||
'getRemoteIngressStatus' // Runtime status of all edges
|
'getRemoteIngressStatus' // Runtime status of all edges
|
||||||
'getRemoteIngressConnectionToken' // Generate a connection token for an edge
|
'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)
|
// Configuration (read-only)
|
||||||
'getConfiguration' // Current system config
|
'getConfiguration' // Current system config
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
'getLogs' // Retrieve system logs
|
'getRecentLogs' // Retrieve system logs with filtering
|
||||||
|
'getLogStream' // Stream live logs
|
||||||
|
|
||||||
// RADIUS
|
// RADIUS
|
||||||
'getRadiusSessions' // Active RADIUS sessions
|
'getRadiusSessions' // Active RADIUS sessions
|
||||||
@@ -1080,6 +1226,77 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
|||||||
'testVlanAssignment' // Test what VLAN a MAC gets
|
'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
|
## API Reference
|
||||||
|
|
||||||
### DcRouter Class
|
### DcRouter Class
|
||||||
@@ -1123,7 +1340,7 @@ const router = new DcRouter(options: IDcRouterOptions);
|
|||||||
|
|
||||||
### Re-exported Types
|
### Re-exported Types
|
||||||
|
|
||||||
DcRouter re-exports key types from smartmta for convenience:
|
DcRouter re-exports key types for convenience:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
@@ -1133,6 +1350,7 @@ import {
|
|||||||
type IUnifiedEmailServerOptions,
|
type IUnifiedEmailServerOptions,
|
||||||
type IEmailRoute,
|
type IEmailRoute,
|
||||||
type IEmailDomainConfig,
|
type IEmailDomainConfig,
|
||||||
|
type IHttp3Config,
|
||||||
} from '@serve.zone/dcrouter';
|
} from '@serve.zone/dcrouter';
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1144,12 +1362,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`](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-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` |
|
| [`@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
|
```typescript
|
||||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||||
|
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
@@ -1171,17 +1391,62 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
|
|||||||
|
|
||||||
| Test File | Area | Tests |
|
| 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.contentscanner.ts` | Content scanning (spam, phishing, malware, attachments) | 13 |
|
||||||
| `test.dcrouter.email.ts` | Email config, domain and route setup | 4 |
|
| `test.dcrouter.email.ts` | Email config, domain and route setup | 4 |
|
||||||
| `test.dns-server-config.ts` | DNS record parsing, grouping, extraction | 5 |
|
| `test.dns-server-config.ts` | DNS record parsing, grouping, extraction | 5 |
|
||||||
| `test.dns-socket-handler.ts` | DNS socket handler and route generation | 6 |
|
| `test.dns-socket-handler.ts` | DNS socket handler and route generation | 6 |
|
||||||
| `test.errors.ts` | Error classes, handler, retry utilities | 5 |
|
| `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.ipreputationchecker.ts` | IP reputation, DNSBL, caching, risk classification | 10 |
|
||||||
| `test.jwt-auth.ts` | JWT login, verification, logout, invalid credentials | 8 |
|
| `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.protected-endpoint.ts` | Admin auth, identity verification, public endpoints | 8 |
|
||||||
| `test.storagemanager.ts` | Memory, filesystem, custom backends, concurrency | 8 |
|
| `test.storagemanager.ts` | Memory, filesystem, custom backends, concurrency | 8 |
|
||||||
|
|
||||||
|
## Docker / OCI Container Deployment
|
||||||
|
|
||||||
|
DcRouter ships with a `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. 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 \
|
||||||
|
-e DCROUTER_MODE=OCI_CONTAINER \
|
||||||
|
-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 53:53/udp -p 3000:3000 \
|
||||||
|
code.foss.global/serve.zone/dcrouter:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DCROUTER_MODE` | Set to `OCI_CONTAINER` to enable container mode | `OCI_CONTAINER` |
|
||||||
|
| `DCROUTER_CONFIG_PATH` | Path to a JSON config file (loaded as base, env vars override) | `/config/dcrouter.json` |
|
||||||
|
| `DCROUTER_BASE_DIR` | Override base data directory | `/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` |
|
||||||
|
|
||||||
|
### 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
|
## 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.
|
||||||
@@ -1196,7 +1461,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
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.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
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,6 +129,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
tls: {
|
tls: {
|
||||||
contactEmail: 'test@example.com'
|
contactEmail: 'test@example.com'
|
||||||
},
|
},
|
||||||
|
opsServerPort: 3104,
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
|
|||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
|
opsServerPort: 3100,
|
||||||
cacheConfig: { enabled: false }
|
cacheConfig: { enabled: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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,6 +9,7 @@ let identity: interfaces.data.IIdentity;
|
|||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
|
opsServerPort: 3102,
|
||||||
cacheConfig: { enabled: false },
|
cacheConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
|
|
||||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
|||||||
|
|
||||||
tap.test('should verify valid JWT identity', async () => {
|
tap.test('should verify valid JWT identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ tap.test('should verify valid JWT identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject invalid JWT', async () => {
|
tap.test('should reject invalid JWT', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ tap.test('should reject invalid JWT', async () => {
|
|||||||
|
|
||||||
tap.test('should verify JWT matches identity data', async () => {
|
tap.test('should verify JWT matches identity data', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ tap.test('should verify JWT matches identity data', async () => {
|
|||||||
|
|
||||||
tap.test('should handle logout', async () => {
|
tap.test('should handle logout', async () => {
|
||||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLogout'
|
'adminLogout'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ tap.test('should handle logout', async () => {
|
|||||||
|
|
||||||
tap.test('should reject wrong credentials', async () => {
|
tap.test('should reject wrong credentials', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,45 @@ import { TypedRequest } from '@api.global/typedrequest';
|
|||||||
import * as interfaces from '../ts_interfaces/index.js';
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
let testDcRouter: DcRouter;
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
|
opsServerPort: 3101,
|
||||||
cacheConfig: { enabled: false },
|
cacheConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'http://localhost:3101/typedrequest',
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('should respond to health status request', async () => {
|
tap.test('should respond to health status request', async () => {
|
||||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await healthRequest.fire({
|
const response = await healthRequest.fire({
|
||||||
detailed: false
|
identity: adminIdentity,
|
||||||
|
detailed: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('health');
|
expect(response).toHaveProperty('health');
|
||||||
expect(response.health.healthy).toBeTrue();
|
expect(response.health.healthy).toBeTrue();
|
||||||
expect(response.health.services).toHaveProperty('OpsServer');
|
expect(response.health.services).toHaveProperty('OpsServer');
|
||||||
@@ -32,14 +50,15 @@ tap.test('should respond to health status request', async () => {
|
|||||||
|
|
||||||
tap.test('should respond to server statistics request', async () => {
|
tap.test('should respond to server statistics request', async () => {
|
||||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getServerStatistics'
|
'getServerStatistics'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await statsRequest.fire({
|
const response = await statsRequest.fire({
|
||||||
includeHistory: false
|
identity: adminIdentity,
|
||||||
|
includeHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('stats');
|
expect(response).toHaveProperty('stats');
|
||||||
expect(response.stats).toHaveProperty('uptime');
|
expect(response.stats).toHaveProperty('uptime');
|
||||||
expect(response.stats).toHaveProperty('cpuUsage');
|
expect(response.stats).toHaveProperty('cpuUsage');
|
||||||
@@ -48,37 +67,58 @@ tap.test('should respond to server statistics request', async () => {
|
|||||||
|
|
||||||
tap.test('should respond to configuration request', async () => {
|
tap.test('should respond to configuration request', async () => {
|
||||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getConfiguration'
|
'getConfiguration'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await configRequest.fire({});
|
const response = await configRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('config');
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
expect(response.config).toHaveProperty('email');
|
expect(response.config).toHaveProperty('email');
|
||||||
expect(response.config).toHaveProperty('dns');
|
expect(response.config).toHaveProperty('dns');
|
||||||
expect(response.config).toHaveProperty('proxy');
|
expect(response.config).toHaveProperty('tls');
|
||||||
expect(response.config).toHaveProperty('security');
|
expect(response.config).toHaveProperty('cache');
|
||||||
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should handle log retrieval request', async () => {
|
tap.test('should handle log retrieval request', async () => {
|
||||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3101/typedrequest',
|
||||||
'getRecentLogs'
|
'getRecentLogs'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await logsRequest.fire({
|
const response = await logsRequest.fire({
|
||||||
limit: 10
|
identity: adminIdentity,
|
||||||
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('logs');
|
expect(response).toHaveProperty('logs');
|
||||||
expect(response).toHaveProperty('total');
|
expect(response).toHaveProperty('total');
|
||||||
expect(response).toHaveProperty('hasMore');
|
expect(response).toHaveProperty('hasMore');
|
||||||
expect(response.logs).toBeArray();
|
expect(response.logs).toBeArray();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated requests', async () => {
|
||||||
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
|
'http://localhost:3101/typedrequest',
|
||||||
|
'getHealthStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('should stop DCRouter', async () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
await testDcRouter.stop();
|
await testDcRouter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ let adminIdentity: interfaces.data.IIdentity;
|
|||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
|
opsServerPort: 3103,
|
||||||
cacheConfig: { enabled: false },
|
cacheConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
|
|
||||||
tap.test('should login as admin', async () => {
|
tap.test('should login as admin', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ tap.test('should login as admin', async () => {
|
|||||||
|
|
||||||
tap.test('should allow admin to verify identity', async () => {
|
tap.test('should allow admin to verify identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ tap.test('should allow admin to verify identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject verify identity without identity', async () => {
|
tap.test('should reject verify identity without identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ tap.test('should reject verify identity without identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,35 +83,42 @@ tap.test('should reject verify identity with invalid JWT', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow access to public endpoints without auth', async () => {
|
tap.test('should reject protected endpoints without auth', async () => {
|
||||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
// No identity provided
|
try {
|
||||||
const response = await healthRequest.fire({});
|
// No identity provided — should be rejected
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
expect(response).toHaveProperty('health');
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
expect(response.health.healthy).toBeTrue();
|
} catch (error) {
|
||||||
console.log('Public endpoint accessible without auth');
|
expect(error).toBeTruthy();
|
||||||
|
console.log('Protected endpoint correctly rejects unauthenticated request');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow read-only config access', async () => {
|
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3103/typedrequest',
|
||||||
'getConfiguration'
|
'getConfiguration'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Config is read-only and doesn't require auth
|
const response = await configRequest.fire({
|
||||||
const response = await configRequest.fire({});
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('config');
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
expect(response.config).toHaveProperty('email');
|
expect(response.config).toHaveProperty('email');
|
||||||
expect(response.config).toHaveProperty('dns');
|
expect(response.config).toHaveProperty('dns');
|
||||||
expect(response.config).toHaveProperty('proxy');
|
expect(response.config).toHaveProperty('tls');
|
||||||
expect(response.config).toHaveProperty('security');
|
expect(response.config).toHaveProperty('cache');
|
||||||
console.log('Configuration read successfully');
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
|
console.log('Authenticated access to config successful');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should stop DCRouter', async () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import { DcRouter } from '../ts/index.js';
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
const devRouter = new DcRouter({
|
const devRouter = new DcRouter({
|
||||||
// Configure services as needed for development
|
// SmartProxy routes for development/demo
|
||||||
// OpsServer always starts on port 3000
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
// Example: Add SmartProxy routes
|
{
|
||||||
// smartProxyConfig: {
|
name: 'web-traffic',
|
||||||
// routes: [...]
|
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||||
// },
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||||
|
},
|
||||||
// Example: Add email configuration
|
{
|
||||||
// emailConfig: {
|
name: 'api-gateway',
|
||||||
// ports: [2525],
|
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||||
// hostname: 'localhost',
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
|
||||||
// domains: [],
|
},
|
||||||
// routes: []
|
{
|
||||||
// },
|
name: 'tls-passthrough',
|
||||||
|
match: { ports: [18443], domains: ['secure.example.com'] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 4443 }],
|
||||||
|
tls: { mode: 'passthrough' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Disable cache/mongo for dev
|
||||||
|
cacheConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Starting DcRouter in development mode...');
|
console.log('Starting DcRouter in development mode...');
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '6.13.2',
|
version: '11.8.4',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import { OpsServer } from './opsserver/index.js';
|
|||||||
import { MetricsManager } from './monitoring/index.js';
|
import { MetricsManager } from './monitoring/index.js';
|
||||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||||
|
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
||||||
|
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||||
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||||
@@ -161,6 +164,17 @@ export interface IDcRouterOptions {
|
|||||||
* Remote Ingress configuration for edge tunnel nodes
|
* Remote Ingress configuration for edge tunnel nodes
|
||||||
* Enables edge nodes to accept incoming connections and tunnel them to this DcRouter
|
* Enables edge nodes to accept incoming connections and tunnel them to this DcRouter
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* HTTP/3 (QUIC) configuration for HTTPS routes.
|
||||||
|
* Enabled by default — qualifying HTTPS routes on port 443 are automatically
|
||||||
|
* augmented with QUIC/H3 fields. Set { enabled: false } to disable globally.
|
||||||
|
* Individual routes can opt out via action.options.http3 = false.
|
||||||
|
*/
|
||||||
|
http3?: IHttp3Config;
|
||||||
|
|
||||||
|
/** Port for the OpsServer web UI (default: 3000) */
|
||||||
|
opsServerPort?: number;
|
||||||
|
|
||||||
remoteIngressConfig?: {
|
remoteIngressConfig?: {
|
||||||
/** Enable remote ingress hub (default: false) */
|
/** Enable remote ingress hub (default: false) */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -212,6 +226,19 @@ export class DcRouter {
|
|||||||
public remoteIngressManager?: RemoteIngressManager;
|
public remoteIngressManager?: RemoteIngressManager;
|
||||||
public tunnelManager?: TunnelManager;
|
public tunnelManager?: TunnelManager;
|
||||||
|
|
||||||
|
// Programmatic config API
|
||||||
|
public routeConfigManager?: RouteConfigManager;
|
||||||
|
public apiTokenManager?: ApiTokenManager;
|
||||||
|
|
||||||
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
|
public detectedPublicIp: string | null = null;
|
||||||
|
|
||||||
|
// DNS query logging rate limiter state
|
||||||
|
private dnsLogWindowSecond: number = 0; // epoch second of current window
|
||||||
|
private dnsLogWindowCount: number = 0; // queries logged this second
|
||||||
|
private dnsBatchCount: number = 0;
|
||||||
|
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// Certificate status tracking from SmartProxy events (keyed by domain)
|
// Certificate status tracking from SmartProxy events (keyed by domain)
|
||||||
public certificateStatusMap = new Map<string, {
|
public certificateStatusMap = new Map<string, {
|
||||||
status: 'valid' | 'failed';
|
status: 'valid' | 'failed';
|
||||||
@@ -228,6 +255,9 @@ export class DcRouter {
|
|||||||
// TypedRouter for API endpoints
|
// TypedRouter for API endpoints
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
||||||
|
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// Environment access
|
// Environment access
|
||||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||||
|
|
||||||
@@ -252,9 +282,7 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
console.log('╔═══════════════════════════════════════════════════════════════════╗');
|
logger.log('info', 'Starting DcRouter Services');
|
||||||
console.log('║ Starting DcRouter Services ║');
|
|
||||||
console.log('╚═══════════════════════════════════════════════════════════════════╝');
|
|
||||||
|
|
||||||
|
|
||||||
this.opsServer = new OpsServer(this);
|
this.opsServer = new OpsServer(this);
|
||||||
@@ -272,7 +300,18 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
|
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
|
||||||
await this.setupSmartProxy();
|
await this.setupSmartProxy();
|
||||||
|
|
||||||
|
// Initialize programmatic config API managers
|
||||||
|
this.routeConfigManager = new RouteConfigManager(
|
||||||
|
this.storageManager,
|
||||||
|
() => this.getConstructorRoutes(),
|
||||||
|
() => this.smartProxy,
|
||||||
|
() => this.options.http3,
|
||||||
|
);
|
||||||
|
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
||||||
|
await this.apiTokenManager.initialize();
|
||||||
|
await this.routeConfigManager.initialize();
|
||||||
|
|
||||||
// Set up unified email handling if configured
|
// Set up unified email handling if configured
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
await this.setupUnifiedEmailHandling();
|
await this.setupUnifiedEmailHandling();
|
||||||
@@ -296,7 +335,7 @@ export class DcRouter {
|
|||||||
|
|
||||||
this.logStartupSummary();
|
this.logStartupSummary();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error starting DcRouter:', error);
|
logger.log('error', 'Error starting DcRouter', { error: String(error) });
|
||||||
// Try to clean up any services that may have started
|
// Try to clean up any services that may have started
|
||||||
await this.stop();
|
await this.stop();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -307,104 +346,60 @@ export class DcRouter {
|
|||||||
* Log comprehensive startup summary
|
* Log comprehensive startup summary
|
||||||
*/
|
*/
|
||||||
private logStartupSummary(): void {
|
private logStartupSummary(): void {
|
||||||
console.log('\n╔═══════════════════════════════════════════════════════════════════╗');
|
logger.log('info', 'DcRouter Started Successfully');
|
||||||
console.log('║ DcRouter Started Successfully ║');
|
|
||||||
console.log('╚═══════════════════════════════════════════════════════════════════╝\n');
|
|
||||||
|
|
||||||
// Metrics summary
|
// Metrics summary
|
||||||
if (this.metricsManager) {
|
if (this.metricsManager) {
|
||||||
console.log('📊 Metrics Service:');
|
logger.log('info', 'Metrics Service: SmartMetrics active, SmartProxy stats active, real-time tracking enabled');
|
||||||
console.log(' ├─ SmartMetrics: Active');
|
|
||||||
console.log(' ├─ SmartProxy Stats: Active');
|
|
||||||
console.log(' └─ Real-time tracking: Enabled');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SmartProxy summary
|
// SmartProxy summary
|
||||||
if (this.smartProxy) {
|
if (this.smartProxy) {
|
||||||
console.log('🌐 SmartProxy Service:');
|
|
||||||
const routeCount = this.options.smartProxyConfig?.routes?.length || 0;
|
const routeCount = this.options.smartProxyConfig?.routes?.length || 0;
|
||||||
console.log(` ├─ Routes configured: ${routeCount}`);
|
const acmeEnabled = this.options.smartProxyConfig?.acme?.enabled || false;
|
||||||
console.log(` ├─ ACME enabled: ${this.options.smartProxyConfig?.acme?.enabled || false}`);
|
const acmeMode = acmeEnabled
|
||||||
if (this.options.smartProxyConfig?.acme?.enabled) {
|
? `email=${this.options.smartProxyConfig!.acme!.email || 'not set'}, mode=${this.options.smartProxyConfig!.acme!.useProduction ? 'production' : 'staging'}`
|
||||||
console.log(` ├─ ACME email: ${this.options.smartProxyConfig.acme.email || 'not set'}`);
|
: 'disabled';
|
||||||
console.log(` └─ ACME mode: ${this.options.smartProxyConfig.acme.useProduction ? 'production' : 'staging'}`);
|
logger.log('info', `SmartProxy Service: ${routeCount} routes, ACME: ${acmeMode}`);
|
||||||
} else {
|
|
||||||
console.log(' └─ ACME: disabled');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email service summary
|
// Email service summary
|
||||||
if (this.emailServer && this.options.emailConfig) {
|
if (this.emailServer && this.options.emailConfig) {
|
||||||
console.log('\n📧 Email Service:');
|
|
||||||
const ports = this.options.emailConfig.ports || [];
|
const ports = this.options.emailConfig.ports || [];
|
||||||
console.log(` ├─ Ports: ${ports.join(', ')}`);
|
const domainCount = this.options.emailConfig.domains?.length || 0;
|
||||||
console.log(` ├─ Hostname: ${this.options.emailConfig.hostname || 'localhost'}`);
|
const domainNames = this.options.emailConfig.domains?.map(d => `${d.domain} (${d.dnsMode || 'default'})`).join(', ') || 'none';
|
||||||
console.log(` ├─ Domains configured: ${this.options.emailConfig.domains?.length || 0}`);
|
logger.log('info', `Email Service: ports=[${ports.join(', ')}], hostname=${this.options.emailConfig.hostname || 'localhost'}, domains=${domainCount} [${domainNames}], DKIM initialized`);
|
||||||
if (this.options.emailConfig.domains && this.options.emailConfig.domains.length > 0) {
|
|
||||||
this.options.emailConfig.domains.forEach((domain, index) => {
|
|
||||||
const isLast = index === this.options.emailConfig!.domains!.length - 1;
|
|
||||||
console.log(` ${isLast ? '└─' : '├─'} ${domain.domain} (${domain.dnsMode || 'default'})`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(` └─ DKIM: Initialized for all domains`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS service summary
|
// DNS service summary
|
||||||
if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) {
|
if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) {
|
||||||
console.log('\n🌍 DNS Service:');
|
logger.log('info', `DNS Service: nameservers=[${this.options.dnsNsDomains.join(', ')}], authoritative for ${this.options.dnsScopes.length} domains [${this.options.dnsScopes.join(', ')}], UDP:53, DoH enabled`);
|
||||||
console.log(` ├─ Nameservers: ${this.options.dnsNsDomains.join(', ')}`);
|
|
||||||
console.log(` ├─ Primary NS: ${this.options.dnsNsDomains[0]}`);
|
|
||||||
console.log(` ├─ Authoritative for: ${this.options.dnsScopes.length} domains`);
|
|
||||||
console.log(` ├─ UDP Port: 53`);
|
|
||||||
console.log(` ├─ DNS-over-HTTPS: Enabled via socket handler`);
|
|
||||||
console.log(` └─ DNSSEC: ${this.options.dnsNsDomains[0] ? 'Enabled' : 'Disabled'}`);
|
|
||||||
|
|
||||||
// Show authoritative domains
|
|
||||||
if (this.options.dnsScopes.length > 0) {
|
|
||||||
console.log('\n Authoritative Domains:');
|
|
||||||
this.options.dnsScopes.forEach((domain, index) => {
|
|
||||||
const isLast = index === this.options.dnsScopes!.length - 1;
|
|
||||||
console.log(` ${isLast ? '└─' : '├─'} ${domain}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RADIUS service summary
|
// RADIUS service summary
|
||||||
if (this.radiusServer && this.options.radiusConfig) {
|
if (this.radiusServer && this.options.radiusConfig) {
|
||||||
console.log('\n🔐 RADIUS Service:');
|
|
||||||
console.log(` ├─ Auth Port: ${this.options.radiusConfig.authPort || 1812}`);
|
|
||||||
console.log(` ├─ Acct Port: ${this.options.radiusConfig.acctPort || 1813}`);
|
|
||||||
console.log(` ├─ Clients configured: ${this.options.radiusConfig.clients?.length || 0}`);
|
|
||||||
const vlanStats = this.radiusServer.getVlanManager().getStats();
|
const vlanStats = this.radiusServer.getVlanManager().getStats();
|
||||||
console.log(` ├─ VLAN mappings: ${vlanStats.totalMappings}`);
|
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
|
||||||
console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote Ingress summary
|
// Remote Ingress summary
|
||||||
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
||||||
console.log('\n🌐 Remote Ingress:');
|
|
||||||
console.log(` ├─ Tunnel Port: ${this.options.remoteIngressConfig.tunnelPort || 8443}`);
|
|
||||||
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
||||||
const connectedCount = this.tunnelManager.getConnectedCount();
|
const connectedCount = this.tunnelManager.getConnectedCount();
|
||||||
console.log(` ├─ Registered Edges: ${edgeCount}`);
|
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
|
||||||
console.log(` └─ Connected Edges: ${connectedCount}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage summary
|
// Storage summary
|
||||||
if (this.storageManager && this.options.storage) {
|
if (this.storageManager && this.options.storage) {
|
||||||
console.log('\n💾 Storage:');
|
logger.log('info', `Storage: path=${this.options.storage.fsPath || 'default'}`);
|
||||||
console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache database summary
|
// Cache database summary
|
||||||
if (this.cacheDb) {
|
if (this.cacheDb) {
|
||||||
console.log('\n🗄️ Cache Database (smartdata + LocalTsmDb):');
|
logger.log('info', `Cache Database: storage=${this.cacheDb.getStoragePath()}, db=${this.cacheDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
|
||||||
console.log(` ├─ Storage: ${this.cacheDb.getStoragePath()}`);
|
|
||||||
console.log(` ├─ Database: ${this.cacheDb.getDbName()}`);
|
|
||||||
console.log(` └─ Cleaner: ${this.cacheCleaner?.isActive() ? 'Active' : 'Inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n✅ All services are running\n');
|
logger.log('info', 'All services are running');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -439,7 +434,7 @@ export class DcRouter {
|
|||||||
* Set up SmartProxy with direct configuration and automatic email routes
|
* Set up SmartProxy with direct configuration and automatic email routes
|
||||||
*/
|
*/
|
||||||
private async setupSmartProxy(): Promise<void> {
|
private async setupSmartProxy(): Promise<void> {
|
||||||
console.log('[DcRouter] Setting up SmartProxy...');
|
logger.log('info', 'Setting up SmartProxy...');
|
||||||
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
|
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
|
||||||
|
|
||||||
@@ -447,22 +442,20 @@ export class DcRouter {
|
|||||||
if (this.options.smartProxyConfig) {
|
if (this.options.smartProxyConfig) {
|
||||||
routes = this.options.smartProxyConfig.routes || [];
|
routes = this.options.smartProxyConfig.routes || [];
|
||||||
acmeConfig = this.options.smartProxyConfig.acme;
|
acmeConfig = this.options.smartProxyConfig.acme;
|
||||||
console.log(`[DcRouter] Found ${routes.length} routes in config`);
|
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
|
||||||
console.log(`[DcRouter] ACME config present: ${!!acmeConfig}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If email config exists, automatically add email routes
|
// If email config exists, automatically add email routes
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||||
console.log(`Email Routes are:`)
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
||||||
console.log(emailRoutes)
|
|
||||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
||||||
}
|
}
|
||||||
|
|
||||||
// If DNS is configured, add DNS routes
|
// If DNS is configured, add DNS routes
|
||||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||||
const dnsRoutes = this.generateDnsRoutes();
|
const dnsRoutes = this.generateDnsRoutes();
|
||||||
console.log(`DNS Routes for nameservers ${this.options.dnsNsDomains.join(', ')}:`, dnsRoutes);
|
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
||||||
routes = [...routes, ...dnsRoutes];
|
routes = [...routes, ...dnsRoutes];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,15 +473,25 @@ export class DcRouter {
|
|||||||
// Configure DNS challenge if available
|
// Configure DNS challenge if available
|
||||||
let challengeHandlers: any[] = [];
|
let challengeHandlers: any[] = [];
|
||||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
||||||
console.log('Configuring Cloudflare DNS challenge for ACME');
|
logger.log('info', 'Configuring Cloudflare DNS challenge for ACME');
|
||||||
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
|
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
|
||||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
|
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
|
||||||
challengeHandlers.push(dns01Handler);
|
challengeHandlers.push(dns01Handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP/3 augmentation (enabled by default unless explicitly disabled)
|
||||||
|
if (this.options.http3?.enabled !== false) {
|
||||||
|
const http3Config: IHttp3Config = { enabled: true, ...this.options.http3 };
|
||||||
|
routes = augmentRoutesWithHttp3(routes, http3Config);
|
||||||
|
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache constructor routes for RouteConfigManager
|
||||||
|
this.constructorRoutes = [...routes];
|
||||||
|
|
||||||
// If we have routes or need a basic SmartProxy instance, create it
|
// If we have routes or need a basic SmartProxy instance, create it
|
||||||
if (routes.length > 0 || this.options.smartProxyConfig) {
|
if (routes.length > 0 || this.options.smartProxyConfig) {
|
||||||
console.log('Setting up SmartProxy with combined configuration');
|
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||||
|
|
||||||
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
|
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
|
||||||
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
|
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
|
||||||
@@ -537,7 +540,7 @@ export class DcRouter {
|
|||||||
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
|
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
|
||||||
if (this.smartAcme) {
|
if (this.smartAcme) {
|
||||||
await this.smartAcme.stop().catch(err =>
|
await this.smartAcme.stop().catch(err =>
|
||||||
console.error('[DcRouter] Error stopping old SmartAcme:', err)
|
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||||
@@ -600,25 +603,19 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create SmartProxy instance
|
// Create SmartProxy instance
|
||||||
console.log('[DcRouter] Creating SmartProxy instance with config:', JSON.stringify({
|
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
||||||
routeCount: smartProxyConfig.routes?.length,
|
|
||||||
acmeEnabled: smartProxyConfig.acme?.enabled,
|
|
||||||
acmeEmail: smartProxyConfig.acme?.email,
|
|
||||||
certProvisionFunction: !!smartProxyConfig.certProvisionFunction
|
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
|
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
|
||||||
|
|
||||||
// Set up event listeners
|
// Set up event listeners
|
||||||
this.smartProxy.on('error', (err) => {
|
this.smartProxy.on('error', (err) => {
|
||||||
console.error('[DcRouter] SmartProxy error:', err);
|
logger.log('error', `SmartProxy error: ${err.message}`, { stack: err.stack });
|
||||||
console.error('[DcRouter] Error stack:', err.stack);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
|
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
|
||||||
// Events are keyed by domain for domain-centric certificate tracking
|
// Events are keyed by domain for domain-centric certificate tracking
|
||||||
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||||
console.log(`[DcRouter] Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
logger.log('info', `Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||||
this.certificateStatusMap.set(event.domain, {
|
this.certificateStatusMap.set(event.domain, {
|
||||||
status: 'valid', routeNames,
|
status: 'valid', routeNames,
|
||||||
@@ -628,7 +625,7 @@ export class DcRouter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||||
console.log(`[DcRouter] Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||||
this.certificateStatusMap.set(event.domain, {
|
this.certificateStatusMap.set(event.domain, {
|
||||||
status: 'valid', routeNames,
|
status: 'valid', routeNames,
|
||||||
@@ -638,7 +635,7 @@ export class DcRouter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
||||||
console.error(`[DcRouter] Certificate failed for ${event.domain} (${event.source}):`, event.error);
|
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
|
||||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||||
this.certificateStatusMap.set(event.domain, {
|
this.certificateStatusMap.set(event.domain, {
|
||||||
status: 'failed', routeNames, error: event.error,
|
status: 'failed', routeNames, error: event.error,
|
||||||
@@ -647,9 +644,9 @@ export class DcRouter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start SmartProxy
|
// Start SmartProxy
|
||||||
console.log('[DcRouter] Starting SmartProxy...');
|
logger.log('info', 'Starting SmartProxy...');
|
||||||
await this.smartProxy.start();
|
await this.smartProxy.start();
|
||||||
console.log('[DcRouter] SmartProxy started successfully');
|
logger.log('info', 'SmartProxy started successfully');
|
||||||
|
|
||||||
// Populate certificateStatusMap for certs loaded from store at startup
|
// Populate certificateStatusMap for certs loaded from store at startup
|
||||||
for (const entry of loadedCertEntries) {
|
for (const entry of loadedCertEntries) {
|
||||||
@@ -701,10 +698,10 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (loadedCertEntries.length > 0) {
|
if (loadedCertEntries.length > 0) {
|
||||||
console.log(`[DcRouter] Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`);
|
logger.log('info', `Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`SmartProxy started with ${routes.length} routes`);
|
logger.log('info', `SmartProxy started with ${routes.length} routes`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,48 +903,83 @@ export class DcRouter {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the routes derived from constructor config (smartProxy + email + DNS).
|
||||||
|
* Used by RouteConfigManager as the "hardcoded" base.
|
||||||
|
*/
|
||||||
|
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
|
||||||
|
return this.constructorRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
console.log('Stopping DcRouter services...');
|
logger.log('info', 'Stopping DcRouter services...');
|
||||||
|
|
||||||
|
// Flush pending DNS batch log
|
||||||
|
if (this.dnsBatchTimer) {
|
||||||
|
clearTimeout(this.dnsBatchTimer);
|
||||||
|
if (this.dnsBatchCount > 0) {
|
||||||
|
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited, final flush)`, { zone: 'dns' });
|
||||||
|
}
|
||||||
|
this.dnsBatchTimer = null;
|
||||||
|
this.dnsBatchCount = 0;
|
||||||
|
this.dnsLogWindowSecond = 0;
|
||||||
|
this.dnsLogWindowCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
await this.opsServer.stop();
|
await this.opsServer.stop();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Remove event listeners before stopping services to prevent leaks
|
||||||
|
if (this.smartProxy) {
|
||||||
|
this.smartProxy.removeAllListeners();
|
||||||
|
}
|
||||||
|
if (this.emailServer) {
|
||||||
|
if ((this.emailServer as any).deliverySystem) {
|
||||||
|
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||||
|
}
|
||||||
|
this.emailServer.removeAllListeners();
|
||||||
|
}
|
||||||
|
if (this.dnsServer) {
|
||||||
|
this.dnsServer.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// Stop all services in parallel for faster shutdown
|
// Stop all services in parallel for faster shutdown
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// Stop cache cleaner if running
|
// Stop cache cleaner if running
|
||||||
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
|
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
|
||||||
|
|
||||||
// Stop metrics manager if running
|
// Stop metrics manager if running
|
||||||
this.metricsManager ? this.metricsManager.stop().catch(err => console.error('Error stopping MetricsManager:', err)) : Promise.resolve(),
|
this.metricsManager ? this.metricsManager.stop().catch(err => logger.log('error', 'Error stopping MetricsManager', { error: String(err) })) : Promise.resolve(),
|
||||||
|
|
||||||
// Stop unified email server if running
|
// Stop unified email server if running
|
||||||
this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(),
|
this.emailServer ? this.emailServer.stop().catch(err => logger.log('error', 'Error stopping email server', { error: String(err) })) : Promise.resolve(),
|
||||||
|
|
||||||
// Stop SmartAcme if running
|
// Stop SmartAcme if running
|
||||||
this.smartAcme ? this.smartAcme.stop().catch(err => console.error('Error stopping SmartAcme:', err)) : Promise.resolve(),
|
this.smartAcme ? this.smartAcme.stop().catch(err => logger.log('error', 'Error stopping SmartAcme', { error: String(err) })) : Promise.resolve(),
|
||||||
|
|
||||||
// Stop HTTP SmartProxy if running
|
// Stop HTTP SmartProxy if running
|
||||||
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
|
this.smartProxy ? this.smartProxy.stop().catch(err => logger.log('error', 'Error stopping SmartProxy', { error: String(err) })) : Promise.resolve(),
|
||||||
|
|
||||||
// Stop DNS server if running
|
// Stop DNS server if running
|
||||||
this.dnsServer ?
|
this.dnsServer ?
|
||||||
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
|
this.dnsServer.stop().catch(err => logger.log('error', 'Error stopping DNS server', { error: String(err) })) :
|
||||||
Promise.resolve(),
|
Promise.resolve(),
|
||||||
|
|
||||||
// Stop RADIUS server if running
|
// Stop RADIUS server if running
|
||||||
this.radiusServer ?
|
this.radiusServer ?
|
||||||
this.radiusServer.stop().catch(err => console.error('Error stopping RADIUS server:', err)) :
|
this.radiusServer.stop().catch(err => logger.log('error', 'Error stopping RADIUS server', { error: String(err) })) :
|
||||||
Promise.resolve(),
|
Promise.resolve(),
|
||||||
|
|
||||||
// Stop Remote Ingress tunnel manager if running
|
// Stop Remote Ingress tunnel manager if running
|
||||||
this.tunnelManager ?
|
this.tunnelManager ?
|
||||||
this.tunnelManager.stop().catch(err => console.error('Error stopping TunnelManager:', err)) :
|
this.tunnelManager.stop().catch(err => logger.log('error', 'Error stopping TunnelManager', { error: String(err) })) :
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Stop cache database after other services (they may need it during shutdown)
|
// Stop cache database after other services (they may need it during shutdown)
|
||||||
if (this.cacheDb) {
|
if (this.cacheDb) {
|
||||||
await this.cacheDb.stop().catch(err => console.error('Error stopping CacheDb:', err));
|
await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
|
||||||
|
CacheDb.resetInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear backoff cache in cert scheduler
|
// Clear backoff cache in cert scheduler
|
||||||
@@ -967,11 +999,18 @@ export class DcRouter {
|
|||||||
this.smartAcme = undefined;
|
this.smartAcme = undefined;
|
||||||
this.certProvisionScheduler = undefined;
|
this.certProvisionScheduler = undefined;
|
||||||
this.remoteIngressManager = undefined;
|
this.remoteIngressManager = undefined;
|
||||||
|
this.routeConfigManager = undefined;
|
||||||
|
this.apiTokenManager = undefined;
|
||||||
this.certificateStatusMap.clear();
|
this.certificateStatusMap.clear();
|
||||||
|
|
||||||
console.log('All DcRouter services stopped');
|
// Reset security singletons to allow GC
|
||||||
|
SecurityLogger.resetInstance();
|
||||||
|
ContentScanner.resetInstance();
|
||||||
|
IPReputationChecker.resetInstance();
|
||||||
|
|
||||||
|
logger.log('info', 'All DcRouter services stopped');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during DcRouter shutdown:', error);
|
logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -983,10 +1022,11 @@ export class DcRouter {
|
|||||||
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
|
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
|
||||||
// Stop existing SmartProxy if running
|
// Stop existing SmartProxy if running
|
||||||
if (this.smartProxy) {
|
if (this.smartProxy) {
|
||||||
|
this.smartProxy.removeAllListeners();
|
||||||
await this.smartProxy.stop();
|
await this.smartProxy.stop();
|
||||||
this.smartProxy = undefined;
|
this.smartProxy = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
this.options.smartProxyConfig = config;
|
this.options.smartProxyConfig = config;
|
||||||
|
|
||||||
@@ -998,7 +1038,12 @@ export class DcRouter {
|
|||||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
||||||
await this.setupSmartProxy();
|
await this.setupSmartProxy();
|
||||||
|
|
||||||
console.log('SmartProxy configuration updated');
|
// Re-apply programmatic routes and overrides after SmartProxy restart
|
||||||
|
if (this.routeConfigManager) {
|
||||||
|
await this.routeConfigManager.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'SmartProxy configuration updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1055,7 +1100,29 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
await this.emailServer.start();
|
await this.emailServer.start();
|
||||||
|
|
||||||
|
// Wire delivery events to MetricsManager and logger
|
||||||
|
if (this.metricsManager && this.emailServer.deliverySystem) {
|
||||||
|
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
||||||
|
this.metricsManager.trackEmailReceived(item?.from);
|
||||||
|
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
||||||
|
this.metricsManager.trackEmailSent(item?.to);
|
||||||
|
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
||||||
|
this.metricsManager.trackEmailFailed(item?.to, error?.message);
|
||||||
|
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.metricsManager && this.emailServer) {
|
||||||
|
this.emailServer.on('bounceProcessed', () => {
|
||||||
|
this.metricsManager.trackEmailBounced();
|
||||||
|
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,7 +1140,7 @@ export class DcRouter {
|
|||||||
// Start email handling with new configuration
|
// Start email handling with new configuration
|
||||||
await this.setupUnifiedEmailHandling();
|
await this.setupUnifiedEmailHandling();
|
||||||
|
|
||||||
console.log('Unified email configuration updated');
|
logger.log('info', 'Unified email configuration updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1083,6 +1150,11 @@ export class DcRouter {
|
|||||||
try {
|
try {
|
||||||
// Stop the unified email server which contains all components
|
// Stop the unified email server which contains all components
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
|
// Remove listeners before stopping to prevent leaks on config update cycles
|
||||||
|
if ((this.emailServer as any).deliverySystem) {
|
||||||
|
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||||
|
}
|
||||||
|
this.emailServer.removeAllListeners();
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
logger.log('info', 'Unified email server stopped');
|
logger.log('info', 'Unified email server stopped');
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
@@ -1113,7 +1185,7 @@ export class DcRouter {
|
|||||||
this.emailServer.updateEmailRoutes(routes);
|
this.emailServer.updateEmailRoutes(routes);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Email routes updated with ${routes.length} routes`);
|
logger.log('info', `Email routes updated with ${routes.length} routes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1238,6 +1310,48 @@ export class DcRouter {
|
|||||||
// Start the DNS server (UDP only)
|
// Start the DNS server (UDP only)
|
||||||
await this.dnsServer.start();
|
await this.dnsServer.start();
|
||||||
logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`);
|
logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`);
|
||||||
|
|
||||||
|
// Wire DNS query events to MetricsManager and logger with adaptive rate limiting
|
||||||
|
if (this.metricsManager && this.dnsServer) {
|
||||||
|
const flushDnsBatch = () => {
|
||||||
|
if (this.dnsBatchCount > 0) {
|
||||||
|
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited)`, { zone: 'dns' });
|
||||||
|
this.dnsBatchCount = 0;
|
||||||
|
}
|
||||||
|
this.dnsBatchTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
|
||||||
|
// Metrics tracking
|
||||||
|
for (const question of event.questions) {
|
||||||
|
this.metricsManager.trackDnsQuery(
|
||||||
|
question.type,
|
||||||
|
question.name,
|
||||||
|
false,
|
||||||
|
event.responseTimeMs,
|
||||||
|
event.answered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive logging: individual logs up to 2/sec, then batch
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
if (nowSec !== this.dnsLogWindowSecond) {
|
||||||
|
this.dnsLogWindowSecond = nowSec;
|
||||||
|
this.dnsLogWindowCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dnsLogWindowCount < 2) {
|
||||||
|
this.dnsLogWindowCount++;
|
||||||
|
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
|
||||||
|
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
|
||||||
|
} else {
|
||||||
|
this.dnsBatchCount++;
|
||||||
|
if (!this.dnsBatchTimer) {
|
||||||
|
this.dnsBatchTimer = setTimeout(flushDnsBatch, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate DNS configuration
|
// Validate DNS configuration
|
||||||
await this.validateDnsConfiguration();
|
await this.validateDnsConfiguration();
|
||||||
@@ -1281,15 +1395,25 @@ export class DcRouter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent uncaught exception from socket 'error' events
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
logger.log('error', `DNS socket error: ${err.message}`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
|
logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the built-in socket handler from smartdns
|
// Use the built-in socket handler from smartdns
|
||||||
// This handles HTTP/2, DoH protocol, etc.
|
// This handles HTTP/2, DoH protocol, etc.
|
||||||
await (this.dnsServer as any).handleHttpsSocket(socket);
|
await (this.dnsServer as any).handleHttpsSocket(socket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('error', `DNS socket handler error: ${error.message}`);
|
logger.log('error', `DNS socket handler error: ${error.message}`);
|
||||||
socket.destroy();
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1495,6 +1619,7 @@ export class DcRouter {
|
|||||||
} else if (this.options.publicIp) {
|
} else if (this.options.publicIp) {
|
||||||
// Use explicitly configured public IP
|
// Use explicitly configured public IP
|
||||||
publicIp = this.options.publicIp;
|
publicIp = this.options.publicIp;
|
||||||
|
this.detectedPublicIp = publicIp;
|
||||||
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
|
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
|
||||||
} else {
|
} else {
|
||||||
// Auto-discover public IP using smartnetwork
|
// Auto-discover public IP using smartnetwork
|
||||||
@@ -1505,6 +1630,7 @@ export class DcRouter {
|
|||||||
|
|
||||||
if (publicIps.v4) {
|
if (publicIps.v4) {
|
||||||
publicIp = publicIps.v4;
|
publicIp = publicIps.v4;
|
||||||
|
this.detectedPublicIp = publicIp;
|
||||||
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
|
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
|
||||||
} else {
|
} else {
|
||||||
logger.log('warn', 'Could not auto-discover public IPv4 address');
|
logger.log('warn', 'Could not auto-discover public IPv4 address');
|
||||||
@@ -1627,13 +1753,45 @@ export class DcRouter {
|
|||||||
await this.remoteIngressManager.initialize();
|
await this.remoteIngressManager.initialize();
|
||||||
|
|
||||||
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
||||||
const currentRoutes = this.options.smartProxyConfig?.routes || [];
|
const currentRoutes = this.constructorRoutes;
|
||||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||||
|
|
||||||
|
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
|
||||||
|
const riCfg = this.options.remoteIngressConfig;
|
||||||
|
let tlsConfig: { certPem: string; keyPem: string } | undefined;
|
||||||
|
|
||||||
|
// Priority 1: Explicit cert/key file paths
|
||||||
|
if (riCfg.tls?.certPath && riCfg.tls?.keyPath) {
|
||||||
|
try {
|
||||||
|
const certPem = plugins.fs.readFileSync(riCfg.tls.certPath, 'utf8');
|
||||||
|
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
|
||||||
|
tlsConfig = { certPem, keyPem };
|
||||||
|
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
|
||||||
|
if (!tlsConfig && riCfg.hubDomain) {
|
||||||
|
try {
|
||||||
|
const stored = await this.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
||||||
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
|
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
|
||||||
|
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
|
||||||
|
}
|
||||||
|
} catch { /* no stored cert, fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tlsConfig) {
|
||||||
|
logger.log('info', 'No TLS cert configured for RemoteIngress tunnel — using auto-generated self-signed');
|
||||||
|
}
|
||||||
|
|
||||||
// Create and start the tunnel manager
|
// Create and start the tunnel manager
|
||||||
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
||||||
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
|
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||||
targetHost: '127.0.0.1',
|
targetHost: '127.0.0.1',
|
||||||
|
tls: tlsConfig,
|
||||||
});
|
});
|
||||||
await this.tunnelManager.start();
|
await this.tunnelManager.start();
|
||||||
|
|
||||||
|
|||||||
173
ts/config/classes.api-token-manager.ts
Normal file
173
ts/config/classes.api-token-manager.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { StorageManager } from '../storage/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredApiToken,
|
||||||
|
IApiTokenInfo,
|
||||||
|
TApiTokenScope,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const TOKENS_PREFIX = '/config-api/tokens/';
|
||||||
|
const TOKEN_PREFIX_STR = 'dcr_';
|
||||||
|
|
||||||
|
export class ApiTokenManager {
|
||||||
|
private tokens = new Map<string, IStoredApiToken>();
|
||||||
|
|
||||||
|
constructor(private storageManager: StorageManager) {}
|
||||||
|
|
||||||
|
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);
|
||||||
|
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
|
||||||
|
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 keys = await this.storageManager.list(TOKENS_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
|
||||||
|
if (stored?.id) {
|
||||||
|
this.tokens.set(stored.id, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
278
ts/config/classes.route-config-manager.ts
Normal file
278
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { StorageManager } from '../storage/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredRoute,
|
||||||
|
IRouteOverride,
|
||||||
|
IMergedRoute,
|
||||||
|
IRouteWarning,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||||
|
|
||||||
|
const ROUTES_PREFIX = '/config-api/routes/';
|
||||||
|
const OVERRIDES_PREFIX = '/config-api/overrides/';
|
||||||
|
|
||||||
|
export class RouteConfigManager {
|
||||||
|
private storedRoutes = new Map<string, IStoredRoute>();
|
||||||
|
private overrides = new Map<string, IRouteOverride>();
|
||||||
|
private warnings: IRouteWarning[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private storageManager: StorageManager,
|
||||||
|
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||||
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
|
||||||
|
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);
|
||||||
|
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeOverride(routeName: string): Promise<boolean> {
|
||||||
|
if (!this.overrides.has(routeName)) return false;
|
||||||
|
this.overrides.delete(routeName);
|
||||||
|
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadStoredRoutes(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(ROUTES_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
|
||||||
|
if (stored?.id) {
|
||||||
|
this.storedRoutes.set(stored.id, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.storedRoutes.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOverrides(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const override = await this.storageManager.getJSON<IRouteOverride>(key);
|
||||||
|
if (override?.routeName) {
|
||||||
|
this.overrides.set(override.routeName, override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.overrides.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 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
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async applyRoutes(): Promise<void> {
|
||||||
|
const smartProxy = this.getSmartProxy();
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
|
// Add enabled hardcoded routes (respecting overrides)
|
||||||
|
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(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
|
||||||
|
const http3Config = this.getHttp3Config?.();
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
if (stored.enabled) {
|
||||||
|
if (http3Config && http3Config.enabled !== false) {
|
||||||
|
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
|
||||||
|
} else {
|
||||||
|
enabledRoutes.push(stored.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 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';
|
||||||
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';
|
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// DcRouter
|
// DcRouter
|
||||||
|
import { DcRouter } from './classes.dcrouter.js';
|
||||||
export * from './classes.dcrouter.js';
|
export * from './classes.dcrouter.js';
|
||||||
|
|
||||||
// RADIUS module
|
// RADIUS module
|
||||||
@@ -13,4 +14,27 @@ export * from './radius/index.js';
|
|||||||
// Remote Ingress module
|
// Remote Ingress module
|
||||||
export * from './remoteingress/index.js';
|
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.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
};
|
||||||
|
|||||||
11
ts/logger.ts
11
ts/logger.ts
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
|
||||||
|
|
||||||
// Map NODE_ENV to valid TEnvironment
|
// Map NODE_ENV to valid TEnvironment
|
||||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||||
@@ -10,8 +11,11 @@ const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
|||||||
'production': 'production'
|
'production': 'production'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default Smartlog instance
|
// In-memory log buffer for the OpsServer UI
|
||||||
const baseLogger = new plugins.smartlog.Smartlog({
|
export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
|
||||||
|
|
||||||
|
// Default Smartlog instance (exported so OpsServer can add push destinations)
|
||||||
|
export const baseLogger = new plugins.smartlog.Smartlog({
|
||||||
logContext: {
|
logContext: {
|
||||||
environment: envMap[nodeEnv] || 'production',
|
environment: envMap[nodeEnv] || 'production',
|
||||||
runtime: 'node',
|
runtime: 'node',
|
||||||
@@ -19,6 +23,9 @@ const baseLogger = new plugins.smartlog.Smartlog({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire the buffer destination so all logs are captured
|
||||||
|
baseLogger.addLogDestination(logBuffer);
|
||||||
|
|
||||||
// Extended logger compatible with the original enhanced logger API
|
// Extended logger compatible with the original enhanced logger API
|
||||||
class StandardLogger {
|
class StandardLogger {
|
||||||
private defaultContext: Record<string, any> = {};
|
private defaultContext: Record<string, any> = {};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { DcRouter } from '../classes.dcrouter.js';
|
import { DcRouter } from '../classes.dcrouter.js';
|
||||||
import { MetricsCache } from './classes.metricscache.js';
|
import { MetricsCache } from './classes.metricscache.js';
|
||||||
|
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
export class MetricsManager {
|
export class MetricsManager {
|
||||||
private logger: plugins.smartlog.Smartlog;
|
private metricsLogger: plugins.smartlog.Smartlog;
|
||||||
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
||||||
private dcRouter: DcRouter;
|
private dcRouter: DcRouter;
|
||||||
private resetInterval?: NodeJS.Timeout;
|
private resetInterval?: NodeJS.Timeout;
|
||||||
@@ -33,10 +35,17 @@ export class MetricsManager {
|
|||||||
queryTypes: {} as Record<string, number>,
|
queryTypes: {} as Record<string, number>,
|
||||||
topDomains: new Map<string, number>(),
|
topDomains: new Map<string, number>(),
|
||||||
lastResetDate: new Date().toDateString(),
|
lastResetDate: new Date().toDateString(),
|
||||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
// Per-second query count ring buffer (300 entries = 5 minutes)
|
||||||
|
queryRing: new Int32Array(300),
|
||||||
|
queryRingLastSecond: 0, // last epoch second that was written
|
||||||
responseTimes: [] as number[], // Track response times in ms
|
responseTimes: [] as number[], // Track response times in ms
|
||||||
|
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Per-minute time-series buckets for charts
|
||||||
|
private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
|
||||||
|
private dnsMinuteBuckets = new Map<number, { queries: number }>();
|
||||||
|
|
||||||
// Track security-specific metrics
|
// Track security-specific metrics
|
||||||
private securityMetrics = {
|
private securityMetrics = {
|
||||||
blockedIPs: 0,
|
blockedIPs: 0,
|
||||||
@@ -50,15 +59,15 @@ export class MetricsManager {
|
|||||||
|
|
||||||
constructor(dcRouter: DcRouter) {
|
constructor(dcRouter: DcRouter) {
|
||||||
this.dcRouter = dcRouter;
|
this.dcRouter = dcRouter;
|
||||||
// Create a new Smartlog instance for metrics
|
// Create a Smartlog instance for SmartMetrics (requires its own instance)
|
||||||
this.logger = new plugins.smartlog.Smartlog({
|
this.metricsLogger = new plugins.smartlog.Smartlog({
|
||||||
logContext: {
|
logContext: {
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
runtime: 'node',
|
runtime: 'node',
|
||||||
zone: 'dcrouter-metrics',
|
zone: 'dcrouter-metrics',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter');
|
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
|
||||||
// Initialize metrics cache with 500ms TTL
|
// Initialize metrics cache with 500ms TTL
|
||||||
this.metricsCache = new MetricsCache(500);
|
this.metricsCache = new MetricsCache(500);
|
||||||
}
|
}
|
||||||
@@ -88,11 +97,13 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses = 0;
|
this.dnsMetrics.cacheMisses = 0;
|
||||||
this.dnsMetrics.queryTypes = {};
|
this.dnsMetrics.queryTypes = {};
|
||||||
this.dnsMetrics.topDomains.clear();
|
this.dnsMetrics.topDomains.clear();
|
||||||
this.dnsMetrics.queryTimestamps = [];
|
this.dnsMetrics.queryRing.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = 0;
|
||||||
this.dnsMetrics.responseTimes = [];
|
this.dnsMetrics.responseTimes = [];
|
||||||
|
this.dnsMetrics.recentQueries = [];
|
||||||
this.dnsMetrics.lastResetDate = currentDate;
|
this.dnsMetrics.lastResetDate = currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentDate !== this.securityMetrics.lastResetDate) {
|
if (currentDate !== this.securityMetrics.lastResetDate) {
|
||||||
this.securityMetrics.blockedIPs = 0;
|
this.securityMetrics.blockedIPs = 0;
|
||||||
this.securityMetrics.authFailures = 0;
|
this.securityMetrics.authFailures = 0;
|
||||||
@@ -102,20 +113,29 @@ export class MetricsManager {
|
|||||||
this.securityMetrics.incidents = [];
|
this.securityMetrics.incidents = [];
|
||||||
this.securityMetrics.lastResetDate = currentDate;
|
this.securityMetrics.lastResetDate = currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune old time-series buckets every minute (don't wait for lazy query)
|
||||||
|
this.pruneOldBuckets();
|
||||||
}, 60000); // Check every minute
|
}, 60000); // Check every minute
|
||||||
|
|
||||||
this.logger.log('info', 'MetricsManager started');
|
logger.log('info', 'MetricsManager started');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
// Clear the reset interval
|
// Clear the reset interval
|
||||||
if (this.resetInterval) {
|
if (this.resetInterval) {
|
||||||
clearInterval(this.resetInterval);
|
clearInterval(this.resetInterval);
|
||||||
this.resetInterval = undefined;
|
this.resetInterval = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.smartMetrics.stop();
|
this.smartMetrics.stop();
|
||||||
this.logger.log('info', 'MetricsManager stopped');
|
|
||||||
|
// Clear caches and time-series buckets on shutdown
|
||||||
|
this.metricsCache.clear();
|
||||||
|
this.emailMinuteBuckets.clear();
|
||||||
|
this.dnsMinuteBuckets.clear();
|
||||||
|
|
||||||
|
logger.log('info', 'MetricsManager stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get server metrics from SmartMetrics and SmartProxy
|
// Get server metrics from SmartMetrics and SmartProxy
|
||||||
@@ -124,23 +144,23 @@ export class MetricsManager {
|
|||||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||||
|
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
startTime: Date.now() - (process.uptime() * 1000),
|
startTime: Date.now() - (process.uptime() * 1000),
|
||||||
memoryUsage: {
|
memoryUsage: {
|
||||||
heapUsed: process.memoryUsage().heapUsed,
|
heapUsed,
|
||||||
heapTotal: process.memoryUsage().heapTotal,
|
heapTotal,
|
||||||
external: process.memoryUsage().external,
|
external,
|
||||||
rss: process.memoryUsage().rss,
|
rss,
|
||||||
// Add SmartMetrics memory data
|
|
||||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||||
},
|
},
|
||||||
cpuUsage: {
|
cpuUsage: {
|
||||||
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
|
user: smartMetricsData.cpuPercentage,
|
||||||
system: 0, // SmartMetrics doesn't separate user/system
|
system: 0,
|
||||||
},
|
},
|
||||||
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
||||||
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
||||||
@@ -202,11 +222,8 @@ export class MetricsManager {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([domain, count]) => ({ domain, count }));
|
.map(([domain, count]) => ({ domain, count }));
|
||||||
|
|
||||||
// Calculate queries per second from recent timestamps
|
// Calculate queries per second from ring buffer (sum last 60 seconds)
|
||||||
const now = Date.now();
|
const queriesPerSecond = this.getQueryRingSum(60) / 60;
|
||||||
const oneMinuteAgo = now - 60000;
|
|
||||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
|
||||||
const queriesPerSecond = recentQueries.length / 60;
|
|
||||||
|
|
||||||
// Calculate average response time
|
// Calculate average response time
|
||||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||||
@@ -223,24 +240,50 @@ export class MetricsManager {
|
|||||||
queryTypes: this.dnsMetrics.queryTypes,
|
queryTypes: this.dnsMetrics.queryTypes,
|
||||||
averageResponseTime: Math.round(avgResponseTime),
|
averageResponseTime: Math.round(avgResponseTime),
|
||||||
activeDomains: this.dnsMetrics.topDomains.size,
|
activeDomains: this.dnsMetrics.topDomains.size,
|
||||||
|
recentQueries: this.dnsMetrics.recentQueries.slice(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync security metrics from the SecurityLogger singleton (last 24h).
|
||||||
|
* Called before returning security stats so counters reflect real events.
|
||||||
|
*/
|
||||||
|
private syncFromSecurityLogger(): void {
|
||||||
|
try {
|
||||||
|
const securityLogger = SecurityLogger.getInstance();
|
||||||
|
const summary = securityLogger.getEventsSummary(86400000); // last 24h
|
||||||
|
|
||||||
|
this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
|
||||||
|
this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
|
||||||
|
this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
|
||||||
|
this.securityMetrics.authFailures =
|
||||||
|
summary.byType[SecurityEventType.AUTHENTICATION] || 0;
|
||||||
|
this.securityMetrics.blockedIPs =
|
||||||
|
(summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
|
||||||
|
(summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
|
||||||
|
} catch {
|
||||||
|
// SecurityLogger may not be initialized yet — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get security metrics
|
// Get security metrics
|
||||||
public async getSecurityStats() {
|
public async getSecurityStats() {
|
||||||
return this.metricsCache.get('securityStats', () => {
|
return this.metricsCache.get('securityStats', () => {
|
||||||
|
// Sync counters from the real SecurityLogger events
|
||||||
|
this.syncFromSecurityLogger();
|
||||||
|
|
||||||
// Get recent incidents (last 20)
|
// Get recent incidents (last 20)
|
||||||
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blockedIPs: this.securityMetrics.blockedIPs,
|
blockedIPs: this.securityMetrics.blockedIPs,
|
||||||
authFailures: this.securityMetrics.authFailures,
|
authFailures: this.securityMetrics.authFailures,
|
||||||
spamDetected: this.securityMetrics.spamDetected,
|
spamDetected: this.securityMetrics.spamDetected,
|
||||||
malwareDetected: this.securityMetrics.malwareDetected,
|
malwareDetected: this.securityMetrics.malwareDetected,
|
||||||
phishingDetected: this.securityMetrics.phishingDetected,
|
phishingDetected: this.securityMetrics.phishingDetected,
|
||||||
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
||||||
this.securityMetrics.malwareDetected +
|
this.securityMetrics.malwareDetected +
|
||||||
this.securityMetrics.phishingDetected,
|
this.securityMetrics.phishingDetected,
|
||||||
recentIncidents,
|
recentIncidents,
|
||||||
};
|
};
|
||||||
@@ -275,6 +318,7 @@ export class MetricsManager {
|
|||||||
// Email event tracking methods
|
// Email event tracking methods
|
||||||
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
||||||
this.emailMetrics.sentToday++;
|
this.emailMetrics.sentToday++;
|
||||||
|
this.incrementEmailBucket('sent');
|
||||||
|
|
||||||
if (recipient) {
|
if (recipient) {
|
||||||
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
||||||
@@ -311,6 +355,7 @@ export class MetricsManager {
|
|||||||
|
|
||||||
public trackEmailReceived(sender?: string): void {
|
public trackEmailReceived(sender?: string): void {
|
||||||
this.emailMetrics.receivedToday++;
|
this.emailMetrics.receivedToday++;
|
||||||
|
this.incrementEmailBucket('received');
|
||||||
|
|
||||||
this.emailMetrics.recentActivity.push({
|
this.emailMetrics.recentActivity.push({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -326,6 +371,7 @@ export class MetricsManager {
|
|||||||
|
|
||||||
public trackEmailFailed(recipient?: string, reason?: string): void {
|
public trackEmailFailed(recipient?: string, reason?: string): void {
|
||||||
this.emailMetrics.failedToday++;
|
this.emailMetrics.failedToday++;
|
||||||
|
this.incrementEmailBucket('failed');
|
||||||
|
|
||||||
this.emailMetrics.recentActivity.push({
|
this.emailMetrics.recentActivity.push({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -359,8 +405,21 @@ export class MetricsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DNS event tracking methods
|
// DNS event tracking methods
|
||||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
|
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void {
|
||||||
this.dnsMetrics.totalQueries++;
|
this.dnsMetrics.totalQueries++;
|
||||||
|
this.incrementDnsBucket();
|
||||||
|
|
||||||
|
// Store recent query entry
|
||||||
|
this.dnsMetrics.recentQueries.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
domain,
|
||||||
|
type: queryType,
|
||||||
|
answered: answered ?? true,
|
||||||
|
responseTimeMs: responseTimeMs ?? 0,
|
||||||
|
});
|
||||||
|
if (this.dnsMetrics.recentQueries.length > 100) {
|
||||||
|
this.dnsMetrics.recentQueries.shift();
|
||||||
|
}
|
||||||
|
|
||||||
if (cacheHit) {
|
if (cacheHit) {
|
||||||
this.dnsMetrics.cacheHits++;
|
this.dnsMetrics.cacheHits++;
|
||||||
@@ -368,12 +427,8 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses++;
|
this.dnsMetrics.cacheMisses++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track query timestamp
|
// Increment per-second query counter in ring buffer
|
||||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
this.incrementQueryRing();
|
||||||
|
|
||||||
// Keep only timestamps from last 5 minutes
|
|
||||||
const fiveMinutesAgo = Date.now() - 300000;
|
|
||||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
|
|
||||||
|
|
||||||
// Track response time if provided
|
// Track response time if provided
|
||||||
if (responseTimeMs) {
|
if (responseTimeMs) {
|
||||||
@@ -545,6 +600,149 @@ export class MetricsManager {
|
|||||||
requestsPerSecond,
|
requestsPerSecond,
|
||||||
requestsTotal,
|
requestsTotal,
|
||||||
};
|
};
|
||||||
}, 200); // Use 200ms cache for more frequent updates
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Time-series helpers ---
|
||||||
|
|
||||||
|
private static minuteKey(ts: number = Date.now()): number {
|
||||||
|
return Math.floor(ts / 60000) * 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
|
||||||
|
const key = MetricsManager.minuteKey();
|
||||||
|
let bucket = this.emailMinuteBuckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { sent: 0, received: 0, failed: 0 };
|
||||||
|
this.emailMinuteBuckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket[field]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementDnsBucket(): void {
|
||||||
|
const key = MetricsManager.minuteKey();
|
||||||
|
let bucket = this.dnsMinuteBuckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { queries: 0 };
|
||||||
|
this.dnsMinuteBuckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket.queries++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the per-second query counter in the ring buffer.
|
||||||
|
* Zeros any stale slots between the last write and the current second.
|
||||||
|
*/
|
||||||
|
private incrementQueryRing(): void {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
// First call — zero and anchor
|
||||||
|
ring.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length] = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) {
|
||||||
|
// Entire ring is stale — clear all
|
||||||
|
ring.fill(0);
|
||||||
|
} else if (gap > 0) {
|
||||||
|
// Zero slots from (last+1) to currentSecond (inclusive)
|
||||||
|
for (let s = last + 1; s <= currentSecond; s++) {
|
||||||
|
ring[s % ring.length] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum query counts from the ring buffer for the last N seconds.
|
||||||
|
*/
|
||||||
|
private getQueryRingSum(seconds: number): number {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) return 0;
|
||||||
|
|
||||||
|
// First, zero stale slots so reads are accurate even without writes
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) return 0; // all data is stale
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
const limit = Math.min(seconds, ring.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const sec = currentSecond - i;
|
||||||
|
if (sec < last - (ring.length - 1)) break; // slot is from older cycle
|
||||||
|
if (sec > last) continue; // no writes yet for this second
|
||||||
|
sum += ring[sec % ring.length];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneOldBuckets(): void {
|
||||||
|
const cutoff = Date.now() - 86400000; // 24h
|
||||||
|
for (const key of this.emailMinuteBuckets.keys()) {
|
||||||
|
if (key < cutoff) this.emailMinuteBuckets.delete(key);
|
||||||
|
}
|
||||||
|
for (const key of this.dnsMinuteBuckets.keys()) {
|
||||||
|
if (key < cutoff) this.dnsMinuteBuckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get email time-series data for the last N hours, aggregated per minute.
|
||||||
|
*/
|
||||||
|
public getEmailTimeSeries(hours: number = 24): {
|
||||||
|
sent: Array<{ timestamp: number; value: number }>;
|
||||||
|
received: Array<{ timestamp: number; value: number }>;
|
||||||
|
failed: Array<{ timestamp: number; value: number }>;
|
||||||
|
} {
|
||||||
|
this.pruneOldBuckets();
|
||||||
|
const cutoff = Date.now() - hours * 3600000;
|
||||||
|
const sent: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
const received: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
const failed: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
|
||||||
|
const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
|
||||||
|
.filter((k) => k >= cutoff)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const bucket = this.emailMinuteBuckets.get(key)!;
|
||||||
|
sent.push({ timestamp: key, value: bucket.sent });
|
||||||
|
received.push({ timestamp: key, value: bucket.received });
|
||||||
|
failed.push({ timestamp: key, value: bucket.failed });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, received, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS time-series data for the last N hours, aggregated per minute.
|
||||||
|
*/
|
||||||
|
public getDnsTimeSeries(hours: number = 24): {
|
||||||
|
queries: Array<{ timestamp: number; value: number }>;
|
||||||
|
} {
|
||||||
|
this.pruneOldBuckets();
|
||||||
|
const cutoff = Date.now() - hours * 3600000;
|
||||||
|
const queries: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
|
||||||
|
const sortedKeys = Array.from(this.dnsMinuteBuckets.keys())
|
||||||
|
.filter((k) => k >= cutoff)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const bucket = this.dnsMinuteBuckets.get(key)!;
|
||||||
|
queries.push({ timestamp: key, value: bucket.queries });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { queries };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,20 @@ import type DcRouter from '../classes.dcrouter.js';
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
import * as paths from '../paths.js';
|
||||||
import * as handlers from './handlers/index.js';
|
import * as handlers from './handlers/index.js';
|
||||||
|
import * as interfaces from '../../ts_interfaces/index.js';
|
||||||
|
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
|
||||||
|
|
||||||
export class OpsServer {
|
export class OpsServer {
|
||||||
public dcRouterRef: DcRouter;
|
public dcRouterRef: DcRouter;
|
||||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||||
|
|
||||||
// TypedRouter for OpsServer-specific handlers
|
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// Auth-enforced routers — middleware validates identity before any handler runs
|
||||||
|
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||||
|
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||||
|
|
||||||
// Handler instances
|
// Handler instances
|
||||||
public adminHandler: handlers.AdminHandler;
|
public adminHandler: handlers.AdminHandler;
|
||||||
private configHandler: handlers.ConfigHandler;
|
private configHandler: handlers.ConfigHandler;
|
||||||
@@ -20,10 +26,12 @@ export class OpsServer {
|
|||||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||||
private certificateHandler: handlers.CertificateHandler;
|
private certificateHandler: handlers.CertificateHandler;
|
||||||
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
||||||
|
private routeManagementHandler: handlers.RouteManagementHandler;
|
||||||
|
private apiTokenHandler: handlers.ApiTokenHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
|
|
||||||
// Add our typedrouter to the dcRouter's main typedrouter
|
// Add our typedrouter to the dcRouter's main typedrouter
|
||||||
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
}
|
}
|
||||||
@@ -42,17 +50,32 @@ export class OpsServer {
|
|||||||
// Set up handlers
|
// Set up handlers
|
||||||
await this.setupHandlers();
|
await this.setupHandlers();
|
||||||
|
|
||||||
await this.server.start(3000);
|
await this.server.start(this.dcRouterRef.options.opsServerPort ?? 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up all TypedRequest handlers
|
* Set up all TypedRequest handlers
|
||||||
*/
|
*/
|
||||||
private async setupHandlers(): Promise<void> {
|
private async setupHandlers(): Promise<void> {
|
||||||
// Instantiate all handlers - they self-register with the typedrouter
|
// AdminHandler must be initialized first (JWT setup needed for guards)
|
||||||
this.adminHandler = new handlers.AdminHandler(this);
|
this.adminHandler = new handlers.AdminHandler(this);
|
||||||
await this.adminHandler.initialize(); // JWT needs async initialization
|
await this.adminHandler.initialize();
|
||||||
|
|
||||||
|
// viewRouter middleware: requires valid identity (any logged-in user)
|
||||||
|
this.viewRouter.addMiddleware(async (typedRequest) => {
|
||||||
|
await requireValidIdentity(this.adminHandler, typedRequest.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// adminRouter middleware: requires admin identity
|
||||||
|
this.adminRouter.addMiddleware(async (typedRequest) => {
|
||||||
|
await requireAdminIdentity(this.adminHandler, typedRequest.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect auth routers to the main typedrouter
|
||||||
|
this.typedrouter.addTypedRouter(this.viewRouter);
|
||||||
|
this.typedrouter.addTypedRouter(this.adminRouter);
|
||||||
|
|
||||||
|
// Instantiate all handlers — they self-register with the appropriate router
|
||||||
this.configHandler = new handlers.ConfigHandler(this);
|
this.configHandler = new handlers.ConfigHandler(this);
|
||||||
this.logsHandler = new handlers.LogsHandler(this);
|
this.logsHandler = new handlers.LogsHandler(this);
|
||||||
this.securityHandler = new handlers.SecurityHandler(this);
|
this.securityHandler = new handlers.SecurityHandler(this);
|
||||||
@@ -61,11 +84,17 @@ export class OpsServer {
|
|||||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||||
this.certificateHandler = new handlers.CertificateHandler(this);
|
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||||
|
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||||
|
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
|
// Clean up log handler streams and push destination before stopping the server
|
||||||
|
if (this.logsHandler) {
|
||||||
|
this.logsHandler.cleanup();
|
||||||
|
}
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
await this.server.stop();
|
await this.server.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
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' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class CertificateHandler {
|
export class CertificateHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
// Get Certificate Overview
|
// Get Certificate Overview
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
||||||
'getCertificateOverview',
|
'getCertificateOverview',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -23,8 +25,10 @@ export class CertificateHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||||
|
|
||||||
// Legacy route-based reprovision (backward compat)
|
// Legacy route-based reprovision (backward compat)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||||
'reprovisionCertificate',
|
'reprovisionCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -34,7 +38,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Domain-based reprovision (preferred)
|
// Domain-based reprovision (preferred)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||||
'reprovisionCertificateDomain',
|
'reprovisionCertificateDomain',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -44,7 +48,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Delete certificate
|
// Delete certificate
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||||
'deleteCertificate',
|
'deleteCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -54,7 +58,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Export certificate
|
// Export certificate
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||||
'exportCertificate',
|
'exportCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -64,7 +68,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Import certificate
|
// Import certificate
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||||
'importCertificate',
|
'importCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
|
import * as paths from '../../paths.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class ConfigHandler {
|
export class ConfigHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Get Configuration Handler (read-only)
|
// Get Configuration Handler (read-only)
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'getConfiguration',
|
'getConfiguration',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
const config = await this.getConfiguration(dataArg.section);
|
const config = await this.getConfiguration();
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
section: dataArg.section,
|
section: dataArg.section,
|
||||||
@@ -26,83 +26,189 @@ export class ConfigHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getConfiguration(section?: string): Promise<{
|
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
|
||||||
email: {
|
|
||||||
enabled: boolean;
|
|
||||||
ports: number[];
|
|
||||||
maxMessageSize: number;
|
|
||||||
rateLimits: {
|
|
||||||
perMinute: number;
|
|
||||||
perHour: number;
|
|
||||||
perDay: number;
|
|
||||||
};
|
|
||||||
domains?: string[];
|
|
||||||
};
|
|
||||||
dns: {
|
|
||||||
enabled: boolean;
|
|
||||||
port: number;
|
|
||||||
nameservers: string[];
|
|
||||||
caching: boolean;
|
|
||||||
ttl: number;
|
|
||||||
};
|
|
||||||
proxy: {
|
|
||||||
enabled: boolean;
|
|
||||||
httpPort: number;
|
|
||||||
httpsPort: number;
|
|
||||||
maxConnections: number;
|
|
||||||
};
|
|
||||||
security: {
|
|
||||||
blockList: string[];
|
|
||||||
rateLimit: boolean;
|
|
||||||
spamDetection: boolean;
|
|
||||||
tlsRequired: boolean;
|
|
||||||
};
|
|
||||||
}> {
|
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const opts = dcRouter.options;
|
||||||
// Get email domains if email server is configured
|
const resolvedPaths = dcRouter.resolvedPaths;
|
||||||
|
|
||||||
|
// --- System ---
|
||||||
|
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction
|
||||||
|
? 'custom'
|
||||||
|
: opts.storage?.fsPath
|
||||||
|
? 'filesystem'
|
||||||
|
: 'memory';
|
||||||
|
|
||||||
|
// 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.storage?.fsPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SmartProxy ---
|
||||||
|
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
|
||||||
|
if (opts.smartProxyConfig?.acme) {
|
||||||
|
const acme = opts.smartProxyConfig.acme;
|
||||||
|
acmeInfo = {
|
||||||
|
enabled: acme.enabled !== false,
|
||||||
|
accountEmail: acme.accountEmail || '',
|
||||||
|
useProduction: acme.useProduction !== false,
|
||||||
|
autoRenew: acme.autoRenew !== false,
|
||||||
|
renewThresholdDays: acme.renewThresholdDays || 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let routeCount = 0;
|
||||||
|
if (dcRouter.routeConfigManager) {
|
||||||
|
try {
|
||||||
|
const merged = await dcRouter.routeConfigManager.getMergedRoutes();
|
||||||
|
routeCount = merged.routes.length;
|
||||||
|
} catch {
|
||||||
|
routeCount = opts.smartProxyConfig?.routes?.length || 0;
|
||||||
|
}
|
||||||
|
} else if (opts.smartProxyConfig?.routes) {
|
||||||
|
routeCount = opts.smartProxyConfig.routes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smartProxy: interfaces.requests.IConfigData['smartProxy'] = {
|
||||||
|
enabled: !!dcRouter.smartProxy,
|
||||||
|
routeCount,
|
||||||
|
acme: acmeInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Email ---
|
||||||
let emailDomains: string[] = [];
|
let emailDomains: string[] = [];
|
||||||
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) {
|
if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
|
||||||
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
|
emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
|
||||||
} else if (dcRouter.options.emailConfig?.domains) {
|
} else if (opts.emailConfig?.domains) {
|
||||||
// Fallback: get domains from email config options
|
emailDomains = opts.emailConfig.domains.map((d: any) =>
|
||||||
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
|
|
||||||
typeof d === 'string' ? d : d.domain
|
typeof d === 'string' ? d : d.domain
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let portMapping: Record<string, number> | null = null;
|
||||||
|
if (opts.emailPortConfig?.portMapping) {
|
||||||
|
portMapping = {};
|
||||||
|
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
|
||||||
|
portMapping[String(ext)] = int as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const email: interfaces.requests.IConfigData['email'] = {
|
||||||
|
enabled: !!dcRouter.emailServer,
|
||||||
|
ports: opts.emailConfig?.ports || [],
|
||||||
|
portMapping,
|
||||||
|
hostname: opts.emailConfig?.hostname || null,
|
||||||
|
domains: emailDomains,
|
||||||
|
emailRouteCount: opts.emailConfig?.routes?.length || 0,
|
||||||
|
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DNS ---
|
||||||
|
const dnsRecords = (opts.dnsRecords || []).map(r => ({
|
||||||
|
name: r.name,
|
||||||
|
type: r.type,
|
||||||
|
value: r.value,
|
||||||
|
ttl: r.ttl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||||
|
enabled: !!dcRouter.dnsServer,
|
||||||
|
port: 53,
|
||||||
|
nsDomains: opts.dnsNsDomains || [],
|
||||||
|
scopes: opts.dnsScopes || [],
|
||||||
|
recordCount: dnsRecords.length,
|
||||||
|
records: dnsRecords,
|
||||||
|
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- TLS ---
|
||||||
|
let tlsSource: 'acme' | 'static' | 'none' = 'none';
|
||||||
|
if (opts.tls?.certPath && opts.tls?.keyPath) {
|
||||||
|
tlsSource = 'static';
|
||||||
|
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
|
||||||
|
tlsSource = 'acme';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tls: interfaces.requests.IConfigData['tls'] = {
|
||||||
|
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
|
||||||
|
domain: opts.tls?.domain || null,
|
||||||
|
source: tlsSource,
|
||||||
|
certPath: opts.tls?.certPath || null,
|
||||||
|
keyPath: opts.tls?.keyPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Cache ---
|
||||||
|
const cacheConfig = opts.cacheConfig;
|
||||||
|
const cache: interfaces.requests.IConfigData['cache'] = {
|
||||||
|
enabled: cacheConfig?.enabled !== false,
|
||||||
|
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
|
dbName: cacheConfig?.dbName || 'dcrouter',
|
||||||
|
defaultTTLDays: cacheConfig?.defaultTTLDays || 30,
|
||||||
|
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1,
|
||||||
|
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 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 stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
||||||
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
|
tlsMode = 'acme';
|
||||||
|
}
|
||||||
|
} catch { /* no stored cert */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
|
||||||
|
enabled: !!dcRouter.remoteIngressManager,
|
||||||
|
tunnelPort: riCfg?.tunnelPort || null,
|
||||||
|
hubDomain: riCfg?.hubDomain || null,
|
||||||
|
tlsMode,
|
||||||
|
connectedEdgeIps,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: {
|
system,
|
||||||
enabled: !!dcRouter.emailServer,
|
smartProxy,
|
||||||
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
|
email,
|
||||||
maxMessageSize: 10 * 1024 * 1024, // 10MB default
|
dns,
|
||||||
rateLimits: {
|
tls,
|
||||||
perMinute: 10,
|
cache,
|
||||||
perHour: 100,
|
radius,
|
||||||
perDay: 1000,
|
remoteIngress,
|
||||||
},
|
|
||||||
domains: emailDomains,
|
|
||||||
},
|
|
||||||
dns: {
|
|
||||||
enabled: !!dcRouter.dnsServer,
|
|
||||||
port: 53,
|
|
||||||
nameservers: dcRouter.options.dnsNsDomains || [],
|
|
||||||
caching: true,
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
enabled: !!dcRouter.smartProxy,
|
|
||||||
httpPort: 80,
|
|
||||||
httpsPort: 443,
|
|
||||||
maxConnections: 1000,
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
blockList: [],
|
|
||||||
rateLimit: true,
|
|
||||||
spamDetection: true,
|
|
||||||
tlsRequired: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,44 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { SecurityLogger } from '../../security/index.js';
|
|
||||||
|
|
||||||
export class EmailOpsHandler {
|
export class EmailOpsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
// Get Queued Emails Handler
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
this.typedrouter.addTypedHandler(
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
|
|
||||||
'getQueuedEmails',
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get All Emails Handler
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
|
||||||
|
'getAllEmails',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emails = this.getAllQueueEmails();
|
||||||
if (!emailServer?.deliveryQueue) {
|
return { emails };
|
||||||
return { items: [], total: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue = emailServer.deliveryQueue;
|
|
||||||
const stats = queue.getStats();
|
|
||||||
|
|
||||||
// Get all queue items and filter by status if provided
|
|
||||||
const items = this.getQueueItems(
|
|
||||||
dataArg.status,
|
|
||||||
dataArg.limit || 50,
|
|
||||||
dataArg.offset || 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: stats.queueSize,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Sent Emails Handler
|
// Get Email Detail Handler
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
||||||
'getSentEmails',
|
'getEmailDetail',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
const items = this.getQueueItems(
|
const email = this.getEmailDetail(dataArg.emailId);
|
||||||
'delivered',
|
return { email };
|
||||||
dataArg.limit || 50,
|
|
||||||
dataArg.offset || 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: items.length, // Note: total would ideally come from a counter
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Failed Emails Handler
|
// ---- Write endpoints (adminRouter) ----
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
|
|
||||||
'getFailedEmails',
|
|
||||||
async (dataArg) => {
|
|
||||||
const items = this.getQueueItems(
|
|
||||||
'failed',
|
|
||||||
dataArg.limit || 50,
|
|
||||||
dataArg.offset || 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: items.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resend Failed Email Handler
|
// Resend Failed Email Handler
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||||
'resendEmail',
|
'resendEmail',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -101,17 +59,12 @@ export class EmailOpsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Re-enqueue the failed email by creating a new queue entry
|
|
||||||
// with the same data but reset attempt count
|
|
||||||
const newQueueId = await queue.enqueue(
|
const newQueueId = await queue.enqueue(
|
||||||
item.processingResult,
|
item.processingResult,
|
||||||
item.processingMode,
|
item.processingMode,
|
||||||
item.route
|
item.route
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optionally remove the old failed entry
|
|
||||||
await queue.removeItem(dataArg.emailId);
|
await queue.removeItem(dataArg.emailId);
|
||||||
|
|
||||||
return { success: true, newQueueId };
|
return { success: true, newQueueId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -122,197 +75,199 @@ export class EmailOpsHandler {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Security Incidents Handler
|
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
|
|
||||||
'getSecurityIncidents',
|
|
||||||
async (dataArg) => {
|
|
||||||
const securityLogger = SecurityLogger.getInstance();
|
|
||||||
|
|
||||||
const filter: {
|
|
||||||
level?: any;
|
|
||||||
type?: any;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
if (dataArg.level) {
|
|
||||||
filter.level = dataArg.level;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataArg.type) {
|
|
||||||
filter.type = dataArg.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
const incidents = securityLogger.getRecentEvents(
|
|
||||||
dataArg.limit || 100,
|
|
||||||
Object.keys(filter).length > 0 ? filter : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
incidents: incidents.map(event => ({
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
level: event.level as interfaces.requests.TSecurityLogLevel,
|
|
||||||
type: event.type as interfaces.requests.TSecurityEventType,
|
|
||||||
message: event.message,
|
|
||||||
details: event.details,
|
|
||||||
ipAddress: event.ipAddress,
|
|
||||||
userId: event.userId,
|
|
||||||
sessionId: event.sessionId,
|
|
||||||
emailId: event.emailId,
|
|
||||||
domain: event.domain,
|
|
||||||
action: event.action,
|
|
||||||
result: event.result,
|
|
||||||
success: event.success,
|
|
||||||
})),
|
|
||||||
total: incidents.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get Bounce Records Handler
|
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
|
|
||||||
'getBounceRecords',
|
|
||||||
async (dataArg) => {
|
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
|
||||||
|
|
||||||
if (!emailServer) {
|
|
||||||
return { records: [], suppressionList: [], total: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use smartmta's public API for bounce/suppression data
|
|
||||||
const suppressionList = emailServer.getSuppressionList();
|
|
||||||
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
|
|
||||||
|
|
||||||
// Create bounce records from the available data
|
|
||||||
const records: interfaces.requests.IBounceRecord[] = [];
|
|
||||||
|
|
||||||
for (const email of hardBouncedAddresses) {
|
|
||||||
const bounceInfo = emailServer.getBounceHistory(email);
|
|
||||||
if (bounceInfo) {
|
|
||||||
records.push({
|
|
||||||
id: `bounce-${email}`,
|
|
||||||
recipient: email,
|
|
||||||
sender: '',
|
|
||||||
domain: email.split('@')[1] || '',
|
|
||||||
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
|
|
||||||
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
|
|
||||||
timestamp: (bounceInfo as any).lastBounce,
|
|
||||||
processed: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply limit and offset
|
|
||||||
const limit = dataArg.limit || 50;
|
|
||||||
const offset = dataArg.offset || 0;
|
|
||||||
const paginatedRecords = records.slice(offset, offset + limit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
records: paginatedRecords,
|
|
||||||
suppressionList,
|
|
||||||
total: records.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove from Suppression List Handler
|
|
||||||
this.typedrouter.addTypedHandler(
|
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
|
|
||||||
'removeFromSuppressionList',
|
|
||||||
async (dataArg) => {
|
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
|
||||||
|
|
||||||
if (!emailServer) {
|
|
||||||
return { success: false, error: 'Email server not available' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
emailServer.removeFromSuppressionList(dataArg.email);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to get queue items with filtering and pagination
|
* Get all queue items mapped to catalog IEmail format
|
||||||
*/
|
*/
|
||||||
private getQueueItems(
|
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||||
status?: interfaces.requests.TEmailQueueStatus,
|
|
||||||
limit: number = 50,
|
|
||||||
offset: number = 0
|
|
||||||
): interfaces.requests.IEmailQueueItem[] {
|
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer?.deliveryQueue) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = emailServer.deliveryQueue;
|
const queue = emailServer.deliveryQueue;
|
||||||
const items: interfaces.requests.IEmailQueueItem[] = [];
|
|
||||||
|
|
||||||
// Access the internal queue map via reflection
|
|
||||||
// This is necessary because the queue doesn't expose iteration methods
|
|
||||||
const queueMap = (queue as any).queue as Map<string, any>;
|
const queueMap = (queue as any).queue as Map<string, any>;
|
||||||
|
|
||||||
if (!queueMap) {
|
if (!queueMap) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter and convert items
|
const emails: interfaces.requests.IEmail[] = [];
|
||||||
|
|
||||||
for (const [id, item] of queueMap.entries()) {
|
for (const [id, item] of queueMap.entries()) {
|
||||||
// Apply status filter if provided
|
emails.push(this.mapQueueItemToEmail(item));
|
||||||
if (status && item.status !== status) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract email details from processingResult if available
|
|
||||||
const processingResult = item.processingResult;
|
|
||||||
let from = '';
|
|
||||||
let to: string[] = [];
|
|
||||||
let subject = '';
|
|
||||||
|
|
||||||
if (processingResult) {
|
|
||||||
// Check if it's an Email object or raw email data
|
|
||||||
if (processingResult.email) {
|
|
||||||
from = processingResult.email.from || '';
|
|
||||||
to = processingResult.email.to || [];
|
|
||||||
subject = processingResult.email.subject || '';
|
|
||||||
} else if (processingResult.from) {
|
|
||||||
from = processingResult.from;
|
|
||||||
to = processingResult.to || [];
|
|
||||||
subject = processingResult.subject || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: item.id,
|
|
||||||
processingMode: item.processingMode,
|
|
||||||
status: item.status,
|
|
||||||
attempts: item.attempts,
|
|
||||||
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
|
|
||||||
lastError: item.lastError,
|
|
||||||
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
|
|
||||||
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
|
|
||||||
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by createdAt descending (newest first)
|
// Sort by createdAt descending (newest first)
|
||||||
items.sort((a, b) => b.createdAt - a.createdAt);
|
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
|
||||||
// Apply pagination
|
return emails;
|
||||||
return items.slice(offset, offset + limit);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single email detail by ID
|
||||||
|
*/
|
||||||
|
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||||
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
|
if (!emailServer?.deliveryQueue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = emailServer.deliveryQueue;
|
||||||
|
const item = queue.getItem(emailId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapQueueItemToEmailDetail(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a queue item to catalog IEmail format
|
||||||
|
*/
|
||||||
|
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
|
||||||
|
const processingResult = item.processingResult;
|
||||||
|
let from = '';
|
||||||
|
let to = '';
|
||||||
|
let subject = '';
|
||||||
|
let messageId = '';
|
||||||
|
let size = '0 B';
|
||||||
|
|
||||||
|
if (processingResult) {
|
||||||
|
if (processingResult.email) {
|
||||||
|
from = processingResult.email.from || '';
|
||||||
|
to = (processingResult.email.to || [])[0] || '';
|
||||||
|
subject = processingResult.email.subject || '';
|
||||||
|
} else if (processingResult.from) {
|
||||||
|
from = processingResult.from;
|
||||||
|
to = (processingResult.to || [])[0] || '';
|
||||||
|
subject = processingResult.subject || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get messageId
|
||||||
|
if (typeof processingResult.getMessageId === 'function') {
|
||||||
|
try {
|
||||||
|
messageId = processingResult.getMessageId() || '';
|
||||||
|
} catch {
|
||||||
|
messageId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute approximate size
|
||||||
|
const textLen = processingResult.text?.length || 0;
|
||||||
|
const htmlLen = processingResult.html?.length || 0;
|
||||||
|
let attachSize = 0;
|
||||||
|
if (typeof processingResult.getAttachmentsSize === 'function') {
|
||||||
|
try {
|
||||||
|
attachSize = processingResult.getAttachmentsSize() || 0;
|
||||||
|
} catch {
|
||||||
|
attachSize = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size = this.formatSize(textLen + htmlLen + attachSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map queue status to catalog TEmailStatus
|
||||||
|
const status = this.mapStatus(item.status);
|
||||||
|
|
||||||
|
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
direction: 'outbound' as interfaces.requests.TEmailDirection,
|
||||||
|
status,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
timestamp: new Date(createdAt).toISOString(),
|
||||||
|
messageId,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a queue item to catalog IEmailDetail format
|
||||||
|
*/
|
||||||
|
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
|
||||||
|
const base = this.mapQueueItemToEmail(item);
|
||||||
|
const processingResult = item.processingResult;
|
||||||
|
|
||||||
|
let toList: string[] = [];
|
||||||
|
let cc: string[] = [];
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
if (processingResult) {
|
||||||
|
if (processingResult.email) {
|
||||||
|
toList = processingResult.email.to || [];
|
||||||
|
cc = processingResult.email.cc || [];
|
||||||
|
} else {
|
||||||
|
toList = processingResult.to || [];
|
||||||
|
cc = processingResult.cc || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = processingResult.headers || {};
|
||||||
|
body = processingResult.html || processingResult.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toList,
|
||||||
|
cc,
|
||||||
|
smtpLog: [],
|
||||||
|
connectionInfo: {
|
||||||
|
sourceIp: '',
|
||||||
|
sourceHostname: '',
|
||||||
|
destinationIp: '',
|
||||||
|
destinationPort: 0,
|
||||||
|
tlsVersion: '',
|
||||||
|
tlsCipher: '',
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: '',
|
||||||
|
authUser: '',
|
||||||
|
},
|
||||||
|
authenticationResults: {
|
||||||
|
spf: 'none',
|
||||||
|
spfDomain: '',
|
||||||
|
dkim: 'none',
|
||||||
|
dkimDomain: '',
|
||||||
|
dmarc: 'none',
|
||||||
|
dmarcPolicy: '',
|
||||||
|
},
|
||||||
|
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
|
||||||
|
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map queue status to catalog TEmailStatus
|
||||||
|
*/
|
||||||
|
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
|
||||||
|
switch (queueStatus) {
|
||||||
|
case 'pending':
|
||||||
|
case 'processing':
|
||||||
|
return 'pending';
|
||||||
|
case 'delivered':
|
||||||
|
return 'delivered';
|
||||||
|
case 'failed':
|
||||||
|
return 'bounced';
|
||||||
|
case 'deferred':
|
||||||
|
return 'deferred';
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format byte size to human-readable string
|
||||||
|
*/
|
||||||
|
private formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ export * from './stats.handler.js';
|
|||||||
export * from './radius.handler.js';
|
export * from './radius.handler.js';
|
||||||
export * from './email-ops.handler.js';
|
export * from './email-ops.handler.js';
|
||||||
export * from './certificate.handler.js';
|
export * from './certificate.handler.js';
|
||||||
export * from './remoteingress.handler.js';
|
export * from './remoteingress.handler.js';
|
||||||
|
export * from './route-management.handler.js';
|
||||||
|
export * from './api-token.handler.js';
|
||||||
@@ -1,19 +1,42 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
import { logBuffer, baseLogger } from '../../logger.js';
|
||||||
|
|
||||||
|
// Module-level singleton: the log push destination is added once and reuses
|
||||||
|
// the current OpsServer reference so it survives OpsServer restarts without
|
||||||
|
// accumulating duplicate destinations.
|
||||||
|
let logPushDestinationInstalled = false;
|
||||||
|
let currentOpsServerRef: OpsServer | null = null;
|
||||||
|
|
||||||
export class LogsHandler {
|
export class LogsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
private activeStreamStops: Set<() => void> = new Set();
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
|
this.setupLogPushDestination();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all active log streams and deactivate the push destination.
|
||||||
|
* Called when OpsServer stops.
|
||||||
|
*/
|
||||||
|
public cleanup(): void {
|
||||||
|
// Stop all active follow-mode log streams
|
||||||
|
for (const stop of this.activeStreamStops) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
this.activeStreamStops.clear();
|
||||||
|
// Deactivate the push destination (it stays registered but becomes a no-op)
|
||||||
|
currentOpsServerRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All log endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Get Recent Logs Handler
|
// Get Recent Logs Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
||||||
'getRecentLogs',
|
'getRecentLogs',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -25,24 +48,24 @@ export class LogsHandler {
|
|||||||
dataArg.search,
|
dataArg.search,
|
||||||
dataArg.timeRange
|
dataArg.timeRange
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logs,
|
logs,
|
||||||
total: logs.length, // TODO: Implement proper total count
|
total: logs.length,
|
||||||
hasMore: false, // TODO: Implement proper pagination
|
hasMore: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Log Stream Handler
|
// Get Log Stream Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
||||||
'getLogStream',
|
'getLogStream',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
// Create a virtual stream for log streaming
|
// Create a virtual stream for log streaming
|
||||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||||
|
|
||||||
// Set up log streaming
|
// Set up log streaming
|
||||||
const streamLogs = this.setupLogStream(
|
const streamLogs = this.setupLogStream(
|
||||||
virtualStream,
|
virtualStream,
|
||||||
@@ -50,20 +73,47 @@ export class LogsHandler {
|
|||||||
dataArg.filters?.category,
|
dataArg.filters?.category,
|
||||||
dataArg.follow
|
dataArg.follow
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start streaming
|
// Start streaming
|
||||||
streamLogs.start();
|
streamLogs.start();
|
||||||
|
|
||||||
// VirtualStream handles cleanup automatically
|
// Track the stop function so we can clean up on shutdown
|
||||||
|
this.activeStreamStops.add(streamLogs.stop);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logStream: virtualStream as any, // Cast to IVirtualStream interface
|
logStream: virtualStream as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
|
||||||
|
switch (smartlogLevel) {
|
||||||
|
case 'silly':
|
||||||
|
case 'debug':
|
||||||
|
return 'debug';
|
||||||
|
case 'warn':
|
||||||
|
return 'warn';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deriveCategory(
|
||||||
|
zone?: string,
|
||||||
|
message?: string
|
||||||
|
): 'smtp' | 'dns' | 'security' | 'system' | 'email' {
|
||||||
|
const msg = (message || '').toLowerCase();
|
||||||
|
if (msg.includes('[security:') || msg.includes('security')) return 'security';
|
||||||
|
if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email';
|
||||||
|
if (zone === 'dns' || msg.includes('dns')) return 'dns';
|
||||||
|
if (msg.includes('smtp')) return 'smtp';
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
private async getRecentLogs(
|
private async getRecentLogs(
|
||||||
level?: 'error' | 'warn' | 'info' | 'debug',
|
level?: 'error' | 'warn' | 'info' | 'debug',
|
||||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
||||||
@@ -78,44 +128,122 @@ export class LogsHandler {
|
|||||||
message: string;
|
message: string;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
}>> {
|
}>> {
|
||||||
// TODO: Implement actual log retrieval from storage or logger
|
// Compute a timestamp cutoff from timeRange
|
||||||
// For now, return mock data
|
let since: number | undefined;
|
||||||
const mockLogs: Array<{
|
if (timeRange) {
|
||||||
|
const rangeMs: Record<string, number> = {
|
||||||
|
'1h': 3600000,
|
||||||
|
'6h': 21600000,
|
||||||
|
'24h': 86400000,
|
||||||
|
'7d': 604800000,
|
||||||
|
'30d': 2592000000,
|
||||||
|
};
|
||||||
|
since = Date.now() - (rangeMs[timeRange] || 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the UI level to smartlog levels for filtering
|
||||||
|
const smartlogLevels: string[] | undefined = level
|
||||||
|
? level === 'debug'
|
||||||
|
? ['debug', 'silly']
|
||||||
|
: level === 'info'
|
||||||
|
? ['info', 'ok', 'success', 'note', 'lifecycle']
|
||||||
|
: [level]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch a larger batch from buffer, then apply category filter client-side
|
||||||
|
const rawEntries = logBuffer.getEntries({
|
||||||
|
level: smartlogLevels as any,
|
||||||
|
search,
|
||||||
|
since,
|
||||||
|
limit: limit * 3, // over-fetch to compensate for category filtering
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map ILogPackage → UI log format and apply category filter
|
||||||
|
const mapped: Array<{
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
level: 'debug' | 'info' | 'warn' | 'error';
|
level: 'debug' | 'info' | 'warn' | 'error';
|
||||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
message: string;
|
message: string;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
for (const pkg of rawEntries) {
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const uiLevel = LogsHandler.mapLogLevel(pkg.level);
|
||||||
const now = Date.now();
|
const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message);
|
||||||
|
|
||||||
// Generate some mock log entries
|
if (category && uiCategory !== category) continue;
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
mapped.push({
|
||||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
timestamp: pkg.timestamp,
|
||||||
|
level: uiLevel,
|
||||||
// Filter by requested criteria
|
category: uiCategory,
|
||||||
if (level && mockLevel !== level) continue;
|
message: pkg.message,
|
||||||
if (category && mockCategory !== category) continue;
|
metadata: pkg.data,
|
||||||
|
|
||||||
mockLogs.push({
|
|
||||||
timestamp: now - (i * 60000), // 1 minute apart
|
|
||||||
level: mockLevel,
|
|
||||||
category: mockCategory,
|
|
||||||
message: `Sample log message ${i} from ${mockCategory}`,
|
|
||||||
metadata: {
|
|
||||||
requestId: plugins.uuid.v4(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (mapped.length >= limit) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pagination
|
return mapped;
|
||||||
return mockLogs.slice(offset, offset + limit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a log destination to the base logger that pushes entries
|
||||||
|
* to all connected ops_dashboard TypedSocket clients.
|
||||||
|
*
|
||||||
|
* Uses a module-level singleton so the destination is added only once,
|
||||||
|
* even across OpsServer restart cycles. The destination reads
|
||||||
|
* `currentOpsServerRef` dynamically so it always uses the active server.
|
||||||
|
*/
|
||||||
|
private setupLogPushDestination(): void {
|
||||||
|
// Update the module-level reference so the existing destination uses the new server
|
||||||
|
currentOpsServerRef = this.opsServerRef;
|
||||||
|
|
||||||
|
if (logPushDestinationInstalled) {
|
||||||
|
return; // destination already registered — just updated the ref
|
||||||
|
}
|
||||||
|
logPushDestinationInstalled = true;
|
||||||
|
|
||||||
|
baseLogger.addLogDestination({
|
||||||
|
async handleLog(logPackage: any) {
|
||||||
|
const opsServer = currentOpsServerRef;
|
||||||
|
if (!opsServer) return;
|
||||||
|
|
||||||
|
const typedsocket = opsServer.server?.typedserver?.typedsocket;
|
||||||
|
if (!typedsocket) return;
|
||||||
|
|
||||||
|
let connections: any[];
|
||||||
|
try {
|
||||||
|
connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connections.length === 0) return;
|
||||||
|
|
||||||
|
const entry: interfaces.data.ILogEntry = {
|
||||||
|
timestamp: logPackage.timestamp || Date.now(),
|
||||||
|
level: LogsHandler.mapLogLevel(logPackage.level),
|
||||||
|
category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message),
|
||||||
|
message: logPackage.message,
|
||||||
|
metadata: logPackage.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
try {
|
||||||
|
const push = typedsocket.createTypedRequest<interfaces.requests.IReq_PushLogEntry>(
|
||||||
|
'pushLogEntry',
|
||||||
|
conn,
|
||||||
|
);
|
||||||
|
push.fire({ entry }).catch(() => {}); // fire-and-forget
|
||||||
|
} catch {
|
||||||
|
// connection may have closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private setupLogStream(
|
private setupLogStream(
|
||||||
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
|
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
|
||||||
levelFilter?: string[],
|
levelFilter?: string[],
|
||||||
@@ -126,8 +254,18 @@ export class LogsHandler {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
} {
|
} {
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
|
let stopped = false;
|
||||||
let logIndex = 0;
|
let logIndex = 0;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
this.activeStreamStops.delete(stop);
|
||||||
|
};
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
if (!follow) {
|
if (!follow) {
|
||||||
// Send existing logs and close
|
// Send existing logs and close
|
||||||
@@ -142,13 +280,19 @@ export class LogsHandler {
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
virtualStream.sendData(encoder.encode(logData));
|
virtualStream.sendData(encoder.encode(logData));
|
||||||
});
|
});
|
||||||
// VirtualStream doesn't have end() method - it closes automatically
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow mode, simulate real-time log streaming
|
// For follow mode, simulate real-time log streaming
|
||||||
intervalId = setInterval(async () => {
|
intervalId = setInterval(async () => {
|
||||||
|
if (stopped) {
|
||||||
|
// Guard: clear interval if stop() was called between ticks
|
||||||
|
clearInterval(intervalId!);
|
||||||
|
intervalId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||||
|
|
||||||
@@ -172,30 +316,25 @@ export class LogsHandler {
|
|||||||
const logData = JSON.stringify(logEntry);
|
const logData = JSON.stringify(logEntry);
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// Stream closed or errored — clean up to prevent interval leak
|
// Stream closed, errored, or timed out — clean up
|
||||||
clearInterval(intervalId!);
|
stop();
|
||||||
intervalId = null;
|
|
||||||
}
|
}
|
||||||
}, 2000); // Send a log every 2 seconds
|
}, 2000);
|
||||||
|
|
||||||
// TODO: Hook into actual logger events
|
|
||||||
// logger.on('log', (logEntry) => {
|
|
||||||
// if (matchesCriteria(logEntry, level, service)) {
|
|
||||||
// virtualStream.sendData(formatLogEntry(logEntry));
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
intervalId = null;
|
|
||||||
}
|
|
||||||
// TODO: Unhook from logger events
|
|
||||||
};
|
|
||||||
|
|
||||||
return { start, stop };
|
return { start, stop };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class RadiusHandler {
|
export class RadiusHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// RADIUS Client Management
|
// RADIUS Client Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get all RADIUS clients
|
// Get all RADIUS clients (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||||
'getRadiusClients',
|
'getRadiusClients',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -40,8 +38,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add or update a RADIUS client
|
// Add or update a RADIUS client (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||||
'setRadiusClient',
|
'setRadiusClient',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -61,8 +59,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a RADIUS client
|
// Remove a RADIUS client (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||||
'removeRadiusClient',
|
'removeRadiusClient',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -85,8 +83,8 @@ export class RadiusHandler {
|
|||||||
// VLAN Mapping Management
|
// VLAN Mapping Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get all VLAN mappings
|
// Get all VLAN mappings (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||||
'getVlanMappings',
|
'getVlanMappings',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -121,8 +119,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add or update a VLAN mapping
|
// Add or update a VLAN mapping (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||||
'setVlanMapping',
|
'setVlanMapping',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -153,8 +151,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a VLAN mapping
|
// Remove a VLAN mapping (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||||
'removeVlanMapping',
|
'removeVlanMapping',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -174,8 +172,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update VLAN configuration
|
// Update VLAN configuration (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||||
'updateVlanConfig',
|
'updateVlanConfig',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -206,8 +204,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test VLAN assignment
|
// Test VLAN assignment (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||||
'testVlanAssignment',
|
'testVlanAssignment',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -240,8 +238,8 @@ export class RadiusHandler {
|
|||||||
// Accounting / Session Management
|
// Accounting / Session Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get active sessions
|
// Get active sessions (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||||
'getRadiusSessions',
|
'getRadiusSessions',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -289,8 +287,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Disconnect a session
|
// Disconnect a session (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||||
'disconnectRadiusSession',
|
'disconnectRadiusSession',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -314,8 +312,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get accounting summary
|
// Get accounting summary (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||||
'getRadiusAccountingSummary',
|
'getRadiusAccountingSummary',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -351,8 +349,8 @@ export class RadiusHandler {
|
|||||||
// Statistics
|
// Statistics
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get RADIUS statistics
|
// Get RADIUS statistics (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||||
'getRadiusStatistics',
|
'getRadiusStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class RemoteIngressHandler {
|
export class RemoteIngressHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
// Get all remote ingress edges
|
// Get all remote ingress edges
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||||
'getRemoteIngresses',
|
'getRemoteIngresses',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -36,8 +38,10 @@ export class RemoteIngressHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter) ----
|
||||||
|
|
||||||
// Create a new remote ingress edge
|
// Create a new remote ingress edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||||
'createRemoteIngress',
|
'createRemoteIngress',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -69,7 +73,7 @@ export class RemoteIngressHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Delete a remote ingress edge
|
// Delete a remote ingress edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||||
'deleteRemoteIngress',
|
'deleteRemoteIngress',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -94,7 +98,7 @@ export class RemoteIngressHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Update a remote ingress edge
|
// Update a remote ingress edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||||
'updateRemoteIngress',
|
'updateRemoteIngress',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -138,7 +142,7 @@ export class RemoteIngressHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Regenerate secret for an edge
|
// Regenerate secret for an edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||||
'regenerateRemoteIngressSecret',
|
'regenerateRemoteIngressSecret',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -164,8 +168,8 @@ export class RemoteIngressHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get runtime status of all edges
|
// Get runtime status of all edges (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||||
'getRemoteIngressStatus',
|
'getRemoteIngressStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -178,8 +182,8 @@ export class RemoteIngressHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get a connection token for an edge
|
// Get a connection token for an edge (write — exposes secret)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||||
'getRemoteIngressConnectionToken',
|
'getRemoteIngressConnectionToken',
|
||||||
async (dataArg, toolsArg) => {
|
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';
|
import { MetricsManager } from '../../monitoring/index.js';
|
||||||
|
|
||||||
export class SecurityHandler {
|
export class SecurityHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All security endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Security Metrics Handler
|
// Security Metrics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||||
'getSecurityMetrics',
|
'getSecurityMetrics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -40,7 +39,7 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Active Connections Handler
|
// Active Connections Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||||
'getActiveConnections',
|
'getActiveConnections',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -77,8 +76,8 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Network Stats Handler - provides comprehensive network metrics
|
// Network Stats Handler - provides comprehensive network metrics
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
|
||||||
'getNetworkStats',
|
'getNetworkStats',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
// Get network stats from MetricsManager if available
|
// Get network stats from MetricsManager if available
|
||||||
@@ -121,7 +120,7 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Rate Limit Status Handler
|
// Rate Limit Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||||
'getRateLimitStatus',
|
'getRateLimitStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import * as plugins from '../../plugins.js';
|
|||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { MetricsManager } from '../../monitoring/index.js';
|
import { MetricsManager } from '../../monitoring/index.js';
|
||||||
|
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||||
|
|
||||||
export class StatsHandler {
|
export class StatsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All stats endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Server Statistics Handler
|
// Server Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
||||||
'getServerStatistics',
|
'getServerStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -37,7 +37,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Email Statistics Handler
|
// Email Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
||||||
'getEmailStatistics',
|
'getEmailStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -76,7 +76,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// DNS Statistics Handler
|
// DNS Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
||||||
'getDnsStatistics',
|
'getDnsStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -113,7 +113,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Queue Status Handler
|
// Queue Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
||||||
'getQueueStatus',
|
'getQueueStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -141,7 +141,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Health Status Handler
|
// Health Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'getHealthStatus',
|
'getHealthStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -166,7 +166,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Combined Metrics Handler - More efficient for frontend polling
|
// Combined Metrics Handler - More efficient for frontend polling
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||||
'getCombinedMetrics',
|
'getCombinedMetrics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -203,6 +203,11 @@ export class StatsHandler {
|
|||||||
if (sections.email) {
|
if (sections.email) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.collectEmailStats().then(stats => {
|
this.collectEmailStats().then(stats => {
|
||||||
|
// Get time-series data from MetricsManager
|
||||||
|
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||||
|
? this.opsServerRef.dcRouterRef.metricsManager.getEmailTimeSeries(24)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
metrics.email = {
|
metrics.email = {
|
||||||
sent: stats.sentToday,
|
sent: stats.sentToday,
|
||||||
received: stats.receivedToday,
|
received: stats.receivedToday,
|
||||||
@@ -212,6 +217,7 @@ export class StatsHandler {
|
|||||||
averageDeliveryTime: 0,
|
averageDeliveryTime: 0,
|
||||||
deliveryRate: stats.deliveryRate,
|
deliveryRate: stats.deliveryRate,
|
||||||
bounceRate: stats.bounceRate,
|
bounceRate: stats.bounceRate,
|
||||||
|
timeSeries,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -220,6 +226,11 @@ export class StatsHandler {
|
|||||||
if (sections.dns) {
|
if (sections.dns) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.collectDnsStats().then(stats => {
|
this.collectDnsStats().then(stats => {
|
||||||
|
// Get time-series data from MetricsManager
|
||||||
|
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||||
|
? this.opsServerRef.dcRouterRef.metricsManager.getDnsTimeSeries(24)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
metrics.dns = {
|
metrics.dns = {
|
||||||
totalQueries: stats.totalQueries,
|
totalQueries: stats.totalQueries,
|
||||||
cacheHits: stats.cacheHits,
|
cacheHits: stats.cacheHits,
|
||||||
@@ -228,6 +239,8 @@ export class StatsHandler {
|
|||||||
activeDomains: stats.topDomains.length,
|
activeDomains: stats.topDomains.length,
|
||||||
averageResponseTime: 0,
|
averageResponseTime: 0,
|
||||||
queryTypes: stats.queryTypes,
|
queryTypes: stats.queryTypes,
|
||||||
|
timeSeries,
|
||||||
|
recentQueries: stats.recentQueries,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -236,6 +249,19 @@ export class StatsHandler {
|
|||||||
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
||||||
|
// Get recent events from the SecurityLogger singleton
|
||||||
|
const securityLogger = SecurityLogger.getInstance();
|
||||||
|
const recentEvents = securityLogger.getRecentEvents(50).map((evt) => ({
|
||||||
|
timestamp: evt.timestamp,
|
||||||
|
level: evt.level,
|
||||||
|
type: evt.type,
|
||||||
|
message: evt.message,
|
||||||
|
details: evt.details,
|
||||||
|
ipAddress: evt.ipAddress,
|
||||||
|
domain: evt.domain,
|
||||||
|
success: evt.success,
|
||||||
|
}));
|
||||||
|
|
||||||
metrics.security = {
|
metrics.security = {
|
||||||
blockedIPs: stats.blockedIPs,
|
blockedIPs: stats.blockedIPs,
|
||||||
reputationScores: {},
|
reputationScores: {},
|
||||||
@@ -244,6 +270,7 @@ export class StatsHandler {
|
|||||||
phishingDetected: stats.phishingDetected,
|
phishingDetected: stats.phishingDetected,
|
||||||
authenticationFailures: stats.authFailures,
|
authenticationFailures: stats.authFailures,
|
||||||
suspiciousActivities: stats.totalThreatsBlocked,
|
suspiciousActivities: stats.totalThreatsBlocked,
|
||||||
|
recentEvents,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -395,6 +422,7 @@ export class StatsHandler {
|
|||||||
count: number;
|
count: number;
|
||||||
}>;
|
}>;
|
||||||
queryTypes: { [key: string]: number };
|
queryTypes: { [key: string]: number };
|
||||||
|
recentQueries?: Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>;
|
||||||
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
||||||
}> {
|
}> {
|
||||||
// Get metrics from MetricsManager if available
|
// Get metrics from MetricsManager if available
|
||||||
@@ -408,9 +436,10 @@ export class StatsHandler {
|
|||||||
cacheHitRate: dnsStats.cacheHitRate,
|
cacheHitRate: dnsStats.cacheHitRate,
|
||||||
topDomains: dnsStats.topDomains,
|
topDomains: dnsStats.topDomains,
|
||||||
queryTypes: dnsStats.queryTypes,
|
queryTypes: dnsStats.queryTypes,
|
||||||
|
recentQueries: dnsStats.recentQueries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if MetricsManager not available
|
// Fallback if MetricsManager not available
|
||||||
return {
|
return {
|
||||||
queriesPerSecond: 0,
|
queriesPerSecond: 0,
|
||||||
|
|||||||
@@ -22,16 +22,17 @@ export async function passGuards<T extends { identity?: any }>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check admin identity in handlers
|
* Helper to check admin identity in handlers and middleware.
|
||||||
|
* Accepts both optional and required identity for flexibility.
|
||||||
*/
|
*/
|
||||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
export async function requireAdminIdentity(
|
||||||
adminHandler: AdminHandler,
|
adminHandler: AdminHandler,
|
||||||
dataArg: T
|
dataArg: { identity?: interfaces.data.IIdentity }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!dataArg.identity) {
|
if (!dataArg.identity) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
@@ -39,16 +40,17 @@ export async function requireAdminIdentity<T extends { identity?: interfaces.dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check valid identity in handlers
|
* Helper to check valid identity in handlers and middleware.
|
||||||
|
* Accepts both optional and required identity for flexibility.
|
||||||
*/
|
*/
|
||||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
export async function requireValidIdentity(
|
||||||
adminHandler: AdminHandler,
|
adminHandler: AdminHandler,
|
||||||
dataArg: T
|
dataArg: { identity?: interfaces.data.IIdentity }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!dataArg.identity) {
|
if (!dataArg.identity) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||||
|
|||||||
14
ts/readme.md
14
ts/readme.md
@@ -37,7 +37,7 @@ const router = new DcRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await router.start();
|
await router.start();
|
||||||
// OpsServer dashboard at http://localhost:3000
|
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
await router.stop();
|
await router.stop();
|
||||||
@@ -60,6 +60,9 @@ ts/
|
|||||||
│ └── documents/ # Cached document models
|
│ └── documents/ # Cached document models
|
||||||
├── config/ # Configuration utilities
|
├── config/ # Configuration utilities
|
||||||
├── errors/ # Error classes and retry logic
|
├── 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)
|
├── monitoring/ # MetricsManager (SmartMetrics integration)
|
||||||
├── opsserver/ # OpsServer dashboard + API handlers
|
├── opsserver/ # OpsServer dashboard + API handlers
|
||||||
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
||||||
@@ -71,7 +74,10 @@ ts/
|
|||||||
│ ├── email.handler.ts # Email operations
|
│ ├── email.handler.ts # Email operations
|
||||||
│ ├── certificate.handler.ts # Certificate management
|
│ ├── certificate.handler.ts # Certificate management
|
||||||
│ ├── radius.handler.ts # RADIUS 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
|
├── radius/ # RADIUS server integration
|
||||||
├── remoteingress/ # Remote ingress hub integration
|
├── remoteingress/ # Remote ingress hub integration
|
||||||
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
||||||
@@ -96,6 +102,9 @@ export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
|
|||||||
|
|
||||||
// Remote Ingress
|
// Remote Ingress
|
||||||
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||||
|
|
||||||
|
// HTTP/3
|
||||||
|
export type { IHttp3Config } from './http3/index.js';
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Classes
|
## Key Classes
|
||||||
@@ -112,6 +121,7 @@ The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle o
|
|||||||
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
||||||
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
||||||
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
|
| `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` |
|
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
|
||||||
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
||||||
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const STORAGE_PREFIX = '/remote-ingress/';
|
|||||||
/**
|
/**
|
||||||
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
* 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>();
|
const ports = new Set<number>();
|
||||||
if (typeof portRange === 'number') {
|
if (typeof portRange === 'number') {
|
||||||
ports.add(portRange);
|
ports.add(portRange);
|
||||||
@@ -94,6 +94,38 @@ export class RemoteIngressManager {
|
|||||||
return [...ports].sort((a, b) => a - b);
|
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.
|
* Get the effective listen ports for an edge.
|
||||||
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
|
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
|
||||||
@@ -106,6 +138,18 @@ export class RemoteIngressManager {
|
|||||||
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
|
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).
|
* Get manual and derived port breakdown for an edge (used in API responses).
|
||||||
* Derived ports exclude any ports already present in the manual list.
|
* Derived ports exclude any ports already present in the manual list.
|
||||||
@@ -241,15 +285,18 @@ export class RemoteIngressManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of allowed edges (enabled only) for the Rust hub.
|
* 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[] }> {
|
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
|
||||||
const result: Array<{ id: string; secret: string; listenPorts: number[] }> = [];
|
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
|
||||||
for (const edge of this.edges.values()) {
|
for (const edge of this.edges.values()) {
|
||||||
if (edge.enabled) {
|
if (edge.enabled) {
|
||||||
|
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
|
||||||
result.push({
|
result.push({
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
secret: edge.secret,
|
secret: edge.secret,
|
||||||
listenPorts: this.getEffectiveListenPorts(edge),
|
listenPorts: this.getEffectiveListenPorts(edge),
|
||||||
|
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
|
|||||||
export interface ITunnelManagerConfig {
|
export interface ITunnelManagerConfig {
|
||||||
tunnelPort?: number;
|
tunnelPort?: number;
|
||||||
targetHost?: string;
|
targetHost?: string;
|
||||||
|
tls?: {
|
||||||
|
certPem?: string;
|
||||||
|
keyPem?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +19,7 @@ export class TunnelManager {
|
|||||||
private manager: RemoteIngressManager;
|
private manager: RemoteIngressManager;
|
||||||
private config: ITunnelManagerConfig;
|
private config: ITunnelManagerConfig;
|
||||||
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
||||||
|
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
@@ -22,12 +27,11 @@ export class TunnelManager {
|
|||||||
this.hub = new plugins.remoteingress.RemoteIngressHub();
|
this.hub = new plugins.remoteingress.RemoteIngressHub();
|
||||||
|
|
||||||
// Listen for edge connect/disconnect events
|
// Listen for edge connect/disconnect events
|
||||||
this.hub.on('edgeConnected', (data: { edgeId: string }) => {
|
this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
|
||||||
const existing = this.edgeStatuses.get(data.edgeId);
|
|
||||||
this.edgeStatuses.set(data.edgeId, {
|
this.edgeStatuses.set(data.edgeId, {
|
||||||
edgeId: data.edgeId,
|
edgeId: data.edgeId,
|
||||||
connected: true,
|
connected: true,
|
||||||
publicIp: existing?.publicIp ?? null,
|
publicIp: data.peerAddr || null,
|
||||||
activeTunnels: 0,
|
activeTunnels: 0,
|
||||||
lastHeartbeat: Date.now(),
|
lastHeartbeat: Date.now(),
|
||||||
connectedAt: Date.now(),
|
connectedAt: Date.now(),
|
||||||
@@ -35,11 +39,7 @@ export class TunnelManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
|
this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
|
||||||
const existing = this.edgeStatuses.get(data.edgeId);
|
this.edgeStatuses.delete(data.edgeId);
|
||||||
if (existing) {
|
|
||||||
existing.connected = false;
|
|
||||||
existing.activeTunnels = 0;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
|
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
|
||||||
@@ -65,20 +65,73 @@ export class TunnelManager {
|
|||||||
await this.hub.start({
|
await this.hub.start({
|
||||||
tunnelPort: this.config.tunnelPort ?? 8443,
|
tunnelPort: this.config.tunnelPort ?? 8443,
|
||||||
targetHost: this.config.targetHost ?? '127.0.0.1',
|
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||||
|
tls: this.config.tls,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send allowed edges to the hub
|
// Send allowed edges to the hub
|
||||||
await this.syncAllowedEdges();
|
await this.syncAllowedEdges();
|
||||||
|
|
||||||
|
// Periodically reconcile with authoritative Rust hub status
|
||||||
|
this.reconcileInterval = setInterval(() => {
|
||||||
|
this.reconcile().catch(() => {});
|
||||||
|
}, 15_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the tunnel hub.
|
* Stop the tunnel hub.
|
||||||
*/
|
*/
|
||||||
public async stop(): Promise<void> {
|
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();
|
await this.hub.stop();
|
||||||
this.edgeStatuses.clear();
|
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.
|
* Sync allowed edges from the manager to the hub.
|
||||||
* Call this after creating/deleting/updating edges.
|
* Call this after creating/deleting/updating edges.
|
||||||
@@ -113,6 +166,19 @@ export class TunnelManager {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public IPs of all connected edges.
|
||||||
|
*/
|
||||||
|
public getConnectedEdgeIps(): string[] {
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (const status of this.edgeStatuses.values()) {
|
||||||
|
if (status.connected && status.publicIp) {
|
||||||
|
ips.push(status.publicIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the total number of active tunnels across all edges.
|
* Get the total number of active tunnels across all edges.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -182,7 +182,14 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
return ContentScanner.instance;
|
return ContentScanner.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
ContentScanner.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan an email for malicious content
|
* Scan an email for malicious content
|
||||||
* @param email The email to scan
|
* @param email The email to scan
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export class IPReputationChecker {
|
|||||||
private reputationCache: LRUCache<string, IReputationResult>;
|
private reputationCache: LRUCache<string, IReputationResult>;
|
||||||
private options: Required<IIPReputationOptions>;
|
private options: Required<IIPReputationOptions>;
|
||||||
private storageManager?: any; // StorageManager instance
|
private storageManager?: any; // StorageManager instance
|
||||||
|
private saveCacheTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private static readonly SAVE_CACHE_DEBOUNCE_MS = 30_000;
|
||||||
|
|
||||||
// Default DNSBL servers
|
// Default DNSBL servers
|
||||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||||
@@ -143,7 +145,20 @@ export class IPReputationChecker {
|
|||||||
}
|
}
|
||||||
return IPReputationChecker.instance;
|
return IPReputationChecker.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
if (IPReputationChecker.instance) {
|
||||||
|
if (IPReputationChecker.instance.saveCacheTimer) {
|
||||||
|
clearTimeout(IPReputationChecker.instance.saveCacheTimer);
|
||||||
|
IPReputationChecker.instance.saveCacheTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IPReputationChecker.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check an IP address's reputation
|
* Check an IP address's reputation
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
@@ -213,12 +228,9 @@ export class IPReputationChecker {
|
|||||||
// Update cache with result
|
// Update cache with result
|
||||||
this.reputationCache.set(ip, result);
|
this.reputationCache.set(ip, result);
|
||||||
|
|
||||||
// Save cache if enabled
|
// Schedule debounced cache save if enabled
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
// Fire and forget the save operation
|
this.debouncedSaveCache();
|
||||||
this.saveCache().catch(error => {
|
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the reputation check
|
// Log the reputation check
|
||||||
@@ -447,6 +459,21 @@ export class IPReputationChecker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a debounced cache save (at most once per SAVE_CACHE_DEBOUNCE_MS)
|
||||||
|
*/
|
||||||
|
private debouncedSaveCache(): void {
|
||||||
|
if (this.saveCacheTimer) {
|
||||||
|
return; // already scheduled
|
||||||
|
}
|
||||||
|
this.saveCacheTimer = setTimeout(() => {
|
||||||
|
this.saveCacheTimer = null;
|
||||||
|
this.saveCache().catch(error => {
|
||||||
|
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||||
|
});
|
||||||
|
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save cache to disk or storage manager
|
* Save cache to disk or storage manager
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -83,7 +83,14 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
return SecurityLogger.instance;
|
return SecurityLogger.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
SecurityLogger.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a security event
|
* Log a security event
|
||||||
* @param event The security event to log
|
* @param event The security event to log
|
||||||
@@ -155,8 +162,9 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return most recent events up to limit
|
// Return most recent events up to limit (slice first to avoid mutating source)
|
||||||
return filteredEvents
|
return filteredEvents
|
||||||
|
.slice()
|
||||||
.sort((a, b) => b.timestamp - a.timestamp)
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
@@ -242,58 +250,46 @@ export class SecurityLogger {
|
|||||||
topIPs: Array<{ ip: string; count: number }>;
|
topIPs: Array<{ ip: string; count: number }>;
|
||||||
topDomains: Array<{ domain: string; count: number }>;
|
topDomains: Array<{ domain: string; count: number }>;
|
||||||
} {
|
} {
|
||||||
// Filter by time window if provided
|
const cutoff = timeWindow ? Date.now() - timeWindow : 0;
|
||||||
let events = this.securityEvents;
|
|
||||||
if (timeWindow) {
|
// Initialize counters
|
||||||
const cutoff = Date.now() - timeWindow;
|
const byLevel = {} as Record<SecurityLogLevel, number>;
|
||||||
events = events.filter(e => e.timestamp >= cutoff);
|
for (const level of Object.values(SecurityLogLevel)) {
|
||||||
|
byLevel[level] = 0;
|
||||||
|
}
|
||||||
|
const byType = {} as Record<SecurityEventType, number>;
|
||||||
|
for (const type of Object.values(SecurityEventType)) {
|
||||||
|
byType[type] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count by level
|
|
||||||
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
|
|
||||||
acc[level] = events.filter(e => e.level === level).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityLogLevel, number>);
|
|
||||||
|
|
||||||
// Count by type
|
|
||||||
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
|
|
||||||
acc[type] = events.filter(e => e.type === type).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityEventType, number>);
|
|
||||||
|
|
||||||
// Count by IP
|
|
||||||
const ipCounts = new Map<string, number>();
|
const ipCounts = new Map<string, number>();
|
||||||
events.forEach(e => {
|
const domainCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
// Single pass over all events
|
||||||
|
let total = 0;
|
||||||
|
for (const e of this.securityEvents) {
|
||||||
|
if (cutoff && e.timestamp < cutoff) continue;
|
||||||
|
total++;
|
||||||
|
byLevel[e.level]++;
|
||||||
|
byType[e.type]++;
|
||||||
if (e.ipAddress) {
|
if (e.ipAddress) {
|
||||||
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Count by domain
|
|
||||||
const domainCounts = new Map<string, number>();
|
|
||||||
events.forEach(e => {
|
|
||||||
if (e.domain) {
|
if (e.domain) {
|
||||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Sort and limit top entries
|
// Sort and limit top entries
|
||||||
const topIPs = Array.from(ipCounts.entries())
|
const topIPs = Array.from(ipCounts.entries())
|
||||||
.map(([ip, count]) => ({ ip, count }))
|
.map(([ip, count]) => ({ ip, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
const topDomains = Array.from(domainCounts.entries())
|
const topDomains = Array.from(domainCounts.entries())
|
||||||
.map(([domain, count]) => ({ domain, count }))
|
.map(([domain, count]) => ({ domain, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
return {
|
return { total, byLevel, byType, topIPs, topDomains };
|
||||||
total: events.length,
|
|
||||||
byLevel,
|
|
||||||
byType,
|
|
||||||
topIPs,
|
|
||||||
topDomains
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ export type StorageBackend = 'filesystem' | 'custom' | 'memory';
|
|||||||
* Provides unified key-value storage with multiple backend support
|
* Provides unified key-value storage with multiple backend support
|
||||||
*/
|
*/
|
||||||
export class StorageManager {
|
export class StorageManager {
|
||||||
|
private static readonly MAX_MEMORY_ENTRIES = 10_000;
|
||||||
private backend: StorageBackend;
|
private backend: StorageBackend;
|
||||||
private memoryStore: Map<string, string> = new Map();
|
private memoryStore: Map<string, string> = new Map();
|
||||||
private config: IStorageConfig;
|
private config: IStorageConfig;
|
||||||
@@ -227,6 +228,11 @@ export class StorageManager {
|
|||||||
|
|
||||||
case 'memory': {
|
case 'memory': {
|
||||||
this.memoryStore.set(key, value);
|
this.memoryStore.set(key, value);
|
||||||
|
// Evict oldest entries if memory store exceeds limit
|
||||||
|
while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) {
|
||||||
|
const firstKey = this.memoryStore.keys().next().value;
|
||||||
|
this.memoryStore.delete(firstKey);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
3
ts_apiclient/tspublish.json
Normal file
3
ts_apiclient/tspublish.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"order": 4
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './auth.js';
|
export * from './auth.js';
|
||||||
export * from './stats.js';
|
export * from './stats.js';
|
||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
|
export * from './route-management.js';
|
||||||
@@ -8,6 +8,8 @@ export interface IRemoteIngress {
|
|||||||
name: string;
|
name: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
listenPorts: number[];
|
listenPorts: number[];
|
||||||
|
/** UDP listen ports (e.g. for QUIC/HTTP3). Derived from routes with transport 'udp' or 'all'. */
|
||||||
|
listenPortsUdp?: number[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
|
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
|
||||||
autoDerivePorts: boolean;
|
autoDerivePorts: boolean;
|
||||||
@@ -20,6 +22,8 @@ export interface IRemoteIngress {
|
|||||||
manualPorts?: number[];
|
manualPorts?: number[];
|
||||||
/** Ports auto-derived from route configs — only present in API responses. */
|
/** Ports auto-derived from route configs — only present in API responses. */
|
||||||
derivedPorts?: number[];
|
derivedPorts?: number[];
|
||||||
|
/** Effective UDP ports (union of manual + derived) — only present in API responses. */
|
||||||
|
effectiveListenPortsUdp?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
83
ts_interfaces/data/route-management.ts
Normal file
83
ts_interfaces/data/route-management.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Route Management Data Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A merged route combining hardcoded and programmatic sources.
|
||||||
|
*/
|
||||||
|
export interface IMergedRoute {
|
||||||
|
route: IRouteConfig;
|
||||||
|
source: 'hardcoded' | 'programmatic';
|
||||||
|
enabled: boolean;
|
||||||
|
overridden: boolean;
|
||||||
|
storedRouteId?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A warning generated during route merge/startup.
|
||||||
|
*/
|
||||||
|
export interface IRouteWarning {
|
||||||
|
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
||||||
|
routeName: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public info about an API token (never includes the hash).
|
||||||
|
*/
|
||||||
|
export interface IApiTokenInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopes: TApiTokenScope[];
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number | null;
|
||||||
|
lastUsedAt: number | null;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Storage Schemas (persisted via StorageManager)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A programmatic route stored in /config-api/routes/{id}.json
|
||||||
|
*/
|
||||||
|
export interface IStoredRoute {
|
||||||
|
id: string;
|
||||||
|
route: IRouteConfig;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
|
||||||
|
*/
|
||||||
|
export interface IRouteOverride {
|
||||||
|
routeName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
updatedAt: number;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored API token, stored in /config-api/tokens/{id}.json
|
||||||
|
*/
|
||||||
|
export interface IStoredApiToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tokenHash: string;
|
||||||
|
scopes: TApiTokenScope[];
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number | null;
|
||||||
|
lastUsedAt: number | null;
|
||||||
|
createdBy: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ export interface IServerStats {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ITimeSeriesPoint {
|
||||||
|
timestamp: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IEmailStats {
|
export interface IEmailStats {
|
||||||
sent: number;
|
sent: number;
|
||||||
received: number;
|
received: number;
|
||||||
@@ -35,6 +40,11 @@ export interface IEmailStats {
|
|||||||
averageDeliveryTime: number;
|
averageDeliveryTime: number;
|
||||||
deliveryRate: number;
|
deliveryRate: number;
|
||||||
bounceRate: number;
|
bounceRate: number;
|
||||||
|
timeSeries?: {
|
||||||
|
sent: ITimeSeriesPoint[];
|
||||||
|
received: ITimeSeriesPoint[];
|
||||||
|
failed: ITimeSeriesPoint[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDnsStats {
|
export interface IDnsStats {
|
||||||
@@ -47,6 +57,16 @@ export interface IDnsStats {
|
|||||||
queryTypes: {
|
queryTypes: {
|
||||||
[key: string]: number;
|
[key: string]: number;
|
||||||
};
|
};
|
||||||
|
timeSeries?: {
|
||||||
|
queries: ITimeSeriesPoint[];
|
||||||
|
};
|
||||||
|
recentQueries?: Array<{
|
||||||
|
timestamp: number;
|
||||||
|
domain: string;
|
||||||
|
type: string;
|
||||||
|
answered: boolean;
|
||||||
|
responseTimeMs: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRateLimitInfo {
|
export interface IRateLimitInfo {
|
||||||
@@ -58,6 +78,17 @@ export interface IRateLimitInfo {
|
|||||||
blocked: boolean;
|
blocked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ISecurityEvent {
|
||||||
|
timestamp: number;
|
||||||
|
level: string;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
ipAddress?: string;
|
||||||
|
domain?: string;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ISecurityMetrics {
|
export interface ISecurityMetrics {
|
||||||
blockedIPs: string[];
|
blockedIPs: string[];
|
||||||
reputationScores: {
|
reputationScores: {
|
||||||
@@ -68,6 +99,7 @@ export interface ISecurityMetrics {
|
|||||||
phishingDetected: number;
|
phishingDetected: number;
|
||||||
authenticationFailures: number;
|
authenticationFailures: number;
|
||||||
suspiciousActivities: number;
|
suspiciousActivities: number;
|
||||||
|
recentEvents?: ISecurityEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILogEntry {
|
export interface ILogEntry {
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ interface IIdentity {
|
|||||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||||
|
|
||||||
|
#### Route Management Interfaces
|
||||||
|
| Interface | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `IMergedRoute` | Combined route: routeConfig, source (hardcoded/programmatic), enabled, overridden |
|
||||||
|
| `IRouteWarning` | Merge warning: disabled-hardcoded, disabled-programmatic, orphaned-override |
|
||||||
|
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
||||||
|
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
||||||
|
|
||||||
#### Remote Ingress Interfaces
|
#### Remote Ingress Interfaces
|
||||||
| Interface | Description |
|
| Interface | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
@@ -128,13 +136,29 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
|||||||
#### 📧 Email Operations
|
#### 📧 Email Operations
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
|-----------|--------|-------------|
|
|-----------|--------|-------------|
|
||||||
| `IReq_GetQueuedEmails` | `getQueuedEmails` | List queued emails |
|
| `IReq_GetAllEmails` | `getAllEmails` | List all emails |
|
||||||
| `IReq_GetSentEmails` | `getSentEmails` | List delivered emails |
|
| `IReq_GetEmailDetail` | `getEmailDetail` | Full detail for a specific email |
|
||||||
| `IReq_GetFailedEmails` | `getFailedEmails` | List failed emails |
|
|
||||||
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
|
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
|
||||||
| `IReq_GetSecurityIncidents` | `getSecurityIncidents` | Security events |
|
|
||||||
| `IReq_GetBounceRecords` | `getBounceRecords` | Bounce records |
|
#### 🛣️ Route Management
|
||||||
| `IReq_RemoveFromSuppressionList` | `removeFromSuppressionList` | Unsuppress an address |
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetMergedRoutes` | `getMergedRoutes` | List all routes (hardcoded + programmatic) |
|
||||||
|
| `IReq_CreateRoute` | `createRoute` | Create a new programmatic route |
|
||||||
|
| `IReq_UpdateRoute` | `updateRoute` | Update a programmatic route |
|
||||||
|
| `IReq_DeleteRoute` | `deleteRoute` | Delete a programmatic route |
|
||||||
|
| `IReq_ToggleRoute` | `toggleRoute` | Enable/disable a programmatic route |
|
||||||
|
| `IReq_SetRouteOverride` | `setRouteOverride` | Override a hardcoded route |
|
||||||
|
| `IReq_RemoveRouteOverride` | `removeRouteOverride` | Remove a route override |
|
||||||
|
|
||||||
|
#### 🔑 API Token Management
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_CreateApiToken` | `createApiToken` | Create a new API token |
|
||||||
|
| `IReq_ListApiTokens` | `listApiTokens` | List all tokens |
|
||||||
|
| `IReq_RevokeApiToken` | `revokeApiToken` | Revoke (delete) a token |
|
||||||
|
| `IReq_RollApiToken` | `rollApiToken` | Regenerate token secret |
|
||||||
|
| `IReq_ToggleApiToken` | `toggleApiToken` | Enable/disable a token |
|
||||||
|
|
||||||
#### 🔐 Certificates
|
#### 🔐 Certificates
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
@@ -198,6 +222,8 @@ interface ICertificateInfo {
|
|||||||
|
|
||||||
## Example: Full API Integration
|
## Example: Full API Integration
|
||||||
|
|
||||||
|
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import * as typedrequest from '@api.global/typedrequest';
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||||
@@ -266,7 +292,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
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.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
103
ts_interfaces/requests/api-tokens.ts
Normal file
103
ts_interfaces/requests/api-tokens.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Token Management Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API token. Returns the raw token value once (never shown again).
|
||||||
|
* Admin JWT only — tokens cannot create tokens.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateApiToken
|
||||||
|
> {
|
||||||
|
method: 'createApiToken';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
name: string;
|
||||||
|
scopes: TApiTokenScope[];
|
||||||
|
expiresInDays?: number | null;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
tokenId?: string;
|
||||||
|
tokenValue?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all API tokens (without hashes).
|
||||||
|
*/
|
||||||
|
export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListApiTokens
|
||||||
|
> {
|
||||||
|
method: 'listApiTokens';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
tokens: IApiTokenInfo[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (delete) an API token.
|
||||||
|
*/
|
||||||
|
export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RevokeApiToken
|
||||||
|
> {
|
||||||
|
method: 'revokeApiToken';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll (regenerate) an API token's secret. Returns the new raw token value once.
|
||||||
|
* Admin JWT only.
|
||||||
|
*/
|
||||||
|
export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RollApiToken
|
||||||
|
> {
|
||||||
|
method: 'rollApiToken';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
tokenValue?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable an API token.
|
||||||
|
*/
|
||||||
|
export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ToggleApiToken
|
||||||
|
> {
|
||||||
|
method: 'toggleApiToken';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
|
|||||||
> {
|
> {
|
||||||
method: 'getCertificateOverview';
|
method: 'getCertificateOverview';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
certificates: ICertificateInfo[];
|
certificates: ICertificateInfo[];
|
||||||
@@ -50,7 +50,7 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa
|
|||||||
> {
|
> {
|
||||||
method: 'reprovisionCertificate';
|
method: 'reprovisionCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
routeName: string;
|
routeName: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -66,7 +66,7 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
|||||||
> {
|
> {
|
||||||
method: 'reprovisionCertificateDomain';
|
method: 'reprovisionCertificateDomain';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -82,7 +82,7 @@ export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'deleteCertificate';
|
method: 'deleteCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -98,7 +98,7 @@ export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'exportCertificate';
|
method: 'exportCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -123,7 +123,7 @@ export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'importCertificate';
|
method: 'importCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
cert: {
|
cert: {
|
||||||
id: string;
|
id: string;
|
||||||
domainName: string;
|
domainName: string;
|
||||||
|
|||||||
@@ -1,6 +1,79 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as authInterfaces from '../data/auth.js';
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
|
export interface IConfigData {
|
||||||
|
system: {
|
||||||
|
baseDir: string;
|
||||||
|
dataDir: string;
|
||||||
|
publicIp: string | null;
|
||||||
|
proxyIps: string[];
|
||||||
|
uptime: number;
|
||||||
|
storageBackend: 'filesystem' | 'custom' | 'memory';
|
||||||
|
storagePath: string | null;
|
||||||
|
};
|
||||||
|
smartProxy: {
|
||||||
|
enabled: boolean;
|
||||||
|
routeCount: number;
|
||||||
|
acme: {
|
||||||
|
enabled: boolean;
|
||||||
|
accountEmail: string;
|
||||||
|
useProduction: boolean;
|
||||||
|
autoRenew: boolean;
|
||||||
|
renewThresholdDays: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
email: {
|
||||||
|
enabled: boolean;
|
||||||
|
ports: number[];
|
||||||
|
portMapping: Record<string, number> | null;
|
||||||
|
hostname: string | null;
|
||||||
|
domains: string[];
|
||||||
|
emailRouteCount: number;
|
||||||
|
receivedEmailsPath: string | null;
|
||||||
|
};
|
||||||
|
dns: {
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
nsDomains: string[];
|
||||||
|
scopes: string[];
|
||||||
|
recordCount: number;
|
||||||
|
records: Array<{ name: string; type: string; value: string; ttl?: number }>;
|
||||||
|
dnsChallenge: boolean;
|
||||||
|
};
|
||||||
|
tls: {
|
||||||
|
contactEmail: string | null;
|
||||||
|
domain: string | null;
|
||||||
|
source: 'acme' | 'static' | 'none';
|
||||||
|
certPath: string | null;
|
||||||
|
keyPath: string | null;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
enabled: boolean;
|
||||||
|
storagePath: string | null;
|
||||||
|
dbName: string | null;
|
||||||
|
defaultTTLDays: number;
|
||||||
|
cleanupIntervalHours: number;
|
||||||
|
ttlConfig: Record<string, number>;
|
||||||
|
};
|
||||||
|
radius: {
|
||||||
|
enabled: boolean;
|
||||||
|
authPort: number | null;
|
||||||
|
acctPort: number | null;
|
||||||
|
bindAddress: string | null;
|
||||||
|
clientCount: number;
|
||||||
|
vlanDefaultVlan: number | null;
|
||||||
|
vlanAllowUnknownMacs: boolean | null;
|
||||||
|
vlanMappingCount: number;
|
||||||
|
};
|
||||||
|
remoteIngress: {
|
||||||
|
enabled: boolean;
|
||||||
|
tunnelPort: number | null;
|
||||||
|
hubDomain: string | null;
|
||||||
|
tlsMode: 'custom' | 'acme' | 'self-signed';
|
||||||
|
connectedEdgeIps: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Get Configuration (read-only)
|
// Get Configuration (read-only)
|
||||||
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -8,11 +81,11 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'getConfiguration';
|
method: 'getConfiguration';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
section?: string;
|
section?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
config: any;
|
config: IConfigData;
|
||||||
section?: string;
|
section?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,162 +2,93 @@ import * as plugins from '../plugins.js';
|
|||||||
import * as authInterfaces from '../data/auth.js';
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Email Queue Item Interface (matches backend IQueueItem)
|
// Catalog-compatible email types (matches @serve.zone/catalog IEmail/IEmailDetail)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
export type TEmailStatus = 'delivered' | 'bounced' | 'rejected' | 'deferred' | 'pending';
|
||||||
|
export type TEmailDirection = 'inbound' | 'outbound';
|
||||||
|
|
||||||
export interface IEmailQueueItem {
|
export interface IEmail {
|
||||||
id: string;
|
id: string;
|
||||||
processingMode: 'forward' | 'mta' | 'process';
|
direction: TEmailDirection;
|
||||||
status: TEmailQueueStatus;
|
status: TEmailStatus;
|
||||||
attempts: number;
|
from: string;
|
||||||
nextAttempt: number; // timestamp
|
to: string;
|
||||||
lastError?: string;
|
subject: string;
|
||||||
createdAt: number; // timestamp
|
timestamp: string;
|
||||||
updatedAt: number; // timestamp
|
messageId: string;
|
||||||
deliveredAt?: number; // timestamp
|
size: string;
|
||||||
// Email details extracted from processingResult
|
}
|
||||||
from?: string;
|
|
||||||
to?: string[];
|
export interface ISmtpLogEntry {
|
||||||
subject?: string;
|
timestamp: string;
|
||||||
|
direction: 'client' | 'server';
|
||||||
|
command: string;
|
||||||
|
responseCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConnectionInfo {
|
||||||
|
sourceIp: string;
|
||||||
|
sourceHostname: string;
|
||||||
|
destinationIp: string;
|
||||||
|
destinationPort: number;
|
||||||
|
tlsVersion: string;
|
||||||
|
tlsCipher: string;
|
||||||
|
authenticated: boolean;
|
||||||
|
authMethod: string;
|
||||||
|
authUser: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAuthenticationResults {
|
||||||
|
spf: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none';
|
||||||
|
spfDomain: string;
|
||||||
|
dkim: 'pass' | 'fail' | 'none';
|
||||||
|
dkimDomain: string;
|
||||||
|
dmarc: 'pass' | 'fail' | 'none';
|
||||||
|
dmarcPolicy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEmailDetail extends IEmail {
|
||||||
|
toList: string[];
|
||||||
|
cc?: string[];
|
||||||
|
smtpLog: ISmtpLogEntry[];
|
||||||
|
connectionInfo: IConnectionInfo;
|
||||||
|
authenticationResults: IAuthenticationResults;
|
||||||
|
rejectionReason?: string;
|
||||||
|
bounceMessage?: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Bounce Record Interface (matches backend BounceRecord)
|
// Get All Emails Request
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export type TBounceType =
|
export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
| 'invalid_recipient'
|
|
||||||
| 'domain_not_found'
|
|
||||||
| 'mailbox_full'
|
|
||||||
| 'mailbox_inactive'
|
|
||||||
| 'blocked'
|
|
||||||
| 'spam_related'
|
|
||||||
| 'policy_related'
|
|
||||||
| 'server_unavailable'
|
|
||||||
| 'temporary_failure'
|
|
||||||
| 'quota_exceeded'
|
|
||||||
| 'network_error'
|
|
||||||
| 'timeout'
|
|
||||||
| 'auto_response'
|
|
||||||
| 'challenge_response'
|
|
||||||
| 'unknown';
|
|
||||||
|
|
||||||
export type TBounceCategory = 'hard' | 'soft' | 'auto_response' | 'unknown';
|
|
||||||
|
|
||||||
export interface IBounceRecord {
|
|
||||||
id: string;
|
|
||||||
originalEmailId?: string;
|
|
||||||
recipient: string;
|
|
||||||
sender: string;
|
|
||||||
domain: string;
|
|
||||||
subject?: string;
|
|
||||||
bounceType: TBounceType;
|
|
||||||
bounceCategory: TBounceCategory;
|
|
||||||
timestamp: number;
|
|
||||||
smtpResponse?: string;
|
|
||||||
diagnosticCode?: string;
|
|
||||||
statusCode?: string;
|
|
||||||
processed: boolean;
|
|
||||||
retryCount?: number;
|
|
||||||
nextRetryTime?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Security Incident Interface (matches backend ISecurityEvent)
|
|
||||||
// ============================================================================
|
|
||||||
export type TSecurityLogLevel = 'info' | 'warn' | 'error' | 'critical';
|
|
||||||
|
|
||||||
export type TSecurityEventType =
|
|
||||||
| 'authentication'
|
|
||||||
| 'access_control'
|
|
||||||
| 'email_validation'
|
|
||||||
| 'email_processing'
|
|
||||||
| 'email_forwarding'
|
|
||||||
| 'email_delivery'
|
|
||||||
| 'dkim'
|
|
||||||
| 'spf'
|
|
||||||
| 'dmarc'
|
|
||||||
| 'rate_limit'
|
|
||||||
| 'rate_limiting'
|
|
||||||
| 'spam'
|
|
||||||
| 'malware'
|
|
||||||
| 'connection'
|
|
||||||
| 'data_exposure'
|
|
||||||
| 'configuration'
|
|
||||||
| 'ip_reputation'
|
|
||||||
| 'rejected_connection';
|
|
||||||
|
|
||||||
export interface ISecurityIncident {
|
|
||||||
timestamp: number;
|
|
||||||
level: TSecurityLogLevel;
|
|
||||||
type: TSecurityEventType;
|
|
||||||
message: string;
|
|
||||||
details?: any;
|
|
||||||
ipAddress?: string;
|
|
||||||
userId?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
emailId?: string;
|
|
||||||
domain?: string;
|
|
||||||
action?: string;
|
|
||||||
result?: string;
|
|
||||||
success?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Get Queued Emails Request
|
|
||||||
// ============================================================================
|
|
||||||
export interface IReq_GetQueuedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
IReq_GetQueuedEmails
|
IReq_GetAllEmails
|
||||||
> {
|
> {
|
||||||
method: 'getQueuedEmails';
|
method: 'getAllEmails';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
status?: TEmailQueueStatus;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
items: IEmailQueueItem[];
|
emails: IEmail[];
|
||||||
total: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Get Sent Emails Request
|
// Get Email Detail Request
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
IReq_GetSentEmails
|
IReq_GetEmailDetail
|
||||||
> {
|
> {
|
||||||
method: 'getSentEmails';
|
method: 'getEmailDetail';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
limit?: number;
|
emailId: string;
|
||||||
offset?: number;
|
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
items: IEmailQueueItem[];
|
email: IEmailDetail | null;
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Get Failed Emails Request
|
|
||||||
// ============================================================================
|
|
||||||
export interface IReq_GetFailedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetFailedEmails
|
|
||||||
> {
|
|
||||||
method: 'getFailedEmails';
|
|
||||||
request: {
|
|
||||||
identity?: authInterfaces.IIdentity;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
items: IEmailQueueItem[];
|
|
||||||
total: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +101,7 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
|||||||
> {
|
> {
|
||||||
method: 'resendEmail';
|
method: 'resendEmail';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
emailId: string;
|
emailId: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -179,61 +110,3 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Get Security Incidents Request
|
|
||||||
// ============================================================================
|
|
||||||
export interface IReq_GetSecurityIncidents extends plugins.typedrequestInterfaces.implementsTR<
|
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetSecurityIncidents
|
|
||||||
> {
|
|
||||||
method: 'getSecurityIncidents';
|
|
||||||
request: {
|
|
||||||
identity?: authInterfaces.IIdentity;
|
|
||||||
type?: TSecurityEventType;
|
|
||||||
level?: TSecurityLogLevel;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
incidents: ISecurityIncident[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Get Bounce Records Request
|
|
||||||
// ============================================================================
|
|
||||||
export interface IReq_GetBounceRecords extends plugins.typedrequestInterfaces.implementsTR<
|
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetBounceRecords
|
|
||||||
> {
|
|
||||||
method: 'getBounceRecords';
|
|
||||||
request: {
|
|
||||||
identity?: authInterfaces.IIdentity;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
records: IBounceRecord[];
|
|
||||||
suppressionList: string[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Remove from Suppression List Request
|
|
||||||
// ============================================================================
|
|
||||||
export interface IReq_RemoveFromSuppressionList extends plugins.typedrequestInterfaces.implementsTR<
|
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
|
||||||
IReq_RemoveFromSuppressionList
|
|
||||||
> {
|
|
||||||
method: 'removeFromSuppressionList';
|
|
||||||
request: {
|
|
||||||
identity?: authInterfaces.IIdentity;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ export * from './combined.stats.js';
|
|||||||
export * from './radius.js';
|
export * from './radius.js';
|
||||||
export * from './email-ops.js';
|
export * from './email-ops.js';
|
||||||
export * from './certificate.js';
|
export * from './certificate.js';
|
||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
|
export * from './route-management.js';
|
||||||
|
export * from './api-tokens.js';
|
||||||
@@ -9,7 +9,7 @@ export interface IReq_GetRecentLogs extends plugins.typedrequestInterfaces.imple
|
|||||||
> {
|
> {
|
||||||
method: 'getRecentLogs';
|
method: 'getRecentLogs';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -31,7 +31,7 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
|
|||||||
> {
|
> {
|
||||||
method: 'getLogStream';
|
method: 'getLogStream';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
follow?: boolean;
|
follow?: boolean;
|
||||||
filters?: {
|
filters?: {
|
||||||
level?: string[];
|
level?: string[];
|
||||||
@@ -41,4 +41,16 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
|
|||||||
response: {
|
response: {
|
||||||
logStream: plugins.typedrequestInterfaces.IVirtualStream;
|
logStream: plugins.typedrequestInterfaces.IVirtualStream;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push Log Entry (server → client via TypedSocket)
|
||||||
|
export interface IReq_PushLogEntry extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_PushLogEntry
|
||||||
|
> {
|
||||||
|
method: 'pushLogEntry';
|
||||||
|
request: {
|
||||||
|
entry: statsInterfaces.ILogEntry;
|
||||||
|
};
|
||||||
|
response: {};
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusClients';
|
method: 'getRadiusClients';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
clients: Array<{
|
clients: Array<{
|
||||||
@@ -35,7 +35,7 @@ export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.imp
|
|||||||
> {
|
> {
|
||||||
method: 'setRadiusClient';
|
method: 'setRadiusClient';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
client: {
|
client: {
|
||||||
name: string;
|
name: string;
|
||||||
ipRange: string;
|
ipRange: string;
|
||||||
@@ -59,7 +59,7 @@ export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'removeRadiusClient';
|
method: 'removeRadiusClient';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -81,7 +81,7 @@ export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.imp
|
|||||||
> {
|
> {
|
||||||
method: 'getVlanMappings';
|
method: 'getVlanMappings';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
mappings: Array<{
|
mappings: Array<{
|
||||||
@@ -108,7 +108,7 @@ export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'setVlanMapping';
|
method: 'setVlanMapping';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
mapping: {
|
mapping: {
|
||||||
mac: string;
|
mac: string;
|
||||||
vlan: number;
|
vlan: number;
|
||||||
@@ -139,7 +139,7 @@ export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'removeVlanMapping';
|
method: 'removeVlanMapping';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
mac: string;
|
mac: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -157,7 +157,7 @@ export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'updateVlanConfig';
|
method: 'updateVlanConfig';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
defaultVlan?: number;
|
defaultVlan?: number;
|
||||||
allowUnknownMacs?: boolean;
|
allowUnknownMacs?: boolean;
|
||||||
};
|
};
|
||||||
@@ -179,7 +179,7 @@ export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'testVlanAssignment';
|
method: 'testVlanAssignment';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
mac: string;
|
mac: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -207,7 +207,7 @@ export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusSessions';
|
method: 'getRadiusSessions';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
filter?: {
|
filter?: {
|
||||||
username?: string;
|
username?: string;
|
||||||
nasIpAddress?: string;
|
nasIpAddress?: string;
|
||||||
@@ -243,7 +243,7 @@ export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterf
|
|||||||
> {
|
> {
|
||||||
method: 'disconnectRadiusSession';
|
method: 'disconnectRadiusSession';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
@@ -262,7 +262,7 @@ export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInt
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusAccountingSummary';
|
method: 'getRadiusAccountingSummary';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
};
|
};
|
||||||
@@ -296,7 +296,7 @@ export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusStatistics';
|
method: 'getRadiusStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'createRemoteIngress';
|
method: 'createRemoteIngress';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
name: string;
|
name: string;
|
||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
autoDerivePorts?: boolean;
|
autoDerivePorts?: boolean;
|
||||||
@@ -36,7 +36,7 @@ export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'deleteRemoteIngress';
|
method: 'deleteRemoteIngress';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -54,7 +54,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'updateRemoteIngress';
|
method: 'updateRemoteIngress';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
@@ -77,7 +77,7 @@ export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequest
|
|||||||
> {
|
> {
|
||||||
method: 'regenerateRemoteIngressSecret';
|
method: 'regenerateRemoteIngressSecret';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -95,7 +95,7 @@ export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getRemoteIngresses';
|
method: 'getRemoteIngresses';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
edges: IRemoteIngress[];
|
edges: IRemoteIngress[];
|
||||||
@@ -111,7 +111,7 @@ export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfa
|
|||||||
> {
|
> {
|
||||||
method: 'getRemoteIngressStatus';
|
method: 'getRemoteIngressStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
statuses: IRemoteIngressStatus[];
|
statuses: IRemoteIngressStatus[];
|
||||||
@@ -128,7 +128,7 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
|
|||||||
> {
|
> {
|
||||||
method: 'getRemoteIngressConnectionToken';
|
method: 'getRemoteIngressConnectionToken';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
edgeId: string;
|
edgeId: string;
|
||||||
hubHost?: string;
|
hubHost?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
146
ts_interfaces/requests/route-management.ts
Normal file
146
ts_interfaces/requests/route-management.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js';
|
||||||
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Route Management Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all merged routes (hardcoded + programmatic) with warnings.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetMergedRoutes
|
||||||
|
> {
|
||||||
|
method: 'getMergedRoutes';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
routes: IMergedRoute[];
|
||||||
|
warnings: IRouteWarning[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new programmatic route.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateRoute
|
||||||
|
> {
|
||||||
|
method: 'createRoute';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
route: IRouteConfig;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
storedRouteId?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a programmatic route.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateRoute
|
||||||
|
> {
|
||||||
|
method: 'updateRoute';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
route?: Partial<IRouteConfig>;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a programmatic route.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteRoute
|
||||||
|
> {
|
||||||
|
method: 'deleteRoute';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an override on a hardcoded route (disable/enable by name).
|
||||||
|
*/
|
||||||
|
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_SetRouteOverride
|
||||||
|
> {
|
||||||
|
method: 'setRouteOverride';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
routeName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an override from a hardcoded route (restore default behavior).
|
||||||
|
*/
|
||||||
|
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RemoveRouteOverride
|
||||||
|
> {
|
||||||
|
method: 'removeRouteOverride';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
routeName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a programmatic route on/off by id.
|
||||||
|
*/
|
||||||
|
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ToggleRoute
|
||||||
|
> {
|
||||||
|
method: 'toggleRoute';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export interface IReq_GetServerStatistics extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'getServerStatistics';
|
method: 'getServerStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
includeHistory?: boolean;
|
includeHistory?: boolean;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
};
|
};
|
||||||
@@ -29,7 +29,7 @@ export interface IReq_GetEmailStatistics extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getEmailStatistics';
|
method: 'getEmailStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
includeDetails?: boolean;
|
includeDetails?: boolean;
|
||||||
@@ -49,7 +49,7 @@ export interface IReq_GetDnsStatistics extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'getDnsStatistics';
|
method: 'getDnsStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
includeQueryTypes?: boolean;
|
includeQueryTypes?: boolean;
|
||||||
@@ -69,7 +69,7 @@ export interface IReq_GetRateLimitStatus extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getRateLimitStatus';
|
method: 'getRateLimitStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
includeBlocked?: boolean;
|
includeBlocked?: boolean;
|
||||||
@@ -91,7 +91,7 @@ export interface IReq_GetSecurityMetrics extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getSecurityMetrics';
|
method: 'getSecurityMetrics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
includeDetails?: boolean;
|
includeDetails?: boolean;
|
||||||
};
|
};
|
||||||
@@ -112,7 +112,7 @@ export interface IReq_GetActiveConnections extends plugins.typedrequestInterface
|
|||||||
> {
|
> {
|
||||||
method: 'getActiveConnections';
|
method: 'getActiveConnections';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
||||||
state?: string;
|
state?: string;
|
||||||
};
|
};
|
||||||
@@ -137,7 +137,7 @@ export interface IReq_GetQueueStatus extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'getQueueStatus';
|
method: 'getQueueStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
queueName?: string;
|
queueName?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -153,10 +153,31 @@ export interface IReq_GetHealthStatus extends plugins.typedrequestInterfaces.imp
|
|||||||
> {
|
> {
|
||||||
method: 'getHealthStatus';
|
method: 'getHealthStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
detailed?: boolean;
|
detailed?: boolean;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
health: statsInterfaces.IHealthStatus;
|
health: statsInterfaces.IHealthStatus;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network Stats (raw SmartProxy network data)
|
||||||
|
export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkStats
|
||||||
|
> {
|
||||||
|
method: 'getNetworkStats';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
connectionsByIP: Array<{ ip: string; count: number }>;
|
||||||
|
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
|
||||||
|
topIPs: Array<{ ip: string; count: number }>;
|
||||||
|
totalDataTransferred: { bytesIn: number; bytesOut: number };
|
||||||
|
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
|
||||||
|
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
||||||
|
requestsPerSecond: number;
|
||||||
|
requestsTotal: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
100
ts_oci_container/index.ts
Normal file
100
ts_oci_container/index.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a comma-separated env var into a string array.
|
||||||
|
* Returns undefined if the env var is not set or empty.
|
||||||
|
*/
|
||||||
|
function parseCommaSeparated(envVar: string | undefined): string[] | undefined {
|
||||||
|
if (!envVar || envVar.trim() === '') return undefined;
|
||||||
|
return envVar.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a comma-separated env var into a number array.
|
||||||
|
* Returns undefined if the env var is not set or empty.
|
||||||
|
*/
|
||||||
|
function parseCommaSeparatedNumbers(envVar: string | undefined): number[] | undefined {
|
||||||
|
const parts = parseCommaSeparated(envVar);
|
||||||
|
if (!parts) return undefined;
|
||||||
|
return parts.map((s) => parseInt(s, 10)).filter((n) => !isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds IDcRouterOptions from environment variables for OCI container mode.
|
||||||
|
*
|
||||||
|
* If DCROUTER_CONFIG_PATH is set and the file exists, it is loaded as a JSON base config.
|
||||||
|
* Individual env vars are then applied as overrides on top.
|
||||||
|
*/
|
||||||
|
export function getOciContainerConfig(): IDcRouterOptions {
|
||||||
|
let options: IDcRouterOptions = {};
|
||||||
|
|
||||||
|
// Load JSON config file if specified
|
||||||
|
const configPath = process.env.DCROUTER_CONFIG_PATH;
|
||||||
|
if (configPath && plugins.fs.existsSync(configPath)) {
|
||||||
|
const raw = plugins.fs.readFileSync(configPath, 'utf8');
|
||||||
|
options = JSON.parse(raw);
|
||||||
|
console.log(`[OCI Container] Loaded config from ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply env var overrides
|
||||||
|
if (process.env.DCROUTER_BASE_DIR) {
|
||||||
|
options.baseDir = process.env.DCROUTER_BASE_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS config
|
||||||
|
const tlsEmail = process.env.DCROUTER_TLS_EMAIL;
|
||||||
|
const tlsDomain = process.env.DCROUTER_TLS_DOMAIN;
|
||||||
|
if (tlsEmail || tlsDomain) {
|
||||||
|
options.tls = {
|
||||||
|
...options.tls,
|
||||||
|
contactEmail: tlsEmail || options.tls?.contactEmail || '',
|
||||||
|
...(tlsDomain ? { domain: tlsDomain } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network config
|
||||||
|
if (process.env.DCROUTER_PUBLIC_IP) {
|
||||||
|
options.publicIp = process.env.DCROUTER_PUBLIC_IP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyIps = parseCommaSeparated(process.env.DCROUTER_PROXY_IPS);
|
||||||
|
if (proxyIps) {
|
||||||
|
options.proxyIps = proxyIps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS config
|
||||||
|
const nsDomains = parseCommaSeparated(process.env.DCROUTER_DNS_NS_DOMAINS);
|
||||||
|
if (nsDomains) {
|
||||||
|
options.dnsNsDomains = nsDomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dnsScopes = parseCommaSeparated(process.env.DCROUTER_DNS_SCOPES);
|
||||||
|
if (dnsScopes) {
|
||||||
|
options.dnsScopes = dnsScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email config
|
||||||
|
const emailHostname = process.env.DCROUTER_EMAIL_HOSTNAME;
|
||||||
|
const emailPorts = parseCommaSeparatedNumbers(process.env.DCROUTER_EMAIL_PORTS);
|
||||||
|
if (emailHostname || emailPorts) {
|
||||||
|
options.emailConfig = {
|
||||||
|
...options.emailConfig,
|
||||||
|
...(emailHostname ? { hostname: emailHostname } : {}),
|
||||||
|
...(emailPorts ? { ports: emailPorts } : {}),
|
||||||
|
domains: options.emailConfig?.domains || [],
|
||||||
|
routes: options.emailConfig?.routes || [],
|
||||||
|
} as IDcRouterOptions['emailConfig'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache config
|
||||||
|
const cacheEnabled = process.env.DCROUTER_CACHE_ENABLED;
|
||||||
|
if (cacheEnabled !== undefined) {
|
||||||
|
options.cacheConfig = {
|
||||||
|
...options.cacheConfig,
|
||||||
|
enabled: cacheEnabled === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
7
ts_oci_container/plugins.ts
Normal file
7
ts_oci_container/plugins.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export {
|
||||||
|
fs,
|
||||||
|
path,
|
||||||
|
};
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '6.13.2',
|
version: '11.8.4',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface IStatsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IConfigState {
|
export interface IConfigState {
|
||||||
config: any | null;
|
config: interfaces.requests.IConfigData | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -67,14 +67,7 @@ export interface ICertificateState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IEmailOpsState {
|
export interface IEmailOpsState {
|
||||||
currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
emails: interfaces.requests.IEmail[];
|
||||||
queuedEmails: interfaces.requests.IEmailQueueItem[];
|
|
||||||
sentEmails: interfaces.requests.IEmailQueueItem[];
|
|
||||||
failedEmails: interfaces.requests.IEmailQueueItem[];
|
|
||||||
securityIncidents: interfaces.requests.ISecurityIncident[];
|
|
||||||
bounceRecords: interfaces.requests.IBounceRecord[];
|
|
||||||
suppressionList: string[];
|
|
||||||
selectedEmailId: string | null;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
@@ -116,7 +109,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
|||||||
// Determine initial view from URL path
|
// Determine initial view from URL path
|
||||||
const getInitialView = (): string => {
|
const getInitialView = (): string => {
|
||||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||||
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'];
|
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'];
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
return validViews.includes(view) ? view : 'overview';
|
return validViews.includes(view) ? view : 'overview';
|
||||||
@@ -165,14 +158,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
|||||||
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
||||||
'emailOps',
|
'emailOps',
|
||||||
{
|
{
|
||||||
currentView: 'queued',
|
emails: [],
|
||||||
queuedEmails: [],
|
|
||||||
sentEmails: [],
|
|
||||||
failedEmails: [],
|
|
||||||
securityIncidents: [],
|
|
||||||
bounceRecords: [],
|
|
||||||
suppressionList: [],
|
|
||||||
selectedEmailId: null,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
@@ -220,15 +206,44 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
|
|||||||
'soft'
|
'soft'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Route Management State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IRouteManagementState {
|
||||||
|
mergedRoutes: interfaces.data.IMergedRoute[];
|
||||||
|
warnings: interfaces.data.IRouteWarning[];
|
||||||
|
apiTokens: interfaces.data.IApiTokenInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeManagementStatePart = await appState.getStatePart<IRouteManagementState>(
|
||||||
|
'routeManagement',
|
||||||
|
{
|
||||||
|
mergedRoutes: [],
|
||||||
|
warnings: [],
|
||||||
|
apiTokens: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft'
|
||||||
|
);
|
||||||
|
|
||||||
// Actions for state management
|
// Actions for state management
|
||||||
interface IActionContext {
|
interface IActionContext {
|
||||||
identity: interfaces.data.IIdentity | null;
|
identity: interfaces.data.IIdentity | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getActionContext = (): IActionContext => {
|
const getActionContext = (): IActionContext => {
|
||||||
return {
|
const identity = loginStatePart.getState().identity;
|
||||||
identity: loginStatePart.getState().identity,
|
// Treat expired JWTs as no identity — prevents stale persisted sessions from firing requests
|
||||||
};
|
if (identity && identity.expiresAt && identity.expiresAt < Date.now()) {
|
||||||
|
return { identity: null };
|
||||||
|
}
|
||||||
|
return { identity };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Login Action
|
// Login Action
|
||||||
@@ -259,24 +274,23 @@ export const loginAction = loginStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout Action
|
// Logout Action — always clears state, even if identity is expired/missing
|
||||||
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
if (!context.identity) return statePartArg.getState();
|
|
||||||
|
|
||||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
// Try to notify server, but don't block logout if identity is missing/expired
|
||||||
interfaces.requests.IReq_AdminLogout
|
if (context.identity) {
|
||||||
>('/typedrequest', 'adminLogout');
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AdminLogout
|
||||||
try {
|
>('/typedrequest', 'adminLogout');
|
||||||
await typedRequest.fire({
|
try {
|
||||||
identity: context.identity,
|
await typedRequest.fire({ identity: context.identity });
|
||||||
});
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Logout error:', error);
|
||||||
console.error('Logout error:', error);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear login state regardless
|
// Always clear login state
|
||||||
return {
|
return {
|
||||||
identity: null,
|
identity: null,
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
@@ -286,8 +300,8 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
|
|||||||
// Fetch All Stats Action - Using combined endpoint for efficiency
|
// Fetch All Stats Action - Using combined endpoint for efficiency
|
||||||
export const fetchAllStatsAction = statsStatePart.createAction(async (statePartArg) => {
|
export const fetchAllStatsAction = statsStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use combined metrics endpoint - single request instead of 4
|
// Use combined metrics endpoint - single request instead of 4
|
||||||
@@ -328,8 +342,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
|
|||||||
// Fetch Configuration Action (read-only)
|
// Fetch Configuration Action (read-only)
|
||||||
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
|
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -361,6 +375,7 @@ export const fetchRecentLogsAction = logStatePart.createAction<{
|
|||||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
if (!context.identity) return statePartArg.getState();
|
||||||
|
|
||||||
const logsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const logsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_GetRecentLogs
|
interfaces.requests.IReq_GetRecentLogs
|
||||||
@@ -406,6 +421,20 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If switching to routes view, ensure we fetch route data
|
||||||
|
if (viewName === 'routes' && currentState.activeView !== 'routes') {
|
||||||
|
setTimeout(() => {
|
||||||
|
routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If switching to apitokens view, ensure we fetch token data
|
||||||
|
if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') {
|
||||||
|
setTimeout(() => {
|
||||||
|
routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
// If switching to remoteingress view, ensure we fetch edge data
|
// If switching to remoteingress view, ensure we fetch edge data
|
||||||
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
|
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -422,8 +451,8 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
// Fetch Network Stats Action
|
// Fetch Network Stats Action
|
||||||
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg) => {
|
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch active connections using the existing endpoint
|
// Fetch active connections using the existing endpoint
|
||||||
@@ -492,35 +521,23 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
// Email Operations Actions
|
// Email Operations Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Set Email Ops View Action
|
// Fetch All Emails Action
|
||||||
export const setEmailOpsViewAction = emailOpsStatePart.createAction<IEmailOpsState['currentView']>(
|
export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||||
async (statePartArg, view) => {
|
|
||||||
return {
|
|
||||||
...statePartArg.getState(),
|
|
||||||
currentView: view,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch Queued Emails Action
|
|
||||||
export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_GetQueuedEmails
|
interfaces.requests.IReq_GetAllEmails
|
||||||
>('/typedrequest', 'getQueuedEmails');
|
>('/typedrequest', 'getAllEmails');
|
||||||
|
|
||||||
const response = await request.fire({
|
const response = await request.fire({
|
||||||
identity: context.identity,
|
identity: context.identity,
|
||||||
status: 'pending',
|
|
||||||
limit: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...currentState,
|
emails: response.emails,
|
||||||
queuedEmails: response.items,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
@@ -529,197 +546,11 @@ export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (sta
|
|||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch queued emails',
|
error: error instanceof Error ? error.message : 'Failed to fetch emails',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch Sent Emails Action
|
|
||||||
export const fetchSentEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
|
||||||
const context = getActionContext();
|
|
||||||
const currentState = statePartArg.getState();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
||||||
interfaces.requests.IReq_GetSentEmails
|
|
||||||
>('/typedrequest', 'getSentEmails');
|
|
||||||
|
|
||||||
const response = await request.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
sentEmails: response.items,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
isLoading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch sent emails',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch Failed Emails Action
|
|
||||||
export const fetchFailedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
|
||||||
const context = getActionContext();
|
|
||||||
const currentState = statePartArg.getState();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
||||||
interfaces.requests.IReq_GetFailedEmails
|
|
||||||
>('/typedrequest', 'getFailedEmails');
|
|
||||||
|
|
||||||
const response = await request.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
failedEmails: response.items,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
isLoading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch failed emails',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch Security Incidents Action
|
|
||||||
export const fetchSecurityIncidentsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
|
||||||
const context = getActionContext();
|
|
||||||
const currentState = statePartArg.getState();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
||||||
interfaces.requests.IReq_GetSecurityIncidents
|
|
||||||
>('/typedrequest', 'getSecurityIncidents');
|
|
||||||
|
|
||||||
const response = await request.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
securityIncidents: response.incidents,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
isLoading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch security incidents',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch Bounce Records Action
|
|
||||||
export const fetchBounceRecordsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
|
||||||
const context = getActionContext();
|
|
||||||
const currentState = statePartArg.getState();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
||||||
interfaces.requests.IReq_GetBounceRecords
|
|
||||||
>('/typedrequest', 'getBounceRecords');
|
|
||||||
|
|
||||||
const response = await request.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
bounceRecords: response.records,
|
|
||||||
suppressionList: response.suppressionList,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
isLoading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch bounce records',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resend Failed Email Action
|
|
||||||
export const resendEmailAction = emailOpsStatePart.createAction<string>(async (statePartArg, emailId) => {
|
|
||||||
const context = getActionContext();
|
|
||||||
const currentState = statePartArg.getState();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
||||||
interfaces.requests.IReq_ResendEmail
|
|
||||||
>('/typedrequest', 'resendEmail');
|
|
||||||
|
|
||||||
const response = await request.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
emailId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
// Refresh failed emails list
|
|
||||||
await emailOpsStatePart.dispatchAction(fetchFailedEmailsAction, null);
|
|
||||||
await emailOpsStatePart.dispatchAction(fetchQueuedEmailsAction, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to resend email',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove from Suppression List Action
|
|
||||||
export const removeFromSuppressionListAction = emailOpsStatePart.createAction<string>(
|
|
||||||
async (statePartArg, email) => {
|
|
||||||
const context = getActionContext();
|
|
||||||
const currentState = statePartArg.getState();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
||||||
interfaces.requests.IReq_RemoveFromSuppressionList
|
|
||||||
>('/typedrequest', 'removeFromSuppressionList');
|
|
||||||
|
|
||||||
const response = await request.fire({
|
|
||||||
identity: context.identity,
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
// Refresh bounce records
|
|
||||||
await emailOpsStatePart.dispatchAction(fetchBounceRecordsAction, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Certificate Actions
|
// Certificate Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -727,6 +558,7 @@ export const removeFromSuppressionListAction = emailOpsStatePart.createAction<st
|
|||||||
export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => {
|
export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -754,7 +586,7 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
||||||
async (statePartArg, domain) => {
|
async (statePartArg, domain, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -769,8 +601,7 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch overview after reprovisioning
|
// Re-fetch overview after reprovisioning
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
return await actionContext.dispatch(fetchCertificateOverviewAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -781,7 +612,7 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const deleteCertificateAction = certificateStatePart.createAction<string>(
|
export const deleteCertificateAction = certificateStatePart.createAction<string>(
|
||||||
async (statePartArg, domain) => {
|
async (statePartArg, domain, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -796,8 +627,7 @@ export const deleteCertificateAction = certificateStatePart.createAction<string>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch overview after deletion
|
// Re-fetch overview after deletion
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
return await actionContext.dispatch(fetchCertificateOverviewAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -816,7 +646,7 @@ export const importCertificateAction = certificateStatePart.createAction<{
|
|||||||
publicKey: string;
|
publicKey: string;
|
||||||
csr: string;
|
csr: string;
|
||||||
}>(
|
}>(
|
||||||
async (statePartArg, cert) => {
|
async (statePartArg, cert, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -831,8 +661,7 @@ export const importCertificateAction = certificateStatePart.createAction<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch overview after import
|
// Re-fetch overview after import
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
return await actionContext.dispatch(fetchCertificateOverviewAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -873,6 +702,7 @@ export async function fetchConnectionToken(edgeId: string) {
|
|||||||
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
|
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -910,7 +740,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
autoDerivePorts?: boolean;
|
autoDerivePorts?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -929,7 +759,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// Refresh the list
|
// Refresh the list
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...statePartArg.getState(),
|
...statePartArg.getState(),
|
||||||
@@ -947,7 +777,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
|
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
|
||||||
async (statePartArg, edgeId) => {
|
async (statePartArg, edgeId, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -961,8 +791,7 @@ export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<str
|
|||||||
id: edgeId,
|
id: edgeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
return await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -978,7 +807,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
autoDerivePorts?: boolean;
|
autoDerivePorts?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -996,8 +825,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
tags: dataArg.tags,
|
tags: dataArg.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
return await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -1050,7 +878,7 @@ export const clearNewEdgeIdAction = remoteIngressStatePart.createAction(
|
|||||||
export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -1065,8 +893,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
return await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -1075,11 +902,335 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Route Management Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetMergedRoutes
|
||||||
|
>('/typedrequest', 'getMergedRoutes');
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
mergedRoutes: response.routes,
|
||||||
|
warnings: response.warnings,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch routes',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createRouteAction = routeManagementStatePart.createAction<{
|
||||||
|
route: any;
|
||||||
|
enabled?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateRoute
|
||||||
|
>('/typedrequest', 'createRoute');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
route: dataArg.route,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create route',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
||||||
|
async (statePartArg, routeId, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteRoute
|
||||||
|
>('/typedrequest', 'deleteRoute');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id: routeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete route',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const toggleRouteAction = routeManagementStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ToggleRoute
|
||||||
|
>('/typedrequest', 'toggleRoute');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id: dataArg.id,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to toggle route',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
|
||||||
|
routeName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_SetRouteOverride
|
||||||
|
>('/typedrequest', 'setRouteOverride');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
routeName: dataArg.routeName,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to set override',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
|
||||||
|
async (statePartArg, routeName, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_RemoveRouteOverride
|
||||||
|
>('/typedrequest', 'removeRouteOverride');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
routeName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to remove override',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Token Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListApiTokens
|
||||||
|
>('/typedrequest', 'listApiTokens');
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
apiTokens: response.tokens,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch tokens',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
|
||||||
|
const context = getActionContext();
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateApiToken
|
||||||
|
>('/typedrequest', 'createApiToken');
|
||||||
|
|
||||||
|
return request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
name,
|
||||||
|
scopes,
|
||||||
|
expiresInDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollApiToken(id: string) {
|
||||||
|
const context = getActionContext();
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_RollApiToken
|
||||||
|
>('/typedrequest', 'rollApiToken');
|
||||||
|
|
||||||
|
return request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
|
||||||
|
async (statePartArg, tokenId, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_RevokeApiToken
|
||||||
|
>('/typedrequest', 'revokeApiToken');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id: tokenId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext.dispatch(fetchApiTokensAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to revoke token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ToggleApiToken
|
||||||
|
>('/typedrequest', 'toggleApiToken');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id: dataArg.id,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext.dispatch(fetchApiTokensAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to toggle token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TypedSocket Client for Real-time Log Streaming
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let socketClient: plugins.typedsocket.TypedSocket | null = null;
|
||||||
|
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// Register handler for pushed log entries from the server
|
||||||
|
socketRouter.addTypedHandler(
|
||||||
|
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>(
|
||||||
|
'pushLogEntry',
|
||||||
|
async (dataArg) => {
|
||||||
|
const current = logStatePart.getState();
|
||||||
|
const updated = [...current.recentLogs, dataArg.entry];
|
||||||
|
// Cap at 2000 entries
|
||||||
|
if (updated.length > 2000) {
|
||||||
|
updated.splice(0, updated.length - 2000);
|
||||||
|
}
|
||||||
|
logStatePart.setState({ ...current, recentLogs: updated });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function connectSocket() {
|
||||||
|
if (socketClient) return;
|
||||||
|
try {
|
||||||
|
socketClient = await plugins.typedsocket.TypedSocket.createClient(
|
||||||
|
socketRouter,
|
||||||
|
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
|
||||||
|
);
|
||||||
|
await socketClient.setTag('role', 'ops_dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('TypedSocket connection failed:', err);
|
||||||
|
socketClient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectSocket() {
|
||||||
|
if (socketClient) {
|
||||||
|
try {
|
||||||
|
await socketClient.stop();
|
||||||
|
} catch {
|
||||||
|
// ignore disconnect errors
|
||||||
|
}
|
||||||
|
socketClient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Combined refresh action for efficient polling
|
// Combined refresh action for efficient polling
|
||||||
async function dispatchCombinedRefreshAction() {
|
async function dispatchCombinedRefreshAction() {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
if (!context.identity) return;
|
||||||
const currentView = uiStatePart.getState().activeView;
|
const currentView = uiStatePart.getState().activeView;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Always fetch basic stats for dashboard widgets
|
// Always fetch basic stats for dashboard widgets
|
||||||
const combinedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const combinedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -1178,8 +1329,23 @@ async function dispatchCombinedRefreshAction() {
|
|||||||
console.error('Certificate refresh failed:', error);
|
console.error('Certificate refresh failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh remote ingress data if on remoteingress view
|
||||||
|
if (currentView === 'remoteingress') {
|
||||||
|
try {
|
||||||
|
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Remote ingress refresh failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Combined refresh failed:', error);
|
console.error('Combined refresh failed:', error);
|
||||||
|
// If the error looks like an auth failure (invalid JWT), force re-login
|
||||||
|
const errMsg = String(error);
|
||||||
|
if (errMsg.includes('invalid') || errMsg.includes('unauthorized') || errMsg.includes('401')) {
|
||||||
|
await loginStatePart.dispatchAction(logoutAction, null);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,9 +1403,21 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
|
|||||||
if (state.isLoggedIn !== previousIsLoggedIn) {
|
if (state.isLoggedIn !== previousIsLoggedIn) {
|
||||||
previousIsLoggedIn = state.isLoggedIn;
|
previousIsLoggedIn = state.isLoggedIn;
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|
||||||
|
// Connect/disconnect TypedSocket based on login state
|
||||||
|
if (state.isLoggedIn) {
|
||||||
|
connectSocket();
|
||||||
|
} else {
|
||||||
|
disconnectSocket();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial start
|
// Initial start
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|
||||||
|
// Connect TypedSocket if already logged in (e.g., persistent session)
|
||||||
|
if (loginStatePart.getState().isLoggedIn) {
|
||||||
|
connectSocket();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
@@ -4,6 +4,8 @@ export * from './ops-view-network.js';
|
|||||||
export * from './ops-view-emails.js';
|
export * from './ops-view-emails.js';
|
||||||
export * from './ops-view-logs.js';
|
export * from './ops-view-logs.js';
|
||||||
export * from './ops-view-config.js';
|
export * from './ops-view-config.js';
|
||||||
|
export * from './ops-view-routes.js';
|
||||||
|
export * from './ops-view-apitokens.js';
|
||||||
export * from './ops-view-security.js';
|
export * from './ops-view-security.js';
|
||||||
export * from './ops-view-certificates.js';
|
export * from './ops-view-certificates.js';
|
||||||
export * from './ops-view-remoteingress.js';
|
export * from './ops-view-remoteingress.js';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
import { appRouter } from '../router.js';
|
import { appRouter } from '../router.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,8 @@ import { OpsViewNetwork } from './ops-view-network.js';
|
|||||||
import { OpsViewEmails } from './ops-view-emails.js';
|
import { OpsViewEmails } from './ops-view-emails.js';
|
||||||
import { OpsViewLogs } from './ops-view-logs.js';
|
import { OpsViewLogs } from './ops-view-logs.js';
|
||||||
import { OpsViewConfig } from './ops-view-config.js';
|
import { OpsViewConfig } from './ops-view-config.js';
|
||||||
|
import { OpsViewRoutes } from './ops-view-routes.js';
|
||||||
|
import { OpsViewApiTokens } from './ops-view-apitokens.js';
|
||||||
import { OpsViewSecurity } from './ops-view-security.js';
|
import { OpsViewSecurity } from './ops-view-security.js';
|
||||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||||
@@ -41,34 +44,52 @@ export class OpsDashboard extends DeesElement {
|
|||||||
private viewTabs = [
|
private viewTabs = [
|
||||||
{
|
{
|
||||||
name: 'Overview',
|
name: 'Overview',
|
||||||
|
iconName: 'lucide:layoutDashboard',
|
||||||
element: OpsViewOverview,
|
element: OpsViewOverview,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Configuration',
|
||||||
|
iconName: 'lucide:settings',
|
||||||
|
element: OpsViewConfig,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Network',
|
name: 'Network',
|
||||||
|
iconName: 'lucide:network',
|
||||||
element: OpsViewNetwork,
|
element: OpsViewNetwork,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Emails',
|
name: 'Emails',
|
||||||
|
iconName: 'lucide:mail',
|
||||||
element: OpsViewEmails,
|
element: OpsViewEmails,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Logs',
|
name: 'Logs',
|
||||||
|
iconName: 'lucide:scrollText',
|
||||||
element: OpsViewLogs,
|
element: OpsViewLogs,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Configuration',
|
name: 'Routes',
|
||||||
element: OpsViewConfig,
|
iconName: 'lucide:route',
|
||||||
|
element: OpsViewRoutes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ApiTokens',
|
||||||
|
iconName: 'lucide:key',
|
||||||
|
element: OpsViewApiTokens,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Security',
|
name: 'Security',
|
||||||
|
iconName: 'lucide:shield',
|
||||||
element: OpsViewSecurity,
|
element: OpsViewSecurity,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Certificates',
|
name: 'Certificates',
|
||||||
|
iconName: 'lucide:badgeCheck',
|
||||||
element: OpsViewCertificates,
|
element: OpsViewCertificates,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'RemoteIngress',
|
name: 'RemoteIngress',
|
||||||
|
iconName: 'lucide:globe',
|
||||||
element: OpsViewRemoteIngress,
|
element: OpsViewRemoteIngress,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -198,13 +219,27 @@ export class OpsDashboard extends DeesElement {
|
|||||||
// Handle initial state - check if we have a stored session that's still valid
|
// Handle initial state - check if we have a stored session that's still valid
|
||||||
const loginState = appstate.loginStatePart.getState();
|
const loginState = appstate.loginStatePart.getState();
|
||||||
if (loginState.identity?.jwt) {
|
if (loginState.identity?.jwt) {
|
||||||
// Verify JWT hasn't expired
|
|
||||||
if (loginState.identity.expiresAt > Date.now()) {
|
if (loginState.identity.expiresAt > Date.now()) {
|
||||||
// JWT still valid, restore logged-in state
|
// Client-side expiry looks valid — verify with server (keypair may have changed)
|
||||||
this.loginState = loginState;
|
try {
|
||||||
await simpleLogin.switchToSlottedContent();
|
const verifyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
interfaces.requests.IReq_VerifyIdentity
|
||||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
>('/typedrequest', 'verifyIdentity');
|
||||||
|
const response = await verifyRequest.fire({ identity: loginState.identity });
|
||||||
|
if (response.valid) {
|
||||||
|
// JWT confirmed valid by server
|
||||||
|
this.loginState = loginState;
|
||||||
|
await simpleLogin.switchToSlottedContent();
|
||||||
|
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||||
|
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||||
|
} else {
|
||||||
|
// Server rejected the JWT — clear state, show login
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server unreachable or error — clear state, show login
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// JWT expired, clear the stored state
|
// JWT expired, clear the stored state
|
||||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
|||||||
348
ts_web/elements/ops-view-apitokens.ts
Normal file
348
ts_web/elements/ops-view-apitokens.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from './shared/css.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
type TApiTokenScope = interfaces.data.TApiTokenScope;
|
||||||
|
|
||||||
|
@customElement('ops-view-apitokens')
|
||||||
|
export class OpsViewApiTokens extends DeesElement {
|
||||||
|
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||||
|
mergedRoutes: [],
|
||||||
|
warnings: [],
|
||||||
|
apiTokens: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.routeManagementStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((routeState) => {
|
||||||
|
this.routeState = routeState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
|
||||||
|
// Re-fetch tokens when user logs in (fixes race condition where
|
||||||
|
// the view is created before authentication completes)
|
||||||
|
const loginSub = appstate.loginStatePart
|
||||||
|
.select((s) => s.isLoggedIn)
|
||||||
|
.subscribe((isLoggedIn) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(loginSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.apiTokensContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopePill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')};
|
||||||
|
color: ${cssManager.bdTheme('#0369a1', '#0af')};
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.active {
|
||||||
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||||
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.disabled {
|
||||||
|
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.expired {
|
||||||
|
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||||
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const { apiTokens } = this.routeState;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>API Tokens</ops-sectionheading>
|
||||||
|
|
||||||
|
<div class="apiTokensContainer">
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'API Tokens'}
|
||||||
|
.heading2=${'Manage programmatic access tokens'}
|
||||||
|
.data=${apiTokens}
|
||||||
|
.dataName=${'token'}
|
||||||
|
.searchable=${true}
|
||||||
|
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
||||||
|
name: token.name,
|
||||||
|
scopes: this.renderScopePills(token.scopes),
|
||||||
|
status: this.renderStatusBadge(token),
|
||||||
|
created: new Date(token.createdAt).toLocaleDateString(),
|
||||||
|
expires: token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : 'Never',
|
||||||
|
lastUsed: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : 'Never',
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create Token',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header'],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreateTokenDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Enable',
|
||||||
|
iconName: 'lucide:play',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.toggleApiTokenAction,
|
||||||
|
{ id: token.id, enabled: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Disable',
|
||||||
|
iconName: 'lucide:pause',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.toggleApiTokenAction,
|
||||||
|
{ id: token.id, enabled: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Roll',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||||
|
await this.showRollTokenDialog(token);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Revoke',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.revokeApiTokenAction,
|
||||||
|
token.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderScopePills(scopes: TApiTokenScope[]): TemplateResult {
|
||||||
|
return html`<div style="display: flex; flex-wrap: wrap; gap: 2px;">${scopes.map(
|
||||||
|
(s) => html`<span class="scopePill">${s}</span>`,
|
||||||
|
)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult {
|
||||||
|
if (!token.enabled) {
|
||||||
|
return html`<span class="statusBadge disabled">Disabled</span>`;
|
||||||
|
}
|
||||||
|
if (token.expiresAt && token.expiresAt < Date.now()) {
|
||||||
|
return html`<span class="statusBadge expired">Expired</span>`;
|
||||||
|
}
|
||||||
|
return html`<span class="statusBadge active">Active</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateTokenDialog() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
const allScopes: TApiTokenScope[] = [
|
||||||
|
'routes:read',
|
||||||
|
'routes:write',
|
||||||
|
'config:read',
|
||||||
|
'tokens:read',
|
||||||
|
'tokens:manage',
|
||||||
|
];
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Create API Token',
|
||||||
|
content: html`
|
||||||
|
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
|
||||||
|
The token value will be shown once after creation. Copy it immediately.
|
||||||
|
</div>
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Token Name'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-tags
|
||||||
|
.key=${'scopes'}
|
||||||
|
.label=${'Token Scopes'}
|
||||||
|
.value=${['routes:read', 'routes:write']}
|
||||||
|
.suggestions=${allScopes}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-tags>
|
||||||
|
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
iconName: 'lucide:key',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const contentEl = modalArg.shadowRoot?.querySelector('.content');
|
||||||
|
const form = contentEl?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
if (!formData.name) return;
|
||||||
|
|
||||||
|
// dees-input-tags is not in dees-form's FORM_INPUT_TYPES, so collectFormData() won't
|
||||||
|
// include it. Query the tags input directly and call getValue().
|
||||||
|
const tagsInput = form.querySelector('dees-input-tags') as any;
|
||||||
|
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
|
||||||
|
const scopes = rawScopes
|
||||||
|
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
||||||
|
|
||||||
|
const expiresInDays = formData.expiresInDays
|
||||||
|
? parseInt(formData.expiresInDays, 10)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await modalArg.destroy();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays);
|
||||||
|
if (response.success && response.tokenValue) {
|
||||||
|
// Refresh the list first so it's ready when user dismisses the modal
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||||
|
|
||||||
|
// Show the token value in a new modal
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Token Created',
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Copy this token now. It will not be shown again.</p>
|
||||||
|
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
|
||||||
|
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Done',
|
||||||
|
iconName: 'lucide:check',
|
||||||
|
action: async (m: any) => await m.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create token:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Roll Token Secret',
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>This will regenerate the secret for <strong>${token.name}</strong>. The old token value will stop working immediately.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Roll Token',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
try {
|
||||||
|
const response = await appstate.rollApiToken(token.id);
|
||||||
|
if (response.success && response.tokenValue) {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Token Rolled',
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Copy this token now. It will not be shown again.</p>
|
||||||
|
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
|
||||||
|
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Done',
|
||||||
|
iconName: 'lucide:check',
|
||||||
|
action: async (m: any) => await m.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to roll token:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async firstUpdated() {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as shared from './shared/index.js';
|
import * as shared from './shared/index.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
|
import { appRouter } from '../router.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
type TemplateResult,
|
type TemplateResult,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog';
|
||||||
|
|
||||||
@customElement('ops-view-config')
|
@customElement('ops-view-config')
|
||||||
export class OpsViewConfig extends DeesElement {
|
export class OpsViewConfig extends DeesElement {
|
||||||
@state()
|
@state()
|
||||||
@@ -35,165 +38,19 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
shared.viewHostCss,
|
shared.viewHostCss,
|
||||||
css`
|
css`
|
||||||
.configSection {
|
|
||||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHeader {
|
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle dees-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
color: ${cssManager.bdTheme('#666', '#888')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionContent {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.configField {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.configField:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldLabel {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: block;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldValue {
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldValue.empty {
|
|
||||||
color: ${cssManager.bdTheme('#999', '#666')};
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nestedFields {
|
|
||||||
margin-left: 16px;
|
|
||||||
padding-left: 16px;
|
|
||||||
border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badge styles */
|
|
||||||
.statusBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge.enabled {
|
|
||||||
background: ${cssManager.bdTheme('#d4edda', '#1a3d1a')};
|
|
||||||
color: ${cssManager.bdTheme('#155724', '#66cc66')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge.disabled {
|
|
||||||
background: ${cssManager.bdTheme('#f8d7da', '#3d1a1a')};
|
|
||||||
color: ${cssManager.bdTheme('#721c24', '#cc6666')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge dees-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Array/list display */
|
|
||||||
.arrayItems {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrayItem {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
|
|
||||||
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrayCount {
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${cssManager.bdTheme('#999', '#666')};
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Numeric value formatting */
|
|
||||||
.numericValue {
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMessage {
|
|
||||||
background: ${cssManager.bdTheme('#fee', '#4a1f1f')};
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')};
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 16px;
|
|
||||||
color: ${cssManager.bdTheme('#c00', '#ff6666')};
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingMessage {
|
.loadingMessage {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.infoNote {
|
.errorMessage {
|
||||||
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
|
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.1)')};
|
||||||
border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')};
|
border: 1px solid ${cssManager.bdTheme('#fecaca', 'rgba(239,68,68,0.3)')};
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 24px;
|
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||||
color: ${cssManager.bdTheme('#004085', '#88ccff')};
|
margin: 16px 0;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoNote dees-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -202,185 +59,276 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Configuration</ops-sectionheading>
|
<ops-sectionheading>Configuration</ops-sectionheading>
|
||||||
|
|
||||||
${this.configState.isLoading ? html`
|
${this.configState.isLoading
|
||||||
<div class="loadingMessage">
|
? html`
|
||||||
<dees-spinner></dees-spinner>
|
<div class="loadingMessage">
|
||||||
<p>Loading configuration...</p>
|
<dees-spinner></dees-spinner>
|
||||||
</div>
|
<p>Loading configuration...</p>
|
||||||
` : this.configState.error ? html`
|
|
||||||
<div class="errorMessage">
|
|
||||||
Error loading configuration: ${this.configState.error}
|
|
||||||
</div>
|
|
||||||
` : this.configState.config ? html`
|
|
||||||
<div class="infoNote">
|
|
||||||
<dees-icon icon="lucide:info"></dees-icon>
|
|
||||||
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)}
|
|
||||||
${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)}
|
|
||||||
${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)}
|
|
||||||
${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)}
|
|
||||||
` : html`
|
|
||||||
<div class="errorMessage">No configuration loaded</div>
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderConfigSection(key: string, title: string, icon: string, config: any) {
|
|
||||||
const isEnabled = config?.enabled ?? false;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="configSection">
|
|
||||||
<div class="sectionHeader">
|
|
||||||
<h3 class="sectionTitle">
|
|
||||||
<dees-icon icon="${icon}"></dees-icon>
|
|
||||||
${title}
|
|
||||||
</h3>
|
|
||||||
${this.renderStatusBadge(isEnabled)}
|
|
||||||
</div>
|
|
||||||
<div class="sectionContent">
|
|
||||||
${config ? this.renderConfigFields(config) : html`
|
|
||||||
<div class="fieldValue empty">Not configured</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderStatusBadge(enabled: boolean): TemplateResult {
|
|
||||||
return enabled
|
|
||||||
? html`<span class="statusBadge enabled"><dees-icon icon="lucide:check"></dees-icon>Enabled</span>`
|
|
||||||
: html`<span class="statusBadge disabled"><dees-icon icon="lucide:x"></dees-icon>Disabled</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
|
|
||||||
if (!config || typeof config !== 'object') {
|
|
||||||
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(config).map(([key, value]) => {
|
|
||||||
const fieldName = prefix ? `${prefix}.${key}` : key;
|
|
||||||
const displayName = this.formatFieldName(key);
|
|
||||||
|
|
||||||
// Handle boolean values with badges
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return html`
|
|
||||||
<div class="configField">
|
|
||||||
<label class="fieldLabel">${displayName}</label>
|
|
||||||
${this.renderStatusBadge(value)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle arrays
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return html`
|
|
||||||
<div class="configField">
|
|
||||||
<label class="fieldLabel">${displayName}</label>
|
|
||||||
${this.renderArrayValue(value, key)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle nested objects
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
return html`
|
|
||||||
<div class="configField">
|
|
||||||
<label class="fieldLabel">${displayName}</label>
|
|
||||||
<div class="nestedFields">
|
|
||||||
${this.renderConfigFields(value, fieldName)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`
|
||||||
`;
|
: this.configState.error
|
||||||
}
|
? html`
|
||||||
|
<div class="errorMessage">
|
||||||
// Handle primitive values
|
Error loading configuration: ${this.configState.error}
|
||||||
return html`
|
</div>
|
||||||
<div class="configField">
|
`
|
||||||
<label class="fieldLabel">${displayName}</label>
|
: this.configState.config
|
||||||
<div class="fieldValue">${this.formatValue(value, key)}</div>
|
? this.renderConfig()
|
||||||
</div>
|
: html`<div class="errorMessage">No configuration loaded</div>`}
|
||||||
`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult {
|
|
||||||
if (arr.length === 0) {
|
|
||||||
return html`<div class="fieldValue empty">None configured</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we should show as pills/tags
|
|
||||||
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number');
|
|
||||||
|
|
||||||
if (showAsPills) {
|
|
||||||
const itemLabel = this.getArrayItemLabel(fieldKey, arr.length);
|
|
||||||
return html`
|
|
||||||
<div class="arrayCount">${arr.length} ${itemLabel}</div>
|
|
||||||
<div class="arrayItems">
|
|
||||||
${arr.map(item => html`<span class="arrayItem">${item}</span>`)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For complex arrays, show as JSON
|
|
||||||
return html`
|
|
||||||
<div class="fieldValue">
|
|
||||||
${arr.length} items configured
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getArrayItemLabel(fieldKey: string, count: number): string {
|
private renderConfig(): TemplateResult {
|
||||||
const labels: Record<string, [string, string]> = {
|
const cfg = this.configState.config!;
|
||||||
ports: ['port', 'ports'],
|
|
||||||
domains: ['domain', 'domains'],
|
|
||||||
nameservers: ['nameserver', 'nameservers'],
|
|
||||||
blockList: ['IP', 'IPs'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const label = labels[fieldKey] || ['item', 'items'];
|
return html`
|
||||||
return count === 1 ? label[0] : label[1];
|
<sz-config-overview
|
||||||
|
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
|
||||||
|
@navigate=${(e: CustomEvent) => {
|
||||||
|
if (e.detail?.view) {
|
||||||
|
appRouter.navigateToView(e.detail.view);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${this.renderSystemSection(cfg.system)}
|
||||||
|
${this.renderSmartProxySection(cfg.smartProxy)}
|
||||||
|
${this.renderEmailSection(cfg.email)}
|
||||||
|
${this.renderDnsSection(cfg.dns)}
|
||||||
|
${this.renderTlsSection(cfg.tls)}
|
||||||
|
${this.renderCacheSection(cfg.cache)}
|
||||||
|
${this.renderRadiusSection(cfg.radius)}
|
||||||
|
${this.renderRemoteIngressSection(cfg.remoteIngress)}
|
||||||
|
</sz-config-overview>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatFieldName(key: string): string {
|
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
|
||||||
// Convert camelCase to readable format
|
// Annotate proxy IPs with source hint when Remote Ingress is active
|
||||||
return key
|
const ri = this.configState.config?.remoteIngress;
|
||||||
.replace(/([A-Z])/g, ' $1')
|
let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null;
|
||||||
.replace(/^./, str => str.toUpperCase())
|
if (proxyIpValues && ri?.enabled && proxyIpValues.includes('127.0.0.1')) {
|
||||||
.trim();
|
proxyIpValues = proxyIpValues.map(ip =>
|
||||||
}
|
ip === '127.0.0.1' ? '127.0.0.1 (Remote Ingress)' : ip
|
||||||
|
);
|
||||||
private formatValue(value: any, fieldKey?: string): string | TemplateResult {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return html`<span class="empty">Not set</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
const fields: IConfigField[] = [
|
||||||
// Format bytes
|
{ key: 'Base Directory', value: sys.baseDir },
|
||||||
if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) {
|
{ key: 'Data Directory', value: sys.dataDir },
|
||||||
return html`<span class="numericValue">${this.formatBytes(value)}</span>`;
|
{ key: 'Public IP', value: sys.publicIp },
|
||||||
}
|
{ key: 'Proxy IPs', value: proxyIpValues, type: 'pills' },
|
||||||
// Format time values
|
{ key: 'Uptime', value: this.formatUptime(sys.uptime) },
|
||||||
if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) {
|
{ key: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
|
||||||
return html`<span class="numericValue">${value} seconds</span>`;
|
{ key: 'Storage Path', value: sys.storagePath },
|
||||||
}
|
];
|
||||||
// Format port numbers
|
|
||||||
if (fieldKey?.toLowerCase().includes('port')) {
|
|
||||||
return html`<span class="numericValue">${value}</span>`;
|
|
||||||
}
|
|
||||||
// Format counts with separators
|
|
||||||
return html`<span class="numericValue">${value.toLocaleString()}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value);
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="System"
|
||||||
|
subtitle="Base paths and infrastructure"
|
||||||
|
icon="lucide:server"
|
||||||
|
status="enabled"
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatBytes(bytes: number): string {
|
private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult {
|
||||||
if (bytes === 0) return '0 B';
|
const fields: IConfigField[] = [
|
||||||
const k = 1024;
|
{ key: 'Route Count', value: proxy.routeCount },
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
if (proxy.acme) {
|
||||||
|
fields.push(
|
||||||
|
{ key: 'ACME Enabled', value: proxy.acme.enabled, type: 'boolean' },
|
||||||
|
{ key: 'Account Email', value: proxy.acme.accountEmail || null },
|
||||||
|
{ key: 'Use Production', value: proxy.acme.useProduction, type: 'boolean' },
|
||||||
|
{ key: 'Auto Renew', value: proxy.acme.autoRenew, type: 'boolean' },
|
||||||
|
{ key: 'Renew Threshold', value: `${proxy.acme.renewThresholdDays} days` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="SmartProxy"
|
||||||
|
subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
|
||||||
|
icon="lucide:network"
|
||||||
|
.status=${proxy.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
|
||||||
|
{ key: 'Hostname', value: email.hostname },
|
||||||
|
{ key: 'Domains', value: email.domains.length > 0 ? email.domains : null, type: 'pills' },
|
||||||
|
{ key: 'Email Routes', value: email.emailRouteCount },
|
||||||
|
{ key: 'Received Emails Path', value: email.receivedEmailsPath },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (email.portMapping) {
|
||||||
|
const mappingStr = Object.entries(email.portMapping)
|
||||||
|
.map(([ext, int]) => `${ext} → ${int}`)
|
||||||
|
.join(', ');
|
||||||
|
fields.splice(1, 0, { key: 'Port Mapping', value: mappingStr, type: 'code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="Email Server"
|
||||||
|
subtitle="SMTP email handling with smartmta"
|
||||||
|
icon="lucide:mail"
|
||||||
|
.status=${email.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Port', value: dns.port },
|
||||||
|
{ key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' },
|
||||||
|
{ key: 'Scopes', value: dns.scopes.length > 0 ? dns.scopes : null, type: 'pills' },
|
||||||
|
{ key: 'Record Count', value: dns.recordCount },
|
||||||
|
{ key: 'DNS Challenge', value: dns.dnsChallenge, type: 'boolean' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="DNS Server"
|
||||||
|
subtitle="Authoritative DNS with smartdns"
|
||||||
|
icon="lucide:globe"
|
||||||
|
.status=${dns.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Contact Email', value: tls.contactEmail },
|
||||||
|
{ key: 'Domain', value: tls.domain },
|
||||||
|
{ key: 'Source', value: tls.source, type: 'badge' },
|
||||||
|
{ key: 'Certificate Path', value: tls.certPath },
|
||||||
|
{ key: 'Key Path', value: tls.keyPath },
|
||||||
|
];
|
||||||
|
|
||||||
|
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="TLS / Certificates"
|
||||||
|
subtitle="Certificate management and ACME"
|
||||||
|
icon="lucide:shield-check"
|
||||||
|
.status=${status as any}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Storage Path', value: cache.storagePath },
|
||||||
|
{ key: 'DB Name', value: cache.dbName },
|
||||||
|
{ key: 'Default TTL', value: `${cache.defaultTTLDays} days` },
|
||||||
|
{ key: 'Cleanup Interval', value: `${cache.cleanupIntervalHours} hours` },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cache.ttlConfig && Object.keys(cache.ttlConfig).length > 0) {
|
||||||
|
for (const [key, val] of Object.entries(cache.ttlConfig)) {
|
||||||
|
fields.push({ key: `TTL: ${key}`, value: `${val} days` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="Cache Database"
|
||||||
|
subtitle="Persistent caching with smartdata"
|
||||||
|
icon="lucide:database"
|
||||||
|
.status=${cache.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Auth Port', value: radius.authPort },
|
||||||
|
{ key: 'Accounting Port', value: radius.acctPort },
|
||||||
|
{ key: 'Bind Address', value: radius.bindAddress },
|
||||||
|
{ key: 'Client Count', value: radius.clientCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (radius.vlanDefaultVlan !== null) {
|
||||||
|
fields.push(
|
||||||
|
{ key: 'Default VLAN', value: radius.vlanDefaultVlan },
|
||||||
|
{ key: 'Allow Unknown MACs', value: radius.vlanAllowUnknownMacs, type: 'boolean' },
|
||||||
|
{ key: 'VLAN Mappings', value: radius.vlanMappingCount },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = radius.enabled ? 'enabled' : 'not-configured';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="RADIUS Server"
|
||||||
|
subtitle="Network authentication and VLAN assignment"
|
||||||
|
icon="lucide:wifi"
|
||||||
|
.status=${status as any}
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Tunnel Port', value: ri.tunnelPort },
|
||||||
|
{ key: 'Hub Domain', value: ri.hubDomain },
|
||||||
|
{ key: 'TLS Mode', value: ri.tlsMode, type: 'badge' },
|
||||||
|
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="Remote Ingress"
|
||||||
|
subtitle="Edge tunnel nodes"
|
||||||
|
icon="lucide:cloud"
|
||||||
|
.status=${ri.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (days > 0) parts.push(`${days}d`);
|
||||||
|
if (hours > 0) parts.push(`${hours}h`);
|
||||||
|
parts.push(`${mins}m`);
|
||||||
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
import * as shared from './shared/index.js';
|
import * as shared from './shared/index.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
import { appRouter } from '../router.js';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -10,67 +10,30 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TEmailFolder = 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
|
||||||
|
|
||||||
@customElement('ops-view-emails')
|
@customElement('ops-view-emails')
|
||||||
export class OpsViewEmails extends DeesElement {
|
export class OpsViewEmails extends DeesElement {
|
||||||
@state()
|
@state()
|
||||||
accessor selectedFolder: TEmailFolder = 'queued';
|
accessor emails: interfaces.requests.IEmail[] = [];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor queuedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
accessor selectedEmail: interfaces.requests.IEmailDetail | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor sentEmails: interfaces.requests.IEmailQueueItem[] = [];
|
accessor currentView: 'list' | 'detail' = 'list';
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor failedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor securityIncidents: interfaces.requests.ISecurityIncident[] = [];
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor selectedEmail: interfaces.requests.IEmailQueueItem | null = null;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor selectedIncident: interfaces.requests.ISecurityIncident | null = null;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor showCompose = false;
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor isLoading = false;
|
accessor isLoading = false;
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor searchTerm = '';
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor emailDomains: string[] = [];
|
|
||||||
|
|
||||||
private stateSubscription: any;
|
private stateSubscription: any;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.loadData();
|
|
||||||
this.loadEmailDomains();
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
await super.connectedCallback();
|
await super.connectedCallback();
|
||||||
// Subscribe to state changes
|
|
||||||
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
|
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
|
||||||
this.queuedEmails = state.queuedEmails;
|
this.emails = state.emails;
|
||||||
this.sentEmails = state.sentEmails;
|
|
||||||
this.failedEmails = state.failedEmails;
|
|
||||||
this.securityIncidents = state.securityIncidents;
|
|
||||||
this.isLoading = state.isLoading;
|
this.isLoading = state.isLoading;
|
||||||
|
|
||||||
// Sync folder from state (e.g., when URL changes)
|
|
||||||
if (state.currentView !== this.selectedFolder) {
|
|
||||||
this.selectedFolder = state.currentView as TEmailFolder;
|
|
||||||
this.loadFolderData(state.currentView as TEmailFolder);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
// Initial fetch
|
||||||
|
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchAllEmailsAction, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnectedCallback() {
|
async disconnectedCallback() {
|
||||||
@@ -89,730 +52,58 @@ export class OpsViewEmails extends DeesElement {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emailLayout {
|
.viewContainer {
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mainArea {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailToolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailList {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailPreview {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailHeader {
|
|
||||||
padding: 24px;
|
|
||||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailSubject {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailMeta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailMetaRow {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailMetaLabel {
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailBody {
|
|
||||||
flex: 1;
|
|
||||||
padding: 24px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emailActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 400px;
|
|
||||||
color: ${cssManager.bdTheme('#999', '#666')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyIcon {
|
|
||||||
font-size: 64px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyText {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pending {
|
|
||||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-processing {
|
|
||||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-delivered {
|
|
||||||
color: ${cssManager.bdTheme('#10b981', '#34d399')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-failed {
|
|
||||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-deferred {
|
|
||||||
color: ${cssManager.bdTheme('#f97316', '#fb923c')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-info {
|
|
||||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-warn {
|
|
||||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-error {
|
|
||||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-critical {
|
|
||||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incidentDetails {
|
|
||||||
padding: 24px;
|
|
||||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incidentHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incidentTitle {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incidentMeta {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incidentField {
|
|
||||||
padding: 12px;
|
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incidentFieldLabel {
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incidentFieldValue {
|
|
||||||
font-size: 14px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
if (this.selectedEmail) {
|
|
||||||
return this.renderEmailDetail();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.selectedIncident) {
|
|
||||||
return this.renderIncidentDetail();
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Email Operations</ops-sectionheading>
|
<ops-sectionheading>Email Operations</ops-sectionheading>
|
||||||
|
<div class="viewContainer">
|
||||||
<!-- Toolbar -->
|
${this.currentView === 'detail' && this.selectedEmail
|
||||||
<div class="emailToolbar" style="margin-bottom: 16px;">
|
? html`
|
||||||
<dees-button @click=${() => this.openComposeModal()} type="highlighted">
|
<sz-mta-detail-view
|
||||||
<dees-icon icon="lucide:penLine" slot="iconSlot"></dees-icon>
|
.email=${this.selectedEmail}
|
||||||
Compose
|
@back=${this.handleBack}
|
||||||
</dees-button>
|
></sz-mta-detail-view>
|
||||||
|
`
|
||||||
<dees-input-text
|
: html`
|
||||||
class="searchBox"
|
<sz-mta-list-view
|
||||||
placeholder="Search..."
|
.emails=${this.emails}
|
||||||
.value=${this.searchTerm}
|
@email-click=${this.handleEmailClick}
|
||||||
@input=${(e: Event) => this.searchTerm = (e.target as any).value}
|
></sz-mta-list-view>
|
||||||
>
|
`
|
||||||
<dees-icon icon="lucide:search" slot="iconSlot"></dees-icon>
|
}
|
||||||
</dees-input-text>
|
|
||||||
|
|
||||||
<dees-button @click=${() => this.refreshData()}>
|
|
||||||
${this.isLoading ? html`<dees-spinner slot="iconSlot" size="small"></dees-spinner>` : html`<dees-icon slot="iconSlot" icon="lucide:refreshCw"></dees-icon>`}
|
|
||||||
Refresh
|
|
||||||
</dees-button>
|
|
||||||
|
|
||||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
|
||||||
<dees-button-group>
|
|
||||||
<dees-button
|
|
||||||
@click=${() => this.selectFolder('queued')}
|
|
||||||
.type=${this.selectedFolder === 'queued' ? 'highlighted' : 'normal'}
|
|
||||||
>
|
|
||||||
Queued ${this.queuedEmails.length > 0 ? `(${this.queuedEmails.length})` : ''}
|
|
||||||
</dees-button>
|
|
||||||
<dees-button
|
|
||||||
@click=${() => this.selectFolder('sent')}
|
|
||||||
.type=${this.selectedFolder === 'sent' ? 'highlighted' : 'normal'}
|
|
||||||
>
|
|
||||||
Sent
|
|
||||||
</dees-button>
|
|
||||||
<dees-button
|
|
||||||
@click=${() => this.selectFolder('failed')}
|
|
||||||
.type=${this.selectedFolder === 'failed' ? 'highlighted' : 'normal'}
|
|
||||||
>
|
|
||||||
Failed ${this.failedEmails.length > 0 ? `(${this.failedEmails.length})` : ''}
|
|
||||||
</dees-button>
|
|
||||||
<dees-button
|
|
||||||
@click=${() => this.selectFolder('security')}
|
|
||||||
.type=${this.selectedFolder === 'security' ? 'highlighted' : 'normal'}
|
|
||||||
>
|
|
||||||
Security ${this.securityIncidents.length > 0 ? `(${this.securityIncidents.length})` : ''}
|
|
||||||
</dees-button>
|
|
||||||
</dees-button-group>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.renderContent()}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderContent() {
|
private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
|
||||||
switch (this.selectedFolder) {
|
const emailSummary = e.detail;
|
||||||
case 'queued':
|
try {
|
||||||
return this.renderEmailTable(this.queuedEmails, 'Queued Emails', 'Emails waiting to be delivered');
|
const context = appstate.loginStatePart.getState();
|
||||||
case 'sent':
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
return this.renderEmailTable(this.sentEmails, 'Sent Emails', 'Successfully delivered emails');
|
interfaces.requests.IReq_GetEmailDetail
|
||||||
case 'failed':
|
>('/typedrequest', 'getEmailDetail');
|
||||||
return this.renderEmailTable(this.failedEmails, 'Failed Emails', 'Emails that failed to deliver', true);
|
|
||||||
case 'security':
|
|
||||||
return this.renderSecurityIncidents();
|
|
||||||
default:
|
|
||||||
return this.renderEmptyState('Select a folder');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderEmailTable(
|
const response = await request.fire({
|
||||||
emails: interfaces.requests.IEmailQueueItem[],
|
identity: context.identity,
|
||||||
heading1: string,
|
emailId: emailSummary.id,
|
||||||
heading2: string,
|
|
||||||
showResend = false
|
|
||||||
) {
|
|
||||||
const filteredEmails = this.filterEmails(emails);
|
|
||||||
|
|
||||||
if (filteredEmails.length === 0) {
|
|
||||||
return this.renderEmptyState(`No emails in ${this.selectedFolder}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = [
|
|
||||||
{
|
|
||||||
name: 'View Details',
|
|
||||||
iconName: 'lucide:eye',
|
|
||||||
type: ['doubleClick', 'inRow'] as any,
|
|
||||||
actionFunc: async (actionData: any) => {
|
|
||||||
this.selectedEmail = actionData.item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (showResend) {
|
|
||||||
actions.push({
|
|
||||||
name: 'Resend',
|
|
||||||
iconName: 'lucide:send',
|
|
||||||
type: ['inRow'] as any,
|
|
||||||
actionFunc: async (actionData: any) => {
|
|
||||||
await this.resendEmail(actionData.item.id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
if (response.email) {
|
||||||
<dees-table
|
this.selectedEmail = response.email;
|
||||||
.data=${filteredEmails}
|
this.currentView = 'detail';
|
||||||
.displayFunction=${(email: interfaces.requests.IEmailQueueItem) => ({
|
|
||||||
'Status': html`<span class="status-${email.status}">${email.status}</span>`,
|
|
||||||
'From': email.from || 'N/A',
|
|
||||||
'To': email.to?.join(', ') || 'N/A',
|
|
||||||
'Subject': email.subject || 'No subject',
|
|
||||||
'Attempts': email.attempts,
|
|
||||||
'Created': this.formatDate(email.createdAt),
|
|
||||||
})}
|
|
||||||
.dataActions=${actions}
|
|
||||||
.selectionMode=${'single'}
|
|
||||||
heading1=${heading1}
|
|
||||||
heading2=${`${filteredEmails.length} emails - ${heading2}`}
|
|
||||||
></dees-table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderSecurityIncidents() {
|
|
||||||
const incidents = this.securityIncidents;
|
|
||||||
|
|
||||||
if (incidents.length === 0) {
|
|
||||||
return this.renderEmptyState('No security incidents');
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<dees-table
|
|
||||||
.data=${incidents}
|
|
||||||
.displayFunction=${(incident: interfaces.requests.ISecurityIncident) => ({
|
|
||||||
'Severity': html`<span class="severity-${incident.level}">${incident.level.toUpperCase()}</span>`,
|
|
||||||
'Type': incident.type,
|
|
||||||
'Message': incident.message,
|
|
||||||
'IP': incident.ipAddress || 'N/A',
|
|
||||||
'Domain': incident.domain || 'N/A',
|
|
||||||
'Time': this.formatDate(incident.timestamp),
|
|
||||||
})}
|
|
||||||
.dataActions=${[
|
|
||||||
{
|
|
||||||
name: 'View Details',
|
|
||||||
iconName: 'lucide:eye',
|
|
||||||
type: ['doubleClick', 'inRow'],
|
|
||||||
actionFunc: async (actionData: any) => {
|
|
||||||
this.selectedIncident = actionData.item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
.selectionMode=${'single'}
|
|
||||||
heading1="Security Incidents"
|
|
||||||
heading2=${`${incidents.length} incidents`}
|
|
||||||
></dees-table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderEmailDetail() {
|
|
||||||
if (!this.selectedEmail) return '';
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<ops-sectionheading>Email Details</ops-sectionheading>
|
|
||||||
<div class="emailLayout">
|
|
||||||
<div class="sidebar">
|
|
||||||
<dees-windowbox>
|
|
||||||
<dees-button @click=${() => this.selectedEmail = null} type="secondary" style="width: 100%;">
|
|
||||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
|
||||||
Back to List
|
|
||||||
</dees-button>
|
|
||||||
</dees-windowbox>
|
|
||||||
</div>
|
|
||||||
<div class="mainArea">
|
|
||||||
<div class="emailPreview">
|
|
||||||
<div class="emailHeader">
|
|
||||||
<div class="emailSubject">${this.selectedEmail.subject || 'No subject'}</div>
|
|
||||||
<div class="emailMeta">
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">Status:</span>
|
|
||||||
<span class="status-${this.selectedEmail.status}">${this.selectedEmail.status}</span>
|
|
||||||
</div>
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">From:</span>
|
|
||||||
<span>${this.selectedEmail.from || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">To:</span>
|
|
||||||
<span>${this.selectedEmail.to?.join(', ') || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">Mode:</span>
|
|
||||||
<span>${this.selectedEmail.processingMode}</span>
|
|
||||||
</div>
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">Attempts:</span>
|
|
||||||
<span>${this.selectedEmail.attempts}</span>
|
|
||||||
</div>
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">Created:</span>
|
|
||||||
<span>${new Date(this.selectedEmail.createdAt).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
${this.selectedEmail.deliveredAt ? html`
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">Delivered:</span>
|
|
||||||
<span>${new Date(this.selectedEmail.deliveredAt).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${this.selectedEmail.lastError ? html`
|
|
||||||
<div class="emailMetaRow">
|
|
||||||
<span class="emailMetaLabel">Last Error:</span>
|
|
||||||
<span style="color: #ef4444;">${this.selectedEmail.lastError}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="emailActions">
|
|
||||||
${this.selectedEmail.status === 'failed' ? html`
|
|
||||||
<dees-button @click=${() => this.resendEmail(this.selectedEmail!.id)} type="highlighted">
|
|
||||||
<dees-icon icon="lucide:send" slot="iconSlot"></dees-icon>
|
|
||||||
Resend
|
|
||||||
</dees-button>
|
|
||||||
` : ''}
|
|
||||||
<dees-button @click=${() => this.selectedEmail = null}>
|
|
||||||
<dees-icon icon="lucide:x" slot="iconSlot"></dees-icon>
|
|
||||||
Close
|
|
||||||
</dees-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderIncidentDetail() {
|
|
||||||
if (!this.selectedIncident) return '';
|
|
||||||
|
|
||||||
const incident = this.selectedIncident;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<ops-sectionheading>Security Incident Details</ops-sectionheading>
|
|
||||||
<div style="margin-bottom: 16px;">
|
|
||||||
<dees-button @click=${() => this.selectedIncident = null} type="secondary">
|
|
||||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
|
||||||
Back to List
|
|
||||||
</dees-button>
|
|
||||||
</div>
|
|
||||||
<div class="incidentDetails">
|
|
||||||
<div class="incidentHeader">
|
|
||||||
<div>
|
|
||||||
<div class="incidentTitle">${incident.message}</div>
|
|
||||||
<div style="margin-top: 8px; color: #666;">
|
|
||||||
${new Date(incident.timestamp).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="severity-${incident.level}" style="font-size: 16px; padding: 4px 12px; background: rgba(0,0,0,0.1); border-radius: 4px;">
|
|
||||||
${incident.level.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="incidentMeta">
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">Type</div>
|
|
||||||
<div class="incidentFieldValue">${incident.type}</div>
|
|
||||||
</div>
|
|
||||||
${incident.ipAddress ? html`
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">IP Address</div>
|
|
||||||
<div class="incidentFieldValue">${incident.ipAddress}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${incident.domain ? html`
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">Domain</div>
|
|
||||||
<div class="incidentFieldValue">${incident.domain}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${incident.emailId ? html`
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">Email ID</div>
|
|
||||||
<div class="incidentFieldValue">${incident.emailId}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${incident.userId ? html`
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">User ID</div>
|
|
||||||
<div class="incidentFieldValue">${incident.userId}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${incident.action ? html`
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">Action</div>
|
|
||||||
<div class="incidentFieldValue">${incident.action}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${incident.result ? html`
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">Result</div>
|
|
||||||
<div class="incidentFieldValue">${incident.result}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${incident.success !== undefined ? html`
|
|
||||||
<div class="incidentField">
|
|
||||||
<div class="incidentFieldLabel">Success</div>
|
|
||||||
<div class="incidentFieldValue">${incident.success ? 'Yes' : 'No'}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${incident.details ? html`
|
|
||||||
<div style="margin-top: 24px;">
|
|
||||||
<div class="incidentFieldLabel" style="margin-bottom: 8px;">Details</div>
|
|
||||||
<pre style="background: #1a1a1a; color: #e5e5e5; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px;">
|
|
||||||
${JSON.stringify(incident.details, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderEmptyState(message: string) {
|
|
||||||
return html`
|
|
||||||
<div class="emptyState">
|
|
||||||
<dees-icon class="emptyIcon" icon="lucide:inbox"></dees-icon>
|
|
||||||
<div class="emptyText">${message}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async openComposeModal() {
|
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
||||||
|
|
||||||
// Ensure domains are loaded before opening modal
|
|
||||||
if (this.emailDomains.length === 0) {
|
|
||||||
await this.loadEmailDomains();
|
|
||||||
}
|
|
||||||
|
|
||||||
await DeesModal.createAndShow({
|
|
||||||
heading: 'New Email',
|
|
||||||
width: 'large',
|
|
||||||
content: html`
|
|
||||||
<div>
|
|
||||||
<dees-form @formData=${async (e: CustomEvent) => {
|
|
||||||
await this.sendEmail(e.detail);
|
|
||||||
const modals = document.querySelectorAll('dees-modal');
|
|
||||||
modals.forEach(m => (m as any).destroy?.());
|
|
||||||
}}>
|
|
||||||
<div style="display: flex; gap: 8px; align-items: flex-end;">
|
|
||||||
<dees-input-text
|
|
||||||
key="fromUsername"
|
|
||||||
label="From"
|
|
||||||
placeholder="username"
|
|
||||||
.value=${'admin'}
|
|
||||||
required
|
|
||||||
style="flex: 1;"
|
|
||||||
></dees-input-text>
|
|
||||||
<span style="padding-bottom: 12px; font-size: 18px; color: #666;">@</span>
|
|
||||||
<dees-input-dropdown
|
|
||||||
key="fromDomain"
|
|
||||||
label=" "
|
|
||||||
.options=${this.emailDomains.length > 0
|
|
||||||
? this.emailDomains.map(domain => ({ key: domain, value: domain }))
|
|
||||||
: [{ key: 'dcrouter.local', value: 'dcrouter.local' }]}
|
|
||||||
.selectedKey=${this.emailDomains[0] || 'dcrouter.local'}
|
|
||||||
required
|
|
||||||
style="flex: 1;"
|
|
||||||
></dees-input-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dees-input-tags
|
|
||||||
key="to"
|
|
||||||
label="To"
|
|
||||||
placeholder="Enter recipient email addresses..."
|
|
||||||
required
|
|
||||||
></dees-input-tags>
|
|
||||||
|
|
||||||
<dees-input-tags
|
|
||||||
key="cc"
|
|
||||||
label="CC"
|
|
||||||
placeholder="Enter CC recipients..."
|
|
||||||
></dees-input-tags>
|
|
||||||
|
|
||||||
<dees-input-text
|
|
||||||
key="subject"
|
|
||||||
label="Subject"
|
|
||||||
placeholder="Enter email subject..."
|
|
||||||
required
|
|
||||||
></dees-input-text>
|
|
||||||
|
|
||||||
<dees-input-wysiwyg
|
|
||||||
key="body"
|
|
||||||
label="Message"
|
|
||||||
outputFormat="html"
|
|
||||||
></dees-input-wysiwyg>
|
|
||||||
</dees-form>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
menuOptions: [
|
|
||||||
{
|
|
||||||
name: 'Send',
|
|
||||||
iconName: 'lucide:send',
|
|
||||||
action: async (modalArg) => {
|
|
||||||
const form = modalArg.shadowRoot?.querySelector('dees-form') as any;
|
|
||||||
form?.submit();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cancel',
|
|
||||||
iconName: 'lucide:x',
|
|
||||||
action: async (modalArg) => await modalArg.destroy()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterEmails(emails: interfaces.requests.IEmailQueueItem[]): interfaces.requests.IEmailQueueItem[] {
|
|
||||||
if (!this.searchTerm) {
|
|
||||||
return emails;
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = this.searchTerm.toLowerCase();
|
|
||||||
return emails.filter(e =>
|
|
||||||
(e.subject?.toLowerCase().includes(search)) ||
|
|
||||||
(e.from?.toLowerCase().includes(search)) ||
|
|
||||||
(e.to?.some(t => t.toLowerCase().includes(search)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectFolder(folder: TEmailFolder) {
|
|
||||||
// Use router for navigation to update URL
|
|
||||||
appRouter.navigateToEmailFolder(folder);
|
|
||||||
// Clear selections
|
|
||||||
this.selectedEmail = null;
|
|
||||||
this.selectedIncident = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatDate(timestamp: number): string {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - date.getTime();
|
|
||||||
const hours = diff / (1000 * 60 * 60);
|
|
||||||
|
|
||||||
if (hours < 24) {
|
|
||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
} else if (hours < 168) { // 7 days
|
|
||||||
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadData() {
|
|
||||||
this.isLoading = true;
|
|
||||||
await this.loadFolderData(this.selectedFolder);
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadFolderData(folder: TEmailFolder) {
|
|
||||||
switch (folder) {
|
|
||||||
case 'queued':
|
|
||||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchQueuedEmailsAction, null);
|
|
||||||
break;
|
|
||||||
case 'sent':
|
|
||||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSentEmailsAction, null);
|
|
||||||
break;
|
|
||||||
case 'failed':
|
|
||||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchFailedEmailsAction, null);
|
|
||||||
break;
|
|
||||||
case 'security':
|
|
||||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSecurityIncidentsAction, null);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadEmailDomains() {
|
|
||||||
try {
|
|
||||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
|
||||||
const config = appstate.configStatePart.getState().config;
|
|
||||||
|
|
||||||
if (config?.email?.domains && Array.isArray(config.email.domains) && config.email.domains.length > 0) {
|
|
||||||
this.emailDomains = config.email.domains;
|
|
||||||
} else {
|
|
||||||
this.emailDomains = ['dcrouter.local'];
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load email domains:', error);
|
console.error('Failed to fetch email detail:', error);
|
||||||
this.emailDomains = ['dcrouter.local'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshData() {
|
private handleBack() {
|
||||||
this.isLoading = true;
|
this.selectedEmail = null;
|
||||||
await this.loadFolderData(this.selectedFolder);
|
this.currentView = 'list';
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendEmail(formData: any) {
|
|
||||||
try {
|
|
||||||
console.log('Sending email:', formData);
|
|
||||||
// TODO: Implement actual email sending via API
|
|
||||||
// For now, just log the data
|
|
||||||
const fromEmail = `${formData.fromUsername || 'admin'}@${formData.fromDomain || this.emailDomains[0] || 'dcrouter.local'}`;
|
|
||||||
console.log('From:', fromEmail);
|
|
||||||
console.log('To:', formData.to);
|
|
||||||
console.log('Subject:', formData.subject);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to send email', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resendEmail(emailId: string) {
|
|
||||||
try {
|
|
||||||
await appstate.emailOpsStatePart.dispatchAction(appstate.resendEmailAction, emailId);
|
|
||||||
this.selectedEmail = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to resend email:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as shared from './shared/index.js';
|
import * as shared from './shared/index.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
|
|
||||||
@@ -20,6 +19,8 @@ export class OpsViewLogs extends DeesElement {
|
|||||||
filters: {},
|
filters: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private lastPushedCount = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const subscription = appstate.logStatePart
|
const subscription = appstate.logStatePart
|
||||||
@@ -33,175 +34,83 @@ export class OpsViewLogs extends DeesElement {
|
|||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
shared.viewHostCss,
|
shared.viewHostCss,
|
||||||
css`
|
css``,
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterGroup {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logContainer {
|
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#1e1e1e')};
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEntry {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logTimestamp {
|
|
||||||
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logLevel {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-right: 8px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logLevel.debug {
|
|
||||||
color: ${cssManager.bdTheme('#6a9955', '#6a9955')};
|
|
||||||
background: ${cssManager.bdTheme('rgba(106, 153, 85, 0.1)', 'rgba(106, 153, 85, 0.1)')};
|
|
||||||
}
|
|
||||||
.logLevel.info {
|
|
||||||
color: ${cssManager.bdTheme('#569cd6', '#569cd6')};
|
|
||||||
background: ${cssManager.bdTheme('rgba(86, 156, 214, 0.1)', 'rgba(86, 156, 214, 0.1)')};
|
|
||||||
}
|
|
||||||
.logLevel.warn {
|
|
||||||
color: ${cssManager.bdTheme('#ce9178', '#ce9178')};
|
|
||||||
background: ${cssManager.bdTheme('rgba(206, 145, 120, 0.1)', 'rgba(206, 145, 120, 0.1)')};
|
|
||||||
}
|
|
||||||
.logLevel.error {
|
|
||||||
color: ${cssManager.bdTheme('#f44747', '#f44747')};
|
|
||||||
background: ${cssManager.bdTheme('rgba(244, 71, 71, 0.1)', 'rgba(244, 71, 71, 0.1)')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.logCategory {
|
|
||||||
color: ${cssManager.bdTheme('#c586c0', '#c586c0')};
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logMessage {
|
|
||||||
color: ${cssManager.bdTheme('#333', '#d4d4d4')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.noLogs {
|
|
||||||
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Logs</ops-sectionheading>
|
<ops-sectionheading>Logs</ops-sectionheading>
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<div class="filterGroup">
|
|
||||||
<dees-button
|
|
||||||
@click=${() => this.fetchLogs()}
|
|
||||||
>
|
|
||||||
Refresh Logs
|
|
||||||
</dees-button>
|
|
||||||
|
|
||||||
<dees-button
|
|
||||||
@click=${() => this.toggleStreaming()}
|
|
||||||
.type=${this.logState.isStreaming ? 'highlighted' : 'normal'}
|
|
||||||
>
|
|
||||||
${this.logState.isStreaming ? 'Stop Streaming' : 'Start Streaming'}
|
|
||||||
</dees-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filterGroup">
|
<dees-chart-log
|
||||||
<label>Level:</label>
|
.label=${'Application Logs'}
|
||||||
<dees-input-dropdown
|
.autoScroll=${true}
|
||||||
.options=${['all', 'debug', 'info', 'warn', 'error']}
|
.maxEntries=${2000}
|
||||||
.selectedOption=${'all'}
|
.showMetrics=${true}
|
||||||
@selectedOption=${(e) => this.updateFilter('level', e.detail)}
|
></dees-chart-log>
|
||||||
></dees-input-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filterGroup">
|
|
||||||
<label>Category:</label>
|
|
||||||
<dees-input-dropdown
|
|
||||||
.options=${['all', 'smtp', 'dns', 'security', 'system', 'email']}
|
|
||||||
.selectedOption=${'all'}
|
|
||||||
@selectedOption=${(e) => this.updateFilter('category', e.detail)}
|
|
||||||
></dees-input-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filterGroup">
|
|
||||||
<label>Limit:</label>
|
|
||||||
<dees-input-dropdown
|
|
||||||
.options=${['50', '100', '200', '500']}
|
|
||||||
.selectedOption=${'100'}
|
|
||||||
@selectedOption=${(e) => this.updateFilter('limit', e.detail)}
|
|
||||||
></dees-input-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="logContainer">
|
|
||||||
${this.logState.recentLogs.length > 0 ?
|
|
||||||
this.logState.recentLogs.map(log => html`
|
|
||||||
<div class="logEntry">
|
|
||||||
<span class="logTimestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
|
|
||||||
<span class="logLevel ${log.level}">${log.level.toUpperCase()}</span>
|
|
||||||
<span class="logCategory">[${log.category}]</span>
|
|
||||||
<span class="logMessage">${log.message}</span>
|
|
||||||
</div>
|
|
||||||
`) : html`
|
|
||||||
<div class="noLogs">No logs to display</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchLogs() {
|
async connectedCallback() {
|
||||||
const filters = this.getActiveFilters();
|
super.connectedCallback();
|
||||||
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, {
|
this.lastPushedCount = 0;
|
||||||
limit: filters.limit || 100,
|
// Only fetch if state is empty (streaming will handle new entries)
|
||||||
level: filters.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
if (this.logState.recentLogs.length === 0) {
|
||||||
category: filters.category as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined,
|
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateFilter(type: string, value: string) {
|
|
||||||
if (value === 'all') {
|
|
||||||
value = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update filters then fetch logs
|
|
||||||
this.fetchLogs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getActiveFilters() {
|
async updated(changedProperties: Map<string, any>) {
|
||||||
return {
|
super.updated(changedProperties);
|
||||||
level: this.logState.filters.level?.[0],
|
if (changedProperties.has('logState')) {
|
||||||
category: this.logState.filters.category?.[0],
|
this.pushLogsToChart();
|
||||||
limit: 100,
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleStreaming() {
|
private async pushLogsToChart() {
|
||||||
// TODO: Implement log streaming with VirtualStream
|
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
|
||||||
console.log('Streaming toggle not yet implemented');
|
if (!chartLog) return;
|
||||||
|
|
||||||
|
// Ensure the chart element has finished its own initialization
|
||||||
|
await chartLog.updateComplete;
|
||||||
|
|
||||||
|
// Wait for xterm terminal to finish initializing (CDN load)
|
||||||
|
if (!chartLog.terminalReady) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 200; // 200 * 50ms = 10 seconds
|
||||||
|
const check = () => {
|
||||||
|
if (chartLog.terminalReady) { resolve(); return; }
|
||||||
|
if (++attempts >= maxAttempts) {
|
||||||
|
console.warn('ops-view-logs: terminal ready timeout after 10s');
|
||||||
|
resolve(); // resolve gracefully to avoid blocking
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(check, 50);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEntries = this.getMappedLogEntries();
|
||||||
|
if (this.lastPushedCount === 0 && allEntries.length > 0) {
|
||||||
|
// Initial load: push all entries
|
||||||
|
chartLog.updateLog(allEntries);
|
||||||
|
this.lastPushedCount = allEntries.length;
|
||||||
|
} else if (allEntries.length > this.lastPushedCount) {
|
||||||
|
// Incremental: only push new entries
|
||||||
|
const newEntries = allEntries.slice(this.lastPushedCount);
|
||||||
|
chartLog.updateLog(newEntries);
|
||||||
|
this.lastPushedCount = allEntries.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private getMappedLogEntries() {
|
||||||
|
return this.logState.recentLogs.map((log) => ({
|
||||||
|
timestamp: new Date(log.timestamp).toISOString(),
|
||||||
|
level: log.level as 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
message: log.message,
|
||||||
|
source: log.category,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
private lastChartUpdate = 0;
|
private lastChartUpdate = 0;
|
||||||
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
||||||
|
|
||||||
private lastTrafficUpdateTime = 0;
|
|
||||||
private trafficUpdateInterval = 1000; // Update every 1 second
|
|
||||||
private requestCountHistory = new Map<number, number>(); // Track requests per time bucket
|
|
||||||
private trafficUpdateTimer: any = null;
|
private trafficUpdateTimer: any = null;
|
||||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||||
private historyLoaded = false; // Whether server-side throughput history has been loaded
|
private historyLoaded = false; // Whether server-side throughput history has been loaded
|
||||||
@@ -106,8 +103,6 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
|
|
||||||
this.trafficDataIn = [...emptyData];
|
this.trafficDataIn = [...emptyData];
|
||||||
this.trafficDataOut = emptyData.map(point => ({ ...point }));
|
this.trafficDataOut = emptyData.map(point => ({ ...point }));
|
||||||
|
|
||||||
this.lastTrafficUpdateTime = now;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -413,11 +408,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
const throughput = this.calculateThroughput();
|
const throughput = this.calculateThroughput();
|
||||||
const activeConnections = this.statsState.serverStats?.activeConnections || 0;
|
const activeConnections = this.statsState.serverStats?.activeConnections || 0;
|
||||||
|
|
||||||
// Track requests/sec history for the trend sparkline
|
// Build trend data from pre-computed history (mutated in updateNetworkData, not here)
|
||||||
this.requestsPerSecHistory.push(reqPerSec);
|
|
||||||
if (this.requestsPerSecHistory.length > 20) {
|
|
||||||
this.requestsPerSecHistory.shift();
|
|
||||||
}
|
|
||||||
const trendData = [...this.requestsPerSecHistory];
|
const trendData = [...this.requestsPerSecHistory];
|
||||||
while (trendData.length < 20) {
|
while (trendData.length < 20) {
|
||||||
trendData.unshift(0);
|
trendData.unshift(0);
|
||||||
@@ -529,6 +520,13 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async updateNetworkData() {
|
private async updateNetworkData() {
|
||||||
|
// Track requests/sec history for the trend sparkline (moved out of render)
|
||||||
|
const reqPerSec = this.networkState.requestsPerSecond || 0;
|
||||||
|
this.requestsPerSecHistory.push(reqPerSec);
|
||||||
|
if (this.requestsPerSecHistory.length > 20) {
|
||||||
|
this.requestsPerSecHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
// Only update if connections changed significantly
|
// Only update if connections changed significantly
|
||||||
const newConnectionCount = this.networkState.connections.length;
|
const newConnectionCount = this.networkState.connections.length;
|
||||||
const oldConnectionCount = this.networkRequests.length;
|
const oldConnectionCount = this.networkRequests.length;
|
||||||
@@ -602,16 +600,13 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
y: Math.round(throughputOutMbps * 10) / 10
|
y: Math.round(throughputOutMbps * 10) / 10
|
||||||
};
|
};
|
||||||
|
|
||||||
// Efficient array updates - modify in place when possible
|
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
||||||
if (this.trafficDataIn.length >= 60) {
|
if (this.trafficDataIn.length >= 60) {
|
||||||
// Remove oldest and add newest
|
this.trafficDataIn.shift();
|
||||||
this.trafficDataIn = [...this.trafficDataIn.slice(1), newDataPointIn];
|
this.trafficDataOut.shift();
|
||||||
this.trafficDataOut = [...this.trafficDataOut.slice(1), newDataPointOut];
|
|
||||||
} else {
|
|
||||||
// Still filling up the initial data
|
|
||||||
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
|
||||||
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
|
||||||
}
|
}
|
||||||
|
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
||||||
|
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
||||||
|
|
||||||
this.lastChartUpdate = now;
|
this.lastChartUpdate = now;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,36 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor logState: appstate.ILogState = {
|
||||||
|
recentLogs: [],
|
||||||
|
isStreaming: false,
|
||||||
|
filters: {},
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const subscription = appstate.statsStatePart
|
const statsSub = appstate.statsStatePart
|
||||||
.select((stateArg) => stateArg)
|
.select((stateArg) => stateArg)
|
||||||
.subscribe((statsState) => {
|
.subscribe((statsState) => {
|
||||||
this.statsState = statsState;
|
this.statsState = statsState;
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(subscription);
|
this.rxSubscriptions.push(statsSub);
|
||||||
|
|
||||||
|
const logSub = appstate.logStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((logState) => {
|
||||||
|
this.logState = logState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(logSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
// Ensure logs are fetched for the overview charts
|
||||||
|
if (this.logState.recentLogs.length === 0) {
|
||||||
|
appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
@@ -96,10 +118,24 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
${this.renderDnsStats()}
|
${this.renderDnsStats()}
|
||||||
|
|
||||||
<div class="chartGrid">
|
<div class="chartGrid">
|
||||||
<dees-chart-area .label=${'Email Traffic (24h)'} .data=${[]}></dees-chart-area>
|
<dees-chart-area
|
||||||
<dees-chart-area .label=${'DNS Queries (24h)'} .data=${[]}></dees-chart-area>
|
.label=${'Email Traffic (24h)'}
|
||||||
<dees-chart-log .label=${'Recent Events'} .data=${[]}></dees-chart-log>
|
.series=${this.getEmailTrafficSeries()}
|
||||||
<dees-chart-log .label=${'Security Alerts'} .data=${[]}></dees-chart-log>
|
.yAxisFormatter=${(val: number) => `${val}`}
|
||||||
|
></dees-chart-area>
|
||||||
|
<dees-chart-area
|
||||||
|
.label=${'DNS Queries (24h)'}
|
||||||
|
.series=${this.getDnsQuerySeries()}
|
||||||
|
.yAxisFormatter=${(val: number) => `${val}`}
|
||||||
|
></dees-chart-area>
|
||||||
|
<dees-chart-log
|
||||||
|
.label=${'Recent Events'}
|
||||||
|
.logEntries=${this.getRecentEventEntries()}
|
||||||
|
></dees-chart-log>
|
||||||
|
<dees-chart-log
|
||||||
|
.label=${'DNS Queries'}
|
||||||
|
.logEntries=${this.getDnsQueryEntries()}
|
||||||
|
></dees-chart-log>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
@@ -337,4 +373,52 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chart data helpers ---
|
||||||
|
|
||||||
|
private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||||
|
return this.logState.recentLogs.map((log) => ({
|
||||||
|
timestamp: new Date(log.timestamp).toISOString(),
|
||||||
|
level: log.level as 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
message: log.message,
|
||||||
|
source: log.category,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSecurityAlertEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||||
|
const events: any[] = this.statsState.securityMetrics?.recentEvents || [];
|
||||||
|
return events.map((evt: any) => ({
|
||||||
|
timestamp: new Date(evt.timestamp).toISOString(),
|
||||||
|
level: evt.level === 'critical' || evt.level === 'error' ? 'error' as const : evt.level === 'warn' ? 'warn' as const : 'info' as const,
|
||||||
|
message: evt.message,
|
||||||
|
source: evt.type,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDnsQueryEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||||
|
const queries: any[] = (this.statsState.dnsStats as any)?.recentQueries || [];
|
||||||
|
return queries.map((q: any) => ({
|
||||||
|
timestamp: new Date(q.timestamp).toISOString(),
|
||||||
|
level: q.answered ? 'info' as const : 'warn' as const,
|
||||||
|
message: `${q.type} ${q.domain} (${q.responseTimeMs}ms)`,
|
||||||
|
source: 'dns',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmailTrafficSeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
|
||||||
|
const ts = this.statsState.emailStats?.timeSeries;
|
||||||
|
if (!ts) return [];
|
||||||
|
return [
|
||||||
|
{ name: 'Sent', color: '#22c55e', data: (ts.sent || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||||
|
{ name: 'Received', color: '#3b82f6', data: (ts.received || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDnsQuerySeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
|
||||||
|
const ts = this.statsState.dnsStats?.timeSeries;
|
||||||
|
if (!ts) return [];
|
||||||
|
return [
|
||||||
|
{ name: 'Queries', color: '#8b5cf6', data: (ts.queries || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
389
ts_web/elements/ops-view-routes.ts
Normal file
389
ts_web/elements/ops-view-routes.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from './shared/css.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-view-routes')
|
||||||
|
export class OpsViewRoutes extends DeesElement {
|
||||||
|
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||||
|
mergedRoutes: [],
|
||||||
|
warnings: [],
|
||||||
|
apiTokens: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.routeManagementStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((routeState) => {
|
||||||
|
this.routeState = routeState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
|
||||||
|
// Re-fetch routes when user logs in (fixes race condition where
|
||||||
|
// the view is created before authentication completes)
|
||||||
|
const loginSub = appstate.loginStatePart
|
||||||
|
.select((s) => s.isLoggedIn)
|
||||||
|
.subscribe((isLoggedIn) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(loginSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.routesContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-bar {
|
||||||
|
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${cssManager.bdTheme('#b45309', '#fa0')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: ${cssManager.bdTheme('#6b7280', '#666')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const { mergedRoutes, warnings } = this.routeState;
|
||||||
|
|
||||||
|
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
|
||||||
|
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
|
||||||
|
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
||||||
|
|
||||||
|
const statsTiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'totalRoutes',
|
||||||
|
title: 'Total Routes',
|
||||||
|
type: 'number',
|
||||||
|
value: mergedRoutes.length,
|
||||||
|
icon: 'lucide:route',
|
||||||
|
description: 'All configured routes',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hardcoded',
|
||||||
|
title: 'Hardcoded',
|
||||||
|
type: 'number',
|
||||||
|
value: hardcodedCount,
|
||||||
|
icon: 'lucide:lock',
|
||||||
|
description: 'Routes from constructor config',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programmatic',
|
||||||
|
title: 'Programmatic',
|
||||||
|
type: 'number',
|
||||||
|
value: programmaticCount,
|
||||||
|
icon: 'lucide:code',
|
||||||
|
description: 'Routes added via API',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disabled',
|
||||||
|
title: 'Disabled',
|
||||||
|
type: 'number',
|
||||||
|
value: disabledCount,
|
||||||
|
icon: 'lucide:pauseCircle',
|
||||||
|
description: 'Currently disabled routes',
|
||||||
|
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map merged routes to sz-route-list-view format
|
||||||
|
const szRoutes = mergedRoutes.map((mr) => {
|
||||||
|
const tags = [...(mr.route.tags || [])];
|
||||||
|
tags.push(mr.source);
|
||||||
|
if (!mr.enabled) tags.push('disabled');
|
||||||
|
if (mr.overridden) tags.push('overridden');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mr.route,
|
||||||
|
enabled: mr.enabled,
|
||||||
|
tags,
|
||||||
|
id: mr.storedRouteId || mr.route.name || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>Route Management</ops-sectionheading>
|
||||||
|
|
||||||
|
<div class="routesContainer">
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${statsTiles}
|
||||||
|
.gridActions=${[
|
||||||
|
{
|
||||||
|
name: 'Add Route',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: () => this.showCreateRouteDialog(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:refreshCw',
|
||||||
|
action: () => this.refreshData(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
${warnings.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="warnings-bar">
|
||||||
|
${warnings.map(
|
||||||
|
(w) => html`
|
||||||
|
<div class="warning-item">
|
||||||
|
<span class="warning-icon">⚠</span>
|
||||||
|
<span>${w.message}</span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
|
||||||
|
${szRoutes.length > 0
|
||||||
|
? html`
|
||||||
|
<sz-route-list-view
|
||||||
|
.routes=${szRoutes}
|
||||||
|
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||||
|
></sz-route-list-view>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No routes configured</p>
|
||||||
|
<p>Add a programmatic route or check your constructor configuration.</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRouteClick(e: CustomEvent) {
|
||||||
|
const clickedRoute = e.detail;
|
||||||
|
if (!clickedRoute) return;
|
||||||
|
|
||||||
|
// Find the corresponding merged route
|
||||||
|
const merged = this.routeState.mergedRoutes.find(
|
||||||
|
(mr) => mr.route.name === clickedRoute.name,
|
||||||
|
);
|
||||||
|
if (!merged) return;
|
||||||
|
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
if (merged.source === 'hardcoded') {
|
||||||
|
const menuOptions = merged.enabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Disable Route',
|
||||||
|
iconName: 'lucide:pause',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.setRouteOverrideAction,
|
||||||
|
{ routeName: merged.route.name!, enabled: false },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: 'Enable Route',
|
||||||
|
iconName: 'lucide:play',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.setRouteOverrideAction,
|
||||||
|
{ routeName: merged.route.name!, enabled: true },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Remove Override',
|
||||||
|
iconName: 'lucide:undo',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.removeRouteOverrideAction,
|
||||||
|
merged.route.name!,
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Route: ${merged.route.name}`,
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
|
||||||
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
|
||||||
|
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Programmatic route
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Route: ${merged.route.name}`,
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
|
||||||
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||||
|
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: merged.enabled ? 'Disable' : 'Enable',
|
||||||
|
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.toggleRouteAction,
|
||||||
|
{ id: merged.storedRouteId!, enabled: !merged.enabled },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash-2',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.deleteRouteAction,
|
||||||
|
merged.storedRouteId!,
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateRouteDialog() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Add Programmatic Route',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, optional)'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .value=${'localhost'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .required=${true}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
if (!formData.name || !formData.ports) return;
|
||||||
|
|
||||||
|
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
||||||
|
const domains = formData.domains
|
||||||
|
? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const route: any = {
|
||||||
|
name: formData.name,
|
||||||
|
match: {
|
||||||
|
ports,
|
||||||
|
...(domains && domains.length > 0 ? { domains } : {}),
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
host: formData.targetHost || 'localhost',
|
||||||
|
port: parseInt(formData.targetPort, 10),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.createRouteAction,
|
||||||
|
{ route },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshData() {
|
||||||
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async firstUpdated() {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,7 +249,14 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
private renderOverview(metrics: any) {
|
private renderOverview(metrics: any) {
|
||||||
const threatLevel = this.calculateThreatLevel(metrics);
|
const threatLevel = this.calculateThreatLevel(metrics);
|
||||||
const threatScore = this.getThreatScore(metrics);
|
const threatScore = this.getThreatScore(metrics);
|
||||||
|
|
||||||
|
// Derive active sessions from recent successful auth events (last hour)
|
||||||
|
const allEvents: any[] = metrics.recentEvents || [];
|
||||||
|
const oneHourAgo = Date.now() - 3600000;
|
||||||
|
const recentAuthSuccesses = allEvents.filter(
|
||||||
|
(evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo
|
||||||
|
).length;
|
||||||
|
|
||||||
const tiles: IStatsTile[] = [
|
const tiles: IStatsTile[] = [
|
||||||
{
|
{
|
||||||
id: 'threatLevel',
|
id: 'threatLevel',
|
||||||
@@ -271,7 +278,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
{
|
{
|
||||||
id: 'blockedThreats',
|
id: 'blockedThreats',
|
||||||
title: 'Blocked Threats',
|
title: 'Blocked Threats',
|
||||||
value: metrics.blockedIPs.length + metrics.spamDetected,
|
value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:ShieldCheck',
|
icon: 'lucide:ShieldCheck',
|
||||||
color: '#ef4444',
|
color: '#ef4444',
|
||||||
@@ -280,11 +287,11 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
{
|
{
|
||||||
id: 'activeSessions',
|
id: 'activeSessions',
|
||||||
title: 'Active Sessions',
|
title: 'Active Sessions',
|
||||||
value: 0,
|
value: recentAuthSuccesses,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:Users',
|
icon: 'lucide:Users',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
description: 'Current authenticated sessions',
|
description: 'Authenticated in last hour',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'authFailures',
|
id: 'authFailures',
|
||||||
@@ -349,6 +356,11 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderAuthentication(metrics: any) {
|
private renderAuthentication(metrics: any) {
|
||||||
|
// Derive auth events from recentEvents
|
||||||
|
const allEvents: any[] = metrics.recentEvents || [];
|
||||||
|
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
|
||||||
|
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
|
||||||
|
|
||||||
const tiles: IStatsTile[] = [
|
const tiles: IStatsTile[] = [
|
||||||
{
|
{
|
||||||
id: 'authFailures',
|
id: 'authFailures',
|
||||||
@@ -362,7 +374,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
{
|
{
|
||||||
id: 'successfulLogins',
|
id: 'successfulLogins',
|
||||||
title: 'Successful Logins',
|
title: 'Successful Logins',
|
||||||
value: 0,
|
value: successfulLogins,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:Lock',
|
icon: 'lucide:Lock',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
@@ -370,6 +382,15 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Map auth events to login history table data
|
||||||
|
const loginHistory = authEvents.map((evt: any) => ({
|
||||||
|
timestamp: evt.timestamp,
|
||||||
|
username: evt.details?.username || 'unknown',
|
||||||
|
ipAddress: evt.ipAddress || 'unknown',
|
||||||
|
success: evt.success ?? false,
|
||||||
|
reason: evt.success ? '' : evt.message || 'Authentication failed',
|
||||||
|
}));
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${tiles}
|
.tiles=${tiles}
|
||||||
@@ -380,7 +401,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
<dees-table
|
<dees-table
|
||||||
.heading1=${'Login History'}
|
.heading1=${'Login History'}
|
||||||
.heading2=${'Recent authentication attempts'}
|
.heading2=${'Recent authentication attempts'}
|
||||||
.data=${[]}
|
.data=${loginHistory}
|
||||||
.displayFunction=${(item) => ({
|
.displayFunction=${(item) => ({
|
||||||
'Time': new Date(item.timestamp).toLocaleString(),
|
'Time': new Date(item.timestamp).toLocaleString(),
|
||||||
'Username': item.username,
|
'Username': item.username,
|
||||||
@@ -483,48 +504,38 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
private getThreatScore(metrics: any): number {
|
private getThreatScore(metrics: any): number {
|
||||||
// Simple scoring algorithm
|
// Simple scoring algorithm
|
||||||
let score = 100;
|
let score = 100;
|
||||||
score -= metrics.blockedIPs.length * 2;
|
const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0);
|
||||||
score -= metrics.authenticationFailures * 1;
|
score -= blockedCount * 2;
|
||||||
score -= metrics.spamDetected * 0.5;
|
score -= (metrics.authenticationFailures || 0) * 1;
|
||||||
score -= metrics.malwareDetected * 3;
|
score -= (metrics.spamDetected || 0) * 0.5;
|
||||||
score -= metrics.phishingDetected * 3;
|
score -= (metrics.malwareDetected || 0) * 3;
|
||||||
score -= metrics.suspiciousActivities * 2;
|
score -= (metrics.phishingDetected || 0) * 3;
|
||||||
|
score -= (metrics.suspiciousActivities || 0) * 2;
|
||||||
return Math.max(0, Math.min(100, Math.round(score)));
|
return Math.max(0, Math.min(100, Math.round(score)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSecurityEvents(metrics: any): any[] {
|
private getSecurityEvents(metrics: any): any[] {
|
||||||
// Mock data - in real implementation, this would come from the server
|
const events: any[] = metrics.recentEvents || [];
|
||||||
return [
|
return events.map((evt: any) => ({
|
||||||
{
|
timestamp: evt.timestamp,
|
||||||
timestamp: Date.now() - 1000 * 60 * 5,
|
event: evt.message,
|
||||||
event: 'Multiple failed login attempts',
|
severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info',
|
||||||
severity: 'warning',
|
details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type,
|
||||||
details: 'IP: 192.168.1.100',
|
}));
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 15,
|
|
||||||
event: 'SPF check failed',
|
|
||||||
severity: 'medium',
|
|
||||||
details: 'Domain: example.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 30,
|
|
||||||
event: 'IP blocked due to spam',
|
|
||||||
severity: 'high',
|
|
||||||
details: 'IP: 10.0.0.1',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearBlockedIPs() {
|
private async clearBlockedIPs() {
|
||||||
console.log('Clear blocked IPs');
|
// SmartProxy manages IP blocking — not yet exposed via API
|
||||||
|
alert('Clearing blocked IPs is not yet supported from the UI.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async unblockIP(ip: string) {
|
private async unblockIP(ip: string) {
|
||||||
console.log('Unblock IP:', ip);
|
// SmartProxy manages IP blocking — not yet exposed via API
|
||||||
|
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveEmailSecuritySettings() {
|
private async saveEmailSecuritySettings() {
|
||||||
console.log('Save email security settings');
|
// Config is read-only from the UI for now
|
||||||
|
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,17 @@
|
|||||||
import * as deesElement from '@design.estate/dees-element';
|
import * as deesElement from '@design.estate/dees-element';
|
||||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
// @serve.zone scope
|
||||||
|
import * as szCatalog from '@serve.zone/catalog';
|
||||||
|
|
||||||
|
// TypedSocket for real-time push communication
|
||||||
|
import * as typedsocket from '@api.global/typedsocket';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
deesElement,
|
deesElement,
|
||||||
deesCatalog
|
deesCatalog,
|
||||||
|
szCatalog,
|
||||||
|
typedsocket,
|
||||||
}
|
}
|
||||||
|
|
||||||
// domtools gives us TypedRequest and other utilities
|
// domtools gives us TypedRequest and other utilities
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- Filter by log level (error, warning, info, debug)
|
- Filter by log level (error, warning, info, debug)
|
||||||
- Search and time-range selection
|
- Search and time-range selection
|
||||||
|
|
||||||
|
### 🛣️ Route & API Token Management
|
||||||
|
- Programmatic route CRUD with enable/disable and override controls
|
||||||
|
- API token creation, revocation, and scope management
|
||||||
|
- Routes tab and API Tokens tab in unified view
|
||||||
|
|
||||||
### ⚙️ Configuration
|
### ⚙️ Configuration
|
||||||
- Read-only display of current system configuration
|
- Read-only display of current system configuration
|
||||||
- Status badges for boolean values (enabled/disabled)
|
- Status badges for boolean values (enabled/disabled)
|
||||||
@@ -96,6 +101,7 @@ ts_web/
|
|||||||
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
||||||
├── ops-view-remoteingress.ts # Remote ingress edge management
|
├── ops-view-remoteingress.ts # Remote ingress edge management
|
||||||
├── ops-view-logs.ts # Log viewer
|
├── ops-view-logs.ts # Log viewer
|
||||||
|
├── ops-view-routes.ts # Route & API token management
|
||||||
├── ops-view-config.ts # Configuration display
|
├── ops-view-config.ts # Configuration display
|
||||||
├── ops-view-security.ts # Security dashboard
|
├── ops-view-security.ts # Security dashboard
|
||||||
└── shared/
|
└── shared/
|
||||||
@@ -171,6 +177,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
|||||||
/emails/security → Security incidents
|
/emails/security → Security incidents
|
||||||
/certificates → Certificate management
|
/certificates → Certificate management
|
||||||
/remoteingress → Remote ingress edge management
|
/remoteingress → Remote ingress edge management
|
||||||
|
/routes → Route & API token management
|
||||||
/logs → Log viewer
|
/logs → Log viewer
|
||||||
/configuration → System configuration
|
/configuration → System configuration
|
||||||
/security → Security dashboard
|
/security → Security dashboard
|
||||||
@@ -242,7 +249,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
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.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import * as appstate from './appstate.js';
|
|||||||
|
|
||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||||
|
|
||||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
||||||
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
|
|
||||||
|
|
||||||
export type TValidView = typeof validViews[number];
|
export type TValidView = typeof validViews[number];
|
||||||
export type TValidEmailFolder = typeof validEmailFolders[number];
|
|
||||||
|
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
private router: InstanceType<typeof SmartRouter>;
|
private router: InstanceType<typeof SmartRouter>;
|
||||||
@@ -27,31 +25,10 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupRoutes(): void {
|
private setupRoutes(): void {
|
||||||
// Main views
|
|
||||||
for (const view of validViews) {
|
for (const view of validViews) {
|
||||||
if (view === 'emails') {
|
this.router.on(`/${view}`, async () => {
|
||||||
// Email root - default to queued
|
this.updateViewState(view);
|
||||||
this.router.on('/emails', async () => {
|
});
|
||||||
this.updateViewState('emails');
|
|
||||||
this.updateEmailFolder('queued');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Email with folder parameter
|
|
||||||
this.router.on('/emails/:folder', async (routeInfo) => {
|
|
||||||
const folder = routeInfo.params.folder as string;
|
|
||||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
|
||||||
this.updateViewState('emails');
|
|
||||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
|
||||||
} else {
|
|
||||||
// Invalid folder, redirect to queued
|
|
||||||
this.navigateTo('/emails/queued');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.router.on(`/${view}`, async () => {
|
|
||||||
this.updateViewState(view);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root redirect
|
// Root redirect
|
||||||
@@ -61,60 +38,32 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupStateSync(): void {
|
private setupStateSync(): void {
|
||||||
// Sync URL when state changes programmatically (not from router)
|
|
||||||
appstate.uiStatePart.state.subscribe((uiState) => {
|
appstate.uiStatePart.state.subscribe((uiState) => {
|
||||||
if (this.suppressStateUpdate) return;
|
if (this.suppressStateUpdate) return;
|
||||||
|
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const expectedPath = this.getExpectedPath(uiState.activeView);
|
const expectedPath = `/${uiState.activeView}`;
|
||||||
|
|
||||||
// Only update URL if it doesn't match current state
|
if (currentPath !== expectedPath) {
|
||||||
if (!currentPath.startsWith(expectedPath)) {
|
|
||||||
this.suppressStateUpdate = true;
|
this.suppressStateUpdate = true;
|
||||||
if (uiState.activeView === 'emails') {
|
this.router.pushUrl(expectedPath);
|
||||||
const emailState = appstate.emailOpsStatePart.getState();
|
|
||||||
this.router.pushUrl(`/emails/${emailState.currentView}`);
|
|
||||||
} else {
|
|
||||||
this.router.pushUrl(`/${uiState.activeView}`);
|
|
||||||
}
|
|
||||||
this.suppressStateUpdate = false;
|
this.suppressStateUpdate = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getExpectedPath(view: string): string {
|
|
||||||
if (view === 'emails') {
|
|
||||||
return '/emails';
|
|
||||||
}
|
|
||||||
return `/${view}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleInitialRoute(): void {
|
private handleInitialRoute(): void {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
|
|
||||||
if (!path || path === '/') {
|
if (!path || path === '/') {
|
||||||
// Redirect root to overview
|
|
||||||
this.router.pushUrl('/overview');
|
this.router.pushUrl('/overview');
|
||||||
} else {
|
} else {
|
||||||
// Parse current path and update state
|
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
|
|
||||||
if (validViews.includes(view as TValidView)) {
|
if (validViews.includes(view as TValidView)) {
|
||||||
this.updateViewState(view as TValidView);
|
this.updateViewState(view as TValidView);
|
||||||
|
|
||||||
if (view === 'emails' && segments[1]) {
|
|
||||||
const folder = segments[1];
|
|
||||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
|
||||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
|
||||||
} else {
|
|
||||||
this.updateEmailFolder('queued');
|
|
||||||
}
|
|
||||||
} else if (view === 'emails') {
|
|
||||||
this.updateEmailFolder('queued');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Invalid view, redirect to overview
|
|
||||||
this.router.pushUrl('/overview');
|
this.router.pushUrl('/overview');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,18 +81,6 @@ class AppRouter {
|
|||||||
this.suppressStateUpdate = false;
|
this.suppressStateUpdate = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateEmailFolder(folder: TValidEmailFolder): void {
|
|
||||||
this.suppressStateUpdate = true;
|
|
||||||
const currentState = appstate.emailOpsStatePart.getState();
|
|
||||||
if (currentState.currentView !== folder) {
|
|
||||||
appstate.emailOpsStatePart.setState({
|
|
||||||
...currentState,
|
|
||||||
currentView: folder as appstate.IEmailOpsState['currentView'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.suppressStateUpdate = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public navigateTo(path: string): void {
|
public navigateTo(path: string): void {
|
||||||
this.router.pushUrl(path);
|
this.router.pushUrl(path);
|
||||||
}
|
}
|
||||||
@@ -156,22 +93,10 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public navigateToEmailFolder(folder: string): void {
|
|
||||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
|
||||||
this.navigateTo(`/emails/${folder}`);
|
|
||||||
} else {
|
|
||||||
this.navigateTo('/emails/queued');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCurrentView(): string {
|
public getCurrentView(): string {
|
||||||
return appstate.uiStatePart.getState().activeView;
|
return appstate.uiStatePart.getState().activeView;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCurrentEmailFolder(): string {
|
|
||||||
return appstate.emailOpsStatePart.getState().currentView;
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.router.destroy();
|
this.router.destroy();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user