Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 | |||
| 99b40fea3f | |||
| 6f72e4fdbc | |||
| fbe845cd8e | |||
| 31413d28be | |||
| cd286cede6 | |||
| 36a3060cce | |||
| d2b108317e | |||
| dcd75f5e47 | |||
| 3d443fa147 | |||
| 2efdd2f16b | |||
| ec0348a83c | |||
| 6c4adf70c7 | |||
| 29d6076355 | |||
| fa96a41e68 | |||
| 1ea38ed5d2 | |||
| 7209903d02 | |||
| 20eda1ab3e | |||
| 44f2a7f0a9 | |||
| 0195a21f30 | |||
| 4dca747386 | |||
| 7663f502fa | |||
| 104cd417d8 | |||
| 93254d5d3d | |||
| 9a3f121a9c | |||
| bef74eb1aa | |||
| 308d8e4851 | |||
| dc010dc3ae | |||
| 61d5d3b1ad | |||
| dd70790d40 | |||
| 2f8c04edc4 | |||
| 474cc328dd | |||
| 39ff159bf7 | |||
| c7fe7aeb50 | |||
| 2cf362020f | |||
| b62bad3616 | |||
| 3d372863a4 | |||
| 1045dc04fe | |||
| 89ef7597df | |||
| 0804544564 | |||
| 671e72452a | |||
| 647c705b81 | |||
| 40c3202082 | |||
| 3b91ed3d5a | |||
| 133b17f136 | |||
| efa45dfdc9 | |||
| 79b4ea6bd9 | |||
| b483412a2e | |||
| d964515ff9 | |||
| e2c453423e | |||
| c44b7d513a | |||
| 2487f77b8a | |||
| ea80ef005c | |||
| dd45b7fbe7 | |||
| ca73da7b9b | |||
| f6e1951aa2 | |||
| 76fd563e21 | |||
| ee831ea057 | |||
| a65c2ec096 | |||
| 65822278d5 | |||
| aa3955fc67 | |||
| d4605062bb | |||
| cd3f08d55f | |||
| 6d447f0086 | |||
| c7de3873d8 | |||
| 6d4e30e8a9 | |||
| 0e308b692b | |||
| 9f74b6e063 | |||
| 1d0f47f256 | |||
| 4e9301ae2a | |||
| 7e2142ce53 | |||
| 67190605a6 | |||
| 9479a07ddf | |||
| fbed56092f | |||
| 547b82b35b | |||
| 3dc63fa02e | |||
| e0154f5b70 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
|
image: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -82,15 +82,13 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @git.zone/tsdocker
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
run: |
|
||||||
npmci docker login
|
tsdocker login
|
||||||
npmci docker build
|
tsdocker build
|
||||||
npmci docker test
|
tsdocker push
|
||||||
# npmci docker push gitea.lossless.digital
|
|
||||||
npmci docker push dockerregistry.lossless.digital
|
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
54
Dockerfile
54
Dockerfile
@@ -1,46 +1,34 @@
|
|||||||
# gitzone dockerfile_service
|
# gitzone dockerfile_service
|
||||||
## STAGE 1 // BUILD
|
## STAGE 1 // BUILD
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
|
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||||
COPY ./ /app
|
COPY ./ /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
RUN pnpm config set store-dir .pnpm-store
|
||||||
RUN rm -rf node_modules && pnpm install
|
RUN rm -rf node_modules && pnpm install
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
|
||||||
|
|
||||||
|
## STAGE 2 // PRODUCTION
|
||||||
|
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
|
||||||
|
|
||||||
|
# gcompat + libstdc++ for glibc-linked Rust binaries (smartproxy, smartmta, remoteingress)
|
||||||
|
RUN apk add --no-cache gcompat libstdc++
|
||||||
|
|
||||||
# gitzone dockerfile_service
|
|
||||||
## STAGE 2 // install production
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=node1 /app /app
|
COPY --from=build /app /app
|
||||||
RUN rm -rf .pnpm-store
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
RUN rm -rf node_modules/ && pnpm install --prod
|
|
||||||
|
|
||||||
|
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||||
|
ENV DCROUTER_HEAP_SIZE=512
|
||||||
|
ENV UV_THREADPOOL_SIZE=16
|
||||||
|
|
||||||
## STAGE 3 // rebuild dependencies for alpine
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node2 /app /app
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
RUN pnpm rebuild -r
|
|
||||||
|
|
||||||
## STAGE 4 // the final production image with all dependencies in place
|
|
||||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=node3 /app /app
|
|
||||||
|
|
||||||
### Healthchecks
|
|
||||||
RUN pnpm install -g @servezone/healthy
|
RUN pnpm install -g @servezone/healthy
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
|
||||||
|
|
||||||
EXPOSE 80
|
LABEL org.opencontainers.image.title="dcrouter" \
|
||||||
CMD ["npm", "start"]
|
org.opencontainers.image.description="Multi-service datacenter gateway" \
|
||||||
|
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
|
||||||
|
|
||||||
|
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
|
||||||
|
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]
|
||||||
|
|||||||
257
changelog.md
257
changelog.md
@@ -1,5 +1,262 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.15.0 - feat(vpn)
|
||||||
|
add tag-based VPN route access control and support configured initial VPN clients
|
||||||
|
|
||||||
|
- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet
|
||||||
|
- create configured VPN clients automatically on startup and re-apply routes when VPN clients change
|
||||||
|
- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load
|
||||||
|
- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.14.0 - feat(docs)
|
||||||
|
document VPN access control and add OpsServer VPN navigation
|
||||||
|
|
||||||
|
- Adds comprehensive README documentation for VPN access control, configuration, operating modes, and client management
|
||||||
|
- Updates TypeScript interface documentation with VPN-related route, client, status, telemetry, and API request types
|
||||||
|
- Extends web dashboard documentation and router view list to include VPN management
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.13.0 - feat(vpn)
|
||||||
|
add VPN server management and route-based VPN access control
|
||||||
|
|
||||||
|
- introduces a VPN manager backed by @push.rocks/smartvpn with persisted server keys and client registrations
|
||||||
|
- adds ops API handlers and typed request interfaces for VPN client lifecycle, status, config export, and telemetry
|
||||||
|
- adds ops dashboard VPN view and application state for managing VPN clients from the web UI
|
||||||
|
- supports vpn.required on routes by injecting VPN subnet allowlists into static and programmatic SmartProxy routes
|
||||||
|
- configures SmartProxy to accept proxy protocol in VPN socket forwarding mode to preserve client tunnel IPs
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.4 - fix(acme)
|
||||||
|
use X509 certificate expiry when reporting ACME certificate validity
|
||||||
|
|
||||||
|
- Parse the actual X509 validTo value from the PEM public certificate and fall back to SmartAcme's stored expiry if parsing fails
|
||||||
|
- Update reported certificate expiry data and event communication timestamps to use the verified validity date
|
||||||
|
- Bump @push.rocks/smartacme to ^9.3.1 and @push.rocks/smartproxy to ^27.1.0
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.3 - fix(dcrouter)
|
||||||
|
re-trigger auto certificate provisioning after SmartAcme becomes ready
|
||||||
|
|
||||||
|
- clear certificate provisioning scheduler state before retrying startup-affected routes
|
||||||
|
- use route updates to re-run certificate provisioning for all current auto-cert routes
|
||||||
|
- remove the unused single-route domain lookup helper
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.2 - fix(dcrouter)
|
||||||
|
guard auto certificate reprovisioning against unnamed routes
|
||||||
|
|
||||||
|
- Only re-triggers certificate provisioning for auto-cert routes when a route name is present.
|
||||||
|
- Prevents reprovision attempts from running with an undefined route name and reduces warning noise.
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.1 - fix(dcrouter)
|
||||||
|
retry auto certificate provisioning after SmartAcme becomes ready
|
||||||
|
|
||||||
|
- detects certificates that failed during startup before the DNS-01 provider was available
|
||||||
|
- clears provisioning backoff and failed status for affected domains before retrying
|
||||||
|
- re-triggers auto certificate provisioning for SmartProxy routes once SmartAcme is ready
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.0 - feat(web-ui)
|
||||||
|
pause dashboard polling, sockets, and chart updates when the tab is hidden
|
||||||
|
|
||||||
|
- replace interval-based auto-refresh with scheduled actions using visibility-aware auto-pause
|
||||||
|
- disconnect and reconnect the TypedSocket on tab visibility changes to avoid background log buildup
|
||||||
|
- batch pushed log entries per animation frame and add an in-flight refresh guard to reduce unnecessary re-renders and overlapping requests
|
||||||
|
- update state subscriptions to use select() and document the new tab visibility optimization behavior
|
||||||
|
- bump smartdb, smartproxy, smartstate, remoteingress, dees-element, and tstest dependencies
|
||||||
|
|
||||||
|
## 2026-03-26 - 11.11.0 - feat(docker,cache,proxy)
|
||||||
|
improve container runtime defaults and add configurable connection limits
|
||||||
|
|
||||||
|
- replace the embedded cache backend integration from smartmongo LocalTsmDb to smartdb LocalSmartDb
|
||||||
|
- add OCI container settings for heap size, threadpool size, expanded exposed ports, image metadata, and a direct node startup command
|
||||||
|
- introduce startup checks for file descriptor limits and warn when container nofile limits are too low for production
|
||||||
|
- set gateway-oriented SmartProxy default limits and allow max connections, per-IP connections, and rate limits to be configured through OCI environment variables
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-17 - 11.2.54 - fix(deps)
|
||||||
bump @serve.zone/remoteingress to ^4.8.16
|
bump @serve.zone/remoteingress to ^4.8.16
|
||||||
|
|
||||||
|
|||||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
52
package.json
52
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "11.2.54",
|
"version": "11.15.0",
|
||||||
"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": {
|
||||||
@@ -13,54 +13,58 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --logfile --timeout 60)",
|
"test": "(tstest test/ --logfile --timeout 60)",
|
||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
|
"build:docker": "tsdocker build --verbose",
|
||||||
|
"release:docker": "tsdocker push --verbose",
|
||||||
"bundle": "(tsbundle)",
|
"bundle": "(tsbundle)",
|
||||||
"watch": "tswatch"
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.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.3",
|
||||||
"@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.4",
|
||||||
"@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.1",
|
||||||
"@push.rocks/smartdata": "^7.1.0",
|
"@push.rocks/smartdata": "^7.1.3",
|
||||||
|
"@push.rocks/smartdb": "^2.0.0",
|
||||||
"@push.rocks/smartdns": "^7.9.0",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@push.rocks/smartfs": "^1.5.0",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
"@push.rocks/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/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.5.2",
|
||||||
"@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.24",
|
"@push.rocks/smartproxy": "^27.1.0",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.2.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@serve.zone/catalog": "^2.6.2",
|
"@push.rocks/smartvpn": "1.13.0",
|
||||||
|
"@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.16",
|
"@serve.zone/remoteingress": "^4.15.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 +113,7 @@
|
|||||||
"dist_ts_apiclient/**/*",
|
"dist_ts_apiclient/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2999
pnpm-lock.yaml
generated
2999
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -133,7 +133,7 @@ The project now uses tswatch for development:
|
|||||||
```bash
|
```bash
|
||||||
pnpm run watch
|
pnpm run watch
|
||||||
```
|
```
|
||||||
Configuration in `npmextra.json`:
|
Configuration in `.smartconfig.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@git.zone/tswatch": {
|
"@git.zone/tswatch": {
|
||||||
|
|||||||
315
readme.md
315
readme.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
||||||
|
|
||||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
|
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -18,10 +18,12 @@ 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)
|
||||||
- [Remote Ingress](#remote-ingress)
|
- [Remote Ingress](#remote-ingress)
|
||||||
|
- [VPN Access Control](#vpn-access-control)
|
||||||
- [Certificate Management](#certificate-management)
|
- [Certificate Management](#certificate-management)
|
||||||
- [Storage & Caching](#storage--caching)
|
- [Storage & Caching](#storage--caching)
|
||||||
- [Security Features](#security-features)
|
- [Security Features](#security-features)
|
||||||
@@ -30,12 +32,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)
|
||||||
@@ -70,6 +74,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
||||||
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
||||||
|
|
||||||
|
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
|
||||||
|
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
|
||||||
|
- **Route-level VPN gating** — mark any route with `vpn: { required: true }` to restrict access to VPN clients only
|
||||||
|
- **Rootless operation** — auto-detects privileges: kernel TUN when running as root, userspace NAT (smoltcp) when not
|
||||||
|
- **Client management** — create, enable, disable, rotate keys, export WireGuard `.conf` files via OpsServer API
|
||||||
|
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||||
|
- **PROXY protocol v2** — in socket mode, the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||||
|
|
||||||
### ⚡ High Performance
|
### ⚡ High Performance
|
||||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||||
@@ -80,16 +92,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
|
|
||||||
### 💾 Persistent Storage & Caching
|
### 💾 Persistent Storage & Caching
|
||||||
- **Multiple storage backends**: filesystem, custom functions, or in-memory
|
- **Multiple storage backends**: filesystem, custom functions, or in-memory
|
||||||
- **Embedded cache database** via smartdata + LocalTsmDb (MongoDB-compatible)
|
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
|
||||||
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
||||||
|
|
||||||
### 🖥️ OpsServer Dashboard
|
### 🖥️ OpsServer Dashboard
|
||||||
- **Web-based management interface** with real-time monitoring
|
- **Web-based management interface** with real-time monitoring
|
||||||
- **JWT authentication** with session persistence
|
- **JWT authentication** with session persistence
|
||||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
|
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
||||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||||
- **Remote ingress management** with connection token generation and one-click copy
|
- **Remote ingress management** with connection token generation and one-click copy
|
||||||
- **Read-only configuration display** — DcRouter is configured through code
|
- **Read-only configuration display** — DcRouter is configured through code
|
||||||
|
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
||||||
|
|
||||||
### 🔧 Programmatic API Client
|
### 🔧 Programmatic API Client
|
||||||
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
|
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
|
||||||
@@ -244,6 +257,13 @@ const router = new DcRouter({
|
|||||||
hubDomain: 'hub.example.com',
|
hubDomain: 'hub.example.com',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// VPN — restrict sensitive routes to VPN clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.example.com',
|
||||||
|
wgListenPort: 51820,
|
||||||
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// Persistent storage
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||||
|
|
||||||
@@ -272,6 +292,7 @@ graph TB
|
|||||||
DNS[DNS Queries]
|
DNS[DNS Queries]
|
||||||
RAD[RADIUS Clients]
|
RAD[RADIUS Clients]
|
||||||
EDGE[Edge Nodes]
|
EDGE[Edge Nodes]
|
||||||
|
VPN[VPN Clients]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "DcRouter Core"
|
subgraph "DcRouter Core"
|
||||||
@@ -281,6 +302,7 @@ graph TB
|
|||||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||||
RS[SmartRadius Server]
|
RS[SmartRadius Server]
|
||||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
||||||
|
VS[SmartVPN Server<br/><i>Rust data plane</i>]
|
||||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||||
OS[OpsServer Dashboard]
|
OS[OpsServer Dashboard]
|
||||||
MM[Metrics Manager]
|
MM[Metrics Manager]
|
||||||
@@ -301,12 +323,14 @@ graph TB
|
|||||||
DNS --> DS
|
DNS --> DS
|
||||||
RAD --> RS
|
RAD --> RS
|
||||||
EDGE --> RI
|
EDGE --> RI
|
||||||
|
VPN --> VS
|
||||||
|
|
||||||
DC --> SP
|
DC --> SP
|
||||||
DC --> ES
|
DC --> ES
|
||||||
DC --> DS
|
DC --> DS
|
||||||
DC --> RS
|
DC --> RS
|
||||||
DC --> RI
|
DC --> RI
|
||||||
|
DC --> VS
|
||||||
DC --> CM
|
DC --> CM
|
||||||
DC --> OS
|
DC --> OS
|
||||||
DC --> MM
|
DC --> MM
|
||||||
@@ -337,13 +361,13 @@ graph TB
|
|||||||
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
|
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
|
||||||
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
|
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
|
||||||
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
|
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
|
||||||
| **CacheDb** | `@push.rocks/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) for persistent caching |
|
| **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
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 +448,42 @@ interface IDcRouterOptions {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── VPN ───────────────────────────────────────────────────────
|
||||||
|
/** VPN server for route-level access control */
|
||||||
|
vpnConfig?: {
|
||||||
|
enabled?: boolean; // default: false
|
||||||
|
subnet?: string; // default: '10.8.0.0/24'
|
||||||
|
wgListenPort?: number; // default: 51820
|
||||||
|
dns?: string[]; // DNS servers pushed to VPN clients
|
||||||
|
serverEndpoint?: string; // Hostname in generated client configs
|
||||||
|
forwardingMode?: 'tun' | 'socket'; // default: auto-detect (root → tun, else socket)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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 +571,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.
|
||||||
@@ -850,6 +1006,78 @@ The OpsServer Remote Ingress view provides:
|
|||||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||||
| **Delete** | Remove the edge registration |
|
| **Delete** | Remove the edge registration |
|
||||||
|
|
||||||
|
## VPN Access Control
|
||||||
|
|
||||||
|
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
|
||||||
|
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
|
||||||
|
3. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected with the VPN subnet
|
||||||
|
4. SmartProxy enforces the allowlist — only VPN-sourced traffic is accepted on those routes
|
||||||
|
|
||||||
|
### Two Operating Modes
|
||||||
|
|
||||||
|
| Mode | Root Required? | How It Works |
|
||||||
|
|------|---------------|-------------|
|
||||||
|
| **TUN** (`forwardingMode: 'tun'`) | Yes | Kernel TUN device — VPN traffic enters the network stack with real VPN IPs |
|
||||||
|
| **Socket** (`forwardingMode: 'socket'`) | No | Userspace NAT via smoltcp — outbound connections send PROXY protocol v2 to preserve VPN client IPs |
|
||||||
|
|
||||||
|
DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls back to socket mode. You can override this with the `forwardingMode` option.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const router = new DcRouter({
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
||||||
|
wgListenPort: 51820, // WireGuard UDP port (default)
|
||||||
|
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
||||||
|
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
||||||
|
// forwardingMode: 'socket', // Override auto-detection
|
||||||
|
},
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
|
// This route is VPN-only — non-VPN clients are blocked
|
||||||
|
{
|
||||||
|
name: 'admin-panel',
|
||||||
|
match: { domains: ['admin.example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
vpn: { required: true }, // 🔐 Only VPN clients can access this
|
||||||
|
},
|
||||||
|
// This route is public — anyone can access it
|
||||||
|
{
|
||||||
|
name: 'public-site',
|
||||||
|
match: { domains: ['example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.10', port: 80 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Management via OpsServer API
|
||||||
|
|
||||||
|
Once the VPN server is running, you can manage clients through the OpsServer dashboard or API:
|
||||||
|
|
||||||
|
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
|
||||||
|
- **Enable / Disable** — toggle client access without deleting
|
||||||
|
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
||||||
|
- **Export config** — re-export in WireGuard or SmartVPN format
|
||||||
|
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
|
||||||
|
|
||||||
|
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or QR code — no custom VPN software needed.
|
||||||
|
|
||||||
## Certificate Management
|
## Certificate Management
|
||||||
|
|
||||||
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
||||||
@@ -942,7 +1170,7 @@ Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, I
|
|||||||
|
|
||||||
### Cache Database
|
### Cache Database
|
||||||
|
|
||||||
An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persistent caching with automatic TTL cleanup:
|
An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
@@ -1015,7 +1243,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 +1444,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 +1454,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,12 +1501,78 @@ 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 production-ready `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. The container image includes tini as PID 1 (via the base image), proper health checks, and configurable resource limits. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
|
||||||
|
|
||||||
|
### Running with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--ulimit nofile=65536:65536 \
|
||||||
|
-e DCROUTER_TLS_EMAIL=admin@example.com \
|
||||||
|
-e DCROUTER_PUBLIC_IP=203.0.113.1 \
|
||||||
|
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
|
||||||
|
-e DCROUTER_DNS_SCOPES=example.com \
|
||||||
|
-p 80:80 -p 443:443 -p 25:25 -p 587:587 -p 465:465 \
|
||||||
|
-p 53:53/udp -p 3000:3000 -p 8443:8443 \
|
||||||
|
code.foss.global/serve.zone/dcrouter:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚡ **Production tip:** Always set `--ulimit nofile=65536:65536` for production deployments. DcRouter will log a warning at startup if the file descriptor limit is below 65536.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default | Example |
|
||||||
|
|----------|-------------|---------|---------|
|
||||||
|
| `DCROUTER_MODE` | Container mode (set automatically in image) | `OCI_CONTAINER` | — |
|
||||||
|
| `DCROUTER_CONFIG_PATH` | Path to JSON config file (env vars override) | — | `/config/dcrouter.json` |
|
||||||
|
| `DCROUTER_BASE_DIR` | Base data directory | `~/.serve.zone/dcrouter` | `/data/dcrouter` |
|
||||||
|
| `DCROUTER_TLS_EMAIL` | ACME contact email | — | `admin@example.com` |
|
||||||
|
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | — | `example.com` |
|
||||||
|
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | — | `203.0.113.1` |
|
||||||
|
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | — | `198.51.100.1,198.51.100.2` |
|
||||||
|
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | — | `ns1.example.com,ns2.example.com` |
|
||||||
|
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | — | `example.com,other.com` |
|
||||||
|
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | — | `mail.example.com` |
|
||||||
|
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | — | `25,587,465` |
|
||||||
|
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | `false` |
|
||||||
|
| `DCROUTER_HEAP_SIZE` | Node.js V8 heap size in MB | `512` | `1024` |
|
||||||
|
| `DCROUTER_MAX_CONNECTIONS` | Global max concurrent connections | `50000` | `100000` |
|
||||||
|
| `DCROUTER_MAX_CONNECTIONS_PER_IP` | Max connections per source IP | `100` | `200` |
|
||||||
|
| `DCROUTER_CONNECTION_RATE_LIMIT` | Max new connections/min per IP | `600` | `1200` |
|
||||||
|
|
||||||
|
### Exposed Ports
|
||||||
|
|
||||||
|
The container exposes all service ports:
|
||||||
|
|
||||||
|
| Port(s) | Protocol | Service |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| 80, 443 | TCP | HTTP/HTTPS (SmartProxy) |
|
||||||
|
| 25, 587, 465 | TCP | SMTP, Submission, SMTPS |
|
||||||
|
| 53 | TCP/UDP | DNS |
|
||||||
|
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||||
|
| 3000 | TCP | OpsServer dashboard |
|
||||||
|
| 8443 | TCP | Remote ingress tunnels |
|
||||||
|
| 51820 | UDP | WireGuard VPN |
|
||||||
|
| 29000–30000 | TCP | Dynamic port range |
|
||||||
|
|
||||||
|
### Building the Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build:docker # Build the container image
|
||||||
|
pnpm run release:docker # Push to registry
|
||||||
|
```
|
||||||
|
|
||||||
|
The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsdocker](https://code.foss.global/git.zone/tsdocker).
|
||||||
|
|
||||||
## License and Legal Information
|
## 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.
|
||||||
@@ -1292,7 +1587,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
tls: {
|
tls: {
|
||||||
contactEmail: 'test@example.com'
|
contactEmail: 'test@example.com'
|
||||||
},
|
},
|
||||||
|
opsServerPort: 3104,
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
|
|||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
|
opsServerPort: 3100,
|
||||||
cacheConfig: { enabled: false }
|
cacheConfig: { enabled: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
304
test/test.http3-augmentation.ts
Normal file
304
test/test.http3-augmentation.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import {
|
||||||
|
routeQualifiesForHttp3,
|
||||||
|
augmentRouteWithHttp3,
|
||||||
|
augmentRoutesWithHttp3,
|
||||||
|
type IHttp3Config,
|
||||||
|
} from '../ts/http3/index.js';
|
||||||
|
import type * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Helper to create a basic HTTPS forward route on port 443
|
||||||
|
function makeRoute(
|
||||||
|
overrides: Partial<plugins.smartproxy.IRouteConfig> = {},
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
return {
|
||||||
|
match: { ports: 443, ...overrides.match },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
...overrides.action,
|
||||||
|
},
|
||||||
|
name: overrides.name ?? 'test-https-route',
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(overrides).filter(([k]) => !['match', 'action', 'name'].includes(k)),
|
||||||
|
),
|
||||||
|
} as plugins.smartproxy.IRouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfig: IHttp3Config = { enabled: true };
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Qualification tests
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should augment qualifying HTTPS route on port 443', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp).toBeTruthy();
|
||||||
|
expect(result.action.udp!.quic).toBeTruthy();
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment route on non-443 port', async () => {
|
||||||
|
const route = makeRoute({ match: { ports: 8080 } });
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
expect(result.action.udp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment socket-handler type route', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'socket-handler' as any,
|
||||||
|
socketHandler: (() => {}) as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment route without TLS', async () => {
|
||||||
|
const route: plugins.smartproxy.IRouteConfig = {
|
||||||
|
match: { ports: 443 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
},
|
||||||
|
name: 'no-tls-route',
|
||||||
|
};
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment email routes', async () => {
|
||||||
|
const emailNames = ['smtp-route', 'submission-route', 'smtps-route', 'email-port-2525-route'];
|
||||||
|
for (const name of emailNames) {
|
||||||
|
const route = makeRoute({ name });
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respect per-route opt-out (options.http3 = false)', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
options: { http3: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
expect(result.action.udp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respect per-route opt-in when global is disabled', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
options: { http3: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, { enabled: false });
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT double-augment routes with transport: all', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: 443, transport: 'all' as any },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
// Should be the exact same object (no augmentation)
|
||||||
|
expect(result).toEqual(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT double-augment routes with existing udp.quic', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
udp: { quic: { enableHttp3: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result).toEqual(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment route with port range including 443', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: [{ from: 400, to: 500 }] },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment route with port array including 443', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: [80, 443] },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should NOT augment route with port range NOT including 443', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
match: { ports: [{ from: 8000, to: 9000 }] },
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment TLS passthrough routes', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'passthrough' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should augment terminate-and-reencrypt routes', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Configuration tests
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should apply default QUIC settings when none provided', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
|
||||||
|
// Undefined means SmartProxy will use its own defaults
|
||||||
|
expect(result.action.udp!.quic!.maxIdleTimeout).toBeUndefined();
|
||||||
|
expect(result.action.udp!.quic!.altSvcPort).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should apply custom QUIC settings', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const config: IHttp3Config = {
|
||||||
|
enabled: true,
|
||||||
|
quicSettings: {
|
||||||
|
maxIdleTimeout: 60000,
|
||||||
|
maxConcurrentBidiStreams: 200,
|
||||||
|
maxConcurrentUniStreams: 50,
|
||||||
|
initialCongestionWindow: 65536,
|
||||||
|
},
|
||||||
|
altSvc: {
|
||||||
|
port: 8443,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
|
udpSettings: {
|
||||||
|
sessionTimeout: 120000,
|
||||||
|
maxSessionsPerIP: 500,
|
||||||
|
maxDatagramSize: 32768,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = augmentRouteWithHttp3(route, config);
|
||||||
|
|
||||||
|
expect(result.action.udp!.quic!.maxIdleTimeout).toEqual(60000);
|
||||||
|
expect(result.action.udp!.quic!.maxConcurrentBidiStreams).toEqual(200);
|
||||||
|
expect(result.action.udp!.quic!.maxConcurrentUniStreams).toEqual(50);
|
||||||
|
expect(result.action.udp!.quic!.initialCongestionWindow).toEqual(65536);
|
||||||
|
expect(result.action.udp!.quic!.altSvcPort).toEqual(8443);
|
||||||
|
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(3600);
|
||||||
|
expect(result.action.udp!.sessionTimeout).toEqual(120000);
|
||||||
|
expect(result.action.udp!.maxSessionsPerIP).toEqual(500);
|
||||||
|
expect(result.action.udp!.maxDatagramSize).toEqual(32768);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should not mutate the original route', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const originalTransport = route.match.transport;
|
||||||
|
const originalUdp = route.action.udp;
|
||||||
|
|
||||||
|
augmentRouteWithHttp3(route, defaultConfig);
|
||||||
|
|
||||||
|
expect(route.match.transport).toEqual(originalTransport);
|
||||||
|
expect(route.action.udp).toEqual(originalUdp);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch augmentation
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should augment multiple routes in a batch', async () => {
|
||||||
|
const routes = [
|
||||||
|
makeRoute({ name: 'web-app' }),
|
||||||
|
makeRoute({ name: 'smtp-route', match: { ports: 25 } }),
|
||||||
|
makeRoute({ name: 'api-gateway' }),
|
||||||
|
makeRoute({
|
||||||
|
name: 'dns-query',
|
||||||
|
action: { type: 'socket-handler' as any, socketHandler: (() => {}) as any },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = augmentRoutesWithHttp3(routes, defaultConfig);
|
||||||
|
|
||||||
|
// web-app and api-gateway should be augmented
|
||||||
|
expect(results[0].match.transport).toEqual('all');
|
||||||
|
expect(results[2].match.transport).toEqual('all');
|
||||||
|
|
||||||
|
// smtp and dns should NOT be augmented
|
||||||
|
expect(results[1].match.transport).toBeUndefined();
|
||||||
|
expect(results[3].match.transport).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Default enabled behavior
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('should treat undefined enabled as true (default on)', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, {}); // no enabled field at all
|
||||||
|
|
||||||
|
expect(result.match.transport).toEqual('all');
|
||||||
|
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should disable when enabled is explicitly false', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const result = augmentRouteWithHttp3(route, { enabled: false });
|
||||||
|
|
||||||
|
expect(result.match.transport).toBeUndefined();
|
||||||
|
expect(result.action.udp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -9,6 +9,7 @@ let identity: interfaces.data.IIdentity;
|
|||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
|
opsServerPort: 3102,
|
||||||
cacheConfig: { enabled: false },
|
cacheConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
|
|
||||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
|||||||
|
|
||||||
tap.test('should verify valid JWT identity', async () => {
|
tap.test('should verify valid JWT identity', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ tap.test('should verify valid JWT identity', async () => {
|
|||||||
|
|
||||||
tap.test('should reject invalid JWT', async () => {
|
tap.test('should reject invalid JWT', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ tap.test('should reject invalid JWT', async () => {
|
|||||||
|
|
||||||
tap.test('should verify JWT matches identity data', async () => {
|
tap.test('should verify JWT matches identity data', async () => {
|
||||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'verifyIdentity'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ tap.test('should verify JWT matches identity data', async () => {
|
|||||||
|
|
||||||
tap.test('should handle logout', async () => {
|
tap.test('should handle logout', async () => {
|
||||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLogout'
|
'adminLogout'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ tap.test('should handle logout', async () => {
|
|||||||
|
|
||||||
tap.test('should reject wrong credentials', async () => {
|
tap.test('should reject wrong credentials', async () => {
|
||||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3102/typedrequest',
|
||||||
'adminLoginWithUsernameAndPassword'
|
'adminLoginWithUsernameAndPassword'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.2.54',
|
version: '11.15.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
20
ts/cache/classes.cache.cleaner.ts
vendored
20
ts/cache/classes.cache.cleaner.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
ts/cache/classes.cached.document.ts
vendored
2
ts/cache/classes.cached.document.ts
vendored
@@ -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)
|
||||||
|
|||||||
36
ts/cache/classes.cachedb.ts
vendored
36
ts/cache/classes.cachedb.ts
vendored
@@ -15,16 +15,16 @@ export interface ICacheDbOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
* CacheDb - Wrapper around LocalSmartDb and smartdata
|
||||||
*
|
*
|
||||||
* Provides persistent caching using smartdata as the ORM layer
|
* Provides persistent caching using smartdata as the ORM layer
|
||||||
* and LocalTsmDb as the embedded database engine.
|
* and LocalSmartDb as the embedded database engine.
|
||||||
*/
|
*/
|
||||||
export class CacheDb {
|
export class CacheDb {
|
||||||
private static instance: CacheDb | null = null;
|
private static instance: CacheDb | null = null;
|
||||||
|
|
||||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
private localSmartDb!: plugins.smartdb.LocalSmartDb;
|
||||||
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;
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ export class CacheDb {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the cache database
|
* Start the cache database
|
||||||
* - Initializes LocalTsmDb with file persistence
|
* - Initializes LocalSmartDb with file persistence
|
||||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
* - Connects smartdata to the LocalSmartDb via Unix socket
|
||||||
*/
|
*/
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
if (this.isStarted) {
|
if (this.isStarted) {
|
||||||
@@ -68,16 +68,16 @@ export class CacheDb {
|
|||||||
// Ensure storage directory exists
|
// Ensure storage directory exists
|
||||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||||
|
|
||||||
// Create LocalTsmDb instance
|
// Create LocalSmartDb instance
|
||||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
|
||||||
folderPath: this.options.storagePath,
|
folderPath: this.options.storagePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start LocalTsmDb and get connection info
|
// Start LocalSmartDb and get connection info
|
||||||
const connectionInfo = await this.localTsmDb.start();
|
const connectionInfo = await this.localSmartDb.start();
|
||||||
|
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
logger.log('debug', `LocalSmartDb started with URI: ${connectionInfo.connectionUri}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize smartdata with the connection URI
|
// Initialize smartdata with the connection URI
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,15 +109,15 @@ export class CacheDb {
|
|||||||
await this.smartdataDb.close();
|
await this.smartdataDb.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop LocalTsmDb
|
// Stop LocalSmartDb
|
||||||
if (this.localTsmDb) {
|
if (this.localSmartDb) {
|
||||||
await this.localTsmDb.stop();
|
await this.localSmartDb.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
ts/cache/documents/classes.cached.email.ts
vendored
28
ts/cache/documents/classes.cached.email.ts
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import type {
|
|||||||
IMergedRoute,
|
IMergedRoute,
|
||||||
IRouteWarning,
|
IRouteWarning,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.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 +22,8 @@ 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,
|
||||||
|
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,7 +246,7 @@ export class RouteConfigManager {
|
|||||||
// Private: apply merged routes to SmartProxy
|
// Private: apply merged routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async applyRoutes(): Promise<void> {
|
public async applyRoutes(): Promise<void> {
|
||||||
const smartProxy = this.getSmartProxy();
|
const smartProxy = this.getSmartProxy();
|
||||||
if (!smartProxy) return;
|
if (!smartProxy) return;
|
||||||
|
|
||||||
@@ -258,10 +262,31 @@ export class RouteConfigManager {
|
|||||||
enabledRoutes.push(route);
|
enabledRoutes.push(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add enabled programmatic routes
|
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||||
|
const http3Config = this.getHttp3Config?.();
|
||||||
|
const vpnAllowList = this.getVpnAllowList;
|
||||||
for (const stored of this.storedRoutes.values()) {
|
for (const stored of this.storedRoutes.values()) {
|
||||||
if (stored.enabled) {
|
if (stored.enabled) {
|
||||||
enabledRoutes.push(stored.route);
|
let route = stored.route;
|
||||||
|
if (http3Config && http3Config.enabled !== false) {
|
||||||
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
|
}
|
||||||
|
// Inject VPN security for programmatic routes with vpn.required
|
||||||
|
if (vpnAllowList) {
|
||||||
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
|
if (dcRoute.vpn?.required) {
|
||||||
|
const existing = route.security?.ipAllowList || [];
|
||||||
|
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: [...existing, ...allowList],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabledRoutes.push(route);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export class ConfigValidator {
|
|||||||
} else if (rules.items.schema && itemType === 'object') {
|
} else if (rules.items.schema && itemType === 'object') {
|
||||||
const itemResult = this.validate(value[i], rules.items.schema);
|
const itemResult = this.validate(value[i], rules.items.schema);
|
||||||
if (!itemResult.valid) {
|
if (!itemResult.valid) {
|
||||||
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ export class ConfigValidator {
|
|||||||
if (rules.schema) {
|
if (rules.schema) {
|
||||||
const nestedResult = this.validate(value, rules.schema);
|
const nestedResult = this.validate(value, rules.schema);
|
||||||
if (!nestedResult.valid) {
|
if (!nestedResult.valid) {
|
||||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||||
}
|
}
|
||||||
validatedConfig[key] = nestedResult.config;
|
validatedConfig[key] = nestedResult.config;
|
||||||
}
|
}
|
||||||
@@ -233,8 +233,8 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
// Apply defaults to array items
|
// Apply defaults to array items
|
||||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||||
result[key] = result[key].map(item =>
|
result[key] = result[key].map(item =>
|
||||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||||
'CONFIG_VALIDATION_ERROR',
|
'CONFIG_VALIDATION_ERROR',
|
||||||
{ data: { errors: result.errors } }
|
{ data: { errors: result.errors } }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export class PlatformError extends Error {
|
|||||||
const { retry } = this.context;
|
const { retry } = this.context;
|
||||||
if (!retry) return false;
|
if (!retry) return false;
|
||||||
|
|
||||||
return retry.currentRetry < retry.maxRetries;
|
return (retry.currentRetry ?? 0) < (retry.maxRetries ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
153
ts/http3/http3-route-augmentation.ts
Normal file
153
ts/http3/http3-route-augmentation.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for HTTP/3 (QUIC) route augmentation.
|
||||||
|
* HTTP/3 is enabled by default on all qualifying HTTPS routes.
|
||||||
|
*/
|
||||||
|
export interface IHttp3Config {
|
||||||
|
/** Enable HTTP/3 augmentation on qualifying routes (default: true) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** QUIC-specific settings applied to all augmented routes */
|
||||||
|
quicSettings?: {
|
||||||
|
/** QUIC connection idle timeout in ms (default: 30000) */
|
||||||
|
maxIdleTimeout?: number;
|
||||||
|
/** Max concurrent bidirectional streams per connection (default: 100) */
|
||||||
|
maxConcurrentBidiStreams?: number;
|
||||||
|
/** Max concurrent unidirectional streams per connection (default: 100) */
|
||||||
|
maxConcurrentUniStreams?: number;
|
||||||
|
/** Initial congestion window size in bytes */
|
||||||
|
initialCongestionWindow?: number;
|
||||||
|
};
|
||||||
|
/** Alt-Svc header settings */
|
||||||
|
altSvc?: {
|
||||||
|
/** Port advertised in Alt-Svc header (default: same as listening port) */
|
||||||
|
port?: number;
|
||||||
|
/** Max age for Alt-Svc advertisement in seconds (default: 86400) */
|
||||||
|
maxAge?: number;
|
||||||
|
};
|
||||||
|
/** UDP session settings */
|
||||||
|
udpSettings?: {
|
||||||
|
/** Idle timeout for UDP sessions in ms (default: 60000) */
|
||||||
|
sessionTimeout?: number;
|
||||||
|
/** Max concurrent UDP sessions per source IP (default: 1000) */
|
||||||
|
maxSessionsPerIP?: number;
|
||||||
|
/** Max accepted datagram size in bytes (default: 65535) */
|
||||||
|
maxDatagramSize?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a TPortRange includes port 443.
|
||||||
|
*/
|
||||||
|
function portRangeIncludes443(ports: TPortRange): boolean {
|
||||||
|
if (typeof ports === 'number') return ports === 443;
|
||||||
|
if (Array.isArray(ports)) {
|
||||||
|
return ports.some((p) => {
|
||||||
|
if (typeof p === 'number') return p === 443;
|
||||||
|
return p.from <= 443 && p.to >= 443;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a route name indicates an email route that should not get HTTP/3.
|
||||||
|
*/
|
||||||
|
function isEmailRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||||
|
const name = route.name?.toLowerCase() || '';
|
||||||
|
return (
|
||||||
|
name.startsWith('smtp-') ||
|
||||||
|
name.startsWith('submission-') ||
|
||||||
|
name.startsWith('smtps-') ||
|
||||||
|
name.startsWith('email-')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a route qualifies for HTTP/3 augmentation.
|
||||||
|
*/
|
||||||
|
export function routeQualifiesForHttp3(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
globalConfig: IHttp3Config,
|
||||||
|
): boolean {
|
||||||
|
// Check global enable + per-route override
|
||||||
|
const globalEnabled = globalConfig.enabled !== false; // default true
|
||||||
|
const perRouteOverride = route.action.options?.http3;
|
||||||
|
|
||||||
|
// If per-route explicitly set, use that; otherwise use global
|
||||||
|
const shouldAugment =
|
||||||
|
perRouteOverride !== undefined ? perRouteOverride : globalEnabled;
|
||||||
|
if (!shouldAugment) return false;
|
||||||
|
|
||||||
|
// Must be forward type
|
||||||
|
if (route.action.type !== 'forward') return false;
|
||||||
|
|
||||||
|
// Must include port 443
|
||||||
|
if (!portRangeIncludes443(route.match.ports)) return false;
|
||||||
|
|
||||||
|
// Must have TLS
|
||||||
|
if (!route.action.tls) return false;
|
||||||
|
|
||||||
|
// Skip email routes
|
||||||
|
if (isEmailRoute(route)) return false;
|
||||||
|
|
||||||
|
// Skip if already configured with transport 'all' or 'udp'
|
||||||
|
if (route.match.transport === 'all' || route.match.transport === 'udp') return false;
|
||||||
|
|
||||||
|
// Skip if already has QUIC config
|
||||||
|
if (route.action.udp?.quic) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augment a single route with HTTP/3 fields.
|
||||||
|
* Returns a new route object (does not mutate the original).
|
||||||
|
*/
|
||||||
|
export function augmentRouteWithHttp3(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
config: IHttp3Config,
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
if (!routeQualifiesForHttp3(route, config)) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
match: {
|
||||||
|
...route.match,
|
||||||
|
transport: 'all' as const,
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
...route.action,
|
||||||
|
udp: {
|
||||||
|
...(route.action.udp || {}),
|
||||||
|
sessionTimeout: config.udpSettings?.sessionTimeout,
|
||||||
|
maxSessionsPerIP: config.udpSettings?.maxSessionsPerIP,
|
||||||
|
maxDatagramSize: config.udpSettings?.maxDatagramSize,
|
||||||
|
quic: {
|
||||||
|
enableHttp3: true,
|
||||||
|
maxIdleTimeout: config.quicSettings?.maxIdleTimeout,
|
||||||
|
maxConcurrentBidiStreams: config.quicSettings?.maxConcurrentBidiStreams,
|
||||||
|
maxConcurrentUniStreams: config.quicSettings?.maxConcurrentUniStreams,
|
||||||
|
altSvcPort: config.altSvc?.port,
|
||||||
|
altSvcMaxAge: config.altSvc?.maxAge ?? 86400,
|
||||||
|
initialCongestionWindow: config.quicSettings?.initialCongestionWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augment all qualifying routes in an array.
|
||||||
|
* Returns a new array (does not mutate originals).
|
||||||
|
*/
|
||||||
|
export function augmentRoutesWithHttp3(
|
||||||
|
routes: plugins.smartproxy.IRouteConfig[],
|
||||||
|
config: IHttp3Config,
|
||||||
|
): plugins.smartproxy.IRouteConfig[] {
|
||||||
|
return routes.map((route) => augmentRouteWithHttp3(route, config));
|
||||||
|
}
|
||||||
1
ts/http3/index.ts
Normal file
1
ts/http3/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './http3-route-augmentation.js';
|
||||||
26
ts/index.ts
26
ts/index.ts
@@ -5,6 +5,7 @@ export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
|||||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// DcRouter
|
// DcRouter
|
||||||
|
import { DcRouter } from './classes.dcrouter.js';
|
||||||
export * from './classes.dcrouter.js';
|
export * from './classes.dcrouter.js';
|
||||||
|
|
||||||
// RADIUS module
|
// RADIUS module
|
||||||
@@ -13,4 +14,27 @@ export * from './radius/index.js';
|
|||||||
// Remote Ingress module
|
// Remote Ingress module
|
||||||
export * from './remoteingress/index.js';
|
export * from './remoteingress/index.js';
|
||||||
|
|
||||||
export const runCli = async () => {};
|
// HTTP/3 module
|
||||||
|
export type { IHttp3Config } from './http3/index.js';
|
||||||
|
|
||||||
|
export const runCli = async () => {
|
||||||
|
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
|
||||||
|
|
||||||
|
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
|
||||||
|
const { getOciContainerConfig } = await import('../ts_oci_container/index.js');
|
||||||
|
options = getOciContainerConfig();
|
||||||
|
console.log('[DCRouter] Starting in OCI Container mode...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dcRouter = new DcRouter(options);
|
||||||
|
await dcRouter.start();
|
||||||
|
console.log('[DCRouter] Running. Send SIGTERM or SIGINT to stop.');
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log('[DCRouter] Shutting down...');
|
||||||
|
await dcRouter.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.once('SIGINT', shutdown);
|
||||||
|
process.once('SIGTERM', shutdown);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 @@ 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;
|
||||||
|
private vpnHandler!: handlers.VpnHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -39,7 +40,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 +51,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +87,7 @@ export class OpsServer {
|
|||||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||||
|
this.vpnHandler = new handlers.VpnHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export * from './email-ops.handler.js';
|
|||||||
export * from './certificate.handler.js';
|
export * from './certificate.handler.js';
|
||||||
export * from './remoteingress.handler.js';
|
export * from './remoteingress.handler.js';
|
||||||
export * from './route-management.handler.js';
|
export * from './route-management.handler.js';
|
||||||
export * from './api-token.handler.js';
|
export * from './api-token.handler.js';
|
||||||
|
export * from './vpn.handler.js';
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,44 +490,41 @@ export class StatsHandler {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
const services: Array<{
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
name: string;
|
const health = dcRouter.serviceManager.getHealth();
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
||||||
message?: string;
|
const services = health.services.map((svc) => {
|
||||||
}> = [];
|
let status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
|
switch (svc.state) {
|
||||||
// Check HTTP Proxy
|
case 'running':
|
||||||
if (this.opsServerRef.dcRouterRef.smartProxy) {
|
status = 'healthy';
|
||||||
services.push({
|
break;
|
||||||
name: 'HTTP/HTTPS Proxy',
|
case 'starting':
|
||||||
status: 'healthy',
|
case 'degraded':
|
||||||
});
|
status = 'degraded';
|
||||||
}
|
break;
|
||||||
|
case 'failed':
|
||||||
// Check Email Server
|
status = svc.criticality === 'critical' ? 'unhealthy' : 'degraded';
|
||||||
if (this.opsServerRef.dcRouterRef.emailServer) {
|
break;
|
||||||
services.push({
|
case 'stopped':
|
||||||
name: 'Email Server',
|
case 'stopping':
|
||||||
status: 'healthy',
|
default:
|
||||||
});
|
status = 'degraded';
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
// Check DNS Server
|
|
||||||
if (this.opsServerRef.dcRouterRef.dnsServer) {
|
let message: string | undefined;
|
||||||
services.push({
|
if (svc.state === 'failed' && svc.lastError) {
|
||||||
name: 'DNS Server',
|
message = svc.lastError;
|
||||||
status: 'healthy',
|
} else if (svc.retryCount > 0 && svc.state !== 'running') {
|
||||||
});
|
message = `Retry attempt ${svc.retryCount}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check OpsServer
|
return { name: svc.name, status, message };
|
||||||
services.push({
|
|
||||||
name: 'OpsServer',
|
|
||||||
status: 'healthy',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const healthy = services.every(s => s.status === 'healthy');
|
const healthy = health.overall === 'healthy';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
healthy,
|
healthy,
|
||||||
services,
|
services,
|
||||||
|
|||||||
257
ts/opsserver/handlers/vpn.handler.ts
Normal file
257
ts/opsserver/handlers/vpn.handler.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class VpnHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get all registered VPN clients
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
|
||||||
|
'getVpnClients',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { clients: [] };
|
||||||
|
}
|
||||||
|
const clients = manager.listClients().map((c) => ({
|
||||||
|
clientId: c.clientId,
|
||||||
|
enabled: c.enabled,
|
||||||
|
serverDefinedClientTags: c.serverDefinedClientTags,
|
||||||
|
description: c.description,
|
||||||
|
assignedIp: c.assignedIp,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
expiresAt: c.expiresAt,
|
||||||
|
}));
|
||||||
|
return { clients };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get VPN server status
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
|
||||||
|
'getVpnStatus',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
|
||||||
|
if (!manager) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
running: false,
|
||||||
|
forwardingMode: 'socket' as const,
|
||||||
|
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||||
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
|
serverPublicKeys: null,
|
||||||
|
registeredClients: 0,
|
||||||
|
connectedClients: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await manager.getConnectedClients();
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
running: manager.running,
|
||||||
|
forwardingMode: manager.forwardingMode,
|
||||||
|
subnet: manager.getSubnet(),
|
||||||
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
|
serverPublicKeys: manager.getServerPublicKeys(),
|
||||||
|
registeredClients: manager.listClients().length,
|
||||||
|
connectedClients: connected.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||||
|
|
||||||
|
// Create a new VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
|
||||||
|
'createVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundle = await manager.createClient({
|
||||||
|
clientId: dataArg.clientId,
|
||||||
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
|
description: dataArg.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
client: {
|
||||||
|
clientId: bundle.entry.clientId,
|
||||||
|
enabled: bundle.entry.enabled ?? true,
|
||||||
|
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||||
|
description: bundle.entry.description,
|
||||||
|
assignedIp: bundle.entry.assignedIp,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
expiresAt: bundle.entry.expiresAt,
|
||||||
|
},
|
||||||
|
wireguardConfig: bundle.wireguardConfig,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
|
||||||
|
'deleteVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.removeClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
|
||||||
|
'enableVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.enableClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disable a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
|
||||||
|
'disableVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.disableClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rotate a VPN client's keys
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
|
||||||
|
'rotateVpnClientKey',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundle = await manager.rotateClientKey(dataArg.clientId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
wireguardConfig: bundle.wireguardConfig,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export a VPN client config
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
|
||||||
|
'exportVpnClientConfig',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format);
|
||||||
|
return { success: true, config };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get telemetry for a specific VPN client
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
|
||||||
|
'getVpnClientTelemetry',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const telemetry = await manager.getClientTelemetry(dataArg.clientId);
|
||||||
|
if (!telemetry) {
|
||||||
|
return { success: false, message: 'Client not found or not connected' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
telemetry: {
|
||||||
|
clientId: telemetry.clientId,
|
||||||
|
assignedIp: telemetry.assignedIp,
|
||||||
|
bytesSent: telemetry.bytesSent,
|
||||||
|
bytesReceived: telemetry.bytesReceived,
|
||||||
|
packetsDropped: telemetry.packetsDropped,
|
||||||
|
bytesDropped: telemetry.bytesDropped,
|
||||||
|
lastKeepaliveAt: telemetry.lastKeepaliveAt,
|
||||||
|
keepalivesReceived: telemetry.keepalivesReceived,
|
||||||
|
rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec,
|
||||||
|
burstBytes: telemetry.burstBytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,23 +47,25 @@ import * as qenv from '@push.rocks/qenv';
|
|||||||
import * as smartacme from '@push.rocks/smartacme';
|
import * as smartacme from '@push.rocks/smartacme';
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
import * as smartdns from '@push.rocks/smartdns';
|
import * as smartdns from '@push.rocks/smartdns';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfs from '@push.rocks/smartfs';
|
||||||
import * as smartguard from '@push.rocks/smartguard';
|
import * as smartguard from '@push.rocks/smartguard';
|
||||||
import * as smartjwt from '@push.rocks/smartjwt';
|
import * as smartjwt from '@push.rocks/smartjwt';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||||
import * as smartmta from '@push.rocks/smartmta';
|
import * as smartmta from '@push.rocks/smartmta';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartdb from '@push.rocks/smartdb';
|
||||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartproxy from '@push.rocks/smartproxy';
|
import * as smartproxy from '@push.rocks/smartproxy';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
import * as smartvpn from '@push.rocks/smartvpn';
|
||||||
import * as smartradius from '@push.rocks/smartradius';
|
import * as smartradius from '@push.rocks/smartradius';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique };
|
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
|
||||||
|
|
||||||
// Define SmartLog types for use in error handling
|
// Define SmartLog types for use in error handling
|
||||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||||
@@ -89,7 +91,7 @@ export {
|
|||||||
uuid,
|
uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filesystem utilities (compatibility helpers for smartfile v13+)
|
// Filesystem utilities
|
||||||
export const fsUtils = {
|
export const fsUtils = {
|
||||||
/**
|
/**
|
||||||
* Ensure a directory exists, creating it recursively if needed (sync)
|
* Ensure a directory exists, creating it recursively if needed (sync)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
ts/readme.md
16
ts/readme.md
@@ -37,7 +37,7 @@ const router = new DcRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await router.start();
|
await router.start();
|
||||||
// OpsServer dashboard at http://localhost:3000
|
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
await router.stop();
|
await router.stop();
|
||||||
@@ -60,6 +60,9 @@ ts/
|
|||||||
│ └── documents/ # Cached document models
|
│ └── documents/ # Cached document models
|
||||||
├── config/ # Configuration utilities
|
├── config/ # Configuration utilities
|
||||||
├── errors/ # Error classes and retry logic
|
├── errors/ # Error classes and retry logic
|
||||||
|
├── http3/ # HTTP/3 (QUIC) route augmentation
|
||||||
|
│ ├── index.ts # Barrel export
|
||||||
|
│ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
|
||||||
├── monitoring/ # MetricsManager (SmartMetrics integration)
|
├── monitoring/ # MetricsManager (SmartMetrics integration)
|
||||||
├── opsserver/ # OpsServer dashboard + API handlers
|
├── opsserver/ # OpsServer dashboard + API handlers
|
||||||
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
||||||
@@ -71,7 +74,10 @@ ts/
|
|||||||
│ ├── email.handler.ts # Email operations
|
│ ├── email.handler.ts # Email operations
|
||||||
│ ├── certificate.handler.ts # Certificate management
|
│ ├── certificate.handler.ts # Certificate management
|
||||||
│ ├── radius.handler.ts # RADIUS management
|
│ ├── radius.handler.ts # RADIUS management
|
||||||
│ └── remoteingress.handler.ts # Remote ingress edge + token management
|
│ ├── remoteingress.handler.ts # Remote ingress edge + token management
|
||||||
|
│ ├── route-management.handler.ts # Programmatic route CRUD
|
||||||
|
│ ├── api-token.handler.ts # API token management
|
||||||
|
│ └── security.handler.ts # Security metrics + connections
|
||||||
├── radius/ # RADIUS server integration
|
├── radius/ # RADIUS server integration
|
||||||
├── remoteingress/ # Remote ingress hub integration
|
├── remoteingress/ # Remote ingress hub integration
|
||||||
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
||||||
@@ -96,6 +102,9 @@ export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
|
|||||||
|
|
||||||
// Remote Ingress
|
// Remote Ingress
|
||||||
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||||
|
|
||||||
|
// HTTP/3
|
||||||
|
export type { IHttp3Config } from './http3/index.js';
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Classes
|
## Key Classes
|
||||||
@@ -112,6 +121,7 @@ The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle o
|
|||||||
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
||||||
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
||||||
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
|
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
|
||||||
|
| `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
|
||||||
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
|
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
|
||||||
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
||||||
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +258,12 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
logger.log('error', `Error scanning email: ${(error as Error).message}`, {
|
||||||
messageId: email.getMessageId(),
|
messageId: email.getMessageId(),
|
||||||
error: error.stack
|
error: (error as Error).stack
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return a safe default with error indication
|
// Return a safe default with error indication
|
||||||
return {
|
return {
|
||||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||||
@@ -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,10 +699,10 @@ export class ContentScanner {
|
|||||||
subject: email.subject
|
subject: email.subject
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
domain: email.getFromDomain()
|
domain: email.getFromDomain() ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a threat finding to the security logger
|
* Log a threat finding to the security logger
|
||||||
* @param email The email containing the threat
|
* @param email The email containing the threat
|
||||||
@@ -722,10 +722,10 @@ export class ContentScanner {
|
|||||||
subject: email.subject
|
subject: email.subject
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
domain: email.getFromDomain()
|
domain: email.getFromDomain() ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get threat level description based on score
|
* Get threat level description based on score
|
||||||
* @param score Threat score
|
* @param score Threat score
|
||||||
|
|||||||
@@ -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,11 +506,11 @@ 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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load cache from disk or storage manager
|
* Load cache from disk or storage manager
|
||||||
*/
|
*/
|
||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,19 +127,19 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal filesystem write function
|
* Internal filesystem write function
|
||||||
*/
|
*/
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
429
ts/vpn/classes.vpn-manager.ts
Normal file
429
ts/vpn/classes.vpn-manager.ts
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
||||||
|
|
||||||
|
const STORAGE_PREFIX_KEYS = '/vpn/server-keys';
|
||||||
|
const STORAGE_PREFIX_CLIENTS = '/vpn/clients/';
|
||||||
|
|
||||||
|
export interface IVpnManagerConfig {
|
||||||
|
/** VPN subnet CIDR (default: '10.8.0.0/24') */
|
||||||
|
subnet?: string;
|
||||||
|
/** WireGuard UDP listen port (default: 51820) */
|
||||||
|
wgListenPort?: number;
|
||||||
|
/** DNS servers pushed to VPN clients */
|
||||||
|
dns?: string[];
|
||||||
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
|
serverEndpoint?: string;
|
||||||
|
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
||||||
|
forwardingMode?: 'tun' | 'socket';
|
||||||
|
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||||
|
initialClients?: Array<{
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||||
|
onClientChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPersistedServerKeys {
|
||||||
|
noisePrivateKey: string;
|
||||||
|
noisePublicKey: string;
|
||||||
|
wgPrivateKey: string;
|
||||||
|
wgPublicKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPersistedClient {
|
||||||
|
clientId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
assignedIp?: string;
|
||||||
|
noisePublicKey: string;
|
||||||
|
wgPublicKey: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the SmartVPN server lifecycle and VPN client CRUD.
|
||||||
|
* Persists server keys and client registrations via StorageManager.
|
||||||
|
*/
|
||||||
|
export class VpnManager {
|
||||||
|
private storageManager: StorageManager;
|
||||||
|
private config: IVpnManagerConfig;
|
||||||
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
|
private clients: Map<string, IPersistedClient> = new Map();
|
||||||
|
private serverKeys?: IPersistedServerKeys;
|
||||||
|
private _forwardingMode: 'tun' | 'socket';
|
||||||
|
|
||||||
|
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
||||||
|
this.storageManager = storageManager;
|
||||||
|
this.config = config;
|
||||||
|
// Auto-detect forwarding mode: tun if root, socket otherwise
|
||||||
|
this._forwardingMode = config.forwardingMode
|
||||||
|
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective forwarding mode (tun or socket). */
|
||||||
|
public get forwardingMode(): 'tun' | 'socket' {
|
||||||
|
return this._forwardingMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The VPN subnet CIDR. */
|
||||||
|
public getSubnet(): string {
|
||||||
|
return this.config.subnet || '10.8.0.0/24';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the VPN server is running. */
|
||||||
|
public get running(): boolean {
|
||||||
|
return this.vpnServer?.running ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the VPN server.
|
||||||
|
* Loads or generates server keys, loads persisted clients, starts VpnServer.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
// Load or generate server keys
|
||||||
|
this.serverKeys = await this.loadOrGenerateServerKeys();
|
||||||
|
|
||||||
|
// Load persisted clients
|
||||||
|
await this.loadPersistedClients();
|
||||||
|
|
||||||
|
// Build client entries for the daemon
|
||||||
|
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
|
||||||
|
for (const client of this.clients.values()) {
|
||||||
|
clientEntries.push({
|
||||||
|
clientId: client.clientId,
|
||||||
|
publicKey: client.noisePublicKey,
|
||||||
|
wgPublicKey: client.wgPublicKey,
|
||||||
|
enabled: client.enabled,
|
||||||
|
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||||
|
description: client.description,
|
||||||
|
assignedIp: client.assignedIp,
|
||||||
|
expiresAt: client.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnet = this.getSubnet();
|
||||||
|
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||||
|
|
||||||
|
// Create and start VpnServer
|
||||||
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
||||||
|
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
||||||
|
privateKey: this.serverKeys.noisePrivateKey,
|
||||||
|
publicKey: this.serverKeys.noisePublicKey,
|
||||||
|
subnet,
|
||||||
|
dns: this.config.dns,
|
||||||
|
forwardingMode: this._forwardingMode,
|
||||||
|
transportMode: 'all',
|
||||||
|
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||||
|
wgListenPort,
|
||||||
|
clients: clientEntries,
|
||||||
|
socketForwardProxyProtocol: this._forwardingMode === 'socket',
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.vpnServer.start(serverConfig);
|
||||||
|
|
||||||
|
// Create initial clients from config (idempotent — skip already-persisted)
|
||||||
|
if (this.config.initialClients) {
|
||||||
|
for (const initial of this.config.initialClients) {
|
||||||
|
if (!this.clients.has(initial.clientId)) {
|
||||||
|
const bundle = await this.createClient({
|
||||||
|
clientId: initial.clientId,
|
||||||
|
serverDefinedClientTags: initial.serverDefinedClientTags,
|
||||||
|
description: initial.description,
|
||||||
|
});
|
||||||
|
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the VPN server.
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.vpnServer) {
|
||||||
|
try {
|
||||||
|
await this.vpnServer.stopServer();
|
||||||
|
} catch {
|
||||||
|
// Ignore stop errors
|
||||||
|
}
|
||||||
|
this.vpnServer.stop();
|
||||||
|
this.vpnServer = undefined;
|
||||||
|
}
|
||||||
|
logger.log('info', 'VPN server stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new VPN client. Returns the config bundle (secrets only shown once).
|
||||||
|
*/
|
||||||
|
public async createClient(opts: {
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
|
if (!this.vpnServer) {
|
||||||
|
throw new Error('VPN server not running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = await this.vpnServer.createClient({
|
||||||
|
clientId: opts.clientId,
|
||||||
|
serverDefinedClientTags: opts.serverDefinedClientTags,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update WireGuard config endpoint if serverEndpoint is configured
|
||||||
|
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
||||||
|
const wgPort = this.config.wgListenPort ?? 51820;
|
||||||
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
|
/Endpoint\s*=\s*.+/,
|
||||||
|
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist client entry (without private keys)
|
||||||
|
const persisted: IPersistedClient = {
|
||||||
|
clientId: bundle.entry.clientId,
|
||||||
|
enabled: bundle.entry.enabled ?? true,
|
||||||
|
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||||
|
description: bundle.entry.description,
|
||||||
|
assignedIp: bundle.entry.assignedIp,
|
||||||
|
noisePublicKey: bundle.entry.publicKey,
|
||||||
|
wgPublicKey: bundle.entry.wgPublicKey || '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
expiresAt: bundle.entry.expiresAt,
|
||||||
|
};
|
||||||
|
this.clients.set(persisted.clientId, persisted);
|
||||||
|
await this.persistClient(persisted);
|
||||||
|
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a VPN client.
|
||||||
|
*/
|
||||||
|
public async removeClient(clientId: string): Promise<void> {
|
||||||
|
if (!this.vpnServer) {
|
||||||
|
throw new Error('VPN server not running');
|
||||||
|
}
|
||||||
|
await this.vpnServer.removeClient(clientId);
|
||||||
|
this.clients.delete(clientId);
|
||||||
|
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all registered clients (without secrets).
|
||||||
|
*/
|
||||||
|
public listClients(): IPersistedClient[] {
|
||||||
|
return [...this.clients.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a client.
|
||||||
|
*/
|
||||||
|
public async enableClient(clientId: string): Promise<void> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
await this.vpnServer.enableClient(clientId);
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
client.enabled = true;
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a client.
|
||||||
|
*/
|
||||||
|
public async disableClient(clientId: string): Promise<void> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
await this.vpnServer.disableClient(clientId);
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
client.enabled = false;
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate a client's keys. Returns the new config bundle.
|
||||||
|
*/
|
||||||
|
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
||||||
|
|
||||||
|
// Update endpoint in WireGuard config
|
||||||
|
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
||||||
|
const wgPort = this.config.wgListenPort ?? 51820;
|
||||||
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
|
/Endpoint\s*=\s*.+/,
|
||||||
|
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update persisted entry with new public keys
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
client.noisePublicKey = bundle.entry.publicKey;
|
||||||
|
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a client config (without secrets).
|
||||||
|
*/
|
||||||
|
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
||||||
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
|
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
||||||
|
|
||||||
|
// Update endpoint in WireGuard config
|
||||||
|
if (format === 'wireguard' && this.config.serverEndpoint) {
|
||||||
|
const wgPort = this.config.wgListenPort ?? 51820;
|
||||||
|
config = config.replace(
|
||||||
|
/Endpoint\s*=\s*.+/,
|
||||||
|
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag-based access control ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
|
||||||
|
*/
|
||||||
|
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (const client of this.clients.values()) {
|
||||||
|
if (!client.enabled || !client.assignedIp) continue;
|
||||||
|
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
|
||||||
|
ips.push(client.assignedIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status and telemetry ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server status.
|
||||||
|
*/
|
||||||
|
public async getStatus(): Promise<plugins.smartvpn.IVpnStatus | null> {
|
||||||
|
if (!this.vpnServer) return null;
|
||||||
|
return this.vpnServer.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server statistics.
|
||||||
|
*/
|
||||||
|
public async getStatistics(): Promise<plugins.smartvpn.IVpnServerStatistics | null> {
|
||||||
|
if (!this.vpnServer) return null;
|
||||||
|
return this.vpnServer.getStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List currently connected clients.
|
||||||
|
*/
|
||||||
|
public async getConnectedClients(): Promise<plugins.smartvpn.IVpnClientInfo[]> {
|
||||||
|
if (!this.vpnServer) return [];
|
||||||
|
return this.vpnServer.listClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get telemetry for a specific client.
|
||||||
|
*/
|
||||||
|
public async getClientTelemetry(clientId: string): Promise<plugins.smartvpn.IVpnClientTelemetry | null> {
|
||||||
|
if (!this.vpnServer) return null;
|
||||||
|
return this.vpnServer.getClientTelemetry(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server public keys (for display/info).
|
||||||
|
*/
|
||||||
|
public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null {
|
||||||
|
if (!this.serverKeys) return null;
|
||||||
|
return {
|
||||||
|
noisePublicKey: this.serverKeys.noisePublicKey,
|
||||||
|
wgPublicKey: this.serverKeys.wgPublicKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async loadOrGenerateServerKeys(): Promise<IPersistedServerKeys> {
|
||||||
|
const stored = await this.storageManager.getJSON<IPersistedServerKeys>(STORAGE_PREFIX_KEYS);
|
||||||
|
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
|
||||||
|
logger.log('info', 'Loaded VPN server keys from storage');
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new keys via the daemon
|
||||||
|
const tempServer = new plugins.smartvpn.VpnServer({
|
||||||
|
transport: { transport: 'stdio' },
|
||||||
|
});
|
||||||
|
await tempServer.start();
|
||||||
|
|
||||||
|
const noiseKeys = await tempServer.generateKeypair();
|
||||||
|
const wgKeys = await tempServer.generateWgKeypair();
|
||||||
|
tempServer.stop();
|
||||||
|
|
||||||
|
const keys: IPersistedServerKeys = {
|
||||||
|
noisePrivateKey: noiseKeys.privateKey,
|
||||||
|
noisePublicKey: noiseKeys.publicKey,
|
||||||
|
wgPrivateKey: wgKeys.privateKey,
|
||||||
|
wgPublicKey: wgKeys.publicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.storageManager.setJSON(STORAGE_PREFIX_KEYS, keys);
|
||||||
|
logger.log('info', 'Generated and persisted new VPN server keys');
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPersistedClients(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(STORAGE_PREFIX_CLIENTS);
|
||||||
|
for (const key of keys) {
|
||||||
|
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
||||||
|
if (client) {
|
||||||
|
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||||
|
if (!client.serverDefinedClientTags && client.tags) {
|
||||||
|
client.serverDefinedClientTags = client.tags;
|
||||||
|
delete client.tags;
|
||||||
|
await this.persistClient(client);
|
||||||
|
}
|
||||||
|
this.clients.set(client.clientId, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.clients.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistClient(client: IPersistedClient): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(`${STORAGE_PREFIX_CLIENTS}${client.clientId}`, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/vpn/index.ts
Normal file
1
ts/vpn/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './classes.vpn-manager.js';
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './auth.js';
|
export * from './auth.js';
|
||||||
export * from './stats.js';
|
export * from './stats.js';
|
||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
export * from './route-management.js';
|
export * from './route-management.js';
|
||||||
|
export * from './vpn.js';
|
||||||
@@ -8,6 +8,8 @@ export interface IRemoteIngress {
|
|||||||
name: string;
|
name: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
listenPorts: number[];
|
listenPorts: number[];
|
||||||
|
/** UDP listen ports (e.g. for QUIC/HTTP3). Derived from routes with transport 'udp' or 'all'. */
|
||||||
|
listenPortsUdp?: number[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
|
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
|
||||||
autoDerivePorts: boolean;
|
autoDerivePorts: boolean;
|
||||||
@@ -20,6 +22,8 @@ export interface IRemoteIngress {
|
|||||||
manualPorts?: number[];
|
manualPorts?: number[];
|
||||||
/** Ports auto-derived from route configs — only present in API responses. */
|
/** Ports auto-derived from route configs — only present in API responses. */
|
||||||
derivedPorts?: number[];
|
derivedPorts?: number[];
|
||||||
|
/** Effective UDP ports (union of manual + derived) — only present in API responses. */
|
||||||
|
effectiveListenPortsUdp?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,11 +51,23 @@ export interface IRouteRemoteIngress {
|
|||||||
edgeFilter?: string[];
|
edgeFilter?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level VPN access configuration.
|
||||||
|
* When attached to a route, restricts access to VPN clients only.
|
||||||
|
*/
|
||||||
|
export interface IRouteVpn {
|
||||||
|
/** Whether this route requires VPN access */
|
||||||
|
required: boolean;
|
||||||
|
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
||||||
|
allowedServerDefinedClientTags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended route config used within dcrouter.
|
* Extended route config used within dcrouter.
|
||||||
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
|
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
|
||||||
* SmartProxy ignores unknown properties at runtime.
|
* SmartProxy ignores unknown properties at runtime.
|
||||||
*/
|
*/
|
||||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||||
remoteIngress?: IRouteRemoteIngress;
|
remoteIngress?: IRouteRemoteIngress;
|
||||||
|
vpn?: IRouteVpn;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -174,4 +175,26 @@ export interface IConnectionDetails {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
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;
|
||||||
}
|
}
|
||||||
45
ts_interfaces/data/vpn.ts
Normal file
45
ts_interfaces/data/vpn.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* A registered VPN client (secrets excluded from API responses).
|
||||||
|
*/
|
||||||
|
export interface IVpnClient {
|
||||||
|
clientId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
assignedIp?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VPN server status.
|
||||||
|
*/
|
||||||
|
export interface IVpnServerStatus {
|
||||||
|
running: boolean;
|
||||||
|
forwardingMode: 'tun' | 'socket';
|
||||||
|
subnet: string;
|
||||||
|
wgListenPort: number;
|
||||||
|
serverPublicKeys: {
|
||||||
|
noisePublicKey: string;
|
||||||
|
wgPublicKey: string;
|
||||||
|
} | null;
|
||||||
|
registeredClients: number;
|
||||||
|
connectedClients: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VPN client telemetry data.
|
||||||
|
*/
|
||||||
|
export interface IVpnClientTelemetry {
|
||||||
|
clientId: string;
|
||||||
|
assignedIp: string;
|
||||||
|
bytesSent: number;
|
||||||
|
bytesReceived: number;
|
||||||
|
packetsDropped: number;
|
||||||
|
bytesDropped: number;
|
||||||
|
lastKeepaliveAt?: string;
|
||||||
|
keepalivesReceived: number;
|
||||||
|
rateLimitBytesPerSec?: number;
|
||||||
|
burstBytes?: number;
|
||||||
|
}
|
||||||
@@ -96,7 +96,15 @@ interface IIdentity {
|
|||||||
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
||||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` property |
|
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
||||||
|
| `IRouteVpn` | Route-level VPN config: `required` flag to restrict access to VPN clients |
|
||||||
|
|
||||||
|
#### VPN Interfaces
|
||||||
|
| Interface | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `IVpnClient` | Client registration: clientId, enabled, tags, description, assignedIp, timestamps |
|
||||||
|
| `IVpnServerStatus` | Server status: running, forwardingMode, subnet, wgListenPort, publicKeys, client counts |
|
||||||
|
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
||||||
|
|
||||||
### Request Interfaces (`requests`)
|
### Request Interfaces (`requests`)
|
||||||
|
|
||||||
@@ -205,6 +213,19 @@ interface ICertificateInfo {
|
|||||||
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
||||||
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
||||||
|
|
||||||
|
#### 🔐 VPN
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
|
||||||
|
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
|
||||||
|
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
|
||||||
|
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
|
||||||
|
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
|
||||||
|
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
|
||||||
|
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
|
||||||
|
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
|
||||||
|
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
|
||||||
|
|
||||||
#### 📡 RADIUS
|
#### 📡 RADIUS
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
|-----------|--------|-------------|
|
|-----------|--------|-------------|
|
||||||
@@ -280,7 +301,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.
|
||||||
|
|
||||||
@@ -292,7 +313,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export * from './email-ops.js';
|
|||||||
export * from './certificate.js';
|
export * from './certificate.js';
|
||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
export * from './route-management.js';
|
export * from './route-management.js';
|
||||||
export * from './api-tokens.js';
|
export * from './api-tokens.js';
|
||||||
|
export * from './vpn.js';
|
||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
175
ts_interfaces/requests/vpn.ts
Normal file
175
ts_interfaces/requests/vpn.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry } from '../data/vpn.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VPN Client Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered VPN clients.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetVpnClients extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetVpnClients
|
||||||
|
> {
|
||||||
|
method: 'getVpnClients';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
clients: IVpnClient[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get VPN server status.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetVpnStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetVpnStatus
|
||||||
|
> {
|
||||||
|
method: 'getVpnStatus';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
status: IVpnServerStatus;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new VPN client. Returns the config bundle (secrets only shown once).
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateVpnClient
|
||||||
|
> {
|
||||||
|
method: 'createVpnClient';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
client?: IVpnClient;
|
||||||
|
/** WireGuard .conf file content (only returned at creation) */
|
||||||
|
wireguardConfig?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a VPN client.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteVpnClient
|
||||||
|
> {
|
||||||
|
method: 'deleteVpnClient';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a VPN client.
|
||||||
|
*/
|
||||||
|
export interface IReq_EnableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_EnableVpnClient
|
||||||
|
> {
|
||||||
|
method: 'enableVpnClient';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a VPN client.
|
||||||
|
*/
|
||||||
|
export interface IReq_DisableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DisableVpnClient
|
||||||
|
> {
|
||||||
|
method: 'disableVpnClient';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate a VPN client's keys. Returns the new config bundle.
|
||||||
|
*/
|
||||||
|
export interface IReq_RotateVpnClientKey extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RotateVpnClientKey
|
||||||
|
> {
|
||||||
|
method: 'rotateVpnClientKey';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
/** WireGuard .conf file content with new keys */
|
||||||
|
wireguardConfig?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a VPN client config.
|
||||||
|
*/
|
||||||
|
export interface IReq_ExportVpnClientConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ExportVpnClientConfig
|
||||||
|
> {
|
||||||
|
method: 'exportVpnClientConfig';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
format: 'smartvpn' | 'wireguard';
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
config?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get telemetry for a specific VPN client.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetVpnClientTelemetry extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetVpnClientTelemetry
|
||||||
|
> {
|
||||||
|
method: 'getVpnClientTelemetry';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
telemetry?: IVpnClientTelemetry;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
122
ts_oci_container/index.ts
Normal file
122
ts_oci_container/index.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection capacity config
|
||||||
|
const maxConnections = process.env.DCROUTER_MAX_CONNECTIONS;
|
||||||
|
const maxConnectionsPerIP = process.env.DCROUTER_MAX_CONNECTIONS_PER_IP;
|
||||||
|
const connectionRateLimit = process.env.DCROUTER_CONNECTION_RATE_LIMIT;
|
||||||
|
if (maxConnections || maxConnectionsPerIP || connectionRateLimit) {
|
||||||
|
options.smartProxyConfig = {
|
||||||
|
...options.smartProxyConfig,
|
||||||
|
routes: options.smartProxyConfig?.routes || [],
|
||||||
|
...(maxConnectionsPerIP ? { maxConnectionsPerIP: parseInt(maxConnectionsPerIP, 10) } : {}),
|
||||||
|
...(connectionRateLimit ? { connectionRateLimitPerMinute: parseInt(connectionRateLimit, 10) } : {}),
|
||||||
|
...(maxConnections ? {
|
||||||
|
defaults: {
|
||||||
|
...options.smartProxyConfig?.defaults,
|
||||||
|
security: {
|
||||||
|
...options.smartProxyConfig?.defaults?.security,
|
||||||
|
maxConnections: parseInt(maxConnections, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
7
ts_oci_container/plugins.ts
Normal file
7
ts_oci_container/plugins.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export {
|
||||||
|
fs,
|
||||||
|
path,
|
||||||
|
};
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.2.54',
|
version: '11.15.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,4 +9,5 @@ export * from './ops-view-apitokens.js';
|
|||||||
export * from './ops-view-security.js';
|
export * from './ops-view-security.js';
|
||||||
export * from './ops-view-certificates.js';
|
export * from './ops-view-certificates.js';
|
||||||
export * from './ops-view-remoteingress.js';
|
export * from './ops-view-remoteingress.js';
|
||||||
|
export * from './ops-view-vpn.js';
|
||||||
export * from './shared/index.js';
|
export * from './shared/index.js';
|
||||||
@@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
|
|||||||
import { OpsViewSecurity } from './ops-view-security.js';
|
import { OpsViewSecurity } from './ops-view-security.js';
|
||||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||||
|
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||||
|
|
||||||
@customElement('ops-dashboard')
|
@customElement('ops-dashboard')
|
||||||
export class OpsDashboard extends DeesElement {
|
export class OpsDashboard extends DeesElement {
|
||||||
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
|
|||||||
iconName: 'lucide:globe',
|
iconName: 'lucide:globe',
|
||||||
element: OpsViewRemoteIngress,
|
element: OpsViewRemoteIngress,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'VPN',
|
||||||
|
iconName: 'lucide:shield',
|
||||||
|
element: OpsViewVpn,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,17 +201,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 +224,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 +236,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 +257,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 +269,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,11 @@ 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();
|
||||||
const sub = appstate.certificateStatePart.state.subscribe((newState) => {
|
const sub = appstate.certificateStatePart.select().subscribe((newState) => {
|
||||||
this.certState = newState;
|
this.certState = newState;
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(sub);
|
this.rxSubscriptions.push(sub);
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class OpsViewEmails extends DeesElement {
|
|||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
await super.connectedCallback();
|
await super.connectedCallback();
|
||||||
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
|
this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
|
||||||
this.emails = state.emails;
|
this.emails = state.emails;
|
||||||
this.isLoading = state.isLoading;
|
this.isLoading = state.isLoading;
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -46,10 +47,11 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
// Track if we need to update the chart to avoid unnecessary re-renders
|
// Track if we need to update the chart to avoid unnecessary re-renders
|
||||||
private lastChartUpdate = 0;
|
private lastChartUpdate = 0;
|
||||||
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
||||||
|
|
||||||
private trafficUpdateTimer: any = null;
|
private trafficUpdateTimer: any = null;
|
||||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||||
private historyLoaded = false; // Whether server-side throughput history has been loaded
|
private historyLoaded = false; // Whether server-side throughput history has been loaded
|
||||||
|
private visibilityHandler: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -58,28 +60,42 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
this.updateNetworkData();
|
this.updateNetworkData();
|
||||||
this.startTrafficUpdateTimer();
|
this.startTrafficUpdateTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
await super.connectedCallback();
|
await super.connectedCallback();
|
||||||
|
|
||||||
|
// Pause/resume traffic timer when tab visibility changes
|
||||||
|
this.visibilityHandler = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
this.stopTrafficUpdateTimer();
|
||||||
|
} else {
|
||||||
|
this.startTrafficUpdateTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||||
|
|
||||||
// When network view becomes visible, ensure we fetch network data
|
// When network view becomes visible, ensure we fetch network data
|
||||||
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
|
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnectedCallback() {
|
async disconnectedCallback() {
|
||||||
await super.disconnectedCallback();
|
await super.disconnectedCallback();
|
||||||
this.stopTrafficUpdateTimer();
|
this.stopTrafficUpdateTimer();
|
||||||
|
if (this.visibilityHandler) {
|
||||||
|
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||||
|
this.visibilityHandler = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToStateParts() {
|
private subscribeToStateParts() {
|
||||||
// Subscribe and track unsubscribe functions
|
// Subscribe and track unsubscribe functions
|
||||||
const statsUnsubscribe = appstate.statsStatePart.state.subscribe((state) => {
|
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
|
||||||
this.statsState = state;
|
this.statsState = state;
|
||||||
this.updateNetworkData();
|
this.updateNetworkData();
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(statsUnsubscribe);
|
this.rxSubscriptions.push(statsUnsubscribe);
|
||||||
|
|
||||||
const networkUnsubscribe = appstate.networkStatePart.state.subscribe((state) => {
|
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
|
||||||
this.networkState = state;
|
this.networkState = state;
|
||||||
this.updateNetworkData();
|
this.updateNetworkData();
|
||||||
});
|
});
|
||||||
@@ -198,6 +214,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 +313,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 +570,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;
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ 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();
|
||||||
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
|
const sub = appstate.remoteIngressStatePart.select().subscribe((newState) => {
|
||||||
this.riState = newState;
|
this.riState = newState;
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(sub);
|
this.rxSubscriptions.push(sub);
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
330
ts_web/elements/ops-view-vpn.ts
Normal file
330
ts_web/elements/ops-view-vpn.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from './shared/css.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-vpn': OpsViewVpn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('ops-view-vpn')
|
||||||
|
export class OpsViewVpn extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
|
||||||
|
this.vpnState = newState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.vpnContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.enabled {
|
||||||
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||||
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.disabled {
|
||||||
|
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialog {
|
||||||
|
padding: 16px;
|
||||||
|
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialog pre {
|
||||||
|
display: block;
|
||||||
|
padding: 12px;
|
||||||
|
background: ${cssManager.bdTheme('#1f2937', '#111827')};
|
||||||
|
color: #10b981;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 8px 0;
|
||||||
|
user-select: all;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialog .warning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||||
|
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serverInfo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.serverInfo .infoItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serverInfo .infoLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.serverInfo .infoValue {
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
const status = this.vpnState.status;
|
||||||
|
const clients = this.vpnState.clients;
|
||||||
|
const connectedCount = status?.connectedClients ?? 0;
|
||||||
|
const totalClients = clients.length;
|
||||||
|
const enabledClients = clients.filter(c => c.enabled).length;
|
||||||
|
|
||||||
|
const statsTiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'totalClients',
|
||||||
|
title: 'Total Clients',
|
||||||
|
type: 'number',
|
||||||
|
value: totalClients,
|
||||||
|
icon: 'lucide:users',
|
||||||
|
description: 'Registered VPN clients',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'connectedClients',
|
||||||
|
title: 'Connected',
|
||||||
|
type: 'number',
|
||||||
|
value: connectedCount,
|
||||||
|
icon: 'lucide:link',
|
||||||
|
description: 'Currently connected',
|
||||||
|
color: '#10b981',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enabledClients',
|
||||||
|
title: 'Enabled',
|
||||||
|
type: 'number',
|
||||||
|
value: enabledClients,
|
||||||
|
icon: 'lucide:shieldCheck',
|
||||||
|
description: 'Active client registrations',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'serverStatus',
|
||||||
|
title: 'Server',
|
||||||
|
type: 'text',
|
||||||
|
value: status?.running ? 'Running' : 'Stopped',
|
||||||
|
icon: 'lucide:server',
|
||||||
|
description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running',
|
||||||
|
color: status?.running ? '#10b981' : '#ef4444',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>VPN</ops-sectionheading>
|
||||||
|
|
||||||
|
${this.vpnState.newClientConfig ? html`
|
||||||
|
<div class="configDialog">
|
||||||
|
<strong>Client created successfully!</strong>
|
||||||
|
<div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
|
||||||
|
<pre>${this.vpnState.newClientConfig}</pre>
|
||||||
|
<dees-button
|
||||||
|
@click=${async () => {
|
||||||
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||||
|
await navigator.clipboard.writeText(this.vpnState.newClientConfig!);
|
||||||
|
}
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
|
||||||
|
}}
|
||||||
|
>Copy to Clipboard</dees-button>
|
||||||
|
<dees-button
|
||||||
|
@click=${() => {
|
||||||
|
const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'wireguard.conf';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
>Download .conf</dees-button>
|
||||||
|
<dees-button
|
||||||
|
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
|
||||||
|
>Dismiss</dees-button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
|
||||||
|
|
||||||
|
${status ? html`
|
||||||
|
<div class="serverInfo">
|
||||||
|
<div class="infoItem">
|
||||||
|
<span class="infoLabel">Subnet</span>
|
||||||
|
<span class="infoValue">${status.subnet}</span>
|
||||||
|
</div>
|
||||||
|
<div class="infoItem">
|
||||||
|
<span class="infoLabel">WireGuard Port</span>
|
||||||
|
<span class="infoValue">${status.wgListenPort}</span>
|
||||||
|
</div>
|
||||||
|
<div class="infoItem">
|
||||||
|
<span class="infoLabel">Forwarding Mode</span>
|
||||||
|
<span class="infoValue">${status.forwardingMode}</span>
|
||||||
|
</div>
|
||||||
|
${status.serverPublicKeys ? html`
|
||||||
|
<div class="infoItem">
|
||||||
|
<span class="infoLabel">WG Public Key</span>
|
||||||
|
<span class="infoValue" style="font-size: 11px; word-break: break-all;">${status.serverPublicKeys.wgPublicKey}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'VPN Clients'}
|
||||||
|
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
||||||
|
.data=${clients}
|
||||||
|
.displayFunction=${(client: interfaces.data.IVpnClient) => ({
|
||||||
|
'Client ID': client.clientId,
|
||||||
|
'Status': client.enabled
|
||||||
|
? html`<span class="statusBadge enabled">enabled</span>`
|
||||||
|
: html`<span class="statusBadge disabled">disabled</span>`,
|
||||||
|
'VPN IP': client.assignedIp || '-',
|
||||||
|
'Tags': client.serverDefinedClientTags?.length
|
||||||
|
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||||
|
: '-',
|
||||||
|
'Description': client.description || '-',
|
||||||
|
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Toggle',
|
||||||
|
iconName: 'lucide:power',
|
||||||
|
action: async (client: interfaces.data.IVpnClient) => {
|
||||||
|
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
||||||
|
clientId: client.clientId,
|
||||||
|
enabled: !client.enabled,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async (client: interfaces.data.IVpnClient) => {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Delete VPN Client',
|
||||||
|
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
.createNewItem=${async () => {
|
||||||
|
const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Create VPN Client',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text id="clientId" .label=${'Client ID'} .key=${'clientId'} required></dees-input-text>
|
||||||
|
<dees-input-text id="description" .label=${'Description'} .key=${'description'}></dees-input-text>
|
||||||
|
<dees-input-text id="tags" .label=${'Tags (comma-separated)'} .key=${'tags'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modal: any) => {
|
||||||
|
const form = modal.shadowRoot!.querySelector('dees-form') as any;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined;
|
||||||
|
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
|
||||||
|
clientId: data.clientId,
|
||||||
|
description: data.description || undefined,
|
||||||
|
serverDefinedClientTags,
|
||||||
|
});
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
||||||
- Enable/disable, edit, secret regeneration, and delete actions
|
- Enable/disable, edit, secret regeneration, and delete actions
|
||||||
|
|
||||||
|
### 🔐 VPN Management
|
||||||
|
- VPN server status with forwarding mode, subnet, and WireGuard port
|
||||||
|
- Client registration table with create, enable/disable, and delete actions
|
||||||
|
- WireGuard config download and clipboard copy on client creation
|
||||||
|
- Per-client telemetry (bytes sent/received, keepalives)
|
||||||
|
- Server public key display for manual client configuration
|
||||||
|
|
||||||
### 📜 Log Viewer
|
### 📜 Log Viewer
|
||||||
- Real-time log streaming
|
- Real-time log streaming
|
||||||
- Filter by log level (error, warning, info, debug)
|
- Filter by log level (error, warning, info, debug)
|
||||||
@@ -100,6 +107,7 @@ ts_web/
|
|||||||
├── ops-view-emails.ts # Email queue management
|
├── ops-view-emails.ts # Email queue management
|
||||||
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
||||||
├── ops-view-remoteingress.ts # Remote ingress edge management
|
├── ops-view-remoteingress.ts # Remote ingress edge management
|
||||||
|
├── ops-view-vpn.ts # VPN client management
|
||||||
├── ops-view-logs.ts # Log viewer
|
├── ops-view-logs.ts # Log viewer
|
||||||
├── ops-view-routes.ts # Route & API token management
|
├── ops-view-routes.ts # Route & API token management
|
||||||
├── ops-view-config.ts # Configuration display
|
├── ops-view-config.ts # Configuration display
|
||||||
@@ -111,7 +119,7 @@ ts_web/
|
|||||||
|
|
||||||
### State Management
|
### State Management
|
||||||
|
|
||||||
The app uses `@push.rocks/smartstate` with multiple state parts:
|
The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled actions with `autoPause: 'visibility'`, and batched updates:
|
||||||
|
|
||||||
| State Part | Mode | Description |
|
| State Part | Mode | Description |
|
||||||
|-----------|------|-------------|
|
|-----------|------|-------------|
|
||||||
@@ -124,6 +132,17 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
|
|||||||
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
||||||
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
||||||
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
||||||
|
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
|
||||||
|
|
||||||
|
### Tab Visibility Optimization
|
||||||
|
|
||||||
|
The dashboard automatically pauses all background activity when the browser tab is hidden and resumes when visible:
|
||||||
|
|
||||||
|
- **Auto-refresh polling** uses `createScheduledAction` with `autoPause: 'visibility'` — stops HTTP requests while the tab is sleeping
|
||||||
|
- **In-flight guard** prevents concurrent refresh requests from piling up
|
||||||
|
- **WebSocket connection** disconnects when hidden and reconnects when visible, preventing log entry accumulation
|
||||||
|
- **Network traffic timer** pauses chart updates when the tab is backgrounded
|
||||||
|
- **Log entry batching** — incoming WebSocket log pushes are buffered and flushed once per animation frame to avoid per-entry re-renders
|
||||||
|
|
||||||
### Actions
|
### Actions
|
||||||
|
|
||||||
@@ -163,6 +182,13 @@ regenerateRemoteIngressSecretAction(id) // New secret
|
|||||||
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
||||||
clearNewEdgeSecretAction() // Dismiss secret banner
|
clearNewEdgeSecretAction() // Dismiss secret banner
|
||||||
fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
||||||
|
|
||||||
|
// VPN
|
||||||
|
fetchVpnAction() // Clients + server status
|
||||||
|
createVpnClientAction(data) // Create new VPN client
|
||||||
|
deleteVpnClientAction(clientId) // Remove VPN client
|
||||||
|
toggleVpnClientAction(id, enabled) // Enable/disable
|
||||||
|
clearNewClientConfigAction() // Dismiss config banner
|
||||||
```
|
```
|
||||||
|
|
||||||
### Client-Side Routing
|
### Client-Side Routing
|
||||||
@@ -177,6 +203,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
|||||||
/emails/security → Security incidents
|
/emails/security → Security incidents
|
||||||
/certificates → Certificate management
|
/certificates → Certificate management
|
||||||
/remoteingress → Remote ingress edge management
|
/remoteingress → Remote ingress edge management
|
||||||
|
/vpn → VPN client management
|
||||||
/routes → Route & API token management
|
/routes → Route & API token management
|
||||||
/logs → Log viewer
|
/logs → Log viewer
|
||||||
/configuration → System configuration
|
/configuration → System configuration
|
||||||
@@ -237,7 +264,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.
|
||||||
|
|
||||||
@@ -249,7 +276,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
|||||||
|
|
||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||||
|
|
||||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn'] as const;
|
||||||
|
|
||||||
export type TValidView = typeof validViews[number];
|
export type TValidView = typeof validViews[number];
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupStateSync(): void {
|
private setupStateSync(): void {
|
||||||
appstate.uiStatePart.state.subscribe((uiState) => {
|
appstate.uiStatePart.select().subscribe((uiState) => {
|
||||||
if (this.suppressStateUpdate) return;
|
if (this.suppressStateUpdate) return;
|
||||||
|
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user