Compare commits

..

84 Commits

Author SHA1 Message Date
1ea38ed5d2 v11.10.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 08:43:36 +00:00
7209903d02 fix(sms): update sms service to use async ProjectInfo initialization 2026-03-26 08:43:36 +00:00
20eda1ab3e v11.10.6
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:40:56 +00:00
44f2a7f0a9 fix(typescript): tighten TypeScript null safety and error handling across backend and ops UI 2026-03-26 07:40:56 +00:00
0195a21f30 v11.10.5
Some checks failed
Docker (tags) / security (push) Failing after 4s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:10:59 +00:00
4dca747386 fix(build): rename smart tooling config to .smartconfig.json and update package references 2026-03-26 07:10:59 +00:00
7663f502fa v11.10.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-24 13:40:28 +00:00
104cd417d8 fix(monitoring): handle multiple protocol cache entries per backend in metrics output 2026-03-24 13:40:28 +00:00
93254d5d3d v11.10.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 21:18:20 +00:00
9a3f121a9c fix(deps): bump tstest, smartmetrics, and taskbuffer to latest patch releases 2026-03-23 21:18:20 +00:00
bef74eb1aa v11.10.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 14:22:24 +00:00
308d8e4851 fix(deps): bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1 2026-03-23 14:22:24 +00:00
dc010dc3ae v11.10.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 10:29:16 +00:00
61d5d3b1ad fix(deps): bump @push.rocks/smartproxy to ^26.2.0 2026-03-23 10:29:16 +00:00
dd70790d40 v11.10.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 07:17:33 +00:00
2f8c04edc4 feat(monitoring): add backend protocol metrics to network stats and ops dashboard 2026-03-23 07:17:33 +00:00
474cc328dd v11.9.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-21 22:30:30 +00:00
39ff159bf7 fix(lifecycle): clean up service subscriptions, proxy retries, and stale runtime state on shutdown 2026-03-21 22:30:30 +00:00
c7fe7aeb50 v11.9.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 15:35:10 +00:00
2cf362020f feat(dcrouter): add service manager lifecycle orchestration and health-based ops status reporting 2026-03-20 15:35:10 +00:00
b62bad3616 v11.8.11
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 10:31:05 +00:00
3d372863a4 fix(deps): bump @push.rocks/smartproxy to ^25.17.10 2026-03-20 10:31:05 +00:00
1045dc04fe v11.8.10
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 08:31:46 +00:00
89ef7597df fix(deps): bump @push.rocks/smartproxy to ^25.17.9 2026-03-20 08:31:46 +00:00
0804544564 v11.8.9
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 08:08:38 +00:00
671e72452a fix(deps): bump @push.rocks/smartproxy to ^25.17.8 2026-03-20 08:08:38 +00:00
647c705b81 v11.8.8
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 07:51:50 +00:00
40c3202082 fix(deps): bump @push.rocks/smartproxy to ^25.17.7 2026-03-20 07:51:50 +00:00
3b91ed3d5a v11.8.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 03:21:29 +00:00
133b17f136 fix(deps): bump @push.rocks/smartproxy to ^25.17.4 2026-03-20 03:21:29 +00:00
efa45dfdc9 v11.8.6
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 02:55:37 +00:00
79b4ea6bd9 fix(deps): bump @push.rocks/smartproxy to ^25.17.3 2026-03-20 02:55:37 +00:00
b483412a2e v11.8.5
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 02:37:35 +00:00
d964515ff9 fix(deps): bump @push.rocks/smartproxy to ^25.17.1 2026-03-20 02:37:35 +00:00
e2c453423e v11.8.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 00:53:07 +00:00
c44b7d513a fix(deps): bump @serve.zone/remoteingress to ^4.14.0 2026-03-20 00:53:07 +00:00
2487f77b8a v11.8.3
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 00:12:09 +00:00
ea80ef005c fix(deps): bump @serve.zone/remoteingress to ^4.13.2 2026-03-20 00:12:09 +00:00
dd45b7fbe7 v11.8.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 23:29:57 +00:00
ca73da7b9b fix(deps): bump smartproxy and remoteingress dependencies 2026-03-19 23:29:57 +00:00
f6e1951aa2 v11.8.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 22:01:50 +00:00
76fd563e21 fix(dcrouter): use constructor routes for remote ingress setup and bump smartproxy dependency 2026-03-19 22:01:50 +00:00
ee831ea057 v11.8.0
Some checks failed
Docker (tags) / security (push) Failing after 21s
Docker (tags) / test (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
Docker (tags) / release (push) Has been skipped
2026-03-19 21:30:06 +00:00
a65c2ec096 feat(remoteingress): add UDP listen port derivation and edge configuration support 2026-03-19 21:30:06 +00:00
65822278d5 v11.7.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 20:43:47 +00:00
aa3955fc67 fix(deps): bump @push.rocks/smartproxy to ^25.16.0 2026-03-19 20:43:47 +00:00
d4605062bb v11.7.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 19:10:33 +00:00
cd3f08d55f feat(readme): document HTTP/3 QUIC support and configuration options 2026-03-19 19:10:33 +00:00
6d447f0086 v11.6.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 19:06:15 +00:00
c7de3873d8 feat(http3): add automatic HTTP/3 route augmentation for qualifying HTTPS routes 2026-03-19 19:06:15 +00:00
6d4e30e8a9 v11.5.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 16:54:39 +00:00
0e308b692b fix(project): no changes to commit 2026-03-19 16:54:39 +00:00
9f74b6e063 v11.5.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 16:41:16 +00:00
1d0f47f256 feat(opsserver): add configurable OpsServer port and update related tests and documentation 2026-03-19 16:41:16 +00:00
4e9301ae2a v11.4.0
Some checks failed
Docker (tags) / security (push) Failing after 2m0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 14:58:08 +00:00
7e2142ce53 feat(docs): document OCI container deployment and enable verbose docker build scripts 2026-03-19 14:58:08 +00:00
67190605a6 v11.3.0
Some checks failed
Docker (tags) / security (push) Failing after 13s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-18 16:22:02 +00:00
9479a07ddf feat(docker): add OCI container startup configuration and migrate Docker release pipeline to tsdocker 2026-03-18 16:22:02 +00:00
fbed56092f v11.2.56
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-18 00:13:58 +00:00
547b82b35b fix(deps): bump @serve.zone/remoteingress to ^4.9.0 2026-03-18 00:13:58 +00:00
3dc63fa02e v11.2.55
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 23:29:58 +00:00
e0154f5b70 fix(deps): bump @serve.zone/catalog to ^2.7.0 and @serve.zone/remoteingress to ^4.8.18 2026-03-17 23:29:58 +00:00
b268409897 v11.2.54
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 19:14:26 +00:00
f3a9fd12c5 fix(deps): bump @serve.zone/remoteingress to ^4.8.16 2026-03-17 19:14:26 +00:00
ef741d84fb v11.2.53
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 16:49:17 +00:00
b0ea97b922 fix(deps): bump @push.rocks/smartproxy and @serve.zone/remoteingress patch versions 2026-03-17 16:49:17 +00:00
d1560811f5 v11.2.52
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 15:51:31 +00:00
5e872c4e6a fix(deps): bump @serve.zone/remoteingress to ^4.8.13 2026-03-17 15:51:31 +00:00
3620e4549a v11.2.51
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 13:28:33 +00:00
b32865e790 fix(deps): bump @serve.zone/remoteingress to ^4.8.12 2026-03-17 13:28:33 +00:00
ebe71a2a94 v11.2.50
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 13:06:39 +00:00
877a2ad0ee fix(deps): bump @serve.zone/remoteingress to ^4.8.11 2026-03-17 13:06:39 +00:00
7be1aaedb3 v11.2.49
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 12:50:44 +00:00
05eb8e9723 fix(deps): bump @serve.zone/remoteingress to ^4.8.10 2026-03-17 12:50:44 +00:00
d95d89ea6f v11.2.48
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 12:36:14 +00:00
5d1b988579 fix(deps): bump @serve.zone/remoteingress to ^4.8.9 2026-03-17 12:36:14 +00:00
bae85eea9e v11.2.47
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 12:24:37 +00:00
2be7974991 fix(deps): bump @push.rocks/smartproxy to ^25.11.23 2026-03-17 12:24:37 +00:00
ac03b1f081 v11.2.46
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 12:13:49 +00:00
5ca209dd5a fix(deps): bump @push.rocks/smartproxy to ^25.11.22 2026-03-17 12:13:49 +00:00
867e93b246 v11.2.45
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 12:05:41 +00:00
aa9c4c1c28 fix(deps): bump @push.rocks/smartproxy and @serve.zone/remoteingress dependencies 2026-03-17 12:05:41 +00:00
207f21cb77 v11.2.44
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 11:18:11 +00:00
96a47ef588 fix(deps): bump @serve.zone/remoteingress to ^4.8.3 2026-03-17 11:18:11 +00:00
66 changed files with 3530 additions and 2176 deletions

View File

@@ -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}}

View File

@@ -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

View File

