Compare commits

..

46 Commits

Author SHA1 Message Date
d109554134 v11.16.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 13:06:14 +00:00
cc3a7cb5b6 feat(vpn): add destination-based VPN routing policy and standardize socket proxy forwarding 2026-03-30 13:06:14 +00:00
d53cff6a94 v11.15.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 12:07:58 +00:00
eb211348d2 feat(vpn): add tag-based VPN route access control and support configured initial VPN clients 2026-03-30 12:07:58 +00:00
43618abeba v11.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:59:38 +00:00
dd9769b814 feat(docs): document VPN access control and add OpsServer VPN navigation 2026-03-30 08:59:38 +00:00
99b40fea3f v11.13.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:15:09 +00:00
6f72e4fdbc feat(vpn): add VPN server management and route-based VPN access control 2026-03-30 08:15:09 +00:00
fbe845cd8e v11.12.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 22:38:29 +00:00
31413d28be fix(acme): use X509 certificate expiry when reporting ACME certificate validity 2026-03-27 22:38:29 +00:00
cd286cede6 v11.12.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:49:39 +00:00
36a3060cce fix(dcrouter): re-trigger auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:49:38 +00:00
d2b108317e v11.12.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:28:55 +00:00
dcd75f5e47 fix(dcrouter): guard auto certificate reprovisioning against unnamed routes 2026-03-27 19:28:55 +00:00
3d443fa147 v11.12.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:26:40 +00:00
2efdd2f16b fix(dcrouter): retry auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:26:39 +00:00
ec0348a83c v11.12.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 18:46:11 +00:00
6c4adf70c7 feat(web-ui): pause dashboard polling, sockets, and chart updates when the tab is hidden 2026-03-27 18:46:11 +00:00
29d6076355 v11.11.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 16:21:45 +00:00
fa96a41e68 feat(docker,cache,proxy): improve container runtime defaults and add configurable connection limits 2026-03-26 16:21:45 +00:00
1ea38ed5d2 v11.10.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 08:43:36 +00:00
7209903d02 fix(sms): update sms service to use async ProjectInfo initialization 2026-03-26 08:43:36 +00:00
20eda1ab3e v11.10.6
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:40:56 +00:00
44f2a7f0a9 fix(typescript): tighten TypeScript null safety and error handling across backend and ops UI 2026-03-26 07:40:56 +00:00
0195a21f30 v11.10.5
Some checks failed
Docker (tags) / security (push) Failing after 4s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:10:59 +00:00
4dca747386 fix(build): rename smart tooling config to .smartconfig.json and update package references 2026-03-26 07:10:59 +00:00
7663f502fa v11.10.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-24 13:40:28 +00:00
104cd417d8 fix(monitoring): handle multiple protocol cache entries per backend in metrics output 2026-03-24 13:40:28 +00:00
93254d5d3d v11.10.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 21:18:20 +00:00
9a3f121a9c fix(deps): bump tstest, smartmetrics, and taskbuffer to latest patch releases 2026-03-23 21:18:20 +00:00
bef74eb1aa v11.10.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 14:22:24 +00:00
308d8e4851 fix(deps): bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1 2026-03-23 14:22:24 +00:00
dc010dc3ae v11.10.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 10:29:16 +00:00
61d5d3b1ad fix(deps): bump @push.rocks/smartproxy to ^26.2.0 2026-03-23 10:29:16 +00:00
dd70790d40 v11.10.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 07:17:33 +00:00
2f8c04edc4 feat(monitoring): add backend protocol metrics to network stats and ops dashboard 2026-03-23 07:17:33 +00:00
474cc328dd v11.9.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-21 22:30:30 +00:00
39ff159bf7 fix(lifecycle): clean up service subscriptions, proxy retries, and stale runtime state on shutdown 2026-03-21 22:30:30 +00:00
c7fe7aeb50 v11.9.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 15:35:10 +00:00
2cf362020f feat(dcrouter): add service manager lifecycle orchestration and health-based ops status reporting 2026-03-20 15:35:10 +00:00
b62bad3616 v11.8.11
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 10:31:05 +00:00
3d372863a4 fix(deps): bump @push.rocks/smartproxy to ^25.17.10 2026-03-20 10:31:05 +00:00
1045dc04fe v11.8.10
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 08:31:46 +00:00
89ef7597df fix(deps): bump @push.rocks/smartproxy to ^25.17.9 2026-03-20 08:31:46 +00:00
0804544564 v11.8.9
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 08:08:38 +00:00
671e72452a fix(deps): bump @push.rocks/smartproxy to ^25.17.8 2026-03-20 08:08:38 +00:00
64 changed files with 4053 additions and 1705 deletions

View File

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

View File

@@ -18,9 +18,17 @@ WORKDIR /app
COPY --from=build /app /app
ENV DCROUTER_MODE=OCI_CONTAINER
ENV DCROUTER_HEAP_SIZE=512
ENV UV_THREADPOOL_SIZE=16
RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
EXPOSE 80
CMD ["npm", "start"]
LABEL org.opencontainers.image.title="dcrouter" \
org.opencontainers.image.description="Multi-service datacenter gateway" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]

View File

@@ -1,5 +1,164 @@
# Changelog
## 2026-03-30 - 11.16.0 - feat(vpn)
add destination-based VPN routing policy and standardize socket proxy forwarding
- replace configurable VPN forwarding mode with socket-based forwarding and always enable proxy protocol support to SmartProxy from localhost
- add destinationPolicy configuration for controlling default VPN traffic handling, including forceTarget, allow, and block rules
- remove forwarding mode reporting from VPN status APIs, logs, and ops UI to reflect the simplified VPN runtime model
- update @push.rocks/smartvpn to 1.14.0 to support the new VPN routing behavior
## 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

21
license Normal file
View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "11.8.8",
"version": "11.16.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -13,7 +13,7 @@
"license": "MIT",
"scripts": {
"test": "(tstest test/ --logfile --timeout 60)",
"start": "(node --max_old_space_size=250 ./cli.js)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build:docker": "tsdocker build --verbose",
@@ -22,47 +22,49 @@
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.3.0",
"@git.zone/tsbundle": "^2.9.1",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.5.0",
"@git.zone/tswatch": "^3.3.0",
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.0"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
"@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",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.48.5",
"@design.estate/dees-element": "^2.2.3",
"@push.rocks/lik": "^6.3.1",
"@push.rocks/projectinfo": "^5.0.2",
"@design.estate/dees-catalog": "^3.49.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.3.0",
"@push.rocks/smartdata": "^7.1.0",
"@push.rocks/smartacme": "^9.3.1",
"@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^2.0.0",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.2",
"@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmetrics": "^3.0.3",
"@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/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.17.7",
"@push.rocks/smartproxy": "^27.1.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.2.0",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.14.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.14.0",
"@tsclass/tsclass": "^9.4.0",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"lru-cache": "^11.2.7",
"uuid": "^13.0.0"
},
@@ -111,7 +113,7 @@
"dist_ts_apiclient/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
]
}