@@ -72,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"]
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"json.schemas": [ "json.schemas": [
{ {
"fileMatch": ["/npmextra.json"], "fileMatch": ["/.smartconfig.json"],
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -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" ]

View File

@@ -1,5 +1,251 @@
# Changelog # Changelog
## 2026-03-26 - 11.10.7 - fix(sms)
update sms service to use async ProjectInfo initialization
- Replace direct ProjectInfo construction with the async create() factory in the SMS service startup flow
- Bump related dependencies including @push.rocks/projectinfo, @push.rocks/smartdata, @push.rocks/smartmongo, @serve.zone/remoteingress, and @git.zone/tstest
## 2026-03-26 - 11.10.6 - fix(typescript)
tighten TypeScript null safety and error handling across backend and ops UI
- add explicit unknown error typing and safe message access in logging and handler code
- mark deferred-initialized class properties with definite assignment assertions to satisfy stricter TypeScript checks
- harden ops web state access and action return types with non-null assertions and explicit Promise state typing
- update storage reads to allow missing values and align license file references with the lowercase license filename
## 2026-03-26 - 11.10.5 - fix(build)
rename smart tooling config to .smartconfig.json and update package references
- Moves the shared tool configuration from npmextra.json to .smartconfig.json.
- Updates package.json published files and documentation to reference the new config file.
- Refreshes several development and runtime dependency versions alongside the config migration.
## 2026-03-24 - 11.10.4 - fix(monitoring)
handle multiple protocol cache entries per backend in metrics output
- Group detected protocol cache entries by backend host and port so multiple domain-specific records are preserved.
- Emit one backend metrics row per cached domain and avoid dropping unmatched protocol cache entries by tracking seen entries with a composite host:port:domain key.
- Use cached protocol values when available while keeping backend-only rows for metrics without protocol cache data.
## 2026-03-23 - 11.10.3 - fix(deps)
bump tstest, smartmetrics, and taskbuffer to latest patch releases
- update @git.zone/tstest from ^3.5.0 to ^3.5.1
- update @push.rocks/smartmetrics from ^3.0.2 to ^3.0.3
- update @push.rocks/taskbuffer from ^8.0.0 to ^8.0.2
## 2026-03-23 - 11.10.2 - fix(deps)
bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1
- Updates @api.global/typedserver from ^8.4.2 to ^8.4.6
- Updates @push.rocks/smartproxy from ^26.2.0 to ^26.2.1
## 2026-03-23 - 11.10.1 - fix(deps)
bump @push.rocks/smartproxy to ^26.2.0
- Updates the @push.rocks/smartproxy dependency from ^26.1.0 to ^26.2.0 in package.json.
## 2026-03-23 - 11.10.0 - feat(monitoring)
add backend protocol metrics to network stats and ops dashboard
- Expose backend protocol, connection, error, and suppression metrics in stats responses.
- Add typed backend info interfaces and app state support for backend metrics.
- Render a new backend protocols table in the ops network view with detail modal and suppression badges.
- Update smartproxy and lik dependencies to support backend protocol metrics collection.
## 2026-03-21 - 11.9.1 - fix(lifecycle)
clean up service subscriptions, proxy retries, and stale runtime state on shutdown
- unsubscribe from ServiceManager event streams and use one-time signal handlers to avoid duplicate shutdown execution
- reset existing SmartProxy instances before retry setup and prune expired certificate backoff cache entries
- add periodic sweeping and shutdown cleanup for stale RADIUS accounting sessions
## 2026-03-20 - 11.9.0 - feat(dcrouter)
add service manager lifecycle orchestration and health-based ops status reporting
- register dcrouter components with a taskbuffer ServiceManager using dependencies, retries, and critical/optional service roles
- update ops stats health output to reflect aggregated service manager state and per-service error or retry details
- add @push.rocks/taskbuffer to shared plugins and project dependencies for service lifecycle management
## 2026-03-20 - 11.8.11 - fix(deps)
bump @push.rocks/smartproxy to ^25.17.10
- Updates the @push.rocks/smartproxy dependency from ^25.17.9 to ^25.17.10 in package.json
## 2026-03-20 - 11.8.10 - fix(deps)
bump @push.rocks/smartproxy to ^25.17.9
- Updates @push.rocks/smartproxy from ^25.17.8 to ^25.17.9 in package.json
## 2026-03-20 - 11.8.9 - fix(deps)
bump @push.rocks/smartproxy to ^25.17.8
- Updates the @push.rocks/smartproxy dependency from ^25.17.7 to ^25.17.8.
## 2026-03-20 - 11.8.8 - fix(deps)
bump @push.rocks/smartproxy to ^25.17.7
- Updates the @push.rocks/smartproxy dependency from ^25.17.4 to ^25.17.7 in package.json.
## 2026-03-20 - 11.8.7 - fix(deps)
bump @push.rocks/smartproxy to ^25.17.4
- updates @push.rocks/smartproxy from ^25.17.3 to ^25.17.4 in package.json
## 2026-03-20 - 11.8.6 - fix(deps)
bump @push.rocks/smartproxy to ^25.17.3
- updates @push.rocks/smartproxy from ^25.17.1 to ^25.17.3 in package.json
## 2026-03-20 - 11.8.5 - fix(deps)
bump @push.rocks/smartproxy to ^25.17.1
- Updates the @push.rocks/smartproxy dependency from ^25.17.0 to ^25.17.1.
## 2026-03-20 - 11.8.4 - fix(deps)
bump @serve.zone/remoteingress to ^4.14.0
- Updates the @serve.zone/remoteingress dependency from ^4.13.2 to ^4.14.0 in package.json.
## 2026-03-20 - 11.8.3 - fix(deps)
bump @serve.zone/remoteingress to ^4.13.2
- Updates the @serve.zone/remoteingress dependency from ^4.13.1 to ^4.13.2.
## 2026-03-19 - 11.8.2 - fix(deps)
bump smartproxy and remoteingress dependencies
- updates @push.rocks/smartproxy from ^25.16.3 to ^25.17.0
- updates @serve.zone/remoteingress from ^4.13.0 to ^4.13.1
## 2026-03-19 - 11.8.1 - fix(dcrouter)
use constructor routes for remote ingress setup and bump smartproxy dependency
- Switch remote ingress initialization to use constructorRoutes instead of smartProxyConfig routes so derived edge ports are based on the active route set.
- Update @push.rocks/smartproxy from ^25.16.2 to ^25.16.3.
## 2026-03-19 - 11.8.0 - feat(remoteingress)
add UDP listen port derivation and edge configuration support
- derive UDP ports from remote ingress routes using transport 'udp' or 'all'
- expose effective UDP listen ports in allowed edge payloads and remote ingress interfaces
- update @push.rocks/smartproxy to ^25.16.2
## 2026-03-19 - 11.7.1 - fix(deps)
bump @push.rocks/smartproxy to ^25.16.0
- updates the smartproxy dependency from ^25.15.0 to ^25.16.0
## 2026-03-19 - 11.7.0 - feat(readme)
document HTTP/3 QUIC support and configuration options
- Add a dedicated README section explaining default HTTP/3 route augmentation, qualification rules, and opt-out behavior.
- Document the new global `http3` configuration shape and re-exported `IHttp3Config` type.
- Update TypeScript module documentation to include the built-in HTTP/3 augmentation module and exports.
## 2026-03-19 - 11.6.0 - feat(http3)
add automatic HTTP/3 route augmentation for qualifying HTTPS routes
- introduce configurable HTTP/3 augmentation utilities for eligible SmartProxy routes on port 443
- apply HTTP/3 settings to both constructor-defined and stored programmatic routes, with global and per-route opt-out support
- export the HTTP/3 config type and add test coverage for qualification, augmentation behavior, and defaults
- bump @push.rocks/smartproxy to ^25.15.0 for HTTP/3-related support
## 2026-03-19 - 11.5.1 - fix(project)
no changes to commit
## 2026-03-19 - 11.5.0 - feat(opsserver)
add configurable OpsServer port and update related tests and documentation
- introduces an optional `opsServerPort` configuration that overrides the default OpsServer port 3000
- updates OpsServer startup logic to use the configured port
- adjusts integration tests to run against dedicated OpsServer ports to avoid conflicts
- documents the new OpsServer port option in the README and TypeScript docs
- includes dependency updates and a remote ingress port range type refinement
## 2026-03-19 - 11.4.0 - feat(docs)
document OCI container deployment and enable verbose docker build scripts
- adds a new README section covering Docker/OCI container deployment, environment variables, and image build/push commands
- updates docker build and release npm scripts to pass the --verbose flag for more detailed output
## 2026-03-18 - 11.3.0 - feat(docker)
add OCI container startup configuration and migrate Docker release pipeline to tsdocker
- adds OCI container mode startup that reads DcRouter options from environment variables and an optional JSON config file
- simplifies the Docker image to a two-stage build with production dependencies only and Alpine runtime compatibility packages
- updates Gitea workflows and npm scripts to use tsdocker for image build and release
## 2026-03-18 - 11.2.56 - fix(deps)
bump @serve.zone/remoteingress to ^4.9.0
- Updates @serve.zone/remoteingress from ^4.8.18 to ^4.9.0 in package.json
## 2026-03-17 - 11.2.55 - fix(deps)
bump @serve.zone/catalog to ^2.7.0 and @serve.zone/remoteingress to ^4.8.18
- updates @serve.zone/catalog from ^2.6.2 to ^2.7.0
- updates @serve.zone/remoteingress from ^4.8.16 to ^4.8.18
## 2026-03-17 - 11.2.54 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.16
- Updates @serve.zone/remoteingress from ^4.8.14 to ^4.8.16 in package.json.
## 2026-03-17 - 11.2.53 - fix(deps)
bump @push.rocks/smartproxy and @serve.zone/remoteingress patch versions
- update @push.rocks/smartproxy from ^25.11.23 to ^25.11.24
- update @serve.zone/remoteingress from ^4.8.13 to ^4.8.14
## 2026-03-17 - 11.2.52 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.13
- Updates the @serve.zone/remoteingress dependency from ^4.8.12 to ^4.8.13.
## 2026-03-17 - 11.2.51 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.12
- Updates @serve.zone/remoteingress from ^4.8.11 to ^4.8.12 in package.json
## 2026-03-17 - 11.2.50 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.11
- updates @serve.zone/remoteingress from ^4.8.10 to ^4.8.11
## 2026-03-17 - 11.2.49 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.10
- Updates @serve.zone/remoteingress from ^4.8.9 to ^4.8.10 in package.json
## 2026-03-17 - 11.2.48 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.9
- Updates @serve.zone/remoteingress from ^4.8.7 to ^4.8.9 in package.json
## 2026-03-17 - 11.2.47 - fix(deps)
bump @push.rocks/smartproxy to ^25.11.23
- Updates the @push.rocks/smartproxy dependency from ^25.11.22 to ^25.11.23 in package.json
## 2026-03-17 - 11.2.46 - fix(deps)
bump @push.rocks/smartproxy to ^25.11.22
- Updates the @push.rocks/smartproxy dependency from ^25.11.21 to ^25.11.22 in package.json.
## 2026-03-17 - 11.2.45 - fix(deps)
bump @push.rocks/smartproxy and @serve.zone/remoteingress dependencies
- update @push.rocks/smartproxy from ^25.11.20 to ^25.11.21
- update @serve.zone/remoteingress from ^4.8.3 to ^4.8.7
## 2026-03-17 - 11.2.44 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.3
- Updates @serve.zone/remoteingress from ^4.8.2 to ^4.8.3 in package.json
## 2026-03-17 - 11.2.43 - fix(deps) ## 2026-03-17 - 11.2.43 - fix(deps)
bump @serve.zone/remoteingress to ^4.8.2 bump @serve.zone/remoteingress to ^4.8.2

21
license Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "11.2.43", "version": "11.10.7",
"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": {
@@ -16,51 +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.3.0", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.9.1", "@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.3.2", "@git.zone/tstest": "^3.6.1",
"@git.zone/tswatch": "^3.3.0", "@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.0" "@types/node": "^25.5.0"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.3.0", "@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.2", "@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.48.5", "@design.estate/dees-catalog": "^3.49.0",
"@design.estate/dees-element": "^2.2.3", "@design.estate/dees-element": "^2.2.3",
"@push.rocks/lik": "^6.3.1", "@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.1.0",
"@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.1.0", "@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdns": "^7.9.0", "@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.2.1", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.2", "@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmongo": "^5.1.0", "@push.rocks/smartmongo": "^5.1.1",
"@push.rocks/smartmta": "^5.3.1", "@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.11.20", "@push.rocks/smartproxy": "^26.2.4",
"@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.2.0", "@push.rocks/smartstate": "^2.2.1",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@serve.zone/catalog": "^2.6.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.8.2", "@serve.zone/remoteingress": "^4.14.3",
"@tsclass/tsclass": "^9.4.0", "@tsclass/tsclass": "^9.5.0",
"lru-cache": "^11.2.7", "lru-cache": "^11.2.7",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
@@ -109,7 +112,7 @@
"dist_ts_apiclient/**/*", "dist_ts_apiclient/**/*",
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", ".smartconfig.json",
"readme.md" "readme.md"
] ]
} }

2773
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -133,7 +133,7 @@ The project now uses tswatch for development:
```bash ```bash
pnpm run watch pnpm run watch
``` ```
Configuration in `npmextra.json`: Configuration in `.smartconfig.json`:
```json ```json
{ {
"@git.zone/tswatch": { "@git.zone/tswatch": {

179
readme.md
View File

@@ -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)
@@ -30,12 +31,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [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)
@@ -343,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.
@@ -424,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;
@@ -511,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.
@@ -1015,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
@@ -1216,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 {
@@ -1226,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';
``` ```
@@ -1272,15 +1397,59 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
| `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.
**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. **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.

View File

@@ -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,
} }

View File

@@ -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 }
}); });

View 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();

View File

@@ -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'
); );

View File

@@ -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: 3101,
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:3101/typedrequest',
'adminLoginWithUsernameAndPassword' 'adminLoginWithUsernameAndPassword'
); );
@@ -33,7 +34,7 @@ tap.test('should login as admin', async () => {
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'
); );
@@ -49,7 +50,7 @@ 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'
); );
@@ -66,7 +67,7 @@ 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'
); );
@@ -87,7 +88,7 @@ tap.test('should respond to configuration request', async () => {
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'
); );
@@ -104,7 +105,7 @@ tap.test('should handle log retrieval request', async () => {
tap.test('should reject unauthenticated requests', async () => { tap.test('should reject unauthenticated requests', 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'
); );

View File

@@ -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'
); );
@@ -84,7 +85,7 @@ tap.test('should reject verify identity with invalid JWT', async () => {
tap.test('should reject protected 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'
); );
@@ -100,7 +101,7 @@ tap.test('should reject protected endpoints without auth', async () => {
tap.test('should allow authenticated access to protected endpoints', 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'
); );

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.2.43', version: '11.10.7',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -48,14 +48,14 @@ export class CacheCleaner {
this.isRunning = true; this.isRunning = true;
// Run cleanup immediately on start // Run cleanup immediately on start
this.runCleanup().catch((error) => { this.runCleanup().catch((error: unknown) => {
logger.log('error', `Initial cache cleanup failed: ${error.message}`); logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
}); });
// Schedule periodic cleanup // Schedule periodic cleanup
this.cleanupInterval = setInterval(() => { this.cleanupInterval = setInterval(() => {
this.runCleanup().catch((error) => { this.runCleanup().catch((error: unknown) => {
logger.log('error', `Cache cleanup failed: ${error.message}`); logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
}); });
}, this.options.intervalMs); }, this.options.intervalMs);
@@ -113,8 +113,8 @@ export class CacheCleaner {
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}` `Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
); );
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Cache cleanup error: ${error.message}`); logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -138,14 +138,14 @@ export class CacheCleaner {
try { try {
await doc.delete(); await doc.delete();
deletedCount++; deletedCount++;
} catch (deleteError) { } catch (deleteError: unknown) {
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`); logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
} }
} }
return deletedCount; return deletedCount;
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error cleaning collection: ${error.message}`); logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
return 0; return 0;
} }
} }

View File

@@ -22,7 +22,7 @@ export abstract class CachedDocument<T extends CachedDocument<T>> extends plugin
* Timestamp when the document expires and should be cleaned up * Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator * NOTE: Subclasses must add @svDb() decorator
*/ */
public expiresAt: Date; public expiresAt!: Date;
/** /**
* Timestamp of last access (for LRU-style eviction if needed) * Timestamp of last access (for LRU-style eviction if needed)

View File

@@ -23,8 +23,8 @@ export interface ICacheDbOptions {
export class CacheDb { export class CacheDb {
private static instance: CacheDb | null = null; private static instance: CacheDb | null = null;
private localTsmDb: plugins.smartmongo.LocalTsmDb; private localTsmDb!: plugins.smartmongo.LocalTsmDb;
private smartdataDb: plugins.smartdata.SmartdataDb; private smartdataDb!: plugins.smartdata.SmartdataDb;
private options: Required<ICacheDbOptions>; private options: Required<ICacheDbOptions>;
private isStarted: boolean = false; private isStarted: boolean = false;
@@ -89,8 +89,8 @@ export class CacheDb {
this.isStarted = true; this.isStarted = true;
logger.log('info', `CacheDb started at ${this.options.storagePath}`); logger.log('info', `CacheDb started at ${this.options.storagePath}`);
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to start CacheDb: ${error.message}`); logger.log('error', `Failed to start CacheDb: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -116,8 +116,8 @@ export class CacheDb {
this.isStarted = false; this.isStarted = false;
logger.log('info', 'CacheDb stopped'); logger.log('info', 'CacheDb stopped');
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error stopping CacheDb: ${error.message}`); logger.log('error', `Error stopping CacheDb: ${(error as Error).message}`);
throw error; throw error;
} }
} }

View File

@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
*/ */
@plugins.smartdata.unI() @plugins.smartdata.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public id: string; public id!: string;
/** /**
* Email message ID (RFC 822 Message-ID header) * Email message ID (RFC 822 Message-ID header)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public messageId: string; public messageId!: string;
/** /**
* Sender email address (envelope from) * Sender email address (envelope from)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public from: string; public from!: string;
/** /**
* Recipient email addresses * Recipient email addresses
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public to: string[]; public to!: string[];
/** /**
* CC recipients * CC recipients
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public cc: string[]; public cc!: string[];
/** /**
* BCC recipients * BCC recipients
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public bcc: string[]; public bcc!: string[];
/** /**
* Email subject * Email subject
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public subject: string; public subject!: string;
/** /**
* Raw RFC822 email content * Raw RFC822 email content
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public rawContent: string; public rawContent!: string;
/** /**
* Current status of the email * Current status of the email
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public status: TCachedEmailStatus; public status!: TCachedEmailStatus;
/** /**
* Number of delivery attempts * Number of delivery attempts
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
* Timestamp for next delivery attempt * Timestamp for next delivery attempt
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public nextAttempt: Date; public nextAttempt!: Date;
/** /**
* Last error message if delivery failed * Last error message if delivery failed
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public lastError: string; public lastError!: string;
/** /**
* Timestamp when the email was successfully delivered * Timestamp when the email was successfully delivered
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public deliveredAt: Date; public deliveredAt!: Date;
/** /**
* Sender domain (for querying/filtering) * Sender domain (for querying/filtering)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public senderDomain: string; public senderDomain!: string;
/** /**
* Priority level (higher = more important) * Priority level (higher = more important)
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
* JSON-serialized route data * JSON-serialized route data
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public routeData: string; public routeData!: string;
/** /**
* DKIM signature status * DKIM signature status

View File

@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
*/ */
@plugins.smartdata.unI() @plugins.smartdata.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public ipAddress: string; public ipAddress!: string;
/** /**
* Reputation score (0-100, higher = better) * Reputation score (0-100, higher = better)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public score: number; public score!: number;
/** /**
* Whether the IP is flagged as spam source * Whether the IP is flagged as spam source
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isSpam: boolean; public isSpam!: boolean;
/** /**
* Whether the IP is a known proxy * Whether the IP is a known proxy
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isProxy: boolean; public isProxy!: boolean;
/** /**
* Whether the IP is a Tor exit node * Whether the IP is a Tor exit node
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isTor: boolean; public isTor!: boolean;
/** /**
* Whether the IP is a VPN endpoint * Whether the IP is a VPN endpoint
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isVPN: boolean; public isVPN!: boolean;
/** /**
* Country code (ISO 3166-1 alpha-2) * Country code (ISO 3166-1 alpha-2)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public country: string; public country!: string;
/** /**
* Autonomous System Number * Autonomous System Number
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public asn: string; public asn!: string;
/** /**
* Organization name * Organization name
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public org: string; public org!: string;
/** /**
* List of blacklists the IP appears on * List of blacklists the IP appears on
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public blacklists: string[]; public blacklists!: string[];
/** /**
* Number of times this IP has been checked * Number of times this IP has been checked

View File

@@ -61,14 +61,21 @@ export class CertProvisionScheduler {
} }
/** /**
* Check if a domain is currently in backoff * Check if a domain is currently in backoff.
* Expired entries are pruned from the cache to prevent unbounded growth.
*/ */
async isInBackoff(domain: string): Promise<boolean> { async isInBackoff(domain: string): Promise<boolean> {
const entry = await this.loadBackoff(domain); const entry = await this.loadBackoff(domain);
if (!entry) return false; if (!entry) return false;
const retryAfter = new Date(entry.retryAfter); const retryAfter = new Date(entry.retryAfter);
return retryAfter.getTime() > Date.now(); if (retryAfter.getTime() > Date.now()) {
return true;
}
// Backoff has expired — prune the stale entry
this.backoffCache.delete(domain);
return false;
} }
/** /**
@@ -124,9 +131,12 @@ export class CertProvisionScheduler {
const entry = await this.loadBackoff(domain); const entry = await this.loadBackoff(domain);
if (!entry) return null; if (!entry) return null;
// Only return if still in backoff // Only return if still in backoff — prune expired entries
const retryAfter = new Date(entry.retryAfter); const retryAfter = new Date(entry.retryAfter);
if (retryAfter.getTime() <= Date.now()) return null; if (retryAfter.getTime() <= Date.now()) {
this.backoffCache.delete(domain);
return null;
}
return { return {
failures: entry.failures, failures: entry.failures,

View File

@@ -24,6 +24,7 @@ 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 { RouteConfigManager, ApiTokenManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/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 */
@@ -163,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;
@@ -203,7 +215,7 @@ export class DcRouter {
public emailServer?: UnifiedEmailServer; public emailServer?: UnifiedEmailServer;
public radiusServer?: RadiusServer; public radiusServer?: RadiusServer;
public storageManager: StorageManager; public storageManager: StorageManager;
public opsServer: OpsServer; public opsServer!: OpsServer;
public metricsManager?: MetricsManager; public metricsManager?: MetricsManager;
// Cache system (smartdata + LocalTsmDb) // Cache system (smartdata + LocalTsmDb)
@@ -240,6 +252,11 @@ export class DcRouter {
// Certificate provisioning scheduler with per-domain backoff // Certificate provisioning scheduler with per-domain backoff
public certProvisionScheduler?: CertProvisionScheduler; public certProvisionScheduler?: CertProvisionScheduler;
// Service lifecycle management
public serviceManager: plugins.taskbuffer.ServiceManager;
private serviceSubjectSubscription?: plugins.smartrx.rxjs.Subscription;
public smartAcmeReady = false;
// TypedRouter for API endpoints // TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -267,66 +284,253 @@ export class DcRouter {
// Initialize storage manager // Initialize storage manager
this.storageManager = new StorageManager(this.options.storage); this.storageManager = new StorageManager(this.options.storage);
// Initialize service manager and register all services
this.serviceManager = new plugins.taskbuffer.ServiceManager({
name: 'dcrouter',
startupTimeoutMs: 120_000,
shutdownTimeoutMs: 30_000,
});
this.registerServices();
}
/**
* Register all dcrouter services with the ServiceManager.
* Services are started in dependency order, with failure isolation for optional services.
*/
private registerServices(): void {
// OpsServer: critical, no dependencies — provides visibility
this.serviceManager.addService(
new plugins.taskbuffer.Service('OpsServer')
.critical()
.withStart(async () => {
this.opsServer = new OpsServer(this);
await this.opsServer.start();
})
.withStop(async () => {
await this.opsServer?.stop();
})
.withRetry({ maxRetries: 0 }),
);
// CacheDb: optional, no dependencies
if (this.options.cacheConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('CacheDb')
.optional()
.withStart(async () => {
await this.setupCacheDb();
})
.withStop(async () => {
if (this.cacheCleaner) {
this.cacheCleaner.stop();
this.cacheCleaner = undefined;
}
if (this.cacheDb) {
await this.cacheDb.stop();
CacheDb.resetInstance();
this.cacheDb = undefined;
}
})
.withRetry({ maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 5000 }),
);
}
// MetricsManager: optional, depends on OpsServer
this.serviceManager.addService(
new plugins.taskbuffer.Service('MetricsManager')
.optional()
.dependsOn('OpsServer')
.withStart(async () => {
this.metricsManager = new MetricsManager(this);
await this.metricsManager.start();
})
.withStop(async () => {
if (this.metricsManager) {
await this.metricsManager.stop();
this.metricsManager = undefined;
}
})
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
);
// SmartProxy: critical, depends on CacheDb (if enabled)
const smartProxyDeps: string[] = [];
if (this.options.cacheConfig?.enabled !== false) {
smartProxyDeps.push('CacheDb');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy')
.critical()
.dependsOn(...smartProxyDeps)
.withStart(async () => {
await this.setupSmartProxy();
})
.withStop(async () => {
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
await this.smartProxy.stop();
this.smartProxy = undefined;
}
})
.withRetry({ maxRetries: 0 }),
);
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits
// Only registered if DNS challenge is configured
if (this.options.dnsChallenge?.cloudflareApiKey) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartAcme')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
if (this.smartAcme) {
await this.smartAcme.start();
this.smartAcmeReady = true;
logger.log('info', 'SmartAcme DNS-01 provider is now ready');
}
})
.withStop(async () => {
this.smartAcmeReady = false;
if (this.smartAcme) {
await this.smartAcme.stop();
this.smartAcme = undefined;
}
})
.withRetry({ maxRetries: 20, baseDelayMs: 5000, maxDelayMs: 3_600_000, backoffFactor: 2 }),
);
}
// ConfigManagers: optional, depends on SmartProxy
this.serviceManager.addService(
new plugins.taskbuffer.Service('ConfigManagers')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
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();
})
.withStop(async () => {
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
})
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
);
// Email Server: optional, depends on SmartProxy
if (this.options.emailConfig) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailServer')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupUnifiedEmailHandling();
})
.withStop(async () => {
if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
await this.emailServer.stop();
this.emailServer = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// DNS Server: optional, depends on SmartProxy
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && this.options.dnsScopes && this.options.dnsScopes.length > 0) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsServer')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupDnsWithSocketHandler();
})
.withStop(async () => {
// Flush pending DNS batch log
if (this.dnsBatchTimer) {
clearTimeout(this.dnsBatchTimer);
if (this.dnsBatchCount > 0) {
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (final flush)`, { zone: 'dns' });
}
this.dnsBatchTimer = null;
this.dnsBatchCount = 0;
this.dnsLogWindowSecond = 0;
this.dnsLogWindowCount = 0;
}
if (this.dnsServer) {
this.dnsServer.removeAllListeners();
await this.dnsServer.stop();
this.dnsServer = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// RADIUS Server: optional, no dependency on SmartProxy
if (this.options.radiusConfig) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('RadiusServer')
.optional()
.withStart(async () => {
await this.setupRadiusServer();
})
.withStop(async () => {
if (this.radiusServer) {
await this.radiusServer.stop();
this.radiusServer = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// Remote Ingress: optional, depends on SmartProxy
if (this.options.remoteIngressConfig?.enabled) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('RemoteIngress')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupRemoteIngress();
})
.withStop(async () => {
if (this.tunnelManager) {
await this.tunnelManager.stop();
this.tunnelManager = undefined;
}
this.remoteIngressManager = undefined;
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// Wire up aggregated events for logging
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
logger.log(level as any, `Service '${event.serviceName}': ${event.type}`, {
state: event.state,
...(event.error ? { error: event.error } : {}),
...(event.attempt ? { attempt: event.attempt } : {}),
});
});
} }
public async start() { public async start() {
logger.log('info', 'Starting DcRouter Services'); logger.log('info', 'Starting DcRouter Services');
await this.serviceManager.start();
this.logStartupSummary();
this.opsServer = new OpsServer(this);
await this.opsServer.start();
try {
// Initialize cache database if enabled (default: enabled)
if (this.options.cacheConfig?.enabled !== false) {
await this.setupCacheDb();
}
// Initialize MetricsManager
this.metricsManager = new MetricsManager(this);
await this.metricsManager.start();
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
await this.setupSmartProxy();
// Initialize programmatic config API managers
this.routeConfigManager = new RouteConfigManager(
this.storageManager,
() => this.getConstructorRoutes(),
() => this.smartProxy,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize();
// Set up unified email handling if configured
if (this.options.emailConfig) {
await this.setupUnifiedEmailHandling();
}
// Set up DNS server if configured with nameservers and scopes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 &&
this.options.dnsScopes && this.options.dnsScopes.length > 0) {
await this.setupDnsWithSocketHandler();
}
// Set up RADIUS server if configured
if (this.options.radiusConfig) {
await this.setupRadiusServer();
}
// Set up Remote Ingress hub if configured
if (this.options.remoteIngressConfig?.enabled) {
await this.setupRemoteIngress();
}
this.logStartupSummary();
} catch (error) {
logger.log('error', 'Error starting DcRouter', { error: String(error) });
// Try to clean up any services that may have started
await this.stop();
throw error;
}
} }
/** /**
@@ -386,7 +590,21 @@ export class DcRouter {
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)`); 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)`);
} }
logger.log('info', 'All services are running'); // Service status summary from ServiceManager
const health = this.serviceManager.getHealth();
const statuses = health.services;
const running = statuses.filter(s => s.state === 'running').length;
const failed = statuses.filter(s => s.state === 'failed').length;
const retrying = statuses.filter(s => s.state === 'starting' || s.state === 'degraded').length;
if (failed > 0) {
const failedNames = statuses.filter(s => s.state === 'failed').map(s => `${s.name}: ${s.lastError || 'unknown'}`);
logger.log('warn', `DcRouter started in degraded mode — ${running} running, ${failed} failed: ${failedNames.join('; ')}`);
} else if (retrying > 0) {
logger.log('info', `DcRouter started — ${running} running, ${retrying} still initializing`);
} else {
logger.log('info', `All ${running} services are running`);
}
} }
/** /**
@@ -422,6 +640,13 @@ export class DcRouter {
*/ */
private async setupSmartProxy(): Promise<void> { private async setupSmartProxy(): Promise<void> {
logger.log('info', 'Setting up SmartProxy...'); logger.log('info', 'Setting up SmartProxy...');
// Clean up any existing SmartProxy instance (e.g. from a retry)
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
this.smartProxy = undefined;
}
let routes: plugins.smartproxy.IRouteConfig[] = []; let routes: plugins.smartproxy.IRouteConfig[] = [];
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined; let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
@@ -466,6 +691,13 @@ export class DcRouter {
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 // Cache constructor routes for RouteConfigManager
this.constructorRoutes = [...routes]; this.constructorRoutes = [...routes];
@@ -515,10 +747,13 @@ export class DcRouter {
// Initialize cert provision scheduler // Initialize cert provision scheduler
this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager); this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager);
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction // If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction
// Note: SmartAcme.start() is NOT called here — it runs as a separate optional service
// via the ServiceManager, with aggressive retry for rate-limit resilience.
if (challengeHandlers.length > 0) { if (challengeHandlers.length > 0) {
// 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) {
this.smartAcmeReady = false;
await this.smartAcme.stop().catch(err => await this.smartAcme.stop().catch(err =>
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) }) logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
); );
@@ -530,10 +765,15 @@ export class DcRouter {
challengeHandlers: challengeHandlers, challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'], challengePriority: ['dns-01'],
}); });
await this.smartAcme.start();
const scheduler = this.certProvisionScheduler; const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => { smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
// If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01
if (!this.smartAcmeReady) {
eventComms.warn(`SmartAcme not yet initialized, falling back to http-01 for ${domain}`);
return 'http01';
}
// Check backoff before attempting provision // Check backoff before attempting provision
if (await scheduler.isInBackoff(domain)) { if (await scheduler.isInBackoff(domain)) {
const info = await scheduler.getBackoffInfo(domain); const info = await scheduler.getBackoffInfo(domain);
@@ -547,7 +787,7 @@ export class DcRouter {
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`); eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
eventComms.setSource('smartacme-dns-01'); eventComms.setSource('smartacme-dns-01');
const isWildcardDomain = domain.startsWith('*.'); const isWildcardDomain = domain.startsWith('*.');
const cert = await this.smartAcme.getCertificateForDomain(domain, { const cert = await this.smartAcme!.getCertificateForDomain(domain, {
includeWildcard: !isWildcardDomain, includeWildcard: !isWildcardDomain,
}); });
if (cert.validUntil) { if (cert.validUntil) {
@@ -566,10 +806,10 @@ export class DcRouter {
// Success — clear any backoff // Success — clear any backoff
await scheduler.clearBackoff(domain); await scheduler.clearBackoff(domain);
return result; return result;
} catch (err) { } catch (err: unknown) {
// Record failure for backoff tracking // Record failure for backoff tracking
await scheduler.recordFailure(domain, err.message); await scheduler.recordFailure(domain, (err as Error).message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`); eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`);
return 'http01'; return 'http01';
} }
}; };
@@ -894,105 +1134,29 @@ export class DcRouter {
public async stop() { public async stop() {
logger.log('info', 'Stopping DcRouter services...'); logger.log('info', 'Stopping DcRouter services...');
// Flush pending DNS batch log // Unsubscribe from service events before stopping services
if (this.dnsBatchTimer) { if (this.serviceSubjectSubscription) {
clearTimeout(this.dnsBatchTimer); this.serviceSubjectSubscription.unsubscribe();
if (this.dnsBatchCount > 0) { this.serviceSubjectSubscription = undefined;
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(); // ServiceManager handles reverse-dependency-ordered shutdown
await this.serviceManager.stop();
try { // Clear backoff cache in cert scheduler
// Remove event listeners before stopping services to prevent leaks if (this.certProvisionScheduler) {
if (this.smartProxy) { this.certProvisionScheduler.clear();
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
await Promise.all([
// Stop cache cleaner if running
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
// Stop metrics manager if running
this.metricsManager ? this.metricsManager.stop().catch(err => logger.log('error', 'Error stopping MetricsManager', { error: String(err) })) : Promise.resolve(),
// Stop unified email server if running
this.emailServer ? this.emailServer.stop().catch(err => logger.log('error', 'Error stopping email server', { error: String(err) })) : Promise.resolve(),
// Stop SmartAcme if running
this.smartAcme ? this.smartAcme.stop().catch(err => logger.log('error', 'Error stopping SmartAcme', { error: String(err) })) : Promise.resolve(),
// Stop HTTP SmartProxy if running
this.smartProxy ? this.smartProxy.stop().catch(err => logger.log('error', 'Error stopping SmartProxy', { error: String(err) })) : Promise.resolve(),
// Stop DNS server if running
this.dnsServer ?
this.dnsServer.stop().catch(err => logger.log('error', 'Error stopping DNS server', { error: String(err) })) :
Promise.resolve(),
// Stop RADIUS server if running
this.radiusServer ?
this.radiusServer.stop().catch(err => logger.log('error', 'Error stopping RADIUS server', { error: String(err) })) :
Promise.resolve(),
// Stop Remote Ingress tunnel manager if running
this.tunnelManager ?
this.tunnelManager.stop().catch(err => logger.log('error', 'Error stopping TunnelManager', { error: String(err) })) :
Promise.resolve()
]);
// Stop cache database after other services (they may need it during shutdown)
if (this.cacheDb) {
await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
CacheDb.resetInstance();
}
// Clear backoff cache in cert scheduler
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
// Allow GC of stopped services by nulling references
this.smartProxy = undefined;
this.emailServer = undefined;
this.dnsServer = undefined;
this.metricsManager = undefined;
this.cacheCleaner = undefined;
this.cacheDb = undefined;
this.tunnelManager = undefined;
this.radiusServer = undefined;
this.smartAcme = undefined;
this.certProvisionScheduler = undefined; this.certProvisionScheduler = undefined;
this.remoteIngressManager = undefined;
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.certificateStatusMap.clear();
// Reset security singletons to allow GC
SecurityLogger.resetInstance();
ContentScanner.resetInstance();
IPReputationChecker.resetInstance();
logger.log('info', 'All DcRouter services stopped');
} catch (error) {
logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
throw error;
} }
this.certificateStatusMap.clear();
// Reset security singletons to allow GC
SecurityLogger.resetInstance();
ContentScanner.resetInstance();
IPReputationChecker.resetInstance();
logger.log('info', 'All DcRouter services stopped');
} }
/** /**
@@ -1084,21 +1248,21 @@ export class DcRouter {
// Wire delivery events to MetricsManager and logger // Wire delivery events to MetricsManager and logger
if (this.metricsManager && this.emailServer.deliverySystem) { if (this.metricsManager && this.emailServer.deliverySystem) {
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => { this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
this.metricsManager.trackEmailReceived(item?.from); this.metricsManager!.trackEmailReceived(item?.from);
logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' }); logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' });
}); });
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => { this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
this.metricsManager.trackEmailSent(item?.to); this.metricsManager!.trackEmailSent(item?.to);
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' }); logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
}); });
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => { this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
this.metricsManager.trackEmailFailed(item?.to, error?.message); this.metricsManager!.trackEmailFailed(item?.to, error?.message);
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' }); logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
}); });
} }
if (this.metricsManager && this.emailServer) { if (this.metricsManager && this.emailServer) {
this.emailServer.on('bounceProcessed', () => { this.emailServer.on('bounceProcessed', () => {
this.metricsManager.trackEmailBounced(); this.metricsManager!.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' }); logger.log('warn', 'Email bounce processed', { zone: 'email' });
}); });
} }
@@ -1141,8 +1305,8 @@ export class DcRouter {
} }
logger.log('info', 'All unified email components stopped'); logger.log('info', 'All unified email components stopped');
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error stopping unified email components: ${error.message}`); logger.log('error', `Error stopping unified email components: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -1304,7 +1468,7 @@ export class DcRouter {
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => { this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
// Metrics tracking // Metrics tracking
for (const question of event.questions) { for (const question of event.questions) {
this.metricsManager.trackDnsQuery( this.metricsManager?.trackDnsQuery(
question.type, question.type,
question.name, question.name,
false, false,
@@ -1389,8 +1553,8 @@ export class DcRouter {
// 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: unknown) {
logger.log('error', `DNS socket handler error: ${error.message}`); logger.log('error', `DNS socket handler error: ${(error as Error).message}`);
if (!socket.destroyed) { if (!socket.destroyed) {
socket.destroy(); socket.destroy();
} }
@@ -1531,12 +1695,12 @@ export class DcRouter {
} else { } else {
logger.log('warn', `Invalid DKIM record structure in ${file}`); logger.log('warn', `Invalid DKIM record structure in ${file}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to load DKIM record from ${file}: ${error.message}`); logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
} }
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to load DKIM records: ${error.message}`); logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
} }
return records; return records;
@@ -1570,8 +1734,8 @@ export class DcRouter {
// This ensures keys are ready even if DNS mode changes later // This ensures keys are ready even if DNS mode changes later
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain); await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`); logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${error.message}`); logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
} }
} }
@@ -1615,8 +1779,8 @@ export class DcRouter {
} else { } else {
logger.log('warn', 'Could not auto-discover public IPv4 address'); logger.log('warn', 'Could not auto-discover public IPv4 address');
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to auto-discover public IP: ${error.message}`); logger.log('error', `Failed to auto-discover public IP: ${(error as Error).message}`);
} }
if (!publicIp) { if (!publicIp) {
@@ -1712,8 +1876,8 @@ export class DcRouter {
} }
return null; return null;
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to detect public IP: ${error.message}`); logger.log('warn', `Failed to detect public IP: ${(error as Error).message}`);
return null; return null;
} }
} }
@@ -1733,7 +1897,7 @@ 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) // Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
@@ -1747,8 +1911,8 @@ export class DcRouter {
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8'); const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
tlsConfig = { certPem, keyPem }; tlsConfig = { certPem, keyPem };
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel'); logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
} catch (err) { } catch (err: unknown) {
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`); logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${(err as Error).message}`);
} }
} }

View File

@@ -7,6 +7,7 @@ import type {
IMergedRoute, IMergedRoute,
IRouteWarning, IRouteWarning,
} from '../../ts_interfaces/data/route-management.js'; } from '../../ts_interfaces/data/route-management.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
const ROUTES_PREFIX = '/config-api/routes/'; const ROUTES_PREFIX = '/config-api/routes/';
const OVERRIDES_PREFIX = '/config-api/overrides/'; const OVERRIDES_PREFIX = '/config-api/overrides/';
@@ -20,6 +21,7 @@ export class RouteConfigManager {
private storageManager: StorageManager, private storageManager: StorageManager,
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
) {} ) {}
/** /**
@@ -258,10 +260,15 @@ export class RouteConfigManager {
enabledRoutes.push(route); enabledRoutes.push(route);
} }
// Add enabled programmatic routes // Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
const http3Config = this.getHttp3Config?.();
for (const stored of this.storedRoutes.values()) { for (const stored of this.storedRoutes.values()) {
if (stored.enabled) { if (stored.enabled) {
enabledRoutes.push(stored.route); if (http3Config && http3Config.enabled !== false) {
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
} else {
enabledRoutes.push(stored.route);
}
} }
} }

View File

@@ -170,7 +170,7 @@ export class ConfigValidator {
} else if (rules.items.schema && itemType === 'object') { } else if (rules.items.schema && itemType === 'object') {
const itemResult = this.validate(value[i], rules.items.schema); const itemResult = this.validate(value[i], rules.items.schema);
if (!itemResult.valid) { if (!itemResult.valid) {
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`)); errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
} }
} }
} }
@@ -181,7 +181,7 @@ export class ConfigValidator {
if (rules.schema) { if (rules.schema) {
const nestedResult = this.validate(value, rules.schema); const nestedResult = this.validate(value, rules.schema);
if (!nestedResult.valid) { if (!nestedResult.valid) {
errors.push(...nestedResult.errors.map(err => `${key}.${err}`)); errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
} }
validatedConfig[key] = nestedResult.config; validatedConfig[key] = nestedResult.config;
} }
@@ -234,7 +234,7 @@ export class ConfigValidator {
// Apply defaults to array items // Apply defaults to array items
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) { if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
result[key] = result[key].map(item => result[key] = result[key].map(item =>
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
); );
} }
} }
@@ -255,7 +255,7 @@ export class ConfigValidator {
if (!result.valid) { if (!result.valid) {
throw new ValidationError( throw new ValidationError(
`Configuration validation failed: ${result.errors.join(', ')}`, `Configuration validation failed: ${result.errors!.join(', ')}`,
'CONFIG_VALIDATION_ERROR', 'CONFIG_VALIDATION_ERROR',
{ data: { errors: result.errors } } { data: { errors: result.errors } }
); );

View File

@@ -227,7 +227,7 @@ export class PlatformError extends Error {
const { retry } = this.context; const { retry } = this.context;
if (!retry) return false; if (!retry) return false;
return retry.currentRetry < retry.maxRetries; return (retry.currentRetry ?? 0) < (retry.maxRetries ?? 0);
} }
/** /**

View 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
View File

@@ -0,0 +1 @@
export * from './http3-route-augmentation.js';

View File

@@ -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.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
};

View File

@@ -296,11 +296,11 @@ export class MetricsManager {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null; const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) { if (!proxyMetrics) {
return []; return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
} }
const connectionsByRoute = proxyMetrics.connections.byRoute(); const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo = []; const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
for (const [routeName, count] of connectionsByRoute) { for (const [routeName, count] of connectionsByRoute) {
connectionInfo.push({ connectionInfo.push({
@@ -558,6 +558,7 @@ export class MetricsManager {
throughputByIP: new Map<string, { in: number; out: number }>(), throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [] as Array<any>,
}; };
} }
@@ -590,6 +591,110 @@ export class MetricsManager {
const requestsPerSecond = proxyMetrics.requests.perSecond(); const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total(); const requestsTotal = proxyMetrics.requests.total();
// Collect backend protocol data
const backendMetrics = proxyMetrics.backends.byBackend();
const protocolCache = proxyMetrics.backends.detectedProtocols();
// Group protocol cache entries by host:port so we can match them to backend metrics.
// The protocol cache is keyed by (host, port, domain) in Rust, so the same host:port
// can have multiple entries for different domains.
const cacheByBackend = new Map<string, (typeof protocolCache)[number][]>();
for (const entry of protocolCache) {
const backendKey = `${entry.host}:${entry.port}`;
let entries = cacheByBackend.get(backendKey);
if (!entries) {
entries = [];
cacheByBackend.set(backendKey, entries);
}
entries.push(entry);
}
const backends: Array<any> = [];
const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) {
const cacheEntries = cacheByBackend.get(key);
if (!cacheEntries || cacheEntries.length === 0) {
// No protocol cache entry — emit one row with backend metrics only
backends.push({
backend: key,
domain: null,
protocol: bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
cacheAgeSecs: null,
});
} else {
// One row per domain, each enriched with the shared backend metrics
for (const cache of cacheEntries) {
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
seenCacheKeys.add(compositeKey);
backends.push({
backend: key,
domain: cache.domain ?? null,
protocol: cache.protocol ?? bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
h2Suppressed: cache.h2Suppressed,
h3Suppressed: cache.h3Suppressed,
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
h3CooldownRemainingSecs: cache.h3CooldownRemainingSecs,
h2ConsecutiveFailures: cache.h2ConsecutiveFailures,
h3ConsecutiveFailures: cache.h3ConsecutiveFailures,
h3Port: cache.h3Port,
cacheAgeSecs: cache.ageSecs,
});
}
}
}
// Include protocol cache entries with no matching backend metric
for (const entry of protocolCache) {
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
if (!seenCacheKeys.has(compositeKey)) {
backends.push({
backend: `${entry.host}:${entry.port}`,
domain: entry.domain,
protocol: entry.protocol,
activeConnections: 0,
totalConnections: 0,
connectErrors: 0,
handshakeErrors: 0,
requestErrors: 0,
avgConnectTimeMs: 0,
poolHitRate: 0,
h2Failures: 0,
h2Suppressed: entry.h2Suppressed,
h3Suppressed: entry.h3Suppressed,
h2CooldownRemainingSecs: entry.h2CooldownRemainingSecs,
h3CooldownRemainingSecs: entry.h3CooldownRemainingSecs,
h2ConsecutiveFailures: entry.h2ConsecutiveFailures,
h3ConsecutiveFailures: entry.h3ConsecutiveFailures,
h3Port: entry.h3Port,
cacheAgeSecs: entry.ageSecs,
});
}
}
return { return {
connectionsByIP, connectionsByIP,
throughputRate, throughputRate,
@@ -599,6 +704,7 @@ export class MetricsManager {
throughputByIP, throughputByIP,
requestsPerSecond, requestsPerSecond,
requestsTotal, requestsTotal,
backends,
}; };
}, 1000); // 1s cache — matches typical dashboard poll interval }, 1000); // 1s cache — matches typical dashboard poll interval
} }

View File

@@ -7,7 +7,7 @@ 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;
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers // Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -17,17 +17,17 @@ export class OpsServer {
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>(); public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
// Handler instances // Handler instances
public adminHandler: handlers.AdminHandler; public adminHandler!: handlers.AdminHandler;
private configHandler: handlers.ConfigHandler; private configHandler!: handlers.ConfigHandler;
private logsHandler: handlers.LogsHandler; private logsHandler!: handlers.LogsHandler;
private securityHandler: handlers.SecurityHandler; private securityHandler!: handlers.SecurityHandler;
private statsHandler: handlers.StatsHandler; private statsHandler!: handlers.StatsHandler;
private radiusHandler: handlers.RadiusHandler; private radiusHandler!: handlers.RadiusHandler;
private emailOpsHandler: handlers.EmailOpsHandler; private emailOpsHandler!: handlers.EmailOpsHandler;
private certificateHandler: handlers.CertificateHandler; private certificateHandler!: handlers.CertificateHandler;
private remoteIngressHandler: handlers.RemoteIngressHandler; private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler: handlers.RouteManagementHandler; private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler: handlers.ApiTokenHandler; private apiTokenHandler!: handlers.ApiTokenHandler;
constructor(dcRouterRefArg: DcRouter) { constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg; this.dcRouterRef = dcRouterRefArg;
@@ -39,7 +39,7 @@ export class OpsServer {
public async start() { public async start() {
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({ this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost', domain: 'localhost',
feedMetadata: null, feedMetadata: undefined,
serveDir: paths.distServe, serveDir: paths.distServe,
}); });
@@ -50,7 +50,7 @@ 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);
} }
/** /**

View File

@@ -12,7 +12,7 @@ export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// JWT instance // JWT instance
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>; public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
// Simple in-memory user storage (in production, use proper database) // Simple in-memory user storage (in production, use proper database)
private users = new Map<string, { private users = new Map<string, {

View File

@@ -311,8 +311,8 @@ export class CertificateHandler {
} }
} }
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` }; return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err) { } catch (err: unknown) {
return { success: false, message: err.message || 'Failed to reprovision certificate' }; return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
} }
} }
@@ -340,8 +340,8 @@ export class CertificateHandler {
try { try {
await dcRouter.smartAcme.getCertificateForDomain(domain); await dcRouter.smartAcme.getCertificateForDomain(domain);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` }; return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err) { } catch (err: unknown) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` }; return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
} }
} }
@@ -351,8 +351,8 @@ export class CertificateHandler {
try { try {
await smartProxy.provisionCertificate(routeNames[0]); await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` }; return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
} catch (err) { } catch (err: unknown) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` }; return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
} }
} }

View File

@@ -52,8 +52,8 @@ export class RadiusHandler {
try { try {
await radiusServer.addClient(dataArg.client); await radiusServer.addClient(dataArg.client);
return { success: true }; return { success: true };
} catch (error) { } catch (error: unknown) {
return { success: false, message: error.message }; return { success: false, message: (error as Error).message };
} }
} }
) )
@@ -144,8 +144,8 @@ export class RadiusHandler {
updatedAt: mapping.updatedAt, updatedAt: mapping.updatedAt,
}, },
}; };
} catch (error) { } catch (error: unknown) {
return { success: false, message: error.message }; return { success: false, message: (error as Error).message };
} }
} }
) )

View File

@@ -101,6 +101,7 @@ export class SecurityHandler {
throughputByIP, throughputByIP,
requestsPerSecond: networkStats.requestsPerSecond || 0, requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0, requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [],
}; };
} }
@@ -114,6 +115,7 @@ export class SecurityHandler {
throughputByIP: [], throughputByIP: [],
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [],
}; };
} }
) )

View File

@@ -279,7 +279,7 @@ export class StatsHandler {
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) { if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push( promises.push(
(async () => { (async () => {
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats(); const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
const serverStats = await this.collectServerStats(); const serverStats = await this.collectServerStats();
// Build per-IP bandwidth lookup from throughputByIP // Build per-IP bandwidth lookup from throughputByIP
@@ -309,6 +309,7 @@ export class StatsHandler {
throughputHistory: stats.throughputHistory || [], throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0, requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0, requestsTotal: stats.requestsTotal || 0,
backends: stats.backends || [],
}; };
})() })()
); );
@@ -489,43 +490,40 @@ export class StatsHandler {
message?: string; message?: string;
}>; }>;
}> { }> {
const services: Array<{ const dcRouter = this.opsServerRef.dcRouterRef;
name: string; const health = dcRouter.serviceManager.getHealth();
status: 'healthy' | 'degraded' | 'unhealthy';
message?: string;
}> = [];
// Check HTTP Proxy const services = health.services.map((svc) => {
if (this.opsServerRef.dcRouterRef.smartProxy) { let status: 'healthy' | 'degraded' | 'unhealthy';
services.push({ switch (svc.state) {
name: 'HTTP/HTTPS Proxy', case 'running':
status: 'healthy', status = 'healthy';
}); break;
} case 'starting':
case 'degraded':
status = 'degraded';
break;
case 'failed':
status = svc.criticality === 'critical' ? 'unhealthy' : 'degraded';
break;
case 'stopped':
case 'stopping':
default:
status = 'degraded';
break;
}
// Check Email Server let message: string | undefined;
if (this.opsServerRef.dcRouterRef.emailServer) { if (svc.state === 'failed' && svc.lastError) {
services.push({ message = svc.lastError;
name: 'Email Server', } else if (svc.retryCount > 0 && svc.state !== 'running') {
status: 'healthy', message = `Retry attempt ${svc.retryCount}`;
}); }
}
// Check DNS Server return { name: svc.name, status, message };
if (this.opsServerRef.dcRouterRef.dnsServer) {
services.push({
name: 'DNS Server',
status: 'healthy',
});
}
// Check OpsServer
services.push({
name: 'OpsServer',
status: 'healthy',
}); });
const healthy = services.every(s => s.status === 'healthy'); const healthy = health.overall === 'healthy';
return { return {
healthy, healthy,

View File

@@ -62,8 +62,9 @@ import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest'; import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrx from '@push.rocks/smartrx'; import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique'; import * as smartunique from '@push.rocks/smartunique';
import * as taskbuffer from '@push.rocks/taskbuffer';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique }; export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer };
// Define SmartLog types for use in error handling // Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug'; export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';

View File

@@ -92,6 +92,8 @@ export interface IAccountingManagerConfig {
detailedLogging?: boolean; detailedLogging?: boolean;
/** Maximum active sessions to track in memory */ /** Maximum active sessions to track in memory */
maxActiveSessions?: number; maxActiveSessions?: number;
/** Stale session timeout in hours — sessions with no update for this long are evicted (default: 24) */
staleSessionTimeoutHours?: number;
} }
/** /**
@@ -105,6 +107,7 @@ export class AccountingManager {
private activeSessions: Map<string, IAccountingSession> = new Map(); private activeSessions: Map<string, IAccountingSession> = new Map();
private config: Required<IAccountingManagerConfig>; private config: Required<IAccountingManagerConfig>;
private storageManager?: StorageManager; private storageManager?: StorageManager;
private staleSessionSweepTimer?: ReturnType<typeof setInterval>;
// Counters for statistics // Counters for statistics
private stats = { private stats = {
@@ -121,6 +124,7 @@ export class AccountingManager {
retentionDays: config?.retentionDays ?? 30, retentionDays: config?.retentionDays ?? 30,
detailedLogging: config?.detailedLogging ?? false, detailedLogging: config?.detailedLogging ?? false,
maxActiveSessions: config?.maxActiveSessions ?? 10000, maxActiveSessions: config?.maxActiveSessions ?? 10000,
staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24,
}; };
this.storageManager = storageManager; this.storageManager = storageManager;
} }
@@ -132,9 +136,60 @@ export class AccountingManager {
if (this.storageManager) { if (this.storageManager) {
await this.loadActiveSessions(); await this.loadActiveSessions();
} }
// Start periodic sweep to evict stale sessions (every 15 minutes)
this.staleSessionSweepTimer = setInterval(() => {
this.sweepStaleSessions();
}, 15 * 60 * 1000);
// Allow the process to exit even if the timer is pending
if (this.staleSessionSweepTimer.unref) {
this.staleSessionSweepTimer.unref();
}
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`); logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
} }
/**
* Stop the accounting manager and clean up timers
*/
stop(): void {
if (this.staleSessionSweepTimer) {
clearInterval(this.staleSessionSweepTimer);
this.staleSessionSweepTimer = undefined;
}
}
/**
* Sweep stale active sessions that have not received any update
* within the configured timeout. These are orphaned sessions where
* the Stop packet was never received.
*/
private sweepStaleSessions(): void {
const timeoutMs = this.config.staleSessionTimeoutHours * 60 * 60 * 1000;
const cutoff = Date.now() - timeoutMs;
let swept = 0;
for (const [sessionId, session] of this.activeSessions) {
if (session.lastUpdateTime < cutoff) {
session.status = 'terminated';
session.terminateCause = 'StaleSessionTimeout';
session.endTime = Date.now();
session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000);
if (this.storageManager) {
this.archiveSession(session).catch(() => {});
}
this.activeSessions.delete(sessionId);
swept++;
}
}
if (swept > 0) {
logger.log('info', `Swept ${swept} stale RADIUS sessions (no update for ${this.config.staleSessionTimeoutHours}h)`);
}
}
/** /**
* Handle accounting start request * Handle accounting start request
*/ */
@@ -463,8 +518,8 @@ export class AccountingManager {
if (deletedCount > 0) { if (deletedCount > 0) {
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`); logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to cleanup old sessions: ${error.message}`); logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`);
} }
return deletedCount; return deletedCount;
@@ -527,8 +582,8 @@ export class AccountingManager {
// Ignore individual errors // Ignore individual errors
} }
} }
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to load active sessions: ${error.message}`); logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
} }
} }
@@ -543,8 +598,8 @@ export class AccountingManager {
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`; const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
try { try {
await this.storageManager.setJSON(key, session); await this.storageManager.setJSON(key, session);
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`); logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as Error).message}`);
} }
} }
@@ -565,8 +620,8 @@ export class AccountingManager {
const date = new Date(session.endTime); const date = new Date(session.endTime);
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`; const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
await this.storageManager.setJSON(archiveKey, session); await this.storageManager.setJSON(archiveKey, session);
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`); logger.log('error', `Failed to archive session ${session.sessionId}: ${(error as Error).message}`);
} }
} }
@@ -598,8 +653,8 @@ export class AccountingManager {
// Ignore individual errors // Ignore individual errors
} }
} }
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to get archived sessions: ${error.message}`); logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
} }
return sessions; return sessions;

View File

@@ -183,6 +183,8 @@ export class RadiusServer {
this.radiusServer = undefined; this.radiusServer = undefined;
} }
this.accountingManager.stop();
this.running = false; this.running = false;
logger.log('info', 'RADIUS server stopped'); logger.log('info', 'RADIUS server stopped');
} }
@@ -308,8 +310,8 @@ export class RadiusServer {
default: default:
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`); logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `RADIUS accounting error: ${error.message}`); logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
} }
return { code: plugins.smartradius.ERadiusCode.AccountingResponse }; return { code: plugins.smartradius.ERadiusCode.AccountingResponse };

View File

@@ -104,7 +104,7 @@ export class VlanManager {
if (this.normalizedMacCache.size > 10000) { if (this.normalizedMacCache.size > 10000) {
const iterator = this.normalizedMacCache.keys(); const iterator = this.normalizedMacCache.keys();
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
this.normalizedMacCache.delete(iterator.next().value); this.normalizedMacCache.delete(iterator.next().value!);
} }
} }
@@ -348,8 +348,8 @@ export class VlanManager {
} }
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`); logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`); logger.log('warn', `Failed to load VLAN mappings from storage: ${(error as Error).message}`);
} }
} }
@@ -364,8 +364,8 @@ export class VlanManager {
try { try {
const mappings = Array.from(this.mappings.values()); const mappings = Array.from(this.mappings.values());
await this.storageManager.setJSON(this.config.storagePrefix, mappings); await this.storageManager.setJSON(this.config.storagePrefix, mappings);
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`); logger.log('error', `Failed to save VLAN mappings to storage: ${(error as Error).message}`);
} }
} }
} }