1730
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

170
readme.md
View File

@@ -4,7 +4,7 @@
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
## Issue Reporting and Security
@@ -23,6 +23,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [DNS Server](#dns-server)
- [RADIUS Server](#radius-server)
- [Remote Ingress](#remote-ingress)
- [VPN Access Control](#vpn-access-control)
- [Certificate Management](#certificate-management)
- [Storage & Caching](#storage--caching)
- [Security Features](#security-features)
@@ -73,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
- **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
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
@@ -83,16 +92,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 💾 Persistent Storage & Caching
- **Multiple storage backends**: filesystem, custom functions, or in-memory
- **Embedded cache database** via smartdata + LocalTsmDb (MongoDB-compatible)
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
### 🖥️ OpsServer Dashboard
- **Web-based management interface** with real-time monitoring
- **JWT authentication** with session persistence
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
- **Remote ingress management** with connection token generation and one-click copy
- **Read-only configuration display** — DcRouter is configured through code
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
### 🔧 Programmatic API Client
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
@@ -247,6 +257,13 @@ const router = new DcRouter({
hubDomain: 'hub.example.com',
},
// VPN — restrict sensitive routes to VPN clients
vpnConfig: {
enabled: true,
serverEndpoint: 'vpn.example.com',
wgListenPort: 51820,
},
// Persistent storage
storage: { fsPath: '/var/lib/dcrouter/data' },
@@ -275,6 +292,7 @@ graph TB
DNS[DNS Queries]
RAD[RADIUS Clients]
EDGE[Edge Nodes]
VPN[VPN Clients]
end
subgraph "DcRouter Core"
@@ -284,6 +302,7 @@ graph TB
DS[SmartDNS Server<br/><i>Rust-powered</i>]
RS[SmartRadius Server]
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
VS[SmartVPN Server<br/><i>Rust data plane</i>]
CM[Certificate Manager<br/><i>smartacme v9</i>]
OS[OpsServer Dashboard]
MM[Metrics Manager]
@@ -304,12 +323,14 @@ graph TB
DNS --> DS
RAD --> RS
EDGE --> RI
VPN --> VS
DC --> SP
DC --> ES
DC --> DS
DC --> RS
DC --> RI
DC --> VS
DC --> CM
DC --> OS
DC --> MM
@@ -340,7 +361,7 @@ graph TB
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
| **CacheDb** | `@push.rocks/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) for persistent caching |
| **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
### How It Works
@@ -427,6 +448,17 @@ 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?: {
@@ -974,6 +1006,78 @@ The OpsServer Remote Ingress view provides:
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
| **Delete** | Remove the edge registration |
## VPN Access Control
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
### How It Works
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
3. 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
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:
@@ -1066,7 +1170,7 @@ Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, I
### Cache Database
An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persistent caching with automatic TTL cleanup:
An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
```typescript
cacheConfig: {
@@ -1406,37 +1510,59 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
## Docker / OCI Container Deployment
DcRouter ships with a `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
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 \
-e DCROUTER_MODE=OCI_CONTAINER \
--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 53:53/udp -p 3000:3000 \
-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 | Example |
|----------|-------------|---------|
| `DCROUTER_MODE` | Set to `OCI_CONTAINER` to enable container mode | `OCI_CONTAINER` |
| `DCROUTER_CONFIG_PATH` | Path to a JSON config file (loaded as base, env vars override) | `/config/dcrouter.json` |
| `DCROUTER_BASE_DIR` | Override base data directory | `/data/dcrouter` |
| `DCROUTER_TLS_EMAIL` | ACME contact email | `admin@example.com` |
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | `example.com` |
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | `203.0.113.1` |
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | `198.51.100.1,198.51.100.2` |
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | `ns1.example.com,ns2.example.com` |
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | `example.com,other.com` |
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | `mail.example.com` |
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | `25,587,465` |
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` |
| 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 |
| 2900030000 | TCP | Dynamic port range |
### Building the Image

View File

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

View File

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

View File

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

View File

@@ -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
* and LocalTsmDb as the embedded database engine.
* and LocalSmartDb as the embedded database engine.
*/
export class CacheDb {
private static instance: CacheDb | null = null;
private localTsmDb: plugins.smartmongo.LocalTsmDb;
private smartdataDb: plugins.smartdata.SmartdataDb;
private localSmartDb!: plugins.smartdb.LocalSmartDb;
private smartdataDb!: plugins.smartdata.SmartdataDb;
private options: Required<ICacheDbOptions>;
private isStarted: boolean = false;
@@ -55,8 +55,8 @@ export class CacheDb {
/**
* Start the cache database
* - Initializes LocalTsmDb with file persistence
* - Connects smartdata to the LocalTsmDb via Unix socket
* - Initializes LocalSmartDb with file persistence
* - Connects smartdata to the LocalSmartDb via Unix socket
*/
public async start(): Promise<void> {
if (this.isStarted) {
@@ -68,16 +68,16 @@ export class CacheDb {
// Ensure storage directory exists
await plugins.fsUtils.ensureDir(this.options.storagePath);
// Create LocalTsmDb instance
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
// Create LocalSmartDb instance
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
folderPath: this.options.storagePath,
});
// Start LocalTsmDb and get connection info
const connectionInfo = await this.localTsmDb.start();
// Start LocalSmartDb and get connection info
const connectionInfo = await this.localSmartDb.start();
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
@@ -89,8 +89,8 @@ export class CacheDb {
this.isStarted = true;
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
} catch (error) {
logger.log('error', `Failed to start CacheDb: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to start CacheDb: ${(error as Error).message}`);
throw error;
}
}
@@ -109,15 +109,15 @@ export class CacheDb {
await this.smartdataDb.close();
}
// Stop LocalTsmDb
if (this.localTsmDb) {
await this.localTsmDb.stop();
// Stop LocalSmartDb
if (this.localSmartDb) {
await this.localSmartDb.stop();
}
this.isStarted = false;
logger.log('info', 'CacheDb stopped');
} catch (error) {
logger.log('error', `Error stopping CacheDb: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Error stopping CacheDb: ${(error as Error).message}`);
throw error;
}
}

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -188,6 +189,39 @@ export interface IDcRouterOptions {
keyPath?: string;
};
};
/**
* VPN server configuration.
* Enables VPN-based access control: routes with vpn.required are only
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports.
*/
vpnConfig?: {
/** Enable VPN server (default: false) */
enabled?: boolean;
/** VPN subnet CIDR (default: '10.8.0.0/24') */
subnet?: string;
/** WireGuard UDP listen port (default: 51820) */
wgListenPort?: number;
/** DNS servers pushed to VPN clients */
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Pre-defined VPN clients created on startup */
clients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Destination routing policy for VPN client traffic.
* Default in socket mode: { default: 'forceTarget', target: '127.0.0.1' } (all traffic → SmartProxy).
* Default in tun mode: not set (all traffic passes through). */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
};
}
/**
@@ -215,7 +249,7 @@ export class DcRouter {
public emailServer?: UnifiedEmailServer;
public radiusServer?: RadiusServer;
public storageManager: StorageManager;
public opsServer: OpsServer;
public opsServer!: OpsServer;
public metricsManager?: MetricsManager;
// Cache system (smartdata + LocalTsmDb)
@@ -226,6 +260,9 @@ export class DcRouter {
public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager;
// VPN
public vpnManager?: VpnManager;
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
@@ -252,6 +289,11 @@ export class DcRouter {
// Certificate provisioning scheduler with per-domain backoff
public certProvisionScheduler?: CertProvisionScheduler;
// Service lifecycle management
public serviceManager: plugins.taskbuffer.ServiceManager;
private serviceSubjectSubscription?: plugins.smartrx.rxjs.Subscription;
public smartAcmeReady = false;
// TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -279,66 +321,322 @@ export class DcRouter {
// Initialize storage manager
this.storageManager = new StorageManager(this.options.storage);
// Initialize service manager and register all services
this.serviceManager = new plugins.taskbuffer.ServiceManager({
name: 'dcrouter',
startupTimeoutMs: 120_000,
shutdownTimeoutMs: 30_000,
});
this.registerServices();
}
/**
* Register all dcrouter services with the ServiceManager.
* Services are started in dependency order, with failure isolation for optional services.
*/
private registerServices(): void {
// OpsServer: critical, no dependencies — provides visibility
this.serviceManager.addService(
new plugins.taskbuffer.Service('OpsServer')
.critical()
.withStart(async () => {
this.opsServer = new OpsServer(this);
await this.opsServer.start();
})
.withStop(async () => {
await this.opsServer?.stop();
})
.withRetry({ maxRetries: 0 }),
);
// CacheDb: optional, no dependencies
if (this.options.cacheConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('CacheDb')
.optional()
.withStart(async () => {
await this.setupCacheDb();
})
.withStop(async () => {
if (this.cacheCleaner) {
this.cacheCleaner.stop();
this.cacheCleaner = undefined;
}
if (this.cacheDb) {
await this.cacheDb.stop();
CacheDb.resetInstance();
this.cacheDb = undefined;
}
})
.withRetry({ maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 5000 }),
);
}
// MetricsManager: optional, depends on OpsServer
this.serviceManager.addService(
new plugins.taskbuffer.Service('MetricsManager')
.optional()
.dependsOn('OpsServer')
.withStart(async () => {
this.metricsManager = new MetricsManager(this);
await this.metricsManager.start();
})
.withStop(async () => {
if (this.metricsManager) {
await this.metricsManager.stop();
this.metricsManager = undefined;
}
})
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
);
// SmartProxy: critical, depends on CacheDb (if enabled)
const smartProxyDeps: string[] = [];
if (this.options.cacheConfig?.enabled !== false) {
smartProxyDeps.push('CacheDb');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy')
.critical()
.dependsOn(...smartProxyDeps)
.withStart(async () => {
await this.setupSmartProxy();
})
.withStop(async () => {
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
await this.smartProxy.stop();
this.smartProxy = undefined;
}
})
.withRetry({ maxRetries: 0 }),
);
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits
// Only registered if DNS challenge is configured
if (this.options.dnsChallenge?.cloudflareApiKey) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartAcme')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
if (this.smartAcme) {
await this.smartAcme.start();
this.smartAcmeReady = true;
logger.log('info', 'SmartAcme DNS-01 provider is now ready');
// Re-trigger certificate provisioning for all auto-cert routes.
// During startup, certProvisionFunction returned 'http01' (SmartAcme not ready),
// but Rust ACME is disabled when certProvisionFunction is set — so all domains
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
// which calls certProvisionFunction again — now with smartAcmeReady === true.
if (this.smartProxy) {
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
const currentRoutes = this.smartProxy.routeManager.getRoutes();
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
}
}
})
.withStop(async () => {
this.smartAcmeReady = false;
if (this.smartAcme) {
await this.smartAcme.stop();
this.smartAcme = undefined;
}
})
.withRetry({ maxRetries: 20, baseDelayMs: 5000, maxDelayMs: 3_600_000, backoffFactor: 2 }),
);
}
// ConfigManagers: optional, depends on SmartProxy
this.serviceManager.addService(
new plugins.taskbuffer.Service('ConfigManagers')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
this.routeConfigManager = new RouteConfigManager(
this.storageManager,
() => this.getConstructorRoutes(),
() => this.smartProxy,
() => this.options.http3,
this.options.vpnConfig?.enabled
? (tags?: string[]) => {
if (tags?.length && this.vpnManager) {
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
}
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
}
: undefined,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize();
})
.withStop(async () => {
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
})
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
);
// Email Server: optional, depends on SmartProxy
if (this.options.emailConfig) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailServer')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupUnifiedEmailHandling();
})
.withStop(async () => {
if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
await this.emailServer.stop();
this.emailServer = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// DNS Server: optional, depends on SmartProxy
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && this.options.dnsScopes && this.options.dnsScopes.length > 0) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsServer')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupDnsWithSocketHandler();
})
.withStop(async () => {
// Flush pending DNS batch log
if (this.dnsBatchTimer) {
clearTimeout(this.dnsBatchTimer);
if (this.dnsBatchCount > 0) {
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (final flush)`, { zone: 'dns' });
}
this.dnsBatchTimer = null;
this.dnsBatchCount = 0;
this.dnsLogWindowSecond = 0;
this.dnsLogWindowCount = 0;
}
if (this.dnsServer) {
this.dnsServer.removeAllListeners();
await this.dnsServer.stop();
this.dnsServer = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// RADIUS Server: optional, no dependency on SmartProxy
if (this.options.radiusConfig) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('RadiusServer')
.optional()
.withStart(async () => {
await this.setupRadiusServer();
})
.withStop(async () => {
if (this.radiusServer) {
await this.radiusServer.stop();
this.radiusServer = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// Remote Ingress: optional, depends on SmartProxy
if (this.options.remoteIngressConfig?.enabled) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('RemoteIngress')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupRemoteIngress();
})
.withStop(async () => {
if (this.tunnelManager) {
await this.tunnelManager.stop();
this.tunnelManager = undefined;
}
this.remoteIngressManager = undefined;
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// VPN Server: optional, depends on SmartProxy
if (this.options.vpnConfig?.enabled) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('VpnServer')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupVpnServer();
})
.withStop(async () => {
if (this.vpnManager) {
await this.vpnManager.stop();
this.vpnManager = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// Wire up aggregated events for logging
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
logger.log(level as any, `Service '${event.serviceName}': ${event.type}`, {
state: event.state,
...(event.error ? { error: event.error } : {}),
...(event.attempt ? { attempt: event.attempt } : {}),
});
});
}
public async start() {
await this.checkSystemLimits();
logger.log('info', 'Starting DcRouter Services');
this.opsServer = new OpsServer(this);
await this.opsServer.start();
await this.serviceManager.start();
this.logStartupSummary();
}
/**
* Detect OS-level resource limits and warn if they are too low for production use.
* This is detection only — no attempts to raise limits.
*/
private async checkSystemLimits(): Promise<void> {
try {
// Initialize cache database if enabled (default: enabled)
if (this.options.cacheConfig?.enabled !== false) {
await this.setupCacheDb();
const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
const limitsContent = await fs.file('/proc/self/limits').encoding('utf8').read() as string;
const nofileLine = limitsContent.split('\n').find((line: string) => line.startsWith('Max open files'));
if (nofileLine) {
const parts = nofileLine.split(/\s{2,}/);
const softLimit = parseInt(parts[1], 10);
const hardLimit = parseInt(parts[2], 10);
if (softLimit < 65536) {
logger.log('warn', `File descriptor soft limit is ${softLimit} (hard: ${hardLimit}). ` +
`For production use, set --ulimit nofile=65536:65536 on the container runtime.`);
} else {
logger.log('info', `File descriptor limits: soft=${softLimit}, hard=${hardLimit}`);
}
}
// Initialize MetricsManager
this.metricsManager = new MetricsManager(this);
await this.metricsManager.start();
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
await this.setupSmartProxy();
// Initialize programmatic config API managers
this.routeConfigManager = new RouteConfigManager(
this.storageManager,
() => this.getConstructorRoutes(),
() => this.smartProxy,
() => this.options.http3,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize();
// Set up unified email handling if configured
if (this.options.emailConfig) {
await this.setupUnifiedEmailHandling();
}
// Set up DNS server if configured with nameservers and scopes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 &&
this.options.dnsScopes && this.options.dnsScopes.length > 0) {
await this.setupDnsWithSocketHandler();
}
// Set up RADIUS server if configured
if (this.options.radiusConfig) {
await this.setupRadiusServer();
}
// Set up Remote Ingress hub if configured
if (this.options.remoteIngressConfig?.enabled) {
await this.setupRemoteIngress();
}
this.logStartupSummary();
} catch (error) {
logger.log('error', 'Error starting DcRouter', { error: String(error) });
// Try to clean up any services that may have started
await this.stop();
throw error;
} catch {
// Non-Linux or /proc not available — silently skip
}
}
@@ -382,6 +680,14 @@ export class DcRouter {
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
}
// VPN summary
if (this.vpnManager && this.options.vpnConfig?.enabled) {
const subnet = this.vpnManager.getSubnet();
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
const clientCount = this.vpnManager.listClients().length;
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
}
// Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
@@ -399,7 +705,21 @@ export class DcRouter {
logger.log('info', `Cache Database: storage=${this.cacheDb.getStoragePath()}, db=${this.cacheDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
}
logger.log('info', 'All services are running');
// Service status summary from ServiceManager
const health = this.serviceManager.getHealth();
const statuses = health.services;
const running = statuses.filter(s => s.state === 'running').length;
const failed = statuses.filter(s => s.state === 'failed').length;
const retrying = statuses.filter(s => s.state === 'starting' || s.state === 'degraded').length;
if (failed > 0) {
const failedNames = statuses.filter(s => s.state === 'failed').map(s => `${s.name}: ${s.lastError || 'unknown'}`);
logger.log('warn', `DcRouter started in degraded mode — ${running} running, ${failed} failed: ${failedNames.join('; ')}`);
} else if (retrying > 0) {
logger.log('info', `DcRouter started — ${running} running, ${retrying} still initializing`);
} else {
logger.log('info', `All ${running} services are running`);
}
}
/**
@@ -435,6 +755,13 @@ export class DcRouter {
*/
private async setupSmartProxy(): Promise<void> {
logger.log('info', 'Setting up SmartProxy...');
// Clean up any existing SmartProxy instance (e.g. from a retry)
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
this.smartProxy = undefined;
}
let routes: plugins.smartproxy.IRouteConfig[] = [];
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
@@ -486,6 +813,11 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
}
// VPN route security injection: restrict vpn.required routes to VPN subnet
if (this.options.vpnConfig?.enabled) {
routes = this.injectVpnSecurity(routes);
}
// Cache constructor routes for RouteConfigManager
this.constructorRoutes = [...routes];
@@ -496,9 +828,28 @@ export class DcRouter {
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
// Create SmartProxy configuration
// Create SmartProxy configuration with sensible gateway defaults.
// User's smartProxyConfig overrides these defaults via spread.
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
// --- dcrouter gateway defaults ---
maxConnectionsPerIP: 100,
connectionRateLimitPerMinute: 600,
socketTimeout: 120_000,
inactivityTimeout: 120_000,
keepAlive: true,
noDelay: true,
gracefulShutdownTimeout: 30_000,
// --- user overrides ---
...this.options.smartProxyConfig,
// --- deep-merge defaults.security so user can override maxConnections ---
defaults: {
...this.options.smartProxyConfig?.defaults,
security: {
maxConnections: 50_000,
...this.options.smartProxyConfig?.defaults?.security,
},
},
// --- always set by dcrouter (after spread) ---
routes,
acme: acmeConfig,
certStore: {
@@ -535,10 +886,13 @@ export class DcRouter {
// Initialize cert provision scheduler
this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager);
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction
// Note: SmartAcme.start() is NOT called here — it runs as a separate optional service
// via the ServiceManager, with aggressive retry for rate-limit resilience.
if (challengeHandlers.length > 0) {
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
if (this.smartAcme) {
this.smartAcmeReady = false;
await this.smartAcme.stop().catch(err =>
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
);
@@ -550,10 +904,15 @@ export class DcRouter {
challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'],
});
await this.smartAcme.start();
const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
// If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01
if (!this.smartAcmeReady) {
eventComms.warn(`SmartAcme not yet initialized, falling back to http-01 for ${domain}`);
return 'http01';
}
// Check backoff before attempting provision
if (await scheduler.isInBackoff(domain)) {
const info = await scheduler.getBackoffInfo(domain);
@@ -567,17 +926,25 @@ export class DcRouter {
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
eventComms.setSource('smartacme-dns-01');
const isWildcardDomain = domain.startsWith('*.');
const cert = await this.smartAcme.getCertificateForDomain(domain, {
const cert = await this.smartAcme!.getCertificateForDomain(domain, {
includeWildcard: !isWildcardDomain,
});
if (cert.validUntil) {
eventComms.setExpiryDate(new Date(cert.validUntil));
// Parse real X509 expiry from PEM (defense-in-depth over SmartAcme's estimate)
let realValidUntil = cert.validUntil;
if (cert.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(cert.publicKey);
realValidUntil = new Date(x509.validTo).getTime();
} catch { /* fallback to SmartAcme's value */ }
}
if (realValidUntil) {
eventComms.setExpiryDate(new Date(realValidUntil));
}
const result = {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
validUntil: cert.validUntil,
validUntil: realValidUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr,
@@ -586,10 +953,10 @@ export class DcRouter {
// Success — clear any backoff
await scheduler.clearBackoff(domain);
return result;
} catch (err) {
} catch (err: unknown) {
// Record failure for backoff tracking
await scheduler.recordFailure(domain, err.message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`);
await scheduler.recordFailure(domain, (err as Error).message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`);
return 'http01';
}
};
@@ -602,6 +969,17 @@ export class DcRouter {
smartProxyConfig.proxyIPs = ['127.0.0.1'];
}
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
if (this.options.vpnConfig?.enabled) {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
}
// Create SmartProxy instance
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
@@ -865,23 +1243,6 @@ export class DcRouter {
return false;
}
/**
* Find the first route name that matches a given domain
*/
private findRouteNameForDomain(domain: string): string | undefined {
if (!this.smartProxy) return undefined;
for (const route of this.smartProxy.routeManager.getRoutes()) {
if (!route.match.domains || !route.name) continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const pattern of routeDomains) {
if (this.isDomainMatch(domain, pattern)) return route.name;
}
}
return undefined;
}
/**
* Find ALL route names that match a given domain
*/
@@ -914,105 +1275,29 @@ export class DcRouter {
public async stop() {
logger.log('info', 'Stopping DcRouter services...');
// Flush pending DNS batch log
if (this.dnsBatchTimer) {
clearTimeout(this.dnsBatchTimer);
if (this.dnsBatchCount > 0) {
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited, final flush)`, { zone: 'dns' });
}
this.dnsBatchTimer = null;
this.dnsBatchCount = 0;
this.dnsLogWindowSecond = 0;
this.dnsLogWindowCount = 0;
// Unsubscribe from service events before stopping services
if (this.serviceSubjectSubscription) {
this.serviceSubjectSubscription.unsubscribe();
this.serviceSubjectSubscription = undefined;
}
await this.opsServer.stop();
// ServiceManager handles reverse-dependency-ordered shutdown
await this.serviceManager.stop();
try {
// Remove event listeners before stopping services to prevent leaks
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
}
if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
}
if (this.dnsServer) {
this.dnsServer.removeAllListeners();
}
// Stop all services in parallel for faster shutdown
await Promise.all([
// Stop cache cleaner if running
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
// Stop metrics manager if running
this.metricsManager ? this.metricsManager.stop().catch(err => logger.log('error', 'Error stopping MetricsManager', { error: String(err) })) : Promise.resolve(),
// Stop unified email server if running
this.emailServer ? this.emailServer.stop().catch(err => logger.log('error', 'Error stopping email server', { error: String(err) })) : Promise.resolve(),
// Stop SmartAcme if running
this.smartAcme ? this.smartAcme.stop().catch(err => logger.log('error', 'Error stopping SmartAcme', { error: String(err) })) : Promise.resolve(),
// Stop HTTP SmartProxy if running
this.smartProxy ? this.smartProxy.stop().catch(err => logger.log('error', 'Error stopping SmartProxy', { error: String(err) })) : Promise.resolve(),
// Stop DNS server if running
this.dnsServer ?
this.dnsServer.stop().catch(err => logger.log('error', 'Error stopping DNS server', { error: String(err) })) :
Promise.resolve(),
// Stop RADIUS server if running
this.radiusServer ?
this.radiusServer.stop().catch(err => logger.log('error', 'Error stopping RADIUS server', { error: String(err) })) :
Promise.resolve(),
// Stop Remote Ingress tunnel manager if running
this.tunnelManager ?
this.tunnelManager.stop().catch(err => logger.log('error', 'Error stopping TunnelManager', { error: String(err) })) :
Promise.resolve()
]);
// Stop cache database after other services (they may need it during shutdown)
if (this.cacheDb) {
await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
CacheDb.resetInstance();
}
// Clear backoff cache in cert scheduler
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
// Allow GC of stopped services by nulling references
this.smartProxy = undefined;
this.emailServer = undefined;
this.dnsServer = undefined;
this.metricsManager = undefined;
this.cacheCleaner = undefined;
this.cacheDb = undefined;
this.tunnelManager = undefined;
this.radiusServer = undefined;
this.smartAcme = undefined;
// Clear backoff cache in cert scheduler
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
this.certProvisionScheduler = undefined;
this.remoteIngressManager = undefined;
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.certificateStatusMap.clear();
// Reset security singletons to allow GC
SecurityLogger.resetInstance();
ContentScanner.resetInstance();
IPReputationChecker.resetInstance();
logger.log('info', 'All DcRouter services stopped');
} catch (error) {
logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
throw error;
}
this.certificateStatusMap.clear();
// Reset security singletons to allow GC
SecurityLogger.resetInstance();
ContentScanner.resetInstance();
IPReputationChecker.resetInstance();
logger.log('info', 'All DcRouter services stopped');
}
/**
@@ -1104,21 +1389,21 @@ export class DcRouter {
// Wire delivery events to MetricsManager and logger
if (this.metricsManager && this.emailServer.deliverySystem) {
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
this.metricsManager.trackEmailReceived(item?.from);
this.metricsManager!.trackEmailReceived(item?.from);
logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
this.metricsManager.trackEmailSent(item?.to);
this.metricsManager!.trackEmailSent(item?.to);
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
this.metricsManager.trackEmailFailed(item?.to, error?.message);
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
});
}
if (this.metricsManager && this.emailServer) {
this.emailServer.on('bounceProcessed', () => {
this.metricsManager.trackEmailBounced();
this.metricsManager!.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' });
});
}
@@ -1161,12 +1446,12 @@ export class DcRouter {
}
logger.log('info', 'All unified email components stopped');
} catch (error) {
logger.log('error', `Error stopping unified email components: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Error stopping unified email components: ${(error as Error).message}`);
throw error;
}
}
/**
* Update domain rules for email routing
* @param rules New domain rules to apply
@@ -1324,7 +1609,7 @@ export class DcRouter {
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
// Metrics tracking
for (const question of event.questions) {
this.metricsManager.trackDnsQuery(
this.metricsManager?.trackDnsQuery(
question.type,
question.name,
false,
@@ -1409,8 +1694,8 @@ export class DcRouter {
// Use the built-in socket handler from smartdns
// This handles HTTP/2, DoH protocol, etc.
await (this.dnsServer as any).handleHttpsSocket(socket);
} catch (error) {
logger.log('error', `DNS socket handler error: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `DNS socket handler error: ${(error as Error).message}`);
if (!socket.destroyed) {
socket.destroy();
}
@@ -1551,14 +1836,14 @@ export class DcRouter {
} else {
logger.log('warn', `Invalid DKIM record structure in ${file}`);
}
} catch (error) {
logger.log('error', `Failed to load DKIM record from ${file}: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
}
}
} catch (error) {
logger.log('error', `Failed to load DKIM records: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
}
return records;
}
@@ -1590,11 +1875,11 @@ export class DcRouter {
// This ensures keys are ready even if DNS mode changes later
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
} catch (error) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
}
}
logger.log('info', 'DKIM initialization complete');
}
@@ -1635,10 +1920,10 @@ export class DcRouter {
} else {
logger.log('warn', 'Could not auto-discover public IPv4 address');
}
} catch (error) {
logger.log('error', `Failed to auto-discover public IP: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to auto-discover public IP: ${(error as Error).message}`);
}
if (!publicIp) {
logger.log('warn', 'No public IP available. Nameserver A records require either proxyIps, publicIp, or successful auto-discovery.');
}
@@ -1732,8 +2017,8 @@ export class DcRouter {
}
return null;
} catch (error) {
logger.log('warn', `Failed to detect public IP: ${error.message}`);
} catch (error: unknown) {
logger.log('warn', `Failed to detect public IP: ${(error as Error).message}`);
return null;
}
}
@@ -1767,8 +2052,8 @@ export class DcRouter {
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
tlsConfig = { certPem, keyPem };
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
} catch (err) {
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`);
} catch (err: unknown) {
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${(err as Error).message}`);
}
}
@@ -1799,6 +2084,75 @@ export class DcRouter {
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
}
/**
* Set up VPN server for VPN-based route access control.
*/
private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) {
return;
}
logger.log('info', 'Setting up VPN server...');
this.vpnManager = new VpnManager(this.storageManager, {
subnet: this.options.vpnConfig.subnet,
wgListenPort: this.options.vpnConfig.wgListenPort,
dns: this.options.vpnConfig.dns,
serverEndpoint: this.options.vpnConfig.serverEndpoint,
initialClients: this.options.vpnConfig.clients,
destinationPolicy: this.options.vpnConfig.destinationPolicy,
onClientChanged: () => {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
});
await this.vpnManager.start();
}
/**
* Inject VPN security into routes that have vpn.required === true.
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
*/
private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
let injectedCount = 0;
const result = routes.map((route) => {
const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (dcrouterRoute.vpn?.required) {
injectedCount++;
const existing = route.security?.ipAllowList || [];
let vpnAllowList: string[];
if (dcrouterRoute.vpn.allowedServerDefinedClientTags?.length && this.vpnManager) {
// Tag-based: only specific client IPs
vpnAllowList = this.vpnManager.getClientIpsForServerDefinedTags(
dcrouterRoute.vpn.allowedServerDefinedClientTags,
);
} else {
// No tags specified: entire VPN subnet
vpnAllowList = [vpnSubnet];
}
return {
...route,
security: {
...route.security,
ipAllowList: [...existing, ...vpnAllowList],
},
};
}
return route;
});
if (injectedCount > 0) {
logger.log('info', `VPN: Injected ipAllowList into ${injectedCount} VPN-protected route(s)`);
}
return result;
}
/**
* Set up RADIUS server for network authentication
*/