View File

@@ -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` |
@@ -126,7 +136,7 @@ Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks c
## 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.
**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. **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.

View File

@@ -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 } : {}),
}); });
} }
} }

View File

@@ -60,7 +60,7 @@ export enum ThreatCategory {
* Content Scanner for detecting malicious email content * Content Scanner for detecting malicious email content
*/ */
export class ContentScanner { export class ContentScanner {
private static instance: ContentScanner; private static instance: ContentScanner | undefined;
private scanCache: LRUCache<string, IScanResult>; private scanCache: LRUCache<string, IScanResult>;
private options: Required<IContentScannerOptions>; private options: Required<IContentScannerOptions>;
@@ -258,10 +258,10 @@ export class ContentScanner {
} }
return result; return result;
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error scanning email: ${error.message}`, { logger.log('error', `Error scanning email: ${(error as Error).message}`, {
messageId: email.getMessageId(), messageId: email.getMessageId(),
error: error.stack error: (error as Error).stack
}); });
// Return a safe default with error indication // Return a safe default with error indication
@@ -271,7 +271,7 @@ export class ContentScanner {
scannedElements: ['error'], scannedElements: ['error'],
timestamp: Date.now(), timestamp: Date.now(),
threatType: 'scan_error', threatType: 'scan_error',
threatDetails: `Scan error: ${error.message}` threatDetails: `Scan error: ${(error as Error).message}`
}; };
} }
} }
@@ -625,8 +625,8 @@ export class ContentScanner {
return sample.toString('utf8') return sample.toString('utf8')
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars .replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
.replace(/\uFFFD/g, ''); // Remove replacement char .replace(/\uFFFD/g, ''); // Remove replacement char
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Error extracting text from buffer: ${error.message}`); logger.log('warn', `Error extracting text from buffer: ${(error as Error).message}`);
return ''; return '';
} }
} }
@@ -699,7 +699,7 @@ export class ContentScanner {
subject: email.subject subject: email.subject
}, },
success: false, success: false,
domain: email.getFromDomain() domain: email.getFromDomain() ?? undefined
}); });
} }
@@ -722,7 +722,7 @@ export class ContentScanner {
subject: email.subject subject: email.subject
}, },
success: false, success: false,
domain: email.getFromDomain() domain: email.getFromDomain() ?? undefined
}); });
} }

View File

@@ -61,7 +61,7 @@ export interface IIPReputationOptions {
* Class for checking IP reputation of inbound email senders * Class for checking IP reputation of inbound email senders
*/ */
export class IPReputationChecker { export class IPReputationChecker {
private static instance: IPReputationChecker; private static instance: IPReputationChecker | undefined;
private reputationCache: LRUCache<string, IReputationResult>; private reputationCache: LRUCache<string, IReputationResult>;
private options: Required<IIPReputationOptions>; private options: Required<IIPReputationOptions>;
private storageManager?: any; // StorageManager instance private storageManager?: any; // StorageManager instance
@@ -127,8 +127,8 @@ export class IPReputationChecker {
// Load cache from disk if enabled // Load cache from disk if enabled
if (this.options.enableLocalCache) { if (this.options.enableLocalCache) {
// Fire and forget the load operation // Fire and forget the load operation
this.loadCache().catch(error => { this.loadCache().catch((error: unknown) => {
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`); logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
}); });
} }
} }
@@ -237,13 +237,13 @@ export class IPReputationChecker {
this.logReputationCheck(ip, result); this.logReputationCheck(ip, result);
return result; return result;
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, { logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
ip, ip,
stack: error.stack stack: (error as Error).stack
}); });
return this.createErrorResult(ip, error.message); return this.createErrorResult(ip, (error as Error).message);
} }
} }
@@ -266,8 +266,8 @@ export class IPReputationChecker {
const lookupDomain = `${reversedIP}.${server}`; const lookupDomain = `${reversedIP}.${server}`;
await plugins.dns.promises.resolve(lookupDomain); await plugins.dns.promises.resolve(lookupDomain);
return server; // IP is listed in this DNSBL return server; // IP is listed in this DNSBL
} catch (error) { } catch (error: unknown) {
if (error.code === 'ENOTFOUND') { if ((error as any).code === 'ENOTFOUND') {
return null; // IP is not listed in this DNSBL return null; // IP is not listed in this DNSBL
} }
throw error; // Other error throw error; // Other error
@@ -286,8 +286,8 @@ export class IPReputationChecker {
listCount: lists.length, listCount: lists.length,
lists lists
}; };
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`); logger.log('error', `Error checking DNSBL for ${ip}: ${(error as Error).message}`);
return { return {
listCount: 0, listCount: 0,
lists: [] lists: []
@@ -349,8 +349,8 @@ export class IPReputationChecker {
org: this.determineOrg(ip), // Simplified, would use real org data org: this.determineOrg(ip), // Simplified, would use real org data
type type
}; };
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`); logger.log('error', `Error getting IP info for ${ip}: ${(error as Error).message}`);
return { return {
type: IPType.UNKNOWN type: IPType.UNKNOWN
}; };
@@ -468,8 +468,8 @@ export class IPReputationChecker {
} }
this.saveCacheTimer = setTimeout(() => { this.saveCacheTimer = setTimeout(() => {
this.saveCacheTimer = null; this.saveCacheTimer = null;
this.saveCache().catch(error => { this.saveCache().catch((error: unknown) => {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`); logger.log('error', `Failed to save IP reputation cache: ${(error as Error).message}`);
}); });
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS); }, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
} }
@@ -506,8 +506,8 @@ export class IPReputationChecker {
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`); logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`); logger.log('error', `Failed to save IP reputation cache: ${(error as Error).message}`);
} }
} }
@@ -542,12 +542,12 @@ export class IPReputationChecker {
plugins.fs.unlinkSync(cacheFile); plugins.fs.unlinkSync(cacheFile);
logger.log('info', 'Old cache file removed after migration'); logger.log('info', 'Old cache file removed after migration');
} catch (deleteError) { } catch (deleteError) {
logger.log('warn', `Could not delete old cache file: ${deleteError.message}`); logger.log('warn', `Could not delete old cache file: ${(deleteError as Error).message}`);
} }
} }
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error loading from StorageManager: ${error.message}`); logger.log('error', `Error loading from StorageManager: ${(error as Error).message}`);
} }
} else { } else {
// No storage manager, load from filesystem // No storage manager, load from filesystem
@@ -578,8 +578,8 @@ export class IPReputationChecker {
const source = fromFilesystem ? 'disk' : 'StorageManager'; const source = fromFilesystem ? 'disk' : 'StorageManager';
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`); logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to load IP reputation cache: ${error.message}`); logger.log('error', `Failed to load IP reputation cache: ${(error as Error).message}`);
} }
} }
@@ -611,8 +611,8 @@ export class IPReputationChecker {
// If cache is enabled and we have entries, save them to the new storage manager // If cache is enabled and we have entries, save them to the new storage manager
if (this.options.enableLocalCache && this.reputationCache.size > 0) { if (this.options.enableLocalCache && this.reputationCache.size > 0) {
this.saveCache().catch(error => { this.saveCache().catch((error: unknown) => {
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`); logger.log('error', `Failed to save cache to new storage manager: ${(error as Error).message}`);
}); });
} }
} }

View File

@@ -58,7 +58,7 @@ export interface ISecurityEvent {
* Security logger for enhanced security monitoring * Security logger for enhanced security monitoring
*/ */
export class SecurityLogger { export class SecurityLogger {
private static instance: SecurityLogger; private static instance: SecurityLogger | undefined;
private securityEvents: ISecurityEvent[] = []; private securityEvents: ISecurityEvent[] = [];
private maxEventHistory: number; private maxEventHistory: number;
private enableNotifications: boolean; private enableNotifications: boolean;
@@ -154,11 +154,13 @@ export class SecurityLogger {
} }
if (filter.fromTimestamp) { if (filter.fromTimestamp) {
filteredEvents = filteredEvents.filter(event => event.timestamp >= filter.fromTimestamp); const fromTs = filter.fromTimestamp;
filteredEvents = filteredEvents.filter(event => event.timestamp >= fromTs);
} }
if (filter.toTimestamp) { if (filter.toTimestamp) {
filteredEvents = filteredEvents.filter(event => event.timestamp <= filter.toTimestamp); const toTs = filter.toTimestamp;
filteredEvents = filteredEvents.filter(event => event.timestamp <= toTs);
} }
} }

View File

@@ -7,7 +7,7 @@ import { smsConfigSchema } from './config/sms.schema.js';
import { ConfigValidator } from '../config/validator.js'; import { ConfigValidator } from '../config/validator.js';
export class SmsService { export class SmsService {
public projectinfo: plugins.projectinfo.ProjectInfo; public projectinfo!: plugins.projectinfo.ProjectInfo;
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
public config: ISmsConfig; public config: ISmsConfig;
@@ -16,7 +16,7 @@ export class SmsService {
const validationResult = ConfigValidator.validate(options, smsConfigSchema); const validationResult = ConfigValidator.validate(options, smsConfigSchema);
if (!validationResult.valid) { if (!validationResult.valid) {
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors.join(', ')}`); logger.warn(`SMS service configuration has validation errors: ${validationResult.errors!.join(', ')}`);
} }
// Set configuration with defaults // Set configuration with defaults
@@ -30,7 +30,7 @@ export class SmsService {
*/ */
public async start() { public async start() {
logger.log('info', `starting sms service`); logger.log('info', `starting sms service`);
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir); this.projectinfo = await plugins.projectinfo.ProjectInfo.create(paths.packageDir);
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>( new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>(
'sendSms', 'sendSms',

View File

@@ -15,7 +15,7 @@ export interface IStorageConfig {
/** Filesystem path for storage */ /** Filesystem path for storage */
fsPath?: string; fsPath?: string;
/** Custom read function */ /** Custom read function */
readFunction?: (key: string) => Promise<string>; readFunction?: (key: string) => Promise<string | null>;
/** Custom write function */ /** Custom write function */
writeFunction?: (key: string, value: string) => Promise<void>; writeFunction?: (key: string, value: string) => Promise<void>;
} }
@@ -57,9 +57,7 @@ export class StorageManager {
this.ensureDirectory(this.fsBasePath); this.ensureDirectory(this.fsBasePath);
// Set up internal filesystem read/write functions // Set up internal filesystem read/write functions
this.config.readFunction = async (key: string) => { this.config.readFunction = (key: string): Promise<string | null> => this.fsRead(key);
return this.fsRead(key);
};
this.config.writeFunction = async (key: string, value: string) => { this.config.writeFunction = async (key: string, value: string) => {
await this.fsWrite(key, value); await this.fsWrite(key, value);
}; };
@@ -88,8 +86,8 @@ export class StorageManager {
private async ensureDirectory(dirPath: string): Promise<void> { private async ensureDirectory(dirPath: string): Promise<void> {
try { try {
await plugins.fsUtils.ensureDir(dirPath); await plugins.fsUtils.ensureDir(dirPath);
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to create storage directory: ${error.message}`); logger.log('error', `Failed to create storage directory: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -129,13 +127,13 @@ export class StorageManager {
/** /**
* Internal filesystem read function * Internal filesystem read function
*/ */
private async fsRead(key: string): Promise<string> { private async fsRead(key: string): Promise<string | null> {
const filePath = this.keyToPath(key); const filePath = this.keyToPath(key);
try { try {
const content = await readFile(filePath, 'utf8'); const content = await readFile(filePath, 'utf8');
return content; return content;
} catch (error) { } catch (error: unknown) {
if (error.code === 'ENOENT') { if ((error as any).code === 'ENOENT') {
return null; return null;
} }
throw error; throw error;
@@ -186,8 +184,8 @@ export class StorageManager {
default: default:
throw new Error(`Unknown backend: ${this.backend}`); throw new Error(`Unknown backend: ${this.backend}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Storage get error for key ${key}: ${error.message}`); logger.log('error', `Storage get error for key ${key}: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -230,7 +228,7 @@ export class StorageManager {
this.memoryStore.set(key, value); this.memoryStore.set(key, value);
// Evict oldest entries if memory store exceeds limit // Evict oldest entries if memory store exceeds limit
while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) { while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) {
const firstKey = this.memoryStore.keys().next().value; const firstKey = this.memoryStore.keys().next().value!;
this.memoryStore.delete(firstKey); this.memoryStore.delete(firstKey);
} }
break; break;
@@ -239,8 +237,8 @@ export class StorageManager {
default: default:
throw new Error(`Unknown backend: ${this.backend}`); throw new Error(`Unknown backend: ${this.backend}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Storage set error for key ${key}: ${error.message}`); logger.log('error', `Storage set error for key ${key}: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -257,8 +255,8 @@ export class StorageManager {
const filePath = this.keyToPath(key); const filePath = this.keyToPath(key);
try { try {
await unlink(filePath); await unlink(filePath);
} catch (error) { } catch (error: unknown) {
if (error.code !== 'ENOENT') { if ((error as any).code !== 'ENOENT') {
throw error; throw error;
} }
} }
@@ -281,8 +279,8 @@ export class StorageManager {
default: default:
throw new Error(`Unknown backend: ${this.backend}`); throw new Error(`Unknown backend: ${this.backend}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Storage delete error for key ${key}: ${error.message}`); logger.log('error', `Storage delete error for key ${key}: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -319,8 +317,8 @@ export class StorageManager {
} }
} }
} }
} catch (error) { } catch (error: unknown) {
if (error.code !== 'ENOENT') { if ((error as any).code !== 'ENOENT') {
throw error; throw error;
} }
} }
@@ -348,8 +346,8 @@ export class StorageManager {
default: default:
throw new Error(`Unknown backend: ${this.backend}`); throw new Error(`Unknown backend: ${this.backend}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Storage list error for prefix ${prefix}: ${error.message}`); logger.log('error', `Storage list error for prefix ${prefix}: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -390,8 +388,8 @@ export class StorageManager {
try { try {
return JSON.parse(value) as T; return JSON.parse(value) as T;
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to parse JSON for key ${key}: ${error.message}`); logger.log('error', `Failed to parse JSON for key ${key}: ${(error as Error).message}`);
throw error; throw error;
} }
} }

View File

@@ -259,7 +259,7 @@ Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`)
## 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.
**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. **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.

View File

@@ -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[];
} }
/** /**

View File

@@ -165,6 +165,7 @@ export interface INetworkMetrics {
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>; throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number; requestsPerSecond?: number;
requestsTotal?: number; requestsTotal?: number;
backends?: IBackendInfo[];
} }
export interface IConnectionDetails { export interface IConnectionDetails {
@@ -175,3 +176,25 @@ export interface IConnectionDetails {
bytesIn: number; bytesIn: number;
bytesOut: number; bytesOut: number;
} }
export interface IBackendInfo {
backend: string;
domain: string | null;
protocol: string;
activeConnections: number;
totalConnections: number;
connectErrors: number;
handshakeErrors: number;
requestErrors: number;
avgConnectTimeMs: number;
poolHitRate: number;
h2Failures: number;
h2Suppressed: boolean;
h3Suppressed: boolean;
h2CooldownRemainingSecs: number | null;
h3CooldownRemainingSecs: number | null;
h2ConsecutiveFailures: number | null;
h3ConsecutiveFailures: number | null;
h3Port: number | null;
cacheAgeSecs: number | null;
}

View File

@@ -280,7 +280,7 @@ console.log('Connection token:', tokenResponse.token);
## 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.
**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. **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.

View File

@@ -179,5 +179,6 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
throughputByIP: Array<{ ip: string; in: number; out: number }>; throughputByIP: Array<{ ip: string; in: number; out: number }>;
requestsPerSecond: number; requestsPerSecond: number;
requestsTotal: number; requestsTotal: number;
backends?: statsInterfaces.IBackendInfo[];
}; };
} }

100
ts_oci_container/index.ts Normal file
View 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;
}

View File

@@ -0,0 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
export {
fs,
path,
};

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.2.43', version: '11.10.7',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -53,6 +53,7 @@ export interface INetworkState {
throughputHistory: Array<{ timestamp: number; in: number; out: number }>; throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond: number; requestsPerSecond: number;
requestsTotal: number; requestsTotal: number;
backends: interfaces.data.IBackendInfo[];
lastUpdated: number; lastUpdated: number;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -148,6 +149,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
throughputHistory: [], throughputHistory: [],
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [],
lastUpdated: 0, lastUpdated: 0,
isLoading: false, isLoading: false,
error: null, error: null,
@@ -238,7 +240,7 @@ interface IActionContext {
} }
const getActionContext = (): IActionContext => { const getActionContext = (): IActionContext => {
const identity = loginStatePart.getState().identity; const identity = loginStatePart.getState()!.identity;
// Treat expired JWTs as no identity — prevents stale persisted sessions from firing requests // Treat expired JWTs as no identity — prevents stale persisted sessions from firing requests
if (identity && identity.expiresAt && identity.expiresAt < Date.now()) { if (identity && identity.expiresAt && identity.expiresAt < Date.now()) {
return { identity: null }; return { identity: null };
@@ -250,7 +252,7 @@ const getActionContext = (): IActionContext => {
export const loginAction = loginStatePart.createAction<{ export const loginAction = loginStatePart.createAction<{
username: string; username: string;
password: string; password: string;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg): Promise<ILoginState> => {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
>('/typedrequest', 'adminLoginWithUsernameAndPassword'); >('/typedrequest', 'adminLoginWithUsernameAndPassword');
@@ -267,10 +269,10 @@ export const loginAction = loginStatePart.createAction<{
isLoggedIn: true, isLoggedIn: true,
}; };
} }
return statePartArg.getState(); return statePartArg.getState()!;
} catch (error) { } catch (error: unknown) {
console.error('Login failed:', error); console.error('Login failed:', error);
return statePartArg.getState(); return statePartArg.getState()!;
} }
}); });
@@ -298,9 +300,9 @@ 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): Promise<IStatsState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -330,19 +332,19 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
isLoading: false, isLoading: false,
error: null, error: null,
}; };
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
isLoading: false, isLoading: false,
error: error.message || 'Failed to fetch statistics', error: (error as Error).message || 'Failed to fetch statistics',
}; };
} }
}); });
// Fetch Configuration Action (read-only) // Fetch Configuration Action (read-only)
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => { export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg): Promise<IConfigState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -359,11 +361,11 @@ export const fetchConfigurationAction = configStatePart.createAction(async (stat
isLoading: false, isLoading: false,
error: null, error: null,
}; };
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
isLoading: false, isLoading: false,
error: error.message || 'Failed to fetch configuration', error: (error as Error).message || 'Failed to fetch configuration',
}; };
} }
}); });
@@ -373,9 +375,9 @@ export const fetchRecentLogsAction = logStatePart.createAction<{
limit?: number; limit?: number;
level?: 'debug' | 'info' | 'warn' | 'error'; level?: 'debug' | 'info' | 'warn' | 'error';
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email'; category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg): Promise<ILogState> => {
const context = getActionContext(); const context = getActionContext();
if (!context.identity) return statePartArg.getState(); 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
@@ -389,14 +391,14 @@ export const fetchRecentLogsAction = logStatePart.createAction<{
}); });
return { return {
...statePartArg.getState(), ...statePartArg.getState()!,
recentLogs: response.logs, recentLogs: response.logs,
}; };
}); });
// Toggle Auto Refresh Action // Toggle Auto Refresh Action
export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePartArg) => { export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePartArg): Promise<IUiState> => {
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
return { return {
...currentState, ...currentState,
autoRefresh: !currentState.autoRefresh, autoRefresh: !currentState.autoRefresh,
@@ -404,8 +406,8 @@ export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePart
}); });
// Set Active View Action // Set Active View Action
export const setActiveViewAction = uiStatePart.createAction<string>(async (statePartArg, viewName) => { export const setActiveViewAction = uiStatePart.createAction<string>(async (statePartArg, viewName): Promise<IUiState> => {
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
// If switching to network view, ensure we fetch network data // If switching to network view, ensure we fetch network data
if (viewName === 'network' && currentState.activeView !== 'network') { if (viewName === 'network' && currentState.activeView !== 'network') {
@@ -449,9 +451,9 @@ 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): Promise<INetworkState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -503,6 +505,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
throughputHistory: networkStatsResponse.throughputHistory || [], throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0, requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
requestsTotal: networkStatsResponse.requestsTotal || 0, requestsTotal: networkStatsResponse.requestsTotal || 0,
backends: networkStatsResponse.backends || [],
lastUpdated: Date.now(), lastUpdated: Date.now(),
isLoading: false, isLoading: false,
error: null, error: null,
@@ -522,9 +525,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
// ============================================================================ // ============================================================================
// Fetch All Emails Action // Fetch All Emails Action
export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => { export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg): Promise<IEmailOpsState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -555,9 +558,9 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
// Certificate Actions // Certificate Actions
// ============================================================================ // ============================================================================
export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => { export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg): Promise<ICertificateState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -586,9 +589,9 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
}); });
export const reprovisionCertificateAction = certificateStatePart.createAction<string>( export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
async (statePartArg, domain, actionContext) => { async (statePartArg, domain, actionContext): Promise<ICertificateState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -596,13 +599,13 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
>('/typedrequest', 'reprovisionCertificateDomain'); >('/typedrequest', 'reprovisionCertificateDomain');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
domain, domain,
}); });
// Re-fetch overview after reprovisioning // Re-fetch overview after reprovisioning
return await actionContext.dispatch(fetchCertificateOverviewAction, null); return await actionContext!.dispatch(fetchCertificateOverviewAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to reprovision certificate', error: error instanceof Error ? error.message : 'Failed to reprovision certificate',
@@ -612,9 +615,9 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
); );
export const deleteCertificateAction = certificateStatePart.createAction<string>( export const deleteCertificateAction = certificateStatePart.createAction<string>(
async (statePartArg, domain, actionContext) => { async (statePartArg, domain, actionContext): Promise<ICertificateState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -622,13 +625,13 @@ export const deleteCertificateAction = certificateStatePart.createAction<string>
>('/typedrequest', 'deleteCertificate'); >('/typedrequest', 'deleteCertificate');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
domain, domain,
}); });
// Re-fetch overview after deletion // Re-fetch overview after deletion
return await actionContext.dispatch(fetchCertificateOverviewAction, null); return await actionContext!.dispatch(fetchCertificateOverviewAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to delete certificate', error: error instanceof Error ? error.message : 'Failed to delete certificate',
@@ -646,9 +649,9 @@ export const importCertificateAction = certificateStatePart.createAction<{
publicKey: string; publicKey: string;
csr: string; csr: string;
}>( }>(
async (statePartArg, cert, actionContext) => { async (statePartArg, cert, actionContext): Promise<ICertificateState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -656,13 +659,13 @@ export const importCertificateAction = certificateStatePart.createAction<{
>('/typedrequest', 'importCertificate'); >('/typedrequest', 'importCertificate');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
cert, cert,
}); });
// Re-fetch overview after import // Re-fetch overview after import
return await actionContext.dispatch(fetchCertificateOverviewAction, null); return await actionContext!.dispatch(fetchCertificateOverviewAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to import certificate', error: error instanceof Error ? error.message : 'Failed to import certificate',
@@ -678,7 +681,7 @@ export async function fetchCertificateExport(domain: string) {
>('/typedrequest', 'exportCertificate'); >('/typedrequest', 'exportCertificate');
return request.fire({ return request.fire({
identity: context.identity, identity: context.identity!,
domain, domain,
}); });
} }
@@ -692,16 +695,16 @@ export async function fetchConnectionToken(edgeId: string) {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetRemoteIngressConnectionToken interfaces.requests.IReq_GetRemoteIngressConnectionToken
>('/typedrequest', 'getRemoteIngressConnectionToken'); >('/typedrequest', 'getRemoteIngressConnectionToken');
return request.fire({ identity: context.identity, edgeId }); return request.fire({ identity: context.identity!, edgeId });
} }
// ============================================================================ // ============================================================================
// Remote Ingress Actions // Remote Ingress Actions
// ============================================================================ // ============================================================================
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => { export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg): Promise<IRemoteIngressState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -740,9 +743,9 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
listenPorts?: number[]; listenPorts?: number[];
autoDerivePorts?: boolean; autoDerivePorts?: boolean;
tags?: string[]; tags?: string[];
}>(async (statePartArg, dataArg, actionContext) => { }>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -750,7 +753,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
>('/typedrequest', 'createRemoteIngress'); >('/typedrequest', 'createRemoteIngress');
const response = await request.fire({ const response = await request.fire({
identity: context.identity, identity: context.identity!,
name: dataArg.name, name: dataArg.name,
listenPorts: dataArg.listenPorts, listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts, autoDerivePorts: dataArg.autoDerivePorts,
@@ -759,16 +762,16 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
if (response.success) { if (response.success) {
// Refresh the list // Refresh the list
await actionContext.dispatch(fetchRemoteIngressAction, null); await actionContext!.dispatch(fetchRemoteIngressAction, null);
return { return {
...statePartArg.getState(), ...statePartArg.getState()!,
newEdgeId: response.edge.id, newEdgeId: response.edge.id,
}; };
} }
return currentState; return currentState;
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to create edge', error: error instanceof Error ? error.message : 'Failed to create edge',
@@ -777,9 +780,9 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
}); });
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>( export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
async (statePartArg, edgeId, actionContext) => { async (statePartArg, edgeId, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -787,12 +790,12 @@ export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<str
>('/typedrequest', 'deleteRemoteIngress'); >('/typedrequest', 'deleteRemoteIngress');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
id: edgeId, id: edgeId,
}); });
return await actionContext.dispatch(fetchRemoteIngressAction, null); return await actionContext!.dispatch(fetchRemoteIngressAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to delete edge', error: error instanceof Error ? error.message : 'Failed to delete edge',
@@ -807,9 +810,9 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
listenPorts?: number[]; listenPorts?: number[];
autoDerivePorts?: boolean; autoDerivePorts?: boolean;
tags?: string[]; tags?: string[];
}>(async (statePartArg, dataArg, actionContext) => { }>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -817,7 +820,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
>('/typedrequest', 'updateRemoteIngress'); >('/typedrequest', 'updateRemoteIngress');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
name: dataArg.name, name: dataArg.name,
listenPorts: dataArg.listenPorts, listenPorts: dataArg.listenPorts,
@@ -825,8 +828,8 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
tags: dataArg.tags, tags: dataArg.tags,
}); });
return await actionContext.dispatch(fetchRemoteIngressAction, null); return await actionContext!.dispatch(fetchRemoteIngressAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to update edge', error: error instanceof Error ? error.message : 'Failed to update edge',
@@ -835,9 +838,9 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
}); });
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>( export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
async (statePartArg, edgeId) => { async (statePartArg, edgeId): Promise<IRemoteIngressState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -845,7 +848,7 @@ export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.create
>('/typedrequest', 'regenerateRemoteIngressSecret'); >('/typedrequest', 'regenerateRemoteIngressSecret');
const response = await request.fire({ const response = await request.fire({
identity: context.identity, identity: context.identity!,
id: edgeId, id: edgeId,
}); });
@@ -867,9 +870,9 @@ export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.create
); );
export const clearNewEdgeIdAction = remoteIngressStatePart.createAction( export const clearNewEdgeIdAction = remoteIngressStatePart.createAction(
async (statePartArg) => { async (statePartArg): Promise<IRemoteIngressState> => {
return { return {
...statePartArg.getState(), ...statePartArg.getState()!,
newEdgeId: null, newEdgeId: null,
}; };
} }
@@ -878,9 +881,9 @@ 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, actionContext) => { }>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -888,13 +891,13 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
>('/typedrequest', 'updateRemoteIngress'); >('/typedrequest', 'updateRemoteIngress');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
return await actionContext.dispatch(fetchRemoteIngressAction, null); return await actionContext!.dispatch(fetchRemoteIngressAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle edge', error: error instanceof Error ? error.message : 'Failed to toggle edge',
@@ -906,9 +909,9 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
// Route Management Actions // Route Management Actions
// ============================================================================ // ============================================================================
export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => { export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -940,9 +943,9 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
export const createRouteAction = routeManagementStatePart.createAction<{ export const createRouteAction = routeManagementStatePart.createAction<{
route: any; route: any;
enabled?: boolean; enabled?: boolean;
}>(async (statePartArg, dataArg, actionContext) => { }>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -950,13 +953,13 @@ export const createRouteAction = routeManagementStatePart.createAction<{
>('/typedrequest', 'createRoute'); >('/typedrequest', 'createRoute');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
route: dataArg.route, route: dataArg.route,
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
return await actionContext.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to create route', error: error instanceof Error ? error.message : 'Failed to create route',
@@ -965,9 +968,9 @@ export const createRouteAction = routeManagementStatePart.createAction<{
}); });
export const deleteRouteAction = routeManagementStatePart.createAction<string>( export const deleteRouteAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeId, actionContext) => { async (statePartArg, routeId, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -975,12 +978,12 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
>('/typedrequest', 'deleteRoute'); >('/typedrequest', 'deleteRoute');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
id: routeId, id: routeId,
}); });
return await actionContext.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to delete route', error: error instanceof Error ? error.message : 'Failed to delete route',
@@ -992,9 +995,9 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
export const toggleRouteAction = routeManagementStatePart.createAction<{ export const toggleRouteAction = routeManagementStatePart.createAction<{
id: string; id: string;
enabled: boolean; enabled: boolean;
}>(async (statePartArg, dataArg, actionContext) => { }>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1002,13 +1005,13 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
>('/typedrequest', 'toggleRoute'); >('/typedrequest', 'toggleRoute');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
return await actionContext.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle route', error: error instanceof Error ? error.message : 'Failed to toggle route',
@@ -1019,9 +1022,9 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
export const setRouteOverrideAction = routeManagementStatePart.createAction<{ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
routeName: string; routeName: string;
enabled: boolean; enabled: boolean;
}>(async (statePartArg, dataArg, actionContext) => { }>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1029,13 +1032,13 @@ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
>('/typedrequest', 'setRouteOverride'); >('/typedrequest', 'setRouteOverride');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
routeName: dataArg.routeName, routeName: dataArg.routeName,
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
return await actionContext.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to set override', error: error instanceof Error ? error.message : 'Failed to set override',
@@ -1044,9 +1047,9 @@ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
}); });
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>( export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeName, actionContext) => { async (statePartArg, routeName, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1054,12 +1057,12 @@ export const removeRouteOverrideAction = routeManagementStatePart.createAction<s
>('/typedrequest', 'removeRouteOverride'); >('/typedrequest', 'removeRouteOverride');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
routeName, routeName,
}); });
return await actionContext.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to remove override', error: error instanceof Error ? error.message : 'Failed to remove override',
@@ -1072,9 +1075,9 @@ export const removeRouteOverrideAction = routeManagementStatePart.createAction<s
// API Token Actions // API Token Actions
// ============================================================================ // ============================================================================
export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => { export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
@@ -1105,7 +1108,7 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT
>('/typedrequest', 'createApiToken'); >('/typedrequest', 'createApiToken');
return request.fire({ return request.fire({
identity: context.identity, identity: context.identity!,
name, name,
scopes, scopes,
expiresInDays, expiresInDays,
@@ -1119,15 +1122,15 @@ export async function rollApiToken(id: string) {
>('/typedrequest', 'rollApiToken'); >('/typedrequest', 'rollApiToken');
return request.fire({ return request.fire({
identity: context.identity, identity: context.identity!,
id, id,
}); });
} }
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>( export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
async (statePartArg, tokenId, actionContext) => { async (statePartArg, tokenId, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1135,12 +1138,12 @@ export const revokeApiTokenAction = routeManagementStatePart.createAction<string
>('/typedrequest', 'revokeApiToken'); >('/typedrequest', 'revokeApiToken');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
id: tokenId, id: tokenId,
}); });
return await actionContext.dispatch(fetchApiTokensAction, null); return await actionContext!.dispatch(fetchApiTokensAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to revoke token', error: error instanceof Error ? error.message : 'Failed to revoke token',
@@ -1152,9 +1155,9 @@ export const revokeApiTokenAction = routeManagementStatePart.createAction<string
export const toggleApiTokenAction = routeManagementStatePart.createAction<{ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
id: string; id: string;
enabled: boolean; enabled: boolean;
}>(async (statePartArg, dataArg, actionContext) => { }>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState()!;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1162,13 +1165,13 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
>('/typedrequest', 'toggleApiToken'); >('/typedrequest', 'toggleApiToken');
await request.fire({ await request.fire({
identity: context.identity, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
return await actionContext.dispatch(fetchApiTokensAction, null); return await actionContext!.dispatch(fetchApiTokensAction, null);
} catch (error) { } catch (error: unknown) {
return { return {
...currentState, ...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle token', error: error instanceof Error ? error.message : 'Failed to toggle token',
@@ -1188,13 +1191,13 @@ socketRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>( new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>(
'pushLogEntry', 'pushLogEntry',
async (dataArg) => { async (dataArg) => {
const current = logStatePart.getState(); const current = logStatePart.getState()!;
const updated = [...current.recentLogs, dataArg.entry]; const updated = [...current.recentLogs, dataArg.entry];
// Cap at 2000 entries // Cap at 2000 entries
if (updated.length > 2000) { if (updated.length > 2000) {
updated.splice(0, updated.length - 2000); updated.splice(0, updated.length - 2000);
} }
logStatePart.setState({ ...current, recentLogs: updated }); logStatePart.setState({ ...current, recentLogs: updated } as ILogState);
return {}; return {};
} }
) )
@@ -1229,7 +1232,7 @@ async function disconnectSocket() {
async function dispatchCombinedRefreshAction() { async function dispatchCombinedRefreshAction() {
const context = getActionContext(); const context = getActionContext();
if (!context.identity) return; 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
@@ -1249,12 +1252,13 @@ async function dispatchCombinedRefreshAction() {
}); });
// Update all stats from combined response // Update all stats from combined response
const currentStatsState = statsStatePart.getState()!;
statsStatePart.setState({ statsStatePart.setState({
...statsStatePart.getState(), ...currentStatsState,
serverStats: combinedResponse.metrics.server || statsStatePart.getState().serverStats, serverStats: combinedResponse.metrics.server || currentStatsState.serverStats,
emailStats: combinedResponse.metrics.email || statsStatePart.getState().emailStats, emailStats: combinedResponse.metrics.email || currentStatsState.emailStats,
dnsStats: combinedResponse.metrics.dns || statsStatePart.getState().dnsStats, dnsStats: combinedResponse.metrics.dns || currentStatsState.dnsStats,
securityMetrics: combinedResponse.metrics.security || statsStatePart.getState().securityMetrics, securityMetrics: combinedResponse.metrics.security || currentStatsState.securityMetrics,
lastUpdated: Date.now(), lastUpdated: Date.now(),
isLoading: false, isLoading: false,
error: null, error: null,
@@ -1281,7 +1285,7 @@ async function dispatchCombinedRefreshAction() {
}); });
networkStatePart.setState({ networkStatePart.setState({
...networkStatePart.getState(), ...networkStatePart.getState()!,
connections: connectionsResponse.connections, connections: connectionsResponse.connections,
connectionsByIP, connectionsByIP,
throughputRate: { throughputRate: {
@@ -1294,14 +1298,15 @@ async function dispatchCombinedRefreshAction() {
throughputHistory: network.throughputHistory || [], throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0, requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0, requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
lastUpdated: Date.now(), lastUpdated: Date.now(),
isLoading: false, isLoading: false,
error: null, error: null,
}); });
} catch (error) { } catch (error: unknown) {
console.error('Failed to fetch connections:', error); console.error('Failed to fetch connections:', error);
networkStatePart.setState({ networkStatePart.setState({
...networkStatePart.getState(), ...networkStatePart.getState()!,
connections: [], connections: [],
connectionsByIP, connectionsByIP,
throughputRate: { throughputRate: {
@@ -1314,6 +1319,7 @@ async function dispatchCombinedRefreshAction() {
throughputHistory: network.throughputHistory || [], throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0, requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0, requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
lastUpdated: Date.now(), lastUpdated: Date.now(),
isLoading: false, isLoading: false,
error: null, error: null,
@@ -1356,8 +1362,8 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
// Initialize auto-refresh when UI state is ready // Initialize auto-refresh when UI state is ready
(() => { (() => {
const startAutoRefresh = () => { const startAutoRefresh = () => {
const uiState = uiStatePart.getState(); const uiState = uiStatePart.getState()!;
const loginState = loginStatePart.getState(); const loginState = loginStatePart.getState()!;
// Only start if conditions are met and not already running at the same rate // Only start if conditions are met and not already running at the same rate
if (uiState.autoRefresh && loginState.isLoggedIn) { if (uiState.autoRefresh && loginState.isLoggedIn) {
@@ -1384,9 +1390,9 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
}; };
// Watch for relevant changes only // Watch for relevant changes only
let previousAutoRefresh = uiStatePart.getState().autoRefresh; let previousAutoRefresh = uiStatePart.getState()!.autoRefresh;
let previousRefreshInterval = uiStatePart.getState().refreshInterval; let previousRefreshInterval = uiStatePart.getState()!.refreshInterval;
let previousIsLoggedIn = loginStatePart.getState().isLoggedIn; let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn;
uiStatePart.state.subscribe((state) => { uiStatePart.state.subscribe((state) => {
// Only restart if relevant values changed // Only restart if relevant values changed
@@ -1417,7 +1423,7 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
startAutoRefresh(); startAutoRefresh();
// Connect TypedSocket if already logged in (e.g., persistent session) // Connect TypedSocket if already logged in (e.g., persistent session)
if (loginStatePart.getState().isLoggedIn) { if (loginStatePart.getState()!.isLoggedIn) {
connectSocket(); connectSocket();
} }
})(); })();

View File

@@ -195,17 +195,18 @@ export class OpsDashboard extends DeesElement {
} }
public async firstUpdated() { public async firstUpdated() {
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login'); const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
simpleLogin.addEventListener('login', (e: CustomEvent) => { simpleLogin.addEventListener('login', (e: Event) => {
// Handle logout event // Handle logout event
this.login(e.detail.data.username, e.detail.data.password); const detail = (e as CustomEvent).detail;
this.login(detail.data.username, detail.data.password);
}); });
// Handle view changes // Handle view changes
const appDash = this.shadowRoot.querySelector('dees-simple-appdash'); const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
if (appDash) { if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => { appDash.addEventListener('view-select', (e: Event) => {
const viewName = e.detail.view.name.toLowerCase(); const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
// Use router for navigation instead of direct state update // Use router for navigation instead of direct state update
appRouter.navigateToView(viewName); appRouter.navigateToView(viewName);
}); });
@@ -217,7 +218,7 @@ 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) {
if (loginState.identity.expiresAt > Date.now()) { if (loginState.identity.expiresAt > Date.now()) {
// Client-side expiry looks valid — verify with server (keypair may have changed) // Client-side expiry looks valid — verify with server (keypair may have changed)
@@ -229,7 +230,7 @@ export class OpsDashboard extends DeesElement {
if (response.valid) { if (response.valid) {
// JWT confirmed valid by server // JWT confirmed valid by server
this.loginState = loginState; this.loginState = loginState;
await simpleLogin.switchToSlottedContent(); await (simpleLogin as any).switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else { } else {
@@ -250,8 +251,8 @@ export class OpsDashboard extends DeesElement {
private async login(username: string, password: string) { private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
console.log(`Attempting to login...`); console.log(`Attempting to login...`);
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login'); const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin.shadowRoot.querySelector('dees-form'); const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
form.setStatus('pending', 'Logging in...'); form.setStatus('pending', 'Logging in...');
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, { const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
@@ -262,14 +263,14 @@ export class OpsDashboard extends DeesElement {
if (state.identity) { if (state.identity) {
console.log('Login successful'); console.log('Login successful');
this.loginState = state; this.loginState = state;
form.setStatus('success', 'Logged in!'); form!.setStatus('success', 'Logged in!');
await simpleLogin.switchToSlottedContent(); await simpleLogin!.switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else { } else {
form.setStatus('error', 'Login failed!'); form!.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000); await domtools.convenience.smartdelay.delayFor(2000);
form.reset(); form!.reset();
} }
} }
} }

View File

@@ -21,7 +21,7 @@ declare global {
@customElement('ops-view-certificates') @customElement('ops-view-certificates')
export class OpsViewCertificates extends DeesElement { export class OpsViewCertificates extends DeesElement {
@state() @state()
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState(); accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
constructor() { constructor() {
super(); super();
@@ -264,10 +264,10 @@ export class OpsViewCertificates extends DeesElement {
{ {
name: 'Import', name: 'Import',
iconName: 'lucide:upload', iconName: 'lucide:upload',
action: async (modal) => { action: async (modal: any) => {
const { DeesToast } = await import('@design.estate/dees-catalog'); const { DeesToast } = await import('@design.estate/dees-catalog');
try { try {
const form = modal.shadowRoot.querySelector('dees-form') as any; const form = modal.shadowRoot!.querySelector('dees-form') as any;
const formData = await form.collectFormData(); const formData = await form.collectFormData();
const files = formData.certJsonFile; const files = formData.certJsonFile;
if (!files || files.length === 0) { if (!files || files.length === 0) {
@@ -287,8 +287,8 @@ export class OpsViewCertificates extends DeesElement {
); );
DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 }); DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 });
modal.destroy(); modal.destroy();
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Import failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Import failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}, },
}, },
@@ -339,8 +339,8 @@ export class OpsViewCertificates extends DeesElement {
} else { } else {
DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 }); DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 });
} }
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Export failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Export failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}, },
}, },
@@ -363,7 +363,7 @@ export class OpsViewCertificates extends DeesElement {
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:trash-2',
action: async (modal) => { action: async (modal: any) => {
try { try {
await appstate.certificateStatePart.dispatchAction( await appstate.certificateStatePart.dispatchAction(
appstate.deleteCertificateAction, appstate.deleteCertificateAction,
@@ -371,8 +371,8 @@ export class OpsViewCertificates extends DeesElement {
); );
DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 }); DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 });
modal.destroy(); modal.destroy();
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Delete failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Delete failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}, },
}, },

View File

@@ -102,7 +102,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult { private renderSystemSection(sys: NonNullable<appstate.IConfigState['config']>['system']): TemplateResult {
// Annotate proxy IPs with source hint when Remote Ingress is active // Annotate proxy IPs with source hint when Remote Ingress is active
const ri = this.configState.config?.remoteIngress; const ri = this.configState.config?.remoteIngress;
let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null; let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null;
@@ -133,7 +133,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult { private renderSmartProxySection(proxy: NonNullable<appstate.IConfigState['config']>['smartProxy']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Route Count', value: proxy.routeCount }, { key: 'Route Count', value: proxy.routeCount },
]; ];
@@ -164,7 +164,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult { private renderEmailSection(email: NonNullable<appstate.IConfigState['config']>['email']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' }, { key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
{ key: 'Hostname', value: email.hostname }, { key: 'Hostname', value: email.hostname },
@@ -196,7 +196,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult { private renderDnsSection(dns: NonNullable<appstate.IConfigState['config']>['dns']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Port', value: dns.port }, { key: 'Port', value: dns.port },
{ key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' }, { key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' },
@@ -216,7 +216,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult { private renderTlsSection(tls: NonNullable<appstate.IConfigState['config']>['tls']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Contact Email', value: tls.contactEmail }, { key: 'Contact Email', value: tls.contactEmail },
{ key: 'Domain', value: tls.domain }, { key: 'Domain', value: tls.domain },
@@ -242,7 +242,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult { private renderCacheSection(cache: NonNullable<appstate.IConfigState['config']>['cache']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Storage Path', value: cache.storagePath }, { key: 'Storage Path', value: cache.storagePath },
{ key: 'DB Name', value: cache.dbName }, { key: 'DB Name', value: cache.dbName },
@@ -267,7 +267,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult { private renderRadiusSection(radius: NonNullable<appstate.IConfigState['config']>['radius']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Auth Port', value: radius.authPort }, { key: 'Auth Port', value: radius.authPort },
{ key: 'Accounting Port', value: radius.acctPort }, { key: 'Accounting Port', value: radius.acctPort },
@@ -296,7 +296,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult { private renderRemoteIngressSection(ri: NonNullable<appstate.IConfigState['config']>['remoteIngress']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Tunnel Port', value: ri.tunnelPort }, { key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain }, { key: 'Hub Domain', value: ri.hubDomain },

View File

@@ -83,13 +83,13 @@ export class OpsViewEmails extends DeesElement {
private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) { private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
const emailSummary = e.detail; const emailSummary = e.detail;
try { try {
const context = appstate.loginStatePart.getState(); const context = appstate.loginStatePart.getState()!;
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetEmailDetail interfaces.requests.IReq_GetEmailDetail
>('/typedrequest', 'getEmailDetail'); >('/typedrequest', 'getEmailDetail');
const response = await request.fire({ const response = await request.fire({
identity: context.identity, identity: context.identity!,
emailId: emailSummary.id, emailId: emailSummary.id,
}); });

View File

@@ -1,5 +1,6 @@
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 appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
@@ -28,10 +29,10 @@ interface INetworkRequest {
@customElement('ops-view-network') @customElement('ops-view-network')
export class OpsViewNetwork extends DeesElement { export class OpsViewNetwork extends DeesElement {
@state() @state()
accessor statsState = appstate.statsStatePart.getState(); accessor statsState = appstate.statsStatePart.getState()!;
@state() @state()
accessor networkState = appstate.networkStatePart.getState(); accessor networkState = appstate.networkStatePart.getState()!;
@state() @state()
@@ -198,6 +199,38 @@ export class OpsViewNetwork extends DeesElement {
color: ${cssManager.bdTheme('#00796b', '#4db6ac')}; color: ${cssManager.bdTheme('#00796b', '#4db6ac')};
} }
.protocolBadge.h1 {
background: ${cssManager.bdTheme('#e3f2fd', '#1a2c3a')};
color: ${cssManager.bdTheme('#1976d2', '#5a9fd4')};
}
.protocolBadge.h2 {
background: ${cssManager.bdTheme('#e8f5e9', '#1a3a1a')};
color: ${cssManager.bdTheme('#388e3c', '#66bb6a')};
}
.protocolBadge.h3 {
background: ${cssManager.bdTheme('#f3e5f5', '#2a1a3a')};
color: ${cssManager.bdTheme('#7b1fa2', '#ba68c8')};
}
.protocolBadge.unknown {
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
color: ${cssManager.bdTheme('#757575', '#999999')};
}
.suppressionBadge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
margin-left: 4px;
}
.statusBadge { .statusBadge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -265,6 +298,9 @@ export class OpsViewNetwork extends DeesElement {
<!-- Top IPs Section --> <!-- Top IPs Section -->
${this.renderTopIPs()} ${this.renderTopIPs()}
<!-- Backend Protocols Section -->
${this.renderBackendProtocols()}
<!-- Requests Table --> <!-- Requests Table -->
<dees-table <dees-table
.data=${this.networkRequests} .data=${this.networkRequests}
@@ -519,6 +555,106 @@ export class OpsViewNetwork extends DeesElement {
`; `;
} }
private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends;
if (!backends || backends.length === 0) {
return html``;
}
return html`
<dees-table
.data=${backends}
.displayFunction=${(item: interfaces.data.IBackendInfo) => {
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
const protocolClass = item.protocol.toLowerCase().replace(/[^a-z0-9]/g, '');
return {
'Backend': item.backend,
'Domain': item.domain || '-',
'Protocol': html`
<span class="protocolBadge ${protocolClass}">${item.protocol.toUpperCase()}</span>
${item.h2Suppressed ? html`<span class="suppressionBadge" title="H2 suppressed: ${item.h2ConsecutiveFailures ?? 0} failures, cooldown ${item.h2CooldownRemainingSecs ?? 0}s">H2 suppressed</span>` : ''}
${item.h3Suppressed ? html`<span class="suppressionBadge" title="H3 suppressed: ${item.h3ConsecutiveFailures ?? 0} failures, cooldown ${item.h3CooldownRemainingSecs ?? 0}s">H3 suppressed</span>` : ''}
`,
'Active': item.activeConnections,
'Total': this.formatNumber(item.totalConnections),
'Avg Connect': item.avgConnectTimeMs > 0 ? `${item.avgConnectTimeMs.toFixed(1)}ms` : '-',
'Pool Hit Rate': item.poolHitRate > 0 ? `${(item.poolHitRate * 100).toFixed(1)}%` : '-',
'Errors': totalErrors > 0
? html`<span class="statusBadge error">${totalErrors}</span>`
: html`<span class="statusBadge success">0</span>`,
'Cache Age': item.cacheAgeSecs != null ? `${Math.round(item.cacheAgeSecs)}s` : '-',
};
}}
.dataActions=${[
{
name: 'View Details',
iconName: 'lucide:info',
type: ['inRow', 'doubleClick', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await this.showBackendDetails(actionData.item);
}
}
]}
heading1="Backend Protocols"
heading2="Auto-detected backend protocols and connection pool health"
searchable
.pagination=${false}
dataName="backend"
></dees-table>
`;
}
private async showBackendDetails(backend: interfaces.data.IBackendInfo) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Backend: ${backend.backend}`,
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Backend Details'}
progLang="json"
.codeToDisplay=${JSON.stringify({
backend: backend.backend,
domain: backend.domain,
protocol: backend.protocol,
activeConnections: backend.activeConnections,
totalConnections: backend.totalConnections,
avgConnectTimeMs: backend.avgConnectTimeMs,
poolHitRate: backend.poolHitRate,
errors: {
connect: backend.connectErrors,
handshake: backend.handshakeErrors,
request: backend.requestErrors,
h2Failures: backend.h2Failures,
},
suppression: {
h2Suppressed: backend.h2Suppressed,
h3Suppressed: backend.h3Suppressed,
h2CooldownRemainingSecs: backend.h2CooldownRemainingSecs,
h3CooldownRemainingSecs: backend.h3CooldownRemainingSecs,
h2ConsecutiveFailures: backend.h2ConsecutiveFailures,
h3ConsecutiveFailures: backend.h3ConsecutiveFailures,
},
h3Port: backend.h3Port,
cacheAgeSecs: backend.cacheAgeSecs,
}, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Backend Key',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(backend.backend);
}
}
]
});
}
private async updateNetworkData() { private async updateNetworkData() {
// Track requests/sec history for the trend sparkline (moved out of render) // Track requests/sec history for the trend sparkline (moved out of render)
const reqPerSec = this.networkState.requestsPerSecond || 0; const reqPerSec = this.networkState.requestsPerSecond || 0;

View File

@@ -21,7 +21,7 @@ declare global {
@customElement('ops-view-remoteingress') @customElement('ops-view-remoteingress')
export class OpsViewRemoteIngress extends DeesElement { export class OpsViewRemoteIngress extends DeesElement {
@state() @state()
accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState(); accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState()!;
constructor() { constructor() {
super(); super();
@@ -184,7 +184,7 @@ export class OpsViewRemoteIngress extends DeesElement {
@click=${async () => { @click=${async () => {
const { DeesToast } = await import('@design.estate/dees-catalog'); const { DeesToast } = await import('@design.estate/dees-catalog');
try { try {
const response = await appstate.fetchConnectionToken(this.riState.newEdgeId); const response = await appstate.fetchConnectionToken(this.riState.newEdgeId!);
if (response.success && response.token) { if (response.success && response.token) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(response.token); await navigator.clipboard.writeText(response.token);
@@ -202,8 +202,8 @@ export class OpsViewRemoteIngress extends DeesElement {
} else { } else {
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 }); DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
} }
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}} }}
>Copy Connection Token</dees-button> >Copy Connection Token</dees-button>
@@ -399,8 +399,8 @@ export class OpsViewRemoteIngress extends DeesElement {
} else { } else {
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 }); DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
} }
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}, },
}, },

View File

@@ -237,7 +237,7 @@ export class OpsViewMyView extends DeesElement {
## 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.
**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. **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.

View File

@@ -71,12 +71,12 @@ class AppRouter {
private updateViewState(view: string): void { private updateViewState(view: string): void {
this.suppressStateUpdate = true; this.suppressStateUpdate = true;
const currentState = appstate.uiStatePart.getState(); const currentState = appstate.uiStatePart.getState()!;
if (currentState.activeView !== view) { if (currentState.activeView !== view) {
appstate.uiStatePart.setState({ appstate.uiStatePart.setState({
...currentState, ...currentState,
activeView: view, activeView: view,
}); } as appstate.IUiState);
} }
this.suppressStateUpdate = false; this.suppressStateUpdate = false;
} }
@@ -94,7 +94,7 @@ class AppRouter {
} }
public getCurrentView(): string { public getCurrentView(): string {
return appstate.uiStatePart.getState().activeView; return appstate.uiStatePart.getState()!.activeView;
} }
public destroy(): void { public destroy(): void {