View File

@@ -7,6 +7,7 @@ import type {
IMergedRoute,
IRouteWarning,
} from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
const ROUTES_PREFIX = '/config-api/routes/';
@@ -22,6 +23,7 @@ export class RouteConfigManager {
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
) {}
/**
@@ -244,7 +246,7 @@ export class RouteConfigManager {
// Private: apply merged routes to SmartProxy
// =========================================================================
private async applyRoutes(): Promise<void> {
public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
@@ -260,15 +262,31 @@ export class RouteConfigManager {
enabledRoutes.push(route);
}
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
// 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()) {
if (stored.enabled) {
let route = stored.route;
if (http3Config && http3Config.enabled !== false) {
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
} else {
enabledRoutes.push(stored.route);
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);
}
}

View File

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

View File

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

View File

@@ -35,6 +35,6 @@ export const runCli = async () => {
await dcRouter.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
};

View File

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

View File

@@ -7,7 +7,7 @@ import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js'
export class OpsServer {
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
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -17,17 +17,18 @@ export class OpsServer {
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
// Handler instances
public adminHandler: handlers.AdminHandler;
private configHandler: handlers.ConfigHandler;
private logsHandler: handlers.LogsHandler;
private securityHandler: handlers.SecurityHandler;
private statsHandler: handlers.StatsHandler;
private radiusHandler: handlers.RadiusHandler;
private emailOpsHandler: handlers.EmailOpsHandler;
private certificateHandler: handlers.CertificateHandler;
private remoteIngressHandler: handlers.RemoteIngressHandler;
private routeManagementHandler: handlers.RouteManagementHandler;
private apiTokenHandler: handlers.ApiTokenHandler;
public adminHandler!: handlers.AdminHandler;
private configHandler!: handlers.ConfigHandler;
private logsHandler!: handlers.LogsHandler;
private securityHandler!: handlers.SecurityHandler;
private statsHandler!: handlers.StatsHandler;
private radiusHandler!: handlers.RadiusHandler;
private emailOpsHandler!: handlers.EmailOpsHandler;
private certificateHandler!: handlers.CertificateHandler;
private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -39,7 +40,7 @@ export class OpsServer {
public async start() {
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost',
feedMetadata: null,
feedMetadata: undefined,
serveDir: paths.distServe,
});
@@ -86,6 +87,7 @@ export class OpsServer {
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
this.vpnHandler = new handlers.VpnHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

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

View File

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

View File

@@ -8,4 +8,5 @@ export * from './email-ops.handler.js';
export * from './certificate.handler.js';
export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';
export * from './api-token.handler.js';
export * from './vpn.handler.js';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,255 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class VpnHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get all registered VPN clients
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
'getVpnClients',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { clients: [] };
}
const clients = manager.listClients().map((c) => ({
clientId: c.clientId,
enabled: c.enabled,
serverDefinedClientTags: c.serverDefinedClientTags,
description: c.description,
assignedIp: c.assignedIp,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
expiresAt: c.expiresAt,
}));
return { clients };
},
),
);
// Get VPN server status
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
'getVpnStatus',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
if (!manager) {
return {
status: {
running: false,
subnet: vpnConfig?.subnet || '10.8.0.0/24',
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: null,
registeredClients: 0,
connectedClients: 0,
},
};
}
const connected = await manager.getConnectedClients();
return {
status: {
running: manager.running,
subnet: manager.getSubnet(),
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: manager.getServerPublicKeys(),
registeredClients: manager.listClients().length,
connectedClients: connected.length,
},
};
},
),
);
// ---- 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 };
}
},
),
);
}
}

View File

@@ -47,23 +47,25 @@ import * as qenv from '@push.rocks/qenv';
import * as smartacme from '@push.rocks/smartacme';
import * as smartdata from '@push.rocks/smartdata';
import * as smartdns from '@push.rocks/smartdns';
import * as smartfile from '@push.rocks/smartfile';
import * as smartfs from '@push.rocks/smartfs';
import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt';
import * as smartlog from '@push.rocks/smartlog';
import * as smartmetrics from '@push.rocks/smartmetrics';
import * as smartmta from '@push.rocks/smartmta';
import * as smartmongo from '@push.rocks/smartmongo';
import * as smartdb from '@push.rocks/smartdb';
import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartvpn from '@push.rocks/smartvpn';
import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
import * as taskbuffer from '@push.rocks/taskbuffer';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique };
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
// Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
@@ -89,7 +91,7 @@ export {
uuid,
}
// Filesystem utilities (compatibility helpers for smartfile v13+)
// Filesystem utilities
export const fsUtils = {
/**
* Ensure a directory exists, creating it recursively if needed (sync)

View File

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

View File

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

View File

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

View File

@@ -136,7 +136,7 @@ Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks c
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,427 @@
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;
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
}
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;
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
this.storageManager = storageManager;
this.config = config;
}
/** The VPN subnet CIDR. */
public getSubnet(): string {
return this.config.subnet || '10.8.0.0/24';
}
/** Whether the VPN server is running. */
public get running(): boolean {
return this.vpnServer?.running ?? false;
}
/**
* Start the VPN server.
* Loads or generates server keys, loads persisted clients, starts VpnServer.
*/
public async start(): Promise<void> {
// Load or generate server keys
this.serverKeys = await this.loadOrGenerateServerKeys();
// Load persisted clients
await this.loadPersistedClients();
// Build client entries for the daemon
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
for (const client of this.clients.values()) {
clientEntries.push({
clientId: client.clientId,
publicKey: client.noisePublicKey,
wgPublicKey: client.wgPublicKey,
enabled: client.enabled,
serverDefinedClientTags: client.serverDefinedClientTags,
description: client.description,
assignedIp: client.assignedIp,
expiresAt: client.expiresAt,
});
}
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: 'socket',
transportMode: 'all',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: true,
destinationPolicy: this.config.destinationPolicy
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
};
await this.vpnServer.start(serverConfig);
// Create initial clients from config (idempotent — skip already-persisted)
if (this.config.initialClients) {
for (const initial of this.config.initialClients) {
if (!this.clients.has(initial.clientId)) {
const bundle = await this.createClient({
clientId: initial.clientId,
serverDefinedClientTags: initial.serverDefinedClientTags,
description: initial.description,
});
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
}
}
}
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
/**
* Stop the VPN server.
*/
public async stop(): Promise<void> {
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
} catch {
// Ignore stop errors
}
this.vpnServer.stop();
this.vpnServer = undefined;
}
logger.log('info', 'VPN server stopped');
}
// ── Client CRUD ────────────────────────────────────────────────────────
/**
* Create a new VPN client. Returns the config bundle (secrets only shown once).
*/
public async createClient(opts: {
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
const bundle = await this.vpnServer.createClient({
clientId: opts.clientId,
serverDefinedClientTags: opts.serverDefinedClientTags,
description: opts.description,
});
// 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
View File

@@ -0,0 +1 @@
export * from './classes.vpn-manager.js';

View File

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

View File

@@ -1,4 +1,5 @@
export * from './auth.js';
export * from './stats.js';
export * from './remoteingress.js';
export * from './route-management.js';
export * from './route-management.js';
export * from './vpn.js';

View File

@@ -51,11 +51,23 @@ export interface IRouteRemoteIngress {
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.
* 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.
*/
export type IDcRouterRouteConfig = IRouteConfig & {
remoteIngress?: IRouteRemoteIngress;
vpn?: IRouteVpn;
};

View File

@@ -165,6 +165,7 @@ export interface INetworkMetrics {
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number;
requestsTotal?: number;
backends?: IBackendInfo[];
}
export interface IConnectionDetails {
@@ -174,4 +175,26 @@ export interface IConnectionDetails {
startTime: number;
bytesIn: 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;
}

44
ts_interfaces/data/vpn.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* 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;
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;
}

View File

@@ -96,7 +96,15 @@ interface IIdentity {
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
| `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`)
@@ -205,6 +213,19 @@ interface ICertificateInfo {
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
| `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
| Interface | Method | Description |
|-----------|--------|-------------|
@@ -280,7 +301,7 @@ console.log('Connection token:', tokenResponse.token);
## 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.

View File

@@ -8,4 +8,5 @@ export * from './email-ops.js';
export * from './certificate.js';
export * from './remoteingress.js';
export * from './route-management.js';
export * from './api-tokens.js';
export * from './api-tokens.js';
export * from './vpn.js';

View File

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

View 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;
};
}

View File

@@ -96,5 +96,27 @@ export function getOciContainerConfig(): IDcRouterOptions {
};
}
// 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;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,4 +9,5 @@ export * from './ops-view-apitokens.js';
export * from './ops-view-security.js';
export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';
export * from './shared/index.js';

View File

@@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
import { OpsViewVpn } from './ops-view-vpn.js';
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:globe',
element: OpsViewRemoteIngress,
},
{
name: 'VPN',
iconName: 'lucide:shield',
element: OpsViewVpn,
},
];
/**
@@ -195,17 +201,18 @@ export class OpsDashboard extends DeesElement {
}
public async firstUpdated() {
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login');
simpleLogin.addEventListener('login', (e: CustomEvent) => {
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
simpleLogin.addEventListener('login', (e: 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
const appDash = this.shadowRoot.querySelector('dees-simple-appdash');
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => {
const viewName = e.detail.view.name.toLowerCase();
appDash.addEventListener('view-select', (e: Event) => {
const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
// Use router for navigation instead of direct state update
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
const loginState = appstate.loginStatePart.getState();
const loginState = appstate.loginStatePart.getState()!;
if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) {
// Client-side expiry looks valid — verify with server (keypair may have changed)
@@ -229,7 +236,7 @@ export class OpsDashboard extends DeesElement {
if (response.valid) {
// JWT confirmed valid by server
this.loginState = loginState;
await simpleLogin.switchToSlottedContent();
await (simpleLogin as any).switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else {
@@ -250,8 +257,8 @@ export class OpsDashboard extends DeesElement {
private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise;
console.log(`Attempting to login...`);
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login');
const form = simpleLogin.shadowRoot.querySelector('dees-form');
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
form.setStatus('pending', 'Logging in...');
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
@@ -262,14 +269,14 @@ export class OpsDashboard extends DeesElement {
if (state.identity) {
console.log('Login successful');
this.loginState = state;
form.setStatus('success', 'Logged in!');
await simpleLogin.switchToSlottedContent();
form!.setStatus('success', 'Logged in!');
await simpleLogin!.switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else {
form.setStatus('error', 'Login failed!');
form!.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000);
form.reset();
form!.reset();
}
}
}

View File

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

View File

@@ -102,7 +102,7 @@ export class OpsViewConfig extends DeesElement {
`;
}
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
private renderSystemSection(sys: NonNullable<appstate.IConfigState['config']>['system']): TemplateResult {
// Annotate proxy IPs with source hint when Remote Ingress is active
const ri = this.configState.config?.remoteIngress;
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[] = [
{ 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[] = [
{ key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
{ 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[] = [
{ key: 'Port', value: dns.port },
{ 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[] = [
{ key: 'Contact Email', value: tls.contactEmail },
{ 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[] = [
{ key: 'Storage Path', value: cache.storagePath },
{ 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[] = [
{ key: 'Auth Port', value: radius.authPort },
{ 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[] = [
{ key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain },

View File

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

View File

@@ -1,5 +1,6 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
import * 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';
@@ -28,10 +29,10 @@ interface INetworkRequest {
@customElement('ops-view-network')
export class OpsViewNetwork extends DeesElement {
@state()
accessor statsState = appstate.statsStatePart.getState();
accessor statsState = appstate.statsStatePart.getState()!;
@state()
accessor networkState = appstate.networkStatePart.getState();
accessor networkState = appstate.networkStatePart.getState()!;
@state()
@@ -46,10 +47,11 @@ export class OpsViewNetwork extends DeesElement {
// Track if we need to update the chart to avoid unnecessary re-renders
private lastChartUpdate = 0;
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
private trafficUpdateTimer: any = null;
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
private historyLoaded = false; // Whether server-side throughput history has been loaded
private visibilityHandler: (() => void) | null = null;
constructor() {
super();
@@ -58,28 +60,42 @@ export class OpsViewNetwork extends DeesElement {
this.updateNetworkData();
this.startTrafficUpdateTimer();
}
async 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
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
}
async disconnectedCallback() {
await super.disconnectedCallback();
this.stopTrafficUpdateTimer();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
}
private subscribeToStateParts() {
// Subscribe and track unsubscribe functions
const statsUnsubscribe = appstate.statsStatePart.state.subscribe((state) => {
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
this.statsState = state;
this.updateNetworkData();
});
this.rxSubscriptions.push(statsUnsubscribe);
const networkUnsubscribe = appstate.networkStatePart.state.subscribe((state) => {
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
this.networkState = state;
this.updateNetworkData();
});
@@ -198,6 +214,38 @@ export class OpsViewNetwork extends DeesElement {
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 {
display: inline-flex;
align-items: center;
@@ -265,6 +313,9 @@ export class OpsViewNetwork extends DeesElement {
<!-- Top IPs Section -->
${this.renderTopIPs()}
<!-- Backend Protocols Section -->
${this.renderBackendProtocols()}
<!-- Requests Table -->
<dees-table
.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() {
// Track requests/sec history for the trend sparkline (moved out of render)
const reqPerSec = this.networkState.requestsPerSecond || 0;

View File

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

View File

@@ -0,0 +1,326 @@
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 ? 'Active' : '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>
${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>
`;
}
}

View File

@@ -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
- 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
- Real-time log streaming
- Filter by log level (error, warning, info, debug)
@@ -100,6 +107,7 @@ ts_web/
├── ops-view-emails.ts # Email queue management
├── ops-view-certificates.ts # Certificate overview & reprovisioning
├── ops-view-remoteingress.ts # Remote ingress edge management
├── ops-view-vpn.ts # VPN client management
├── ops-view-logs.ts # Log viewer
├── ops-view-routes.ts # Route & API token management
├── ops-view-config.ts # Configuration display
@@ -111,7 +119,7 @@ ts_web/
### 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 |
|-----------|------|-------------|
@@ -124,6 +132,17 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
| `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
@@ -163,6 +182,13 @@ regenerateRemoteIngressSecretAction(id) // New secret
toggleRemoteIngressAction(id, enabled) // Enable/disable
clearNewEdgeSecretAction() // Dismiss secret banner
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
@@ -177,6 +203,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
/emails/security → Security incidents
/certificates → Certificate management
/remoteingress → Remote ingress edge management
/vpn → VPN client management
/routes → Route & API token management
/logs → Log viewer
/configuration → System configuration
@@ -237,7 +264,7 @@ export class OpsViewMyView extends DeesElement {
## 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.

View File

@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
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];
@@ -38,7 +38,7 @@ class AppRouter {
}
private setupStateSync(): void {
appstate.uiStatePart.state.subscribe((uiState) => {
appstate.uiStatePart.select().subscribe((uiState) => {
if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname;
@@ -71,12 +71,12 @@ class AppRouter {
private updateViewState(view: string): void {
this.suppressStateUpdate = true;
const currentState = appstate.uiStatePart.getState();
const currentState = appstate.uiStatePart.getState()!;
if (currentState.activeView !== view) {
appstate.uiStatePart.setState({
...currentState,
activeView: view,
});
} as appstate.IUiState);
}
this.suppressStateUpdate = false;
}
@@ -94,7 +94,7 @@ class AppRouter {
}
public getCurrentView(): string {
return appstate.uiStatePart.getState().activeView;
return appstate.uiStatePart.getState()!.activeView;
}
public destroy(): void {