Compare commits

...

87 Commits

Author SHA1 Message Date
9b7426f1e6 v9.1.3
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-24 23:29:26 +00:00
3c9c865841 fix(deps): bump @api.global/typedserver to ^8.4.0 and @push.rocks/smartproxy to ^25.8.0 2026-02-24 23:29:26 +00:00
8421c9fe46 v9.1.2
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-02-24 20:58:43 +00:00
907e3df156 fix(deps): bump dependency versions for build and runtime packages 2026-02-24 20:58:43 +00:00
aaa0956148 v9.1.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 21:49:21 +00:00
118019fcf5 fix(dcrouter): no changes detected — no files modified, no release necessary 2026-02-23 21:49:21 +00:00
deb80f4fd0 v9.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 21:40:34 +00:00
7d28cea937 feat(ops-dashboard): add lucide icons to Ops dashboard view tabs 2026-02-23 21:40:34 +00:00
2bd5e5c7c5 v9.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 21:34:50 +00:00
4d6ac81c59 BREAKING CHANGE(opsserver): Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections 2026-02-23 21:34:50 +00:00
2ebe0de92d v8.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 12:40:26 +00:00
f5028ffb60 feat(route-management): add programmatic route management API with API tokens and admin UI 2026-02-23 12:40:26 +00:00
90016d1217 v8.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-22 00:45:01 +00:00
48d3d1218f BREAKING CHANGE(email-ops): migrate email operations to catalog-compatible email model and simplify UI/router 2026-02-22 00:45:01 +00:00
4759c4f011 v7.4.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 23:36:10 +00:00
0fbd8d1cdd fix(logging): add adaptive rate-limited DNS query logging, flush pending DNS logs on shutdown, and enhance email delivery logging 2026-02-21 23:36:10 +00:00
447cf44d68 v7.4.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 18:56:44 +00:00
82ce17a941 fix(monitoring,remoteingress,web): Prune old metrics buckets periodically, clear metrics caches on shutdown, simplify edge disconnect handling, and optimize network view data updates 2026-02-21 18:56:44 +00:00
15da996e70 v7.4.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 18:13:10 +00:00
582e19e6a6 fix(dcrouter): replace console logging with structured logger, improve metrics logging, add terminal-ready wait in ops UI, bump dees-catalog patch 2026-02-21 18:13:10 +00:00
79765d6729 v7.4.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 14:02:48 +00:00
ffc93eb9d3 feat(opsserver): add real-time log push to ops dashboard and recent DNS query tracking 2026-02-21 14:02:48 +00:00
1337a4905a v7.3.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-20 15:37:15 +00:00
c7418d9e1a feat(dcrouter): Wire DNS server query events to MetricsManager for time-series tracking and bump @push.rocks/smartdns to ^7.9.0 2026-02-20 15:37:15 +00:00
2a94ffd4c9 v7.2.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-20 12:55:20 +00:00
b2fe6caf33 feat(logs): replace custom logs list with dees-chart-log component and push logs to chart, add log mapping and lifecycle sync, and bump smartlog dependency 2026-02-20 12:55:20 +00:00
822bbc1957 v7.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 17:23:43 +00:00
eacddc7ce1 feat(ops/monitoring): add in-memory log buffer, metrics time-series and ops UI integration 2026-02-19 17:23:43 +00:00
dc6ce341bd v7.0.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 14:36:11 +00:00
1aadc93f92 fix(monitoring): Use smartMetrics cpuPercentage for cpuUsage.user and update smartmetrics and smartproxy dependencies 2026-02-19 14:36:11 +00:00
8fdcd479d6 v7.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 10:16:23 +00:00
d24dde8eff BREAKING CHANGE(deps): bump dependencies: @serve.zone/remoteingress to ^4.0.0 (breaking), @push.rocks/smartproxy to ^25.7.6, @types/node to ^25.3.0 2026-02-19 10:16:23 +00:00
40a34073e9 v6.13.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 08:33:41 +00:00
9ac297c197 fix(runtime): prevent memory leaks and improve shutdown/stream handling across services 2026-02-19 08:33:41 +00:00
ddd0662fb8 v6.13.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 22:56:18 +00:00
11bc0dde6c fix(dcrouter): enable PROXY protocol v1 handling for SmartProxy when remoteIngress is enabled to preserve client IPs 2026-02-18 22:56:18 +00:00
610d691244 v6.13.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 21:35:18 +00:00
c88410ea53 feat(remoteingress): include listenPorts for allowed edges sent to the Rust hub and always resync allowed edges when edge properties change 2026-02-18 21:35:18 +00:00
9cbdd24281 v6.12.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 18:47:18 +00:00
dce1de8c4b feat(remote-ingress): add Remote Ingress hub integration, OpsServer UI, APIs, and docs 2026-02-18 18:47:18 +00:00
86e6c4f600 v6.11.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 06:05:46 +00:00
0618755236 feat(remoteingress): add ability to generate remote ingress connection tokens and UI copy action; add hubDomain config option; update remoteingress dependency to ^3.1.1 2026-02-18 06:05:46 +00:00
b21f3385e1 v6.10.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 17:49:12 +00:00
dd61e0c962 feat(ops-view-certificates): Make Export and Delete actions available inline (inRow) as well as in the context menu; bump @design.estate/dees-catalog to ^3.43.0 2026-02-17 17:49:12 +00:00
ac3a42fc41 v6.9.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 16:28:33 +00:00
c23f16149c feat(certificates): add certificate import, export, and deletion support (server handlers, request types, and UI) 2026-02-17 16:28:33 +00:00
529a4bae00 v6.8.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 14:17:18 +00:00
49606ae007 feat(remote-ingress): support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI 2026-02-17 14:17:18 +00:00
31a6510d8b v6.7.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 11:56:54 +00:00
b5e760ae07 feat(remote-ingress): Support auto-derived effective listen ports, make listenPorts optional, add toggle action and refine remote ingress creation/management UI 2026-02-17 11:56:54 +00:00
ea32babaac v6.6.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 10:57:27 +00:00
a4ddedaf46 fix(icons): standardize icon identifiers to lucide-prefixed names across operational views 2026-02-17 10:57:27 +00:00
7ce09c53ca v6.6.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 10:55:31 +00:00
69be2295f1 feat(remoteingress): derive effective remote ingress listen ports from route configs and expose them via ops API 2026-02-17 10:55:31 +00:00
018efa32f6 v6.5.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 22:42:30 +00:00
2530918dc6 v6.4.5
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 17:47:43 +00:00
0b09ea1573 fix(remoteingress): mark remote ingress data actions as row actions and bump @design.estate/dees-catalog dependency 2026-02-16 17:47:43 +00:00
21157477b4 v6.4.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 14:50:44 +00:00
fcf36e5cd5 fix(deps): bump @push.rocks/smartproxy to ^25.7.3 2026-02-16 14:50:44 +00:00
f5740fa565 v6.4.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:44:38 +00:00
4a9fba53a9 fix(deps): bump @push.rocks/smartproxy to ^25.7.2 2026-02-16 13:44:38 +00:00
da61adc9a2 v6.4.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:32:24 +00:00
616066ffd0 fix(smartproxy): bump @push.rocks/smartproxy to ^25.7.1 2026-02-16 13:32:24 +00:00
bd5cccb405 v6.4.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:16:50 +00:00
fbade85cda fix(deps): bump dependencies: @push.rocks/smartproxy to ^25.7.0 and @serve.zone/remoteingress to ^3.0.2 2026-02-16 13:16:50 +00:00
9060d26f3a v6.4.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 11:25:16 +00:00
c889141ec3 feat(remoteingress): add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI 2026-02-16 11:25:16 +00:00
fb472f353c v6.3.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:52:38 +00:00
090bd747e1 feat(dcrouter): add configurable baseDir and centralized path resolution; use resolved data paths for storage, cache and DNS 2026-02-16 09:52:38 +00:00
4d77a94bbb v6.2.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:02:57 +00:00
7f5284b10f fix(deps): bump @push.rocks/smartproxy to ^25.5.0 2026-02-16 09:02:57 +00:00
9cd5db2d81 v6.2.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 02:50:25 +00:00
de0b7d1fe0 fix(dcrouter): persist proxy certificate validity dates and improve certificate status initialization 2026-02-16 02:50:25 +00:00
4e32745a8f v6.2.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 01:58:39 +00:00
121573de2f fix(certs): Populate certificate status for cert-store-loaded certificates after SmartProxy startup and check proxy-certs in opsserver certificate handler 2026-02-16 01:58:39 +00:00
cd957526e2 v6.2.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:56:41 +00:00
7aa5f07731 fix(smartacme,storage): Respect wildcard domain requests when retrieving certificates and treat empty/whitespace storage values as null in getJSON 2026-02-16 00:56:41 +00:00
5b6f7b30c3 v6.2.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:26:35 +00:00
18cc21a49e feat(ts_web): add Certificate Management documentation and ops-view-certificates reference 2026-02-16 00:26:35 +00:00
46fa2f6ade v6.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:22:23 +00:00
0a6315f177 feat(certs): integrate smartacme v9 for ACME certificate provisioning and add certificate management features, docs, dashboard views, API endpoints, and per-domain backoff scheduler 2026-02-16 00:22:23 +00:00
841f99e19d v6.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-15 16:03:13 +00:00
8e9de46cd2 BREAKING CHANGE(certs): Introduce domain-centric certificate provisioning with per-domain exponential backoff and a staggered serial scheduler; add domain-based reprovision API and UI backoff display; change certificate overview API to be domain-first and include backoff info; bump related deps. 2026-02-15 16:03:13 +00:00
2d44528345 v5.5.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 14:27:59 +00:00
28a38252da feat(certs): persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting 2026-02-14 14:27:58 +00:00
dfb268bbfc v5.4.6
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 12:49:57 +00:00
6532c7ff22 fix(deps): bump @push.rocks/smartproxy dependency to ^25.2.2 2026-02-14 12:49:57 +00:00
79 changed files with 7831 additions and 2928 deletions

View File

@@ -0,0 +1,7 @@
[ 74ms] TypeError: Cannot read properties of null (reading 'appendChild')
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
[ 587ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 697ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0

View File

@@ -0,0 +1,12 @@
[ 669ms] [WARNING] Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information. @ http://localhost:3000/chunk-3L5NJTXF.js:13541
[ 729ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
[ 27973ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 27973ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 29975ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 29975ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 33977ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 33978ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 41980ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 41980ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 51983ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 51983ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141

View File

@@ -0,0 +1,6 @@
[ 55ms] TypeError: Cannot read properties of null (reading 'appendChild')
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
[ 791ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0

View File

@@ -0,0 +1,50 @@
[ 272ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078)
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
[ 272ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 274ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Pause-circle
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078)
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
[ 274ms] [WARNING] Lucide icon 'Pause-circle' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 275ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 275ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078)
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 297ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 377ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
[ 78064ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 78237ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
[ 127969ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 127969ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 129695ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 129695ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 133309ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 133309ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 141762ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 141910ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0

View File

@@ -0,0 +1,23 @@
[ 437ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
[ 38948ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
[ 52895ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 52896ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 52896ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 52897ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 99401ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 99401ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174

View File

@@ -0,0 +1,31 @@
[ 75ms] TypeError: Cannot read properties of null (reading 'appendChild')
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
[ 763ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
[ 22315ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 22315ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 22316ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 22316ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 22321ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
[ 22322ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 22322ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 22322ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
[ 65371ms] [ERROR] method: >>createApiToken<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
[ 65371ms] [ERROR] Failed to create token: zs @ http://localhost:3000/bundle.js:38142

View File

@@ -0,0 +1,25 @@
[ 642ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
[ 114916ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 179731ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 179732ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 179737ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
[ 179738ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 179738ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 179738ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13

View File

@@ -0,0 +1 @@
[ 603ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0

View File

@@ -0,0 +1,24 @@
[ 308ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 309ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 309ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 310ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 349ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
[ 350ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 350ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 351ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
[ 500ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/apitokens:0

View File

@@ -0,0 +1,30 @@
[ 427ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
[ 44124ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
[ 59106ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 59106ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 59107ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 59107ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 59116ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 59116ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 89192ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
at N.updated (http://localhost:3000/bundle.js:1204:736)
at N._$AE (http://localhost:3000/bundle.js:1:9837)
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
[ 89192ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,5 +1,370 @@
# Changelog # Changelog
## 2026-02-24 - 9.1.3 - fix(deps)
bump @api.global/typedserver to ^8.4.0 and @push.rocks/smartproxy to ^25.8.0
- Updated @api.global/typedserver from ^8.3.1 to ^8.4.0
- Updated @push.rocks/smartproxy from ^25.7.9 to ^25.8.0
## 2026-02-24 - 9.1.2 - fix(deps)
bump dependency versions for build and runtime packages
- @git.zone/tsbundle: ^2.8.3 -> ^2.9.0
- @git.zone/tswatch: ^3.1.0 -> ^3.2.0
- @api.global/typedserver: ^8.3.0 -> ^8.3.1
- @design.estate/dees-catalog: ^3.43.2 -> ^3.43.3
## 2026-02-23 - 9.1.1 - fix(dcrouter)
no changes detected — no files modified, no release necessary
- Git diff contained no changes
- No files added, modified, or deleted
## 2026-02-23 - 9.1.0 - feat(ops-dashboard)
add lucide icons to Ops dashboard view tabs
- Added iconName property to 10 view tabs in ts_web/elements/ops-dashboard.ts to enable icons in the UI
- Icon mappings: Overview -> lucide:layoutDashboard, Configuration -> lucide:settings, Network -> lucide:network, Emails -> lucide:mail, Logs -> lucide:scrollText, Routes -> lucide:route, ApiTokens -> lucide:key, Security -> lucide:shield, Certificates -> lucide:badgeCheck, RemoteIngress -> lucide:globe
- Improves visual clarity of dashboard navigation
## 2026-02-23 - 9.0.0 - BREAKING CHANGE(opsserver)
Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections
- Introduce IConfigData interface with typed sections: system, smartProxy, email, dns, tls, cache, radius, remoteIngress.
- Replace ConfigHandler.getConfiguration implementation to assemble and return IConfigData (changes API response shape for getConfiguration).
- Refactor frontend: update appstate types and ops-view-config to render the new config sections, use @serve.zone/catalog IConfigField/IConfigSectionAction, add uptime formatting and remote ingress UI.
- Fix ops-view-apitokens form handling to correctly read dees-input-tags values.
- Update tests to expect new configuration fields.
- Bump dependency @serve.zone/catalog to ^2.5.0.
## 2026-02-23 - 8.1.0 - feat(route-management)
add programmatic route management API with API tokens and admin UI
- Introduce RouteConfigManager to persist and manage programmatic routes and hardcoded-route overrides
- Add ApiTokenManager to create, validate, list, toggle and revoke API tokens (stored hashed)
- New OpsServer TypedRequest handlers: RouteManagementHandler (getMergedRoutes, create/update/delete/toggle routes, set/remove overrides) and ApiTokenHandler (create/list/revoke/toggle tokens)
- DcRouter integration: initialize routeConfigManager and apiTokenManager, expose getConstructorRoutes and re-apply programmatic routes after SmartProxy restarts
- Front-end additions: new 'Routes' and 'ApiTokens' views and UI components (ops-view-routes, ops-view-apitokens), router and appstate actions to fetch/manage routes and tokens
- New TS interfaces and request types for route-management and API tokens, plus storage schemas for persisted routes, overrides and tokens
- Bump dependency @serve.zone/catalog to ^2.3.0
## 2026-02-22 - 8.0.0 - BREAKING CHANGE(email-ops)
migrate email operations to catalog-compatible email model and simplify UI/router
- Add @serve.zone/catalog dependency and import (szCatalog) in web plugins
- Replace queue-based typedrequest methods with catalog APIs: getQueuedEmails / getSentEmails / getFailedEmails => getAllEmails and getEmailDetail (request/response shapes changed)
- Update TypeScript interfaces: IEmailQueueItem/IBounceRecord/ISecurityIncident etc. replaced by IEmail, IEmailDetail, ISmtpLogEntry, IConnectionInfo, IAuthenticationResults (breaking type changes)
- Frontend state and actions consolidated: emailOps state now holds emails array; multiple fetch actions removed and replaced by fetchAllEmailsAction and getEmailDetail usage
- UI components updated: ops-view-emails switched to list/detail view and now requests email detail via new API; router no longer exposes email folder routes and email-folder navigation removed
- Ops server handler refactored to return catalog-style emails and email detail; added status mapping and size formatting helpers
## 2026-02-21 - 7.4.3 - fix(logging)
add adaptive rate-limited DNS query logging, flush pending DNS logs on shutdown, and enhance email delivery logging
- Introduce adaptive DNS logging: allow up to 2 individual DNS query logs per second, then aggregate further queries and emit a batched summary (dnsLogWindow, dnsBatchCount, dnsBatchTimer) with a 5s flush.
- Flush pending DNS batch on stop() and log final DNS batch count during shutdown.
- Enhance email observability by logging deliveryStart, deliverySuccess, deliveryFailed and bounceProcessed events alongside existing MetricsManager tracking.
- Dependency bump: @design.estate/dees-catalog updated from ^3.43.1 to ^3.43.2.
- Non-breaking change; intended as a patch release.
## 2026-02-21 - 7.4.2 - fix(monitoring,remoteingress,web)
Prune old metrics buckets periodically, clear metrics caches on shutdown, simplify edge disconnect handling, and optimize network view data updates
- Call pruneOldBuckets() each minute to proactively remove stale time-series buckets in MetricsManager
- Clear metricsCache, emailMinuteBuckets and dnsMinuteBuckets when MetricsManager stops to avoid stale state on shutdown
- On edgeDisconnected remove the edgeStatuses entry instead of mutating an existing record (more explicit cleanup)
- Remove unused traffic-timer variables and move requestsPerSec history updates from render() into updateNetworkData() to avoid unnecessary re-renders
- Optimize traffic data array updates by shifting in-place then reassigning arrays to preserve Lit reactivity and reduce intermediate allocations
## 2026-02-21 - 7.4.1 - fix(dcrouter)
replace console logging with structured logger, improve metrics logging, add terminal-ready wait in ops UI, bump dees-catalog patch
- Replace console.log/console.error calls in classes.dcrouter.ts with structured logger.log (info/debug/error) including contextual data and stringified errors
- MetricsManager: create a dedicated Smartlog instance (metricsLogger) for SmartMetrics and use shared logger for lifecycle events (start/stop)
- SmartProxy/ACME: convert startup/stop/cert events and error logging to structured logs; include generated route and cert metadata where relevant
- Shutdown/startup flows: unify service start/stop/error messages through logger to provide consistent, structured output
- UI change: ops-view-logs now waits for xterm terminalReady before pushing initial logs to avoid race conditions
- Bump dependency @design.estate/dees-catalog from 3.43.0 to 3.43.1
## 2026-02-21 - 7.4.0 - feat(opsserver)
add real-time log push to ops dashboard and recent DNS query tracking
- Export baseLogger and add a log destination that pushes log entries to connected ops_dashboard TypedSocket clients (ts/opsserver/handlers/logs.handler.ts, ts/logger.ts).
- Introduce a new TypedRequest (pushLogEntry) interface for server→client log pushes (ts_interfaces/requests/logs.ts) and wire client handling in the web UI (ts_web/appstate.ts, ts_web/plugins.ts).
- Add TypedSocket client connection lifecycle to the web app, stream pushed log entries into app state and update log views incrementally (ts_web/appstate.ts, ts_web/elements/ops-view-logs.ts).
- MetricsManager now records recent DNS queries (timestamp, domain, type, answered, responseTimeMs) and exposes them via stats endpoints for display in the UI (ts/monitoring/classes.metricsmanager.ts, ts/opsserver/handlers/stats.handler.ts, ts_interfaces/data/stats.ts).
- UI overview now displays DNS query entries and uses answered flag to set log level (ts_web/elements/ops-view-overview.ts).
- Add import/export for typedsocket in web plugins to enable real-time push (ts_web/plugins.ts).
- Bump dependency @push.rocks/smartproxy patch version ^25.7.8 → ^25.7.9 (package.json).
## 2026-02-20 - 7.3.0 - feat(dcrouter)
Wire DNS server 'query' events to MetricsManager for time-series tracking and bump @push.rocks/smartdns to ^7.9.0
- Add dnsServer 'query' event listener that iterates event.questions and calls metricsManager.trackDnsQuery(question.type, question.name, false, event.responseTimeMs).
- Listener is guarded by a metricsManager existence check to avoid runtime errors when metrics are not configured.
- Bump dependency @push.rocks/smartdns from ^7.8.1 to ^7.9.0 in package.json.
## 2026-02-20 - 7.2.0 - feat(logs)
replace custom logs list with dees-chart-log component and push logs to chart, add log mapping and lifecycle sync, and bump smartlog dependency
- Replaced the legacy in-component log list and styling with a dees-chart-log element to render application logs.
- Added updated() lifecycle handler to push new logs to the chart and new helper methods pushLogsToChart() and getMappedLogEntries() to map log entries to the chart's expected format.
- Removed the streaming toggle, getActiveFilters(), legacy CSS for the log list, and the old per-entry rendering markup.
- Added explicit typing for dropdown @selectedOption handlers (e: any).
- Bumped dependency @push.rocks/smartlog from ^3.2.0 to ^3.2.1 in package.json.
## 2026-02-19 - 7.1.0 - feat(ops/monitoring)
add in-memory log buffer, metrics time-series and ops UI integration
- bump @push.rocks/smartlog to ^3.2.0
- introduce SmartlogDestinationBuffer (logBuffer) and wire it into the base logger to provide an in-memory log store for the Ops UI
- implement minute-resolution time-series buckets in MetricsManager with increment/prune helpers and new APIs getEmailTimeSeries and getDnsTimeSeries
- sync security counters from SecurityLogger and expose recent security events via StatsHandler
- wire email delivery lifecycle events and bounce processing to MetricsManager for tracking sent/received/failed metrics
- LogsHandler now queries the in-memory log buffer, maps smartlog levels/categories, supports search/level/time-range filtering and pagination
- UI updates: ops-view-overview, ops-view-logs and ops-view-security consume time-series and recent events to render charts, tables and filters
## 2026-02-19 - 7.0.1 - fix(monitoring)
Use smartMetrics cpuPercentage for cpuUsage.user and update smartmetrics and smartproxy dependencies
- Switch cpuUsage.user from parseFloat(smartMetricsData.cpuUsageText) to smartMetricsData.cpuPercentage to align with smartmetrics v3 API
- Bump @push.rocks/smartmetrics from ^2.0.10 to ^3.0.1
- Bump @push.rocks/smartproxy from ^25.7.6 to ^25.7.8
## 2026-02-19 - 7.0.0 - BREAKING CHANGE(deps)
bump dependencies: @serve.zone/remoteingress to ^4.0.0 (breaking), @push.rocks/smartproxy to ^25.7.6, @types/node to ^25.3.0
- Updated @serve.zone/remoteingress from ^3.3.0 to ^4.0.0 — major breaking change; may require code changes to adapt to new API.
- Updated @push.rocks/smartproxy from ^25.7.3 to ^25.7.6 — patch update (non-breaking).
- Updated @types/node from ^25.2.3 to ^25.3.0 — patch update (non-breaking).
- Current package version is 6.13.2; recommend bumping to 7.0.0 due to the breaking dependency upgrade.
## 2026-02-19 - 6.13.2 - fix(runtime)
prevent memory leaks and improve shutdown/stream handling across services
- Add CertProvisionScheduler.clear() to reset in-memory backoff cache and call it during DcRouter shutdown
- Stop any existing SmartAcme instance before creating a new one (await stop and log errors) to avoid duplicate running instances
- Null out many DcRouter service references and clear certificateStatusMap on shutdown to allow GC of stopped services
- Cap emailMetrics.recipients map size and trim to ~80% of MAX_TOP_DOMAINS to prevent unbounded growth
- Await virtualStream.sendData in logs follow handler and clear the interval if the stream errors/closes to avoid interval leaks
- Limit normalizedMacCache size and evict oldest entries when it exceeds 10000 to prevent unbounded cache growth
## 2026-02-18 - 6.13.1 - fix(dcrouter)
enable PROXY protocol v1 handling for SmartProxy when remoteIngress is enabled to preserve client IPs
- Set smartProxyConfig.acceptProxyProtocol = true when options.remoteIngressConfig.enabled
- Whitelist loopback address by setting smartProxyConfig.proxyIPs = ['127.0.0.1']
- Only applies when remoteIngress is enabled; used to accept tunneled connections forwarded by the hub to preserve original client IPs
## 2026-02-18 - 6.13.0 - feat(remoteingress)
include listenPorts for allowed edges sent to the Rust hub and always resync allowed edges when edge properties change
- getAllowedEdges now returns listenPorts for each allowed edge (uses getEffectiveListenPorts)
- remoteingress handler now calls tunnelManager.syncAllowedEdges() whenever tunnelManager exists so ports/tags/enabled changes are propagated
- Improves Rust hub routing by providing per-edge listening ports and ensuring allowed-edge list is kept up-to-date
## 2026-02-18 - 6.12.0 - feat(remote-ingress)
add Remote Ingress hub integration, OpsServer UI, APIs, and docs
- Integrates RemoteIngress (hub/tunnel) into DcRouter: runtime manager, tunnel manager and Rust data plane references added
- Bumps dependency @serve.zone/remoteingress to ^3.3.0
- Adds configuration defaults and IDcRouterOptions.remoteIngressConfig with tunnelPort/hubDomain/tls fields
- Introduces OpsServer API endpoints and TypedRequest methods for remote ingress: getRemoteIngresses, createRemoteIngress, updateRemoteIngress, deleteRemoteIngress, regenerateRemoteIngressSecret, getRemoteIngressStatus, getRemoteIngressConnectionToken
- UI updates: new Remote Ingress dashboard view, connection token generation & copy (clipboard API + fallback), auto-derived ports display, and toast notifications
- State/API rename: newEdgeSecret -> newEdgeId and clearNewEdgeIdAction; appstate fetchConnectionToken usage
- Documentation: README, ts/ and ts_web readmes, and ts_interfaces updated with interfaces and examples for Remote Ingress
- Minor UI icon updates (search -> fa:magnifyingGlass, clipboard icon casing) and other doc/README improvements
## 2026-02-18 - 6.11.0 - feat(remoteingress)
add ability to generate remote ingress connection tokens and UI copy action; add hubDomain config option; update remoteingress dependency to ^3.1.1
- Add server typed handler 'getRemoteIngressConnectionToken' to generate an encoded connection token containing hubHost, hubPort, edgeId and secret.
- Add request interface IReq_GetRemoteIngressConnectionToken for typed requests.
- Add fetchConnectionToken helper in web appstate and a 'Copy Token' action in ops-view-remoteingress to copy tokens to the clipboard with toast feedback.
- Add hubDomain option to remoteIngressConfig in dcrouter options so an external hostname can be embedded in connection tokens.
- Bump dependency @serve.zone/remoteingress from ^3.0.4 to ^3.1.1 in package.json.
## 2026-02-17 - 6.10.0 - feat(ops-view-certificates)
Make Export and Delete actions available inline (inRow) as well as in the context menu; bump @design.estate/dees-catalog to ^3.43.0
- Added 'inRow' to action types for 'Export' and 'Delete' in ts_web/elements/ops-view-certificates.ts to expose actions inline in the row
- Updated dependency @design.estate/dees-catalog from ^3.42.2 to ^3.43.0 in package.json
## 2026-02-17 - 6.9.0 - feat(certificates)
add certificate import, export, and deletion support (server handlers, request types, and UI)
- Add typed request handlers in opsserver: deleteCertificate, exportCertificate, importCertificate (ts/opsserver/handlers/certificate.handler.ts)
- Implement deleteCertificate/exportCertificate/importCertificate functions handling storage paths, in-memory status map updates, backoff clearing, validation, and SmartAcme-compatible /certs/ and /proxy-certs/ formats
- Add request interfaces IReq_DeleteCertificate, IReq_ExportCertificate, IReq_ImportCertificate (ts_interfaces/requests/certificate.ts)
- Add web app actions deleteCertificateAction, importCertificateAction and fetchCertificateExport to call new typed requests (ts_web/appstate.ts)
- Update certificates UI to support Import, Export, and Delete actions and add downloadJsonFile helper (ts_web/elements/ops-view-certificates.ts)
## 2026-02-17 - 6.8.0 - feat(remote-ingress)
support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI
- Add autoDerivePorts flag to IRemoteIngress with default true and migration to set existing stored edges to autoDerivePorts = true
- RemoteIngressManager: getEffectiveListenPorts now returns the union of manual + derived ports when autoDerivePorts is enabled; added getPortBreakdown to return manual vs derived lists
- API handlers updated: create/update requests accept autoDerivePorts; responses now include effectiveListenPorts, manualPorts, and derivedPorts (secrets still masked)
- Web UI updated: create and edit dialogs include an Auto-derive checkbox; port badges now visually distinguish manual vs derived ports; added updateRemoteIngressAction
- Non-breaking change: new field defaults to true so existing behavior is preserved
## 2026-02-17 - 6.7.0 - feat(remote-ingress)
Support auto-derived effective listen ports, make listenPorts optional, add toggle action and refine remote ingress creation/management UI
- Add effectiveListenPorts?: number[] to IRemoteIngress interface (present in API responses)
- Make createRemoteIngressAction.listenPorts optional and update creation modal to allow empty ports (auto-derived)
- Add toggleRemoteIngressAction to enable/disable remote ingress edges and wire up Enable/Disable row/context-menu actions
- Update getPortsHtml to prefer manual listenPorts, fall back to effectiveListenPorts, show '(auto)' when derived and 'none' when no ports
- Standardize UI actions to use inRow/contextmenu and actionFunc signatures; update create modal to use explicit Cancel/Create menu options and collect form data programmatically
## 2026-02-17 - 6.6.1 - fix(icons)
standardize icon identifiers to lucide-prefixed names across operational views
- Replaced legacy/ambiguous icon names with 'lucide:...' identifiers in four UI modules: ts_web/elements/ops-view-certificates.ts, ops-view-network.ts, ops-view-overview.ts, and ops-view-security.ts.
- Updated common action/menu icons (e.g. arrowsRotate -> lucide:RefreshCw, magnifyingGlass -> lucide:Search, copy -> lucide:Copy, fileExport -> lucide:FileOutput).
- Mapped dashboard/tile icons to lucide equivalents (e.g. server -> lucide:Server, networkWired/sitemap -> lucide:Network, download/upload -> lucide:Download/Upload, microchip/memory -> lucide:Cpu/MemoryStick).
- Normalized alert and status icons to lucide names (e.g. triangleExclamation -> lucide:TriangleAlert, shield/userShield -> lucide:Shield/ShieldCheck, clock/clockRotateLeft -> lucide:Clock/History).
## 2026-02-17 - 6.6.0 - feat(remoteingress)
derive effective remote ingress listen ports from route configs and expose them via ops API
- Derive listen ports from SmartProxy route configs with remoteIngress.enabled; supports optional edgeFilter to target edges by id or tags.
- Add RemoteIngressManager.setRoutes(), derivePortsForEdge(), and getEffectiveListenPorts() which falls back to manual listenPorts when present.
- dcrouter now supplies route configs to RemoteIngressManager during initialization and when updating SmartProxy configuration to keep derived ports in sync.
- Ops API now returns effectiveListenPorts for edges; createRemoteIngress.listenPorts is optional and createEdge defaults listenPorts to an empty array.
- Bump dependency @serve.zone/remoteingress to ^3.0.4 to align types/behavior.
## 2026-02-16 - 6.5.0 - feat(ops-view-remoteingress)
add 'Create Edge Node' header action to remote ingress table and remove duplicate createNewAction
- Add a 'Create Edge Node' header action in dataActions that opens DeesModal to collect name, listenPorts and tags
- Parse comma-separated listenPorts into integer array and normalize optional tags
- Dispatch appstate.createRemoteIngressAction with the collected payload
- Remove the previously duplicated createNewAction prop from the dees-table
## 2026-02-16 - 6.4.5 - fix(remoteingress)
mark remote ingress data actions as row actions and bump @design.estate/dees-catalog dependency
- Add type:['row'] to 'Regenerate Secret' and 'Delete' dataActions in ts_web/elements/ops-view-remoteingress.ts to ensure they are treated as row actions in the UI
- Bump @design.estate/dees-catalog from ^3.42.0 to ^3.42.2 in package.json
## 2026-02-16 - 6.4.4 - fix(deps)
bump @push.rocks/smartproxy to ^25.7.3
- Updated @push.rocks/smartproxy from ^25.7.2 to ^25.7.3 in package.json
## 2026-02-16 - 6.4.3 - fix(deps)
bump @push.rocks/smartproxy to ^25.7.2
- Updated package.json: @push.rocks/smartproxy ^25.7.1 -> ^25.7.2 (patch dependency update)
## 2026-02-16 - 6.4.2 - fix(smartproxy)
bump @push.rocks/smartproxy to ^25.7.1
- Updated dependency @push.rocks/smartproxy from ^25.7.0 to ^25.7.1 in package.json
- No other source changes; dependency patch bump only
## 2026-02-16 - 6.4.1 - fix(deps)
bump dependencies: @push.rocks/smartproxy to ^25.7.0 and @serve.zone/remoteingress to ^3.0.2
- Bumped @push.rocks/smartproxy from ^25.5.0 to ^25.7.0
- Bumped @serve.zone/remoteingress from ^3.0.1 to ^3.0.2
- Package current version is 6.4.0 — recommended patch release
## 2026-02-16 - 6.4.0 - feat(remoteingress)
add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI
- Introduce RemoteIngressManager for CRUD and persistent storage of edge registrations
- Introduce TunnelManager to run the RemoteIngressHub, track connected edge statuses, and sync allowed edges to the hub
- Integrate remote ingress into DcRouter (options.remoteIngressConfig, setupRemoteIngress, startup/shutdown handling, and startup summary)
- Add OpsServer RemoteIngressHandler exposing typedrequest APIs (create/update/delete/regenerate/get/status)
- Add web UI: Remote Ingress view, app state parts, actions and components to manage edges and display runtime statuses
- Add typedrequest and data interfaces for remoteingress and export the remoteingress module; add @serve.zone/remoteingress dependency in package.json
## 2026-02-16 - 6.3.0 - feat(dcrouter)
add configurable baseDir and centralized path resolution; use resolved data paths for storage, cache and DNS
- Introduce IDcRouterOptions.baseDir to allow configuring base directory for dcrouter data (defaults to ~/.serve.zone/dcrouter).
- Add DcRouter.resolvedPaths and resolvePaths(baseDir) in ts/paths.ts to centralize computation of dcrouterHomeDir, dataDir, defaultTsmDbPath, defaultStoragePath and dnsRecordsDir.
- Use resolvedPaths throughout DcRouter: default filesystem storage fsPath, CacheDb storagePath, and DNS records loading now reference resolved paths.
- Replace ensureDirectories() behavior with ensureDataDirectories(resolvedPaths) to only create data-related directories; keep legacy ensureDirectories wrapper delegating to the new function.
- Simplify paths module by removing unused legacy path constants and adding a focused API for path resolution and directory creation.
- Remove an unused import (paths) in contentscanner, cleaning up imports.
## 2026-02-16 - 6.2.4 - fix(deps)
bump @push.rocks/smartproxy to ^25.5.0
- Updated @push.rocks/smartproxy from ^25.4.0 to ^25.5.0 in package.json
## 2026-02-16 - 6.2.3 - fix(dcrouter)
persist proxy certificate validity dates and improve certificate status initialization
- Bump @push.rocks/smartacme dependency from ^9.0.0 to ^9.1.3
- Store validFrom and validUntil alongside proxy cert entries (/proxy-certs) when saving, extracting values by parsing PEM where possible
- Use stored cert entries (domain, publicKey, validUntil, validFrom) to populate certificateStatusMap at startup
- Fallback to SmartAcme /certs/ metadata and finally to parsing X.509 from stored PEM to determine expiry/issuedAt when initializing status
- Update opsserver certificate handler to parse publicKey PEM from cert-store and set expiry/issuedAt and issuer accordingly
- Adjust variable names and logging to reflect stored cert entry usage
## 2026-02-16 - 6.2.2 - fix(certs)
Populate certificate status for cert-store-loaded certificates after SmartProxy startup and check proxy-certs in opsserver certificate handler
- Track domains loaded from storageManager '/proxy-certs/' and populate certificateStatusMap with status, routeNames, expiryDate and issuedAt (when available) after SmartProxy starts
- Opsserver certificate handler now falls back to '/proxy-certs/{domain}' if '/certs/{cleanDomain}' is missing and marks cert-store-only entries as valid with issuer 'cert-store'
- Bump @push.rocks/smartproxy dependency from ^25.3.1 to ^25.4.0
## 2026-02-16 - 6.2.1 - fix(smartacme,storage)
Respect wildcard domain requests when retrieving certificates and treat empty/whitespace storage values as null in getJSON
- Pass includeWildcard flag to smartAcme.getCertificateForDomain to avoid incorrectly including/excluding wildcard certificates based on whether the requested domain itself is a wildcard
- Detect wildcard domains via domain.startsWith('*.') and set includeWildcard to false for wildcard requests
- Treat empty or whitespace-only stored values as null in StorageManager.getJSON to avoid parsing empty strings as JSON and potential errors
## 2026-02-16 - 6.2.0 - feat(ts_web)
add Certificate Management documentation and ops-view-certificates reference
- Adds a new 'Certificate Management' section to ts_web/readme.md describing domain-centric overview, certificate sources (ACME/provision/static), expiry monitoring, per-domain backoff, and one-click reprovisioning
- Adds ops-view-certificates.ts entry to the ops UI file list
- Documents new route mapping '/certificates' in the readme navigation
## 2026-02-16 - 6.1.0 - feat(certs)
integrate smartacme v9 for ACME certificate provisioning and add certificate management features, docs, dashboard views, API endpoints, and per-domain backoff scheduler
- Bump dependency: @push.rocks/smartacme -> ^9.0.0
- Add Certificate Management documentation, examples, and a new Certificates view in the OpsServer dashboard (status, source, expiry, backoff, oneclick reprovision)
- Integrate smartacme v9 features: per-domain deduplication, global concurrency control, account rate limiting, structured errors, and clean shutdown behavior
- Introduce per-domain exponential backoff persisted via StorageManager (CertProvisionScheduler) and remove the older serial stagger queue (smartacme v9 handles concurrency/deduping)
- Expose new typedrequest API methods: getCertificateOverview, reprovisionCertificate (legacy), reprovisionCertificateDomain (preferred)
- DcRouter now surfaces smartAcme, certProvisionScheduler, and certificateStatusMap; cert provisioning paths call smartAcme directly and clear backoff on success
- Docs updated to note parallel shutdown/cleanup of HTTP agents and DNS clients
## 2026-02-15 - 6.0.0 - BREAKING CHANGE(certs)
Introduce domain-centric certificate provisioning with per-domain exponential backoff and a staggered serial scheduler; add domain-based reprovision API and UI backoff display; change certificate overview API to be domain-first and include backoff info; bump related deps.
- Add CertProvisionScheduler: persistent per-domain exponential backoff, retry calculation, and an in-memory serial stagger queue.
- Integrate scheduler with SmartAcme certProvisionFunction: enqueue provisions, clear backoff on success, record failures to drive backoff.
- Switch certificate event tracking to be keyed by domain (certificateStatusMap now keyed by domain) and add findRouteNamesForDomain helper.
- BREAKING: ICertificateInfo shape changed — replaced routeName/domains with domain and routeNames; added optional backoffInfo (failures, retryAfter, lastError).
- Add domain-based reprovision endpoint (reprovisionCertificateDomain) while retaining legacy route-based reprovision for backward compatibility (internal rename to reprovisionCertificateByRoute).
- Web UI updated to domain-centric certificate overview, displays route pills, backoff indicator and retry timing, and uses domain-based reprovision action.
- Dependency bumps: @push.rocks/smartlog -> ^3.1.11, @push.rocks/smartproxy -> ^25.3.1.
## 2026-02-14 - 5.5.0 - feat(certs)
persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting
- Add StorageBackedCertManager to persist SmartAcme certificates under /certs/ via StorageManager
- Default storage to filesystem path (dcrouterHomeDir/storage) when options.storage is not provided
- Wire SmartAcme to use StorageBackedCertManager and provide SmartProxy certStore handlers that load/save/remove certs under /proxy-certs/
- Ops server certificate handler reads persisted cert data to report expiry/issued dates and treats acme/provision-function routes with no cert data as provisioning
- Bump @push.rocks/smartproxy dependency to ^25.3.0
## 2026-02-14 - 5.4.6 - fix(deps)
bump @push.rocks/smartproxy dependency to ^25.2.2
- Updated dependency @push.rocks/smartproxy: ^25.2.0 → ^25.2.2
- Change is a dependency-only patch update, no source code modifications
- Current package version is 5.4.5; recommend a patch release
## 2026-02-14 - 5.4.5 - fix(dcrouter) ## 2026-02-14 - 5.4.5 - fix(dcrouter)
bump patch for release pipeline consistency - no code changes bump patch for release pipeline consistency - no code changes

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "5.4.5", "version": "9.1.3",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -20,42 +20,44 @@
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.1.2", "@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3", "@git.zone/tsbundle": "^2.9.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.8", "@git.zone/tstest": "^3.1.8",
"@git.zone/tswatch": "^3.1.0", "@git.zone/tswatch": "^3.2.0",
"@types/node": "^25.2.3" "@types/node": "^25.3.0"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.2.6", "@api.global/typedrequest": "^3.2.6",
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.0", "@api.global/typedserver": "^8.4.0",
"@api.global/typedsocket": "^4.1.0", "@api.global/typedsocket": "^4.1.0",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.42.0", "@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.1.6",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^8.0.0", "@push.rocks/smartacme": "^9.1.3",
"@push.rocks/smartdata": "^7.0.15", "@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdns": "^7.8.1", "@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^2.0.10", "@push.rocks/smartmetrics": "^3.0.1",
"@push.rocks/smartmongo": "^5.1.0", "@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.2.2", "@push.rocks/smartmta": "^5.2.2",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.2.0", "@push.rocks/smartproxy": "^25.8.0",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.0.30", "@push.rocks/smartstate": "^2.0.30",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@serve.zone/catalog": "^2.5.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.0.0",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.3.0",
"lru-cache": "^11.2.6", "lru-cache": "^11.2.6",
"uuid": "^13.0.0" "uuid": "^13.0.0"

750
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

267
readme.md
View File

@@ -4,7 +4,7 @@
**dcrouter: The all-in-one gateway for your datacenter.** 🚀 **dcrouter: The all-in-one gateway for your datacenter.** 🚀
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, and RADIUS protocols. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, and enterprise-grade email infrastructure — all from a single process. 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.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -21,6 +21,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [Email System](#email-system) - [Email System](#email-system)
- [DNS Server](#dns-server) - [DNS Server](#dns-server)
- [RADIUS Server](#radius-server) - [RADIUS Server](#radius-server)
- [Remote Ingress](#remote-ingress)
- [Certificate Management](#certificate-management)
- [Storage & Caching](#storage--caching) - [Storage & Caching](#storage--caching)
- [Security Features](#security-features) - [Security Features](#security-features)
- [OpsServer Dashboard](#opsserver-dashboard) - [OpsServer Dashboard](#opsserver-dashboard)
@@ -46,7 +48,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Hierarchical rate limiting** — global, per-domain, per-sender - **Hierarchical rate limiting** — global, per-domain, per-sender
### 🔒 Enterprise Security ### 🔒 Enterprise Security
- **Automatic TLS certificates** via ACME with Cloudflare DNS-01 challenges - **Automatic TLS certificates** via ACME (smartacme v9) with Cloudflare DNS-01 challenges
- **Smart certificate scheduling** — per-domain deduplication, controlled parallelism, and account rate limiting handled automatically
- **Per-domain exponential backoff** — failed provisioning attempts are tracked and backed off to avoid hammering ACME servers
- **IP reputation checking** with caching and configurable thresholds - **IP reputation checking** with caching and configurable thresholds
- **Content scanning** for spam, viruses, and malicious attachments - **Content scanning** for spam, viruses, and malicious attachments
- **Security event logging** with structured audit trails - **Security event logging** with structured audit trails
@@ -57,6 +61,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **RADIUS accounting** for session tracking, traffic metering, and billing - **RADIUS accounting** for session tracking, traffic metering, and billing
- **Real-time management** via OpsServer API - **Real-time management** via OpsServer API
### 🌍 Remote Ingress (powered by [remoteingress](https://code.foss.global/serve.zone/remoteingress))
- **Distributed edge networking** — accept traffic at remote edge nodes and tunnel it to the hub
- **Edge registration CRUD** with secret-based authentication
- **Auto-derived ports** — edges automatically pick up ports from routes tagged with `remoteIngress.enabled`
- **Connection tokens** — generate a single opaque base64url token containing hubHost, hubPort, edgeId, and secret for easy edge provisioning
- **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
### ⚡ High Performance ### ⚡ High Performance
- **Rust-powered proxy engine** via SmartProxy for maximum throughput - **Rust-powered proxy engine** via SmartProxy for maximum throughput
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery - **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
@@ -73,7 +85,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 🖥️ OpsServer Dashboard ### 🖥️ OpsServer Dashboard
- **Web-based management interface** with real-time monitoring - **Web-based management interface** with real-time monitoring
- **JWT authentication** with session persistence - **JWT authentication** with session persistence
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, and security events - **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, 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 - **Read-only configuration display** — DcRouter is configured through code
## Installation ## Installation
@@ -215,6 +229,13 @@ const router = new DcRouter({
accounting: { enabled: true, retentionDays: 30 } accounting: { enabled: true, retentionDays: 30 }
}, },
// Remote Ingress — edge nodes tunnel traffic to this hub
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'hub.example.com',
},
// Persistent storage // Persistent storage
storage: { fsPath: '/var/lib/dcrouter/data' }, storage: { fsPath: '/var/lib/dcrouter/data' },
@@ -242,6 +263,7 @@ graph TB
TCP[TCP Clients] TCP[TCP Clients]
DNS[DNS Queries] DNS[DNS Queries]
RAD[RADIUS Clients] RAD[RADIUS Clients]
EDGE[Edge Nodes]
end end
subgraph "DcRouter Core" subgraph "DcRouter Core"
@@ -250,7 +272,8 @@ graph TB
ES[smartmta Email Server<br/><i>TypeScript + Rust</i>] ES[smartmta Email Server<br/><i>TypeScript + Rust</i>]
DS[SmartDNS Server<br/><i>Rust-powered</i>] DS[SmartDNS Server<br/><i>Rust-powered</i>]
RS[SmartRadius Server] RS[SmartRadius Server]
CM[Certificate Manager] RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
CM[Certificate Manager<br/><i>smartacme v9</i>]
OS[OpsServer Dashboard] OS[OpsServer Dashboard]
MM[Metrics Manager] MM[Metrics Manager]
SM[Storage Manager] SM[Storage Manager]
@@ -269,11 +292,13 @@ graph TB
SMTP --> ES SMTP --> ES
DNS --> DS DNS --> DS
RAD --> RS RAD --> RS
EDGE --> RI
DC --> SP DC --> SP
DC --> ES DC --> ES
DC --> DS DC --> DS
DC --> RS DC --> RS
DC --> RI
DC --> CM DC --> CM
DC --> OS DC --> OS
DC --> MM DC --> MM
@@ -284,6 +309,7 @@ graph TB
SP --> API SP --> API
ES --> MAIL ES --> MAIL
ES --> DB ES --> DB
RI --> SP
CM -.-> SP CM -.-> SP
CM -.-> ES CM -.-> ES
@@ -297,7 +323,9 @@ graph TB
| **SmartProxy** | `@push.rocks/smartproxy` | High-performance HTTP/HTTPS and TCP/SNI proxy with route-based config (Rust engine) | | **SmartProxy** | `@push.rocks/smartproxy` | High-performance HTTP/HTTPS and TCP/SNI proxy with route-based config (Rust engine) |
| **UnifiedEmailServer** | `@push.rocks/smartmta` | Full SMTP server with pattern-based routing, DKIM, queue management (TypeScript + Rust) | | **UnifiedEmailServer** | `@push.rocks/smartmta` | Full SMTP server with pattern-based routing, DKIM, queue management (TypeScript + Rust) |
| **DNS Server** | `@push.rocks/smartdns` | Authoritative DNS with dynamic records and DKIM TXT auto-generation (Rust engine) | | **DNS Server** | `@push.rocks/smartdns` | Authoritative DNS with dynamic records and DKIM TXT auto-generation (Rust engine) |
| **SmartAcme** | `@push.rocks/smartacme` | ACME certificate management with per-domain dedup, concurrency control, and rate limiting |
| **RADIUS Server** | `@push.rocks/smartradius` | Network authentication with MAB, VLAN assignment, and accounting | | **RADIUS Server** | `@push.rocks/smartradius` | Network authentication with MAB, VLAN assignment, and accounting |
| **RemoteIngress** | `@serve.zone/remoteingress` | Distributed edge tunneling with Rust data plane and TS management |
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management | | **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) | | **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) | | **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
@@ -307,19 +335,20 @@ graph TB
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol: DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, and SmartRadius based on which configs are provided. 1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. 2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
3. **On `stop()`**: All services are gracefully shut down in reverse order. 3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
### Rust-Powered Architecture ### Rust-Powered Architecture
DcRouter itself is a pure TypeScript orchestrator, but three of its core sub-components ship with **compiled Rust binaries** for performance-critical paths. At runtime each package detects the platform, unpacks the correct binary, and communicates with TypeScript over IPC/FFI — so you get the ergonomics of TypeScript with the throughput of native code. DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-components ship with **compiled Rust binaries** for performance-critical paths. At runtime each package detects the platform, unpacks the correct binary, and communicates with TypeScript over IPC/FFI — so you get the ergonomics of TypeScript with the throughput of native code.
| Component | Rust Binary | What It Handles | | Component | Rust Binary | What It Handles |
|-----------|-------------|-----------------| |-----------|-------------|-----------------|
| **SmartProxy** | `smartproxy-bin` | All TCP/TLS/HTTP proxy networking, NFTables integration, connection metrics | | **SmartProxy** | `smartproxy-bin` | All TCP/TLS/HTTP proxy networking, NFTables integration, connection metrics |
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation | | **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution | | **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
| **SmartRadius** | — | Pure TypeScript (no Rust component) | | **SmartRadius** | — | Pure TypeScript (no Rust component) |
## Configuration Reference ## Configuration Reference
@@ -328,6 +357,10 @@ DcRouter itself is a pure TypeScript orchestrator, but three of its core sub-com
```typescript ```typescript
interface IDcRouterOptions { interface IDcRouterOptions {
// ── Base ───────────────────────────────────────────────────────
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
baseDir?: string;
// ── Traffic Routing ──────────────────────────────────────────── // ── Traffic Routing ────────────────────────────────────────────
/** SmartProxy config for HTTP/HTTPS and TCP/SNI routing */ /** SmartProxy config for HTTP/HTTPS and TCP/SNI routing */
smartProxyConfig?: ISmartProxyOptions; smartProxyConfig?: ISmartProxyOptions;
@@ -371,6 +404,18 @@ interface IDcRouterOptions {
accounting?: { enabled: boolean; retentionDays?: number }; accounting?: { enabled: boolean; retentionDays?: number };
}; };
// ── Remote Ingress ─────────────────────────────────────────────
/** Remote Ingress hub for edge tunnel connections */
remoteIngressConfig?: {
enabled?: boolean; // default: false
tunnelPort?: number; // default: 8443
hubDomain?: string; // External hostname for connection tokens
tls?: {
certPath?: string;
keyPath?: string;
};
};
// ── TLS & Certificates ──────────────────────────────────────── // ── TLS & Certificates ────────────────────────────────────────
tls?: { tls?: {
contactEmail: string; contactEmail: string;
@@ -584,15 +629,6 @@ match: { sizeRange: { min: 1000, max: 5000000 }, hasAttachments: true }
match: { subject: /invoice|receipt/i } match: { subject: /invoice|receipt/i }
``` ```
### Socket-Handler Mode 🔌
When `useSocketHandler: true` is set, SmartProxy passes sockets directly to the email server — no internal port binding, lower latency, and fewer open ports:
```
Traditional: External Port → SmartProxy → Internal Port → Email Server
Socket Mode: External Port → SmartProxy → (direct socket) → Email Server
```
### Email Security Stack ### Email Security Stack
- **DKIM** — Automatic key generation, signing, and rotation for all domains - **DKIM** — Automatic key generation, signing, and rotation for all domains
@@ -705,6 +741,175 @@ RADIUS is fully manageable at runtime via the OpsServer API:
- Session monitoring and forced disconnects - Session monitoring and forced disconnects
- Accounting summaries and statistics - Accounting summaries and statistics
## Remote Ingress
DcRouter can act as a **hub** for distributed edge nodes using [`@serve.zone/remoteingress`](https://code.foss.global/serve.zone/remoteingress). Edge nodes accept incoming traffic at remote locations and tunnel it back to the hub over a single multiplexed connection. This is ideal for scenarios where you need to accept traffic at multiple geographic locations but process it centrally.
### Enabling Remote Ingress
```typescript
const router = new DcRouter({
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'hub.example.com', // Embedded in connection tokens
},
// Routes tagged with remoteIngress are auto-derived to edge listen ports
smartProxyConfig: {
routes: [
{
name: 'web-via-edge',
match: { domains: ['app.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
remoteIngress: { enabled: true } // Edges will listen on port 443
}
]
}
});
await router.start();
```
### Edge Registration
Edges are registered via the OpsServer API (or dashboard UI). Each edge gets a unique ID and secret:
```typescript
// Via TypedRequest API
const createReq = new TypedRequest<IReq_CreateRemoteIngress>(
'https://hub:3000/typedrequest', 'createRemoteIngress'
);
const { edge } = await createReq.fire({
identity,
name: 'edge-nyc-01',
autoDerivePorts: true,
tags: ['us-east'],
});
// edge.secret is returned only on creation — save it!
```
### Connection Tokens 🔑
Instead of configuring edges with four separate values (hubHost, hubPort, edgeId, secret), DcRouter can generate a single **connection token** — an opaque base64url string that encodes everything:
```typescript
// Via TypedRequest API
const tokenReq = new TypedRequest<IReq_GetRemoteIngressConnectionToken>(
'https://hub:3000/typedrequest', 'getRemoteIngressConnectionToken'
);
const { token } = await tokenReq.fire({ identity, edgeId: 'edge-uuid' });
// token = "eyJoIjoiaHViLmV4YW1wbGUuY29tIiwicCI6ODQ0MywiZSI6I..."
// On the edge side, just pass the token:
const edge = new RemoteIngressEdge({ token });
await edge.start();
```
The token is generated using `remoteingress.encodeConnectionToken()` and contains `{ hubHost, hubPort, edgeId, secret }`. The `hubHost` comes from `remoteIngressConfig.hubDomain` (or can be overridden per-request).
In the OpsServer dashboard, click **"Copy Token"** on any edge row to copy the connection token to your clipboard.
### Auto-Derived Ports
When routes have `remoteIngress: { enabled: true }`, edges with `autoDerivePorts: true` (default) automatically pick up those routes' ports. You can also use `edgeFilter` to restrict which edges get which ports:
```typescript
{
name: 'web-route',
match: { ports: [443] },
action: { /* ... */ },
remoteIngress: {
enabled: true,
edgeFilter: ['us-east', 'edge-uuid-123'] // Only edges with matching id or tags
}
}
```
### Dashboard Actions
The OpsServer Remote Ingress view provides:
| Action | Description |
|--------|-------------|
| **Create Edge Node** | Register a new edge with name, ports, tags |
| **Enable / Disable** | Toggle an edge on or off |
| **Edit** | Modify name, manual ports, auto-derive setting, tags |
| **Regenerate Secret** | Issue a new secret (invalidates the old one) |
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
| **Delete** | Remove the edge registration |
## 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:
### How It Works
When a `dnsChallenge` is configured (e.g. with a Cloudflare API key), DcRouter creates a SmartAcme instance that handles DNS-01 challenges for automatic certificate provisioning. SmartProxy calls the `certProvisionFunction` whenever a route needs a TLS certificate, and SmartAcme takes care of the rest.
```typescript
const router = new DcRouter({
smartProxyConfig: {
routes: [
{
name: 'secure-app',
match: { domains: ['app.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' } // ← triggers ACME provisioning
}
}
],
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
},
tls: { contactEmail: 'admin@example.com' },
dnsChallenge: { cloudflareApiKey: process.env.CLOUDFLARE_API_KEY }
});
```
### smartacme v9 Features
| Feature | Description |
|---------|-------------|
| **Per-domain deduplication** | Concurrent requests for the same domain share a single ACME operation |
| **Global concurrency cap** | Default 5 parallel ACME operations to prevent overload |
| **Account rate limiting** | Sliding window (250 orders / 3 hours) to stay within ACME provider limits |
| **Structured errors** | `AcmeError` with `isRetryable`, `isRateLimited`, `retryAfter` fields |
| **Clean shutdown** | `stop()` properly destroys HTTP agents and DNS clients |
### Per-Domain Backoff
DcRouter's `CertProvisionScheduler` adds **per-domain exponential backoff** on top of smartacme's built-in protections. If a DNS-01 challenge fails for a domain:
1. The failure is recorded (persisted to storage)
2. The domain enters backoff: `min(failures² × 1 hour, 24 hours)`
3. Subsequent requests for that domain are rejected until the backoff expires
4. On success, the backoff is cleared
This prevents hammering ACME servers for domains with persistent issues (e.g. missing DNS delegation).
### Fallback to HTTP-01
If DNS-01 fails, the `certProvisionFunction` returns `'http01'` to tell SmartProxy to fall back to HTTP-01 challenge validation. This provides a safety net for domains where DNS-01 isn't viable.
### Certificate Storage
Certificates are persisted via the `StorageBackedCertManager` which uses DcRouter's `StorageManager`. This means certs survive restarts and don't need to be re-provisioned unless they expire.
### Dashboard
The OpsServer includes a **Certificates** view showing:
- All domains with their certificate status (valid, expiring, expired, failed)
- Certificate source (ACME, provision function, static)
- Expiry dates and issuer information
- Backoff status for failed domains
- One-click reprovisioning per domain
- Certificate import and export
## Storage & Caching ## Storage & Caching
### StorageManager ### StorageManager
@@ -725,7 +930,7 @@ storage: {
// Simply omit the storage config // Simply omit the storage config
``` ```
Used for: DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs. Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs, cert backoff state, remote ingress edge registrations.
### Cache Database ### Cache Database
@@ -811,6 +1016,8 @@ The OpsServer provides a web-based management interface served on port 3000. It'
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput | | 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics | | 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents | | 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
| 📜 **Logs** | Real-time log viewer with level filtering and search | | 📜 **Logs** | Real-time log viewer with level filtering and search |
| ⚙️ **Configuration** | Read-only view of current system configuration | | ⚙️ **Configuration** | Read-only view of current system configuration |
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections | | 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
@@ -838,6 +1045,23 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
'getBounceRecords' // Bounce records 'getBounceRecords' // Bounce records
'removeFromSuppressionList' // Unsuppress an address 'removeFromSuppressionList' // Unsuppress an address
// Certificates
'getCertificateOverview' // Domain-centric certificate status
'reprovisionCertificate' // Reprovision by route name (legacy)
'reprovisionCertificateDomain' // Reprovision by domain (preferred)
'importCertificate' // Import a certificate
'exportCertificate' // Export a certificate
'deleteCertificate' // Delete a certificate
// Remote Ingress
'getRemoteIngresses' // List all edge registrations
'createRemoteIngress' // Register a new edge
'updateRemoteIngress' // Update edge settings
'deleteRemoteIngress' // Remove an edge
'regenerateRemoteIngressSecret' // Issue a new secret
'getRemoteIngressStatus' // Runtime status of all edges
'getRemoteIngressConnectionToken' // Generate a connection token for an edge
// Configuration (read-only) // Configuration (read-only)
'getConfiguration' // Current system config 'getConfiguration' // Current system config
@@ -884,13 +1108,18 @@ const router = new DcRouter(options: IDcRouterOptions);
|----------|------|-------------| |----------|------|-------------|
| `options` | `IDcRouterOptions` | Current configuration | | `options` | `IDcRouterOptions` | Current configuration |
| `smartProxy` | `SmartProxy` | SmartProxy instance | | `smartProxy` | `SmartProxy` | SmartProxy instance |
| `smartAcme` | `SmartAcme` | SmartAcme v9 certificate manager instance |
| `emailServer` | `UnifiedEmailServer` | Email server instance (from smartmta) | | `emailServer` | `UnifiedEmailServer` | Email server instance (from smartmta) |
| `dnsServer` | `DnsServer` | DNS server instance | | `dnsServer` | `DnsServer` | DNS server instance |
| `radiusServer` | `RadiusServer` | RADIUS server instance | | `radiusServer` | `RadiusServer` | RADIUS server instance |
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
| `storageManager` | `StorageManager` | Storage backend | | `storageManager` | `StorageManager` | Storage backend |
| `opsServer` | `OpsServer` | OpsServer/dashboard instance | | `opsServer` | `OpsServer` | OpsServer/dashboard instance |
| `metricsManager` | `MetricsManager` | Metrics collector | | `metricsManager` | `MetricsManager` | Metrics collector |
| `cacheDb` | `CacheDb` | Cache database instance | | `cacheDb` | `CacheDb` | Cache database instance |
| `certProvisionScheduler` | `CertProvisionScheduler` | Per-domain backoff scheduler for cert provisioning |
| `certificateStatusMap` | `Map<string, ...>` | Domain-keyed certificate status from SmartProxy events |
### Re-exported Types ### Re-exported Types
@@ -928,7 +1157,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
DcRouter includes a comprehensive test suite covering all system components: DcRouter includes a comprehensive test suite covering all system components:
```bash ```bash
# Run all tests (10 files, 73 tests) # Run all tests
pnpm test pnpm test
# Run a specific test file # Run a specific test file

View File

@@ -55,10 +55,14 @@ tap.test('should respond to configuration request', async () => {
const response = await configRequest.fire({}); const response = await configRequest.fire({});
expect(response).toHaveProperty('config'); expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy');
expect(response.config).toHaveProperty('email'); expect(response.config).toHaveProperty('email');
expect(response.config).toHaveProperty('dns'); expect(response.config).toHaveProperty('dns');
expect(response.config).toHaveProperty('proxy'); expect(response.config).toHaveProperty('tls');
expect(response.config).toHaveProperty('security'); expect(response.config).toHaveProperty('cache');
expect(response.config).toHaveProperty('radius');
expect(response.config).toHaveProperty('remoteIngress');
}); });
tap.test('should handle log retrieval request', async () => { tap.test('should handle log retrieval request', async () => {

View File

@@ -106,10 +106,14 @@ tap.test('should allow read-only config access', async () => {
const response = await configRequest.fire({}); const response = await configRequest.fire({});
expect(response).toHaveProperty('config'); expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy');
expect(response.config).toHaveProperty('email'); expect(response.config).toHaveProperty('email');
expect(response.config).toHaveProperty('dns'); expect(response.config).toHaveProperty('dns');
expect(response.config).toHaveProperty('proxy'); expect(response.config).toHaveProperty('tls');
expect(response.config).toHaveProperty('security'); expect(response.config).toHaveProperty('cache');
expect(response.config).toHaveProperty('radius');
expect(response.config).toHaveProperty('remoteIngress');
console.log('Configuration read successfully'); console.log('Configuration read successfully');
}); });

View File

@@ -1,21 +1,32 @@
import { DcRouter } from '../ts/index.js'; import { DcRouter } from '../ts/index.js';
const devRouter = new DcRouter({ const devRouter = new DcRouter({
// Configure services as needed for development // SmartProxy routes for development/demo
// OpsServer always starts on port 3000 smartProxyConfig: {
routes: [
// Example: Add SmartProxy routes {
// smartProxyConfig: { name: 'web-traffic',
// routes: [...] match: { ports: [18080], domains: ['example.com', '*.example.com'] },
// }, action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
},
// Example: Add email configuration {
// emailConfig: { name: 'api-gateway',
// ports: [2525], match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
// hostname: 'localhost', action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
// domains: [], },
// routes: [] {
// }, name: 'tls-passthrough',
match: { ports: [18443], domains: ['secure.example.com'] },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 4443 }],
tls: { mode: 'passthrough' },
},
},
],
},
// Disable cache/mongo for dev
cacheConfig: { enabled: false },
}); });
console.log('Starting DcRouter in development mode...'); console.log('Starting DcRouter in development mode...');

View File

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

View File

@@ -0,0 +1,137 @@
import { logger } from './logger.js';
import type { StorageManager } from './storage/index.js';
interface IBackoffEntry {
failures: number;
lastFailure: string; // ISO string
retryAfter: string; // ISO string
lastError?: string;
}
/**
* Manages certificate provisioning scheduling with:
* - Per-domain exponential backoff persisted in StorageManager
*
* Note: Serial stagger queue was removed — smartacme v9 handles
* concurrency, per-domain dedup, and rate limiting internally.
*/
export class CertProvisionScheduler {
private storageManager: StorageManager;
private maxBackoffHours: number;
// In-memory backoff cache (mirrors storage for fast lookups)
private backoffCache = new Map<string, IBackoffEntry>();
constructor(
storageManager: StorageManager,
options?: { maxBackoffHours?: number }
) {
this.storageManager = storageManager;
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
}
/**
* Storage key for a domain's backoff entry
*/
private backoffKey(domain: string): string {
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
return `/cert-backoff/${clean}`;
}
/**
* Load backoff entry from storage (with in-memory cache)
*/
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
const cached = this.backoffCache.get(domain);
if (cached) return cached;
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
if (entry) {
this.backoffCache.set(domain, entry);
}
return entry;
}
/**
* Save backoff entry to both cache and storage
*/
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
this.backoffCache.set(domain, entry);
await this.storageManager.setJSON(this.backoffKey(domain), entry);
}
/**
* Check if a domain is currently in backoff
*/
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();
}
/**
* Record a provisioning failure for a domain.
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
*/
async recordFailure(domain: string, error?: string): Promise<void> {
const existing = await this.loadBackoff(domain);
const failures = (existing?.failures ?? 0) + 1;
// Exponential backoff: failures^2 hours, capped
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
const entry: IBackoffEntry = {
failures,
lastFailure: new Date().toISOString(),
retryAfter: retryAfter.toISOString(),
lastError: error,
};
await this.saveBackoff(domain, entry);
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
}
/**
* Clear backoff for a domain (on success or manual override)
*/
async clearBackoff(domain: string): Promise<void> {
this.backoffCache.delete(domain);
try {
await this.storageManager.delete(this.backoffKey(domain));
} catch {
// Ignore delete errors (key may not exist)
}
}
/**
* Clear all in-memory backoff cache entries
*/
public clear(): void {
this.backoffCache.clear();
}
/**
* Get backoff info for UI display
*/
async getBackoffInfo(domain: string): Promise<{
failures: number;
retryAfter?: string;
lastError?: string;
} | null> {
const entry = await this.loadBackoff(domain);
if (!entry) return null;
// Only return if still in backoff
const retryAfter = new Date(entry.retryAfter);
if (retryAfter.getTime() <= Date.now()) return null;
return {
failures: entry.failures,
retryAfter: entry.retryAfter,
lastError: entry.lastError,
};
}
}

View File

@@ -13,14 +13,21 @@ import {
import { logger } from './logger.js'; import { logger } from './logger.js';
// Import storage manager // Import storage manager
import { StorageManager, type IStorageConfig } from './storage/index.js'; import { StorageManager, type IStorageConfig } from './storage/index.js';
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
// Import cache system // Import cache system
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js'; import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
import { OpsServer } from './opsserver/index.js'; import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js'; import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
export interface IDcRouterOptions { export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
baseDir?: string;
/** /**
* Direct SmartProxy configuration - gives full control over HTTP/HTTPS and TCP/SNI traffic * Direct SmartProxy configuration - gives full control over HTTP/HTTPS and TCP/SNI traffic
* This is the preferred way to configure HTTP/HTTPS and general TCP/SNI traffic * This is the preferred way to configure HTTP/HTTPS and general TCP/SNI traffic
@@ -150,6 +157,24 @@ export interface IDcRouterOptions {
* Enables MAC Authentication Bypass (MAB) and VLAN assignment * Enables MAC Authentication Bypass (MAB) and VLAN assignment
*/ */
radiusConfig?: IRadiusServerConfig; radiusConfig?: IRadiusServerConfig;
/**
* Remote Ingress configuration for edge tunnel nodes
* Enables edge nodes to accept incoming connections and tunnel them to this DcRouter
*/
remoteIngressConfig?: {
/** Enable remote ingress hub (default: false) */
enabled?: boolean;
/** Port for tunnel connections from edge nodes (default: 8443) */
tunnelPort?: number;
/** External hostname of this hub, embedded in connection tokens */
hubDomain?: string;
/** TLS configuration for the tunnel server */
tls?: {
certPath?: string;
keyPath?: string;
};
};
} }
/** /**
@@ -168,6 +193,7 @@ export interface PortProxyRuleContext {
export class DcRouter { export class DcRouter {
public options: IDcRouterOptions; public options: IDcRouterOptions;
public resolvedPaths: ReturnType<typeof paths.resolvePaths>;
// Core services // Core services
public smartProxy?: plugins.smartproxy.SmartProxy; public smartProxy?: plugins.smartproxy.SmartProxy;
@@ -183,19 +209,38 @@ export class DcRouter {
public cacheDb?: CacheDb; public cacheDb?: CacheDb;
public cacheCleaner?: CacheCleaner; public cacheCleaner?: CacheCleaner;
// Certificate status tracking from SmartProxy events // Remote Ingress
public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager;
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
// DNS query logging rate limiter state
private dnsLogWindow: number[] = [];
private dnsBatchCount: number = 0;
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
// Certificate status tracking from SmartProxy events (keyed by domain)
public certificateStatusMap = new Map<string, { public certificateStatusMap = new Map<string, {
status: 'valid' | 'failed'; status: 'valid' | 'failed';
domain: string; routeNames: string[];
expiryDate?: string; expiryDate?: string;
issuedAt?: string; issuedAt?: string;
source?: string; source?: string;
error?: string; error?: string;
}>(); }>();
// Certificate provisioning scheduler with per-domain backoff
public certProvisionScheduler?: CertProvisionScheduler;
// TypedRouter for API endpoints // TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access // Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/'); private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -205,14 +250,22 @@ export class DcRouter {
...optionsArg ...optionsArg
}; };
// Resolve all data paths from baseDir
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
// Default storage to filesystem if not configured
if (!this.options.storage) {
this.options.storage = {
fsPath: this.resolvedPaths.defaultStoragePath,
};
}
// Initialize storage manager // Initialize storage manager
this.storageManager = new StorageManager(this.options.storage); this.storageManager = new StorageManager(this.options.storage);
} }
public async start() { public async start() {
console.log('╔═══════════════════════════════════════════════════════════════════╗'); logger.log('info', 'Starting DcRouter Services');
console.log('║ Starting DcRouter Services ║');
console.log('╚═══════════════════════════════════════════════════════════════════╝');
this.opsServer = new OpsServer(this); this.opsServer = new OpsServer(this);
@@ -231,6 +284,16 @@ export class DcRouter {
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes // Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
await this.setupSmartProxy(); await this.setupSmartProxy();
// Initialize programmatic config API managers
this.routeConfigManager = new RouteConfigManager(
this.storageManager,
() => this.getConstructorRoutes(),
() => this.smartProxy,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize();
// Set up unified email handling if configured // Set up unified email handling if configured
if (this.options.emailConfig) { if (this.options.emailConfig) {
await this.setupUnifiedEmailHandling(); await this.setupUnifiedEmailHandling();
@@ -247,9 +310,14 @@ export class DcRouter {
await this.setupRadiusServer(); await this.setupRadiusServer();
} }
// Set up Remote Ingress hub if configured
if (this.options.remoteIngressConfig?.enabled) {
await this.setupRemoteIngress();
}
this.logStartupSummary(); this.logStartupSummary();
} catch (error) { } catch (error) {
console.error('❌ Error starting DcRouter:', error); logger.log('error', 'Error starting DcRouter', { error: String(error) });
// Try to clean up any services that may have started // Try to clean up any services that may have started
await this.stop(); await this.stop();
throw error; throw error;
@@ -260,94 +328,60 @@ export class DcRouter {
* Log comprehensive startup summary * Log comprehensive startup summary
*/ */
private logStartupSummary(): void { private logStartupSummary(): void {
console.log('\n╔═══════════════════════════════════════════════════════════════════╗'); logger.log('info', 'DcRouter Started Successfully');
console.log('║ DcRouter Started Successfully ║');
console.log('╚═══════════════════════════════════════════════════════════════════╝\n');
// Metrics summary // Metrics summary
if (this.metricsManager) { if (this.metricsManager) {
console.log('📊 Metrics Service:'); logger.log('info', 'Metrics Service: SmartMetrics active, SmartProxy stats active, real-time tracking enabled');
console.log(' ├─ SmartMetrics: Active');
console.log(' ├─ SmartProxy Stats: Active');
console.log(' └─ Real-time tracking: Enabled');
} }
// SmartProxy summary // SmartProxy summary
if (this.smartProxy) { if (this.smartProxy) {
console.log('🌐 SmartProxy Service:');
const routeCount = this.options.smartProxyConfig?.routes?.length || 0; const routeCount = this.options.smartProxyConfig?.routes?.length || 0;
console.log(` ├─ Routes configured: ${routeCount}`); const acmeEnabled = this.options.smartProxyConfig?.acme?.enabled || false;
console.log(` ├─ ACME enabled: ${this.options.smartProxyConfig?.acme?.enabled || false}`); const acmeMode = acmeEnabled
if (this.options.smartProxyConfig?.acme?.enabled) { ? `email=${this.options.smartProxyConfig!.acme!.email || 'not set'}, mode=${this.options.smartProxyConfig!.acme!.useProduction ? 'production' : 'staging'}`
console.log(` ├─ ACME email: ${this.options.smartProxyConfig.acme.email || 'not set'}`); : 'disabled';
console.log(` └─ ACME mode: ${this.options.smartProxyConfig.acme.useProduction ? 'production' : 'staging'}`); logger.log('info', `SmartProxy Service: ${routeCount} routes, ACME: ${acmeMode}`);
} else {
console.log(' └─ ACME: disabled');
}
} }
// Email service summary // Email service summary
if (this.emailServer && this.options.emailConfig) { if (this.emailServer && this.options.emailConfig) {
console.log('\n📧 Email Service:');
const ports = this.options.emailConfig.ports || []; const ports = this.options.emailConfig.ports || [];
console.log(` ├─ Ports: ${ports.join(', ')}`); const domainCount = this.options.emailConfig.domains?.length || 0;
console.log(` ├─ Hostname: ${this.options.emailConfig.hostname || 'localhost'}`); const domainNames = this.options.emailConfig.domains?.map(d => `${d.domain} (${d.dnsMode || 'default'})`).join(', ') || 'none';
console.log(` ├─ Domains configured: ${this.options.emailConfig.domains?.length || 0}`); logger.log('info', `Email Service: ports=[${ports.join(', ')}], hostname=${this.options.emailConfig.hostname || 'localhost'}, domains=${domainCount} [${domainNames}], DKIM initialized`);
if (this.options.emailConfig.domains && this.options.emailConfig.domains.length > 0) {
this.options.emailConfig.domains.forEach((domain, index) => {
const isLast = index === this.options.emailConfig!.domains!.length - 1;
console.log(` ${isLast ? '└─' : '├─'} ${domain.domain} (${domain.dnsMode || 'default'})`);
});
}
console.log(` └─ DKIM: Initialized for all domains`);
} }
// DNS service summary // DNS service summary
if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) { if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) {
console.log('\n🌍 DNS Service:'); logger.log('info', `DNS Service: nameservers=[${this.options.dnsNsDomains.join(', ')}], authoritative for ${this.options.dnsScopes.length} domains [${this.options.dnsScopes.join(', ')}], UDP:53, DoH enabled`);
console.log(` ├─ Nameservers: ${this.options.dnsNsDomains.join(', ')}`);
console.log(` ├─ Primary NS: ${this.options.dnsNsDomains[0]}`);
console.log(` ├─ Authoritative for: ${this.options.dnsScopes.length} domains`);
console.log(` ├─ UDP Port: 53`);
console.log(` ├─ DNS-over-HTTPS: Enabled via socket handler`);
console.log(` └─ DNSSEC: ${this.options.dnsNsDomains[0] ? 'Enabled' : 'Disabled'}`);
// Show authoritative domains
if (this.options.dnsScopes.length > 0) {
console.log('\n Authoritative Domains:');
this.options.dnsScopes.forEach((domain, index) => {
const isLast = index === this.options.dnsScopes!.length - 1;
console.log(` ${isLast ? '└─' : '├─'} ${domain}`);
});
}
} }
// RADIUS service summary // RADIUS service summary
if (this.radiusServer && this.options.radiusConfig) { if (this.radiusServer && this.options.radiusConfig) {
console.log('\n🔐 RADIUS Service:');
console.log(` ├─ Auth Port: ${this.options.radiusConfig.authPort || 1812}`);
console.log(` ├─ Acct Port: ${this.options.radiusConfig.acctPort || 1813}`);
console.log(` ├─ Clients configured: ${this.options.radiusConfig.clients?.length || 0}`);
const vlanStats = this.radiusServer.getVlanManager().getStats(); const vlanStats = this.radiusServer.getVlanManager().getStats();
console.log(` ├─ VLAN mappings: ${vlanStats.totalMappings}`); logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`); }
// Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
const connectedCount = this.tunnelManager.getConnectedCount();
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
} }
// Storage summary // Storage summary
if (this.storageManager && this.options.storage) { if (this.storageManager && this.options.storage) {
console.log('\n💾 Storage:'); logger.log('info', `Storage: path=${this.options.storage.fsPath || 'default'}`);
console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`);
} }
// Cache database summary // Cache database summary
if (this.cacheDb) { if (this.cacheDb) {
console.log('\n🗄 Cache Database (smartdata + LocalTsmDb):'); logger.log('info', `Cache Database: storage=${this.cacheDb.getStoragePath()}, db=${this.cacheDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
console.log(` ├─ Storage: ${this.cacheDb.getStoragePath()}`);
console.log(` ├─ Database: ${this.cacheDb.getDbName()}`);
console.log(` └─ Cleaner: ${this.cacheCleaner?.isActive() ? 'Active' : 'Inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
} }
console.log('\n✅ All services are running\n'); logger.log('info', 'All services are running');
} }
/** /**
@@ -360,7 +394,7 @@ export class DcRouter {
// Initialize CacheDb singleton // Initialize CacheDb singleton
this.cacheDb = CacheDb.getInstance({ this.cacheDb = CacheDb.getInstance({
storagePath: cacheConfig.storagePath || paths.defaultTsmDbPath, storagePath: cacheConfig.storagePath || this.resolvedPaths.defaultTsmDbPath,
dbName: cacheConfig.dbName || 'dcrouter', dbName: cacheConfig.dbName || 'dcrouter',
debug: false, debug: false,
}); });
@@ -382,7 +416,7 @@ export class DcRouter {
* Set up SmartProxy with direct configuration and automatic email routes * Set up SmartProxy with direct configuration and automatic email routes
*/ */
private async setupSmartProxy(): Promise<void> { private async setupSmartProxy(): Promise<void> {
console.log('[DcRouter] Setting up SmartProxy...'); logger.log('info', 'Setting up SmartProxy...');
let routes: plugins.smartproxy.IRouteConfig[] = []; let routes: plugins.smartproxy.IRouteConfig[] = [];
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined; let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
@@ -390,22 +424,20 @@ export class DcRouter {
if (this.options.smartProxyConfig) { if (this.options.smartProxyConfig) {
routes = this.options.smartProxyConfig.routes || []; routes = this.options.smartProxyConfig.routes || [];
acmeConfig = this.options.smartProxyConfig.acme; acmeConfig = this.options.smartProxyConfig.acme;
console.log(`[DcRouter] Found ${routes.length} routes in config`); logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
console.log(`[DcRouter] ACME config present: ${!!acmeConfig}`);
} }
// If email config exists, automatically add email routes // If email config exists, automatically add email routes
if (this.options.emailConfig) { if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig); const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
console.log(`Email Routes are:`) logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
console.log(emailRoutes)
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
} }
// If DNS is configured, add DNS routes // If DNS is configured, add DNS routes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes(); const dnsRoutes = this.generateDnsRoutes();
console.log(`DNS Routes for nameservers ${this.options.dnsNsDomains.join(', ')}:`, dnsRoutes); logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
routes = [...routes, ...dnsRoutes]; routes = [...routes, ...dnsRoutes];
} }
@@ -423,43 +455,100 @@ export class DcRouter {
// Configure DNS challenge if available // Configure DNS challenge if available
let challengeHandlers: any[] = []; let challengeHandlers: any[] = [];
if (this.options.dnsChallenge?.cloudflareApiKey) { if (this.options.dnsChallenge?.cloudflareApiKey) {
console.log('Configuring Cloudflare DNS challenge for ACME'); logger.log('info', 'Configuring Cloudflare DNS challenge for ACME');
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey); const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount); const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
challengeHandlers.push(dns01Handler); challengeHandlers.push(dns01Handler);
} }
// Cache constructor routes for RouteConfigManager
this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it // If we have routes or need a basic SmartProxy instance, create it
if (routes.length > 0 || this.options.smartProxyConfig) { if (routes.length > 0 || this.options.smartProxyConfig) {
console.log('Setting up SmartProxy with combined configuration'); logger.log('info', 'Setting up SmartProxy with combined configuration');
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
// Create SmartProxy configuration // Create SmartProxy configuration
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = { const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
...this.options.smartProxyConfig, ...this.options.smartProxyConfig,
routes, routes,
acme: acmeConfig acme: acmeConfig,
certStore: {
loadAll: async () => {
const keys = await this.storageManager.list('/proxy-certs/');
const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = [];
for (const key of keys) {
const data = await this.storageManager.getJSON(key);
if (data) {
certs.push(data);
loadedCertEntries.push({ domain: data.domain, publicKey: data.publicKey, validUntil: data.validUntil, validFrom: data.validFrom });
}
}
return certs;
},
save: async (domain: string, publicKey: string, privateKey: string, ca?: string) => {
let validUntil: number | undefined;
let validFrom: number | undefined;
try {
const x509 = new plugins.crypto.X509Certificate(publicKey);
validUntil = new Date(x509.validTo).getTime();
validFrom = new Date(x509.validFrom).getTime();
} catch { /* PEM parsing failed */ }
await this.storageManager.setJSON(`/proxy-certs/${domain}`, {
domain, publicKey, privateKey, ca, validUntil, validFrom,
});
},
remove: async (domain: string) => {
await this.storageManager.delete(`/proxy-certs/${domain}`);
},
},
}; };
// 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 and wire to certProvisionFunction
if (challengeHandlers.length > 0) { if (challengeHandlers.length > 0) {
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
if (this.smartAcme) {
await this.smartAcme.stop().catch(err =>
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
);
}
this.smartAcme = new plugins.smartacme.SmartAcme({ this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com', accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), certManager: new StorageBackedCertManager(this.storageManager),
environment: 'production', environment: 'production',
challengeHandlers: challengeHandlers, challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'], challengePriority: ['dns-01'],
}); });
await this.smartAcme.start(); await this.smartAcme.start();
const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => { smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
// Check backoff before attempting provision
if (await scheduler.isInBackoff(domain)) {
const info = await scheduler.getBackoffInfo(domain);
const msg = `Domain ${domain} is in backoff (${info?.failures} failures), retry after ${info?.retryAfter}`;
eventComms.warn(msg);
throw new Error(msg);
}
try { try {
// smartacme v9 handles concurrency, per-domain dedup, and rate limiting internally
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`); eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
eventComms.setSource('smartacme-dns-01'); eventComms.setSource('smartacme-dns-01');
const cert = await this.smartAcme.getCertificateForDomain(domain); const isWildcardDomain = domain.startsWith('*.');
const cert = await this.smartAcme.getCertificateForDomain(domain, {
includeWildcard: !isWildcardDomain,
});
if (cert.validUntil) { if (cert.validUntil) {
eventComms.setExpiryDate(new Date(cert.validUntil)); eventComms.setExpiryDate(new Date(cert.validUntil));
} }
return { const result = {
id: cert.id, id: cert.id,
domainName: cert.domainName, domainName: cert.domainName,
created: cert.created, created: cert.created,
@@ -468,71 +557,126 @@ export class DcRouter {
publicKey: cert.publicKey, publicKey: cert.publicKey,
csr: cert.csr, csr: cert.csr,
}; };
// Success — clear any backoff
await scheduler.clearBackoff(domain);
return result;
} catch (err) { } catch (err) {
// 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`); eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`);
return 'http01'; return 'http01';
} }
}; };
} }
// When remoteIngress is enabled, the hub binary forwards tunneled connections
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
if (this.options.remoteIngressConfig?.enabled) {
smartProxyConfig.acceptProxyProtocol = true;
smartProxyConfig.proxyIPs = ['127.0.0.1'];
}
// Create SmartProxy instance // Create SmartProxy instance
console.log('[DcRouter] Creating SmartProxy instance with config:', JSON.stringify({ logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
routeCount: smartProxyConfig.routes?.length,
acmeEnabled: smartProxyConfig.acme?.enabled,
acmeEmail: smartProxyConfig.acme?.email,
certProvisionFunction: !!smartProxyConfig.certProvisionFunction
}, null, 2));
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig); this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
// Set up event listeners // Set up event listeners
this.smartProxy.on('error', (err) => { this.smartProxy.on('error', (err) => {
console.error('[DcRouter] SmartProxy error:', err); logger.log('error', `SmartProxy error: ${err.message}`, { stack: err.stack });
console.error('[DcRouter] Error stack:', err.stack);
}); });
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths // Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
// Events are keyed by domain for domain-centric certificate tracking
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => { this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
console.log(`[DcRouter] Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`); logger.log('info', `Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
const routeName = this.findRouteNameForDomain(event.domain); const routeNames = this.findRouteNamesForDomain(event.domain);
if (routeName) { this.certificateStatusMap.set(event.domain, {
this.certificateStatusMap.set(routeName, { status: 'valid', routeNames,
status: 'valid', domain: event.domain, expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(), source: event.source,
source: event.source, });
});
}
}); });
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => { this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
console.log(`[DcRouter] Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`); logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
const routeName = this.findRouteNameForDomain(event.domain); const routeNames = this.findRouteNamesForDomain(event.domain);
if (routeName) { this.certificateStatusMap.set(event.domain, {
this.certificateStatusMap.set(routeName, { status: 'valid', routeNames,
status: 'valid', domain: event.domain, expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(), source: event.source,
source: event.source, });
});
}
}); });
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => { this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
console.error(`[DcRouter] Certificate failed for ${event.domain} (${event.source}):`, event.error); logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
const routeName = this.findRouteNameForDomain(event.domain); const routeNames = this.findRouteNamesForDomain(event.domain);
if (routeName) { this.certificateStatusMap.set(event.domain, {
this.certificateStatusMap.set(routeName, { status: 'failed', routeNames, error: event.error,
status: 'failed', domain: event.domain, error: event.error, source: event.source,
source: event.source, });
});
}
}); });
// Start SmartProxy // Start SmartProxy
console.log('[DcRouter] Starting SmartProxy...'); logger.log('info', 'Starting SmartProxy...');
await this.smartProxy.start(); await this.smartProxy.start();
console.log('[DcRouter] SmartProxy started successfully'); logger.log('info', 'SmartProxy started successfully');
console.log(`SmartProxy started with ${routes.length} routes`); // Populate certificateStatusMap for certs loaded from store at startup
for (const entry of loadedCertEntries) {
if (!this.certificateStatusMap.has(entry.domain)) {
const routeNames = this.findRouteNamesForDomain(entry.domain);
let expiryDate: string | undefined;
let issuedAt: string | undefined;
// Use validUntil/validFrom from stored proxy-certs data if available
if (entry.validUntil) {
expiryDate = new Date(entry.validUntil).toISOString();
}
if (entry.validFrom) {
issuedAt = new Date(entry.validFrom).toISOString();
}
// Try SmartAcme /certs/ metadata as secondary source
if (!expiryDate) {
try {
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
const certMeta = await this.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certMeta?.validUntil) {
expiryDate = new Date(certMeta.validUntil).toISOString();
}
if (certMeta?.created && !issuedAt) {
issuedAt = new Date(certMeta.created).toISOString();
}
} catch { /* no metadata available */ }
}
// Fallback: parse X509 from PEM to get expiry
if (!expiryDate && entry.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(entry.publicKey);
expiryDate = new Date(x509.validTo).toISOString();
if (!issuedAt) {
issuedAt = new Date(x509.validFrom).toISOString();
}
} catch { /* PEM parsing failed */ }
}
this.certificateStatusMap.set(entry.domain, {
status: 'valid',
routeNames,
expiryDate,
issuedAt,
source: 'cert-store',
});
}
}
if (loadedCertEntries.length > 0) {
logger.log('info', `Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`);
}
logger.log('info', `SmartProxy started with ${routes.length} routes`);
} }
} }
@@ -697,7 +841,7 @@ export class DcRouter {
} }
/** /**
* Find the route name that matches a given domain * Find the first route name that matches a given domain
*/ */
private findRouteNameForDomain(domain: string): string | undefined { private findRouteNameForDomain(domain: string): string | undefined {
if (!this.smartProxy) return undefined; if (!this.smartProxy) return undefined;
@@ -713,8 +857,48 @@ export class DcRouter {
return undefined; return undefined;
} }
/**
* Find ALL route names that match a given domain
*/
public findRouteNamesForDomain(domain: string): string[] {
if (!this.smartProxy) return [];
const names: string[] = [];
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)) {
names.push(route.name);
break; // This route already matched, no need to check other patterns
}
}
}
return names;
}
/**
* Get the routes derived from constructor config (smartProxy + email + DNS).
* Used by RouteConfigManager as the "hardcoded" base.
*/
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
return this.constructorRoutes;
}
public async stop() { public async stop() {
console.log('Stopping DcRouter services...'); logger.log('info', 'Stopping DcRouter services...');
// Flush pending DNS batch log
if (this.dnsBatchTimer) {
clearTimeout(this.dnsBatchTimer);
if (this.dnsBatchCount > 0) {
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited, final flush)`, { zone: 'dns' });
}
this.dnsBatchTimer = null;
this.dnsBatchCount = 0;
this.dnsLogWindow = [];
}
await this.opsServer.stop(); await this.opsServer.stop();
@@ -725,36 +909,62 @@ export class DcRouter {
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(), this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
// Stop metrics manager if running // Stop metrics manager if running
this.metricsManager ? this.metricsManager.stop().catch(err => console.error('Error stopping MetricsManager:', err)) : Promise.resolve(), this.metricsManager ? this.metricsManager.stop().catch(err => logger.log('error', 'Error stopping MetricsManager', { error: String(err) })) : Promise.resolve(),
// Stop unified email server if running // Stop unified email server if running
this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(), this.emailServer ? this.emailServer.stop().catch(err => logger.log('error', 'Error stopping email server', { error: String(err) })) : Promise.resolve(),
// Stop SmartAcme if running // Stop SmartAcme if running
this.smartAcme ? this.smartAcme.stop().catch(err => console.error('Error stopping SmartAcme:', err)) : Promise.resolve(), this.smartAcme ? this.smartAcme.stop().catch(err => logger.log('error', 'Error stopping SmartAcme', { error: String(err) })) : Promise.resolve(),
// Stop HTTP SmartProxy if running // Stop HTTP SmartProxy if running
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(), this.smartProxy ? this.smartProxy.stop().catch(err => logger.log('error', 'Error stopping SmartProxy', { error: String(err) })) : Promise.resolve(),
// Stop DNS server if running // Stop DNS server if running
this.dnsServer ? this.dnsServer ?
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) : this.dnsServer.stop().catch(err => logger.log('error', 'Error stopping DNS server', { error: String(err) })) :
Promise.resolve(), Promise.resolve(),
// Stop RADIUS server if running // Stop RADIUS server if running
this.radiusServer ? this.radiusServer ?
this.radiusServer.stop().catch(err => console.error('Error stopping RADIUS server:', err)) : this.radiusServer.stop().catch(err => logger.log('error', 'Error stopping RADIUS server', { error: String(err) })) :
Promise.resolve(),
// 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() Promise.resolve()
]); ]);
// Stop cache database after other services (they may need it during shutdown) // Stop cache database after other services (they may need it during shutdown)
if (this.cacheDb) { if (this.cacheDb) {
await this.cacheDb.stop().catch(err => console.error('Error stopping CacheDb:', err)); await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
} }
console.log('All DcRouter services stopped'); // Clear backoff cache in cert scheduler
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
// Allow GC of stopped services by nulling references
this.smartProxy = undefined;
this.emailServer = undefined;
this.dnsServer = undefined;
this.metricsManager = undefined;
this.cacheCleaner = undefined;
this.cacheDb = undefined;
this.tunnelManager = undefined;
this.radiusServer = undefined;
this.smartAcme = undefined;
this.certProvisionScheduler = undefined;
this.remoteIngressManager = undefined;
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.certificateStatusMap.clear();
logger.log('info', 'All DcRouter services stopped');
} catch (error) { } catch (error) {
console.error('Error during DcRouter shutdown:', error); logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
throw error; throw error;
} }
} }
@@ -773,10 +983,20 @@ export class DcRouter {
// Update configuration // Update configuration
this.options.smartProxyConfig = config; this.options.smartProxyConfig = config;
// Update routes on RemoteIngressManager so derived ports stay in sync
if (this.remoteIngressManager && config.routes) {
this.remoteIngressManager.setRoutes(config.routes as any[]);
}
// Start new SmartProxy with updated configuration (will include email routes if configured) // Start new SmartProxy with updated configuration (will include email routes if configured)
await this.setupSmartProxy(); await this.setupSmartProxy();
console.log('SmartProxy configuration updated'); // Re-apply programmatic routes and overrides after SmartProxy restart
if (this.routeConfigManager) {
await this.routeConfigManager.initialize();
}
logger.log('info', 'SmartProxy configuration updated');
} }
@@ -834,6 +1054,28 @@ export class DcRouter {
// Start the server // Start the server
await this.emailServer.start(); await this.emailServer.start();
// Wire delivery events to MetricsManager and logger
if (this.metricsManager && this.emailServer.deliverySystem) {
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
this.metricsManager.trackEmailReceived(item?.from);
logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
this.metricsManager.trackEmailSent(item?.to);
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
this.metricsManager.trackEmailFailed(item?.to, error?.message);
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
});
}
if (this.metricsManager && this.emailServer) {
this.emailServer.on('bounceProcessed', () => {
this.metricsManager.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' });
});
}
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`); logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
} }
@@ -851,7 +1093,7 @@ export class DcRouter {
// Start email handling with new configuration // Start email handling with new configuration
await this.setupUnifiedEmailHandling(); await this.setupUnifiedEmailHandling();
console.log('Unified email configuration updated'); logger.log('info', 'Unified email configuration updated');
} }
/** /**
@@ -891,7 +1133,7 @@ export class DcRouter {
this.emailServer.updateEmailRoutes(routes); this.emailServer.updateEmailRoutes(routes);
} }
console.log(`Email routes updated with ${routes.length} routes`); logger.log('info', `Email routes updated with ${routes.length} routes`);
} }
/** /**
@@ -1017,6 +1259,45 @@ export class DcRouter {
await this.dnsServer.start(); await this.dnsServer.start();
logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`); logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`);
// Wire DNS query events to MetricsManager and logger with adaptive rate limiting
if (this.metricsManager && this.dnsServer) {
const flushDnsBatch = () => {
if (this.dnsBatchCount > 0) {
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited)`, { zone: 'dns' });
this.dnsBatchCount = 0;
}
this.dnsBatchTimer = null;
};
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
// Metrics tracking
for (const question of event.questions) {
this.metricsManager.trackDnsQuery(
question.type,
question.name,
false,
event.responseTimeMs,
event.answered,
);
}
// Adaptive logging: individual logs up to 2/sec, then batch
const now = Date.now();
this.dnsLogWindow = this.dnsLogWindow.filter(t => now - t < 1000);
if (this.dnsLogWindow.length < 2) {
this.dnsLogWindow.push(now);
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
} else {
this.dnsBatchCount++;
if (!this.dnsBatchTimer) {
this.dnsBatchTimer = setTimeout(flushDnsBatch, 5000);
}
}
});
}
// Validate DNS configuration // Validate DNS configuration
await this.validateDnsConfiguration(); await this.validateDnsConfiguration();
@@ -1171,7 +1452,7 @@ export class DcRouter {
try { try {
// Ensure paths are imported // Ensure paths are imported
const dnsDir = paths.dnsRecordsDir; const dnsDir = this.resolvedPaths.dnsRecordsDir;
// Check if directory exists // Check if directory exists
if (!plugins.fs.existsSync(dnsDir)) { if (!plugins.fs.existsSync(dnsDir)) {
@@ -1235,7 +1516,7 @@ export class DcRouter {
} }
// Ensure necessary directories exist // Ensure necessary directories exist
paths.ensureDirectories(); paths.ensureDataDirectories(this.resolvedPaths);
// Generate DKIM keys for each email domain // Generate DKIM keys for each email domain
for (const domainConfig of this.options.emailConfig.domains) { for (const domainConfig of this.options.emailConfig.domains) {
@@ -1390,6 +1671,35 @@ export class DcRouter {
} }
} }
/**
* Set up Remote Ingress hub for edge tunnel connections
*/
private async setupRemoteIngress(): Promise<void> {
if (!this.options.remoteIngressConfig?.enabled) {
return;
}
logger.log('info', 'Setting up Remote Ingress hub...');
// Initialize the edge registration manager
this.remoteIngressManager = new RemoteIngressManager(this.storageManager);
await this.remoteIngressManager.initialize();
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
const currentRoutes = this.options.smartProxyConfig?.routes || [];
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
// Create and start the tunnel manager
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
targetHost: '127.0.0.1',
});
await this.tunnelManager.start();
const edgeCount = this.remoteIngressManager.getAllEdges().length;
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
}
/** /**
* Set up RADIUS server for network authentication * Set up RADIUS server for network authentication
*/ */

View File

@@ -0,0 +1,46 @@
import * as plugins from './plugins.js';
import { StorageManager } from './storage/index.js';
/**
* ICertManager implementation backed by StorageManager.
* Persists SmartAcme certificates under a /certs/ key prefix so they
* survive process restarts without re-hitting ACME.
*/
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
private keyPrefix = '/certs/';
constructor(private storageManager: StorageManager) {}
async init(): Promise<void> {}
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
if (!data) return null;
return new plugins.smartacme.Cert(data);
}
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr,
validUntil: cert.validUntil,
});
}
async deleteCertificate(domainName: string): Promise<void> {
await this.storageManager.delete(this.keyPrefix + domainName);
}
async close(): Promise<void> {}
async wipe(): Promise<void> {
const keys = await this.storageManager.list(this.keyPrefix);
for (const key of keys) {
await this.storageManager.delete(key);
}
}
}

View File

@@ -0,0 +1,155 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import type {
IStoredApiToken,
IApiTokenInfo,
TApiTokenScope,
} from '../../ts_interfaces/data/route-management.js';
const TOKENS_PREFIX = '/config-api/tokens/';
const TOKEN_PREFIX_STR = 'dcr_';
export class ApiTokenManager {
private tokens = new Map<string, IStoredApiToken>();
constructor(private storageManager: StorageManager) {}
public async initialize(): Promise<void> {
await this.loadTokens();
if (this.tokens.size > 0) {
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
}
}
// =========================================================================
// Token lifecycle
// =========================================================================
/**
* Create a new API token. Returns the raw token value (shown once).
*/
public async createToken(
name: string,
scopes: TApiTokenScope[],
expiresInDays: number | null,
createdBy: string,
): Promise<{ id: string; rawToken: string }> {
const id = plugins.uuid.v4();
const randomBytes = plugins.crypto.randomBytes(32);
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
const now = Date.now();
const stored: IStoredApiToken = {
id,
name,
tokenHash,
scopes,
createdAt: now,
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
lastUsedAt: null,
createdBy,
enabled: true,
};
this.tokens.set(id, stored);
await this.persistToken(stored);
logger.log('info', `API token '${name}' created (id: ${id})`);
return { id, rawToken };
}
/**
* Validate a raw token string. Returns the stored token if valid, null otherwise.
* Also updates lastUsedAt.
*/
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
for (const stored of this.tokens.values()) {
if (stored.tokenHash === hash) {
if (!stored.enabled) return null;
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
// Update lastUsedAt (fire and forget)
stored.lastUsedAt = Date.now();
this.persistToken(stored).catch(() => {});
return stored;
}
}
return null;
}
/**
* Check if a token has a specific scope.
*/
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
return token.scopes.includes(scope);
}
/**
* List all tokens (safe info only, no hashes).
*/
public listTokens(): IApiTokenInfo[] {
const result: IApiTokenInfo[] = [];
for (const stored of this.tokens.values()) {
result.push({
id: stored.id,
name: stored.name,
scopes: stored.scopes,
createdAt: stored.createdAt,
expiresAt: stored.expiresAt,
lastUsedAt: stored.lastUsedAt,
enabled: stored.enabled,
});
}
return result;
}
/**
* Revoke (delete) a token.
*/
public async revokeToken(id: string): Promise<boolean> {
if (!this.tokens.has(id)) return false;
const token = this.tokens.get(id)!;
this.tokens.delete(id);
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
return true;
}
/**
* Enable or disable a token.
*/
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
const stored = this.tokens.get(id);
if (!stored) return false;
stored.enabled = enabled;
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
return true;
}
// =========================================================================
// Private
// =========================================================================
private async loadTokens(): Promise<void> {
const keys = await this.storageManager.list(TOKENS_PREFIX);
for (const key of keys) {
if (!key.endsWith('.json')) continue;
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
if (stored?.id) {
this.tokens.set(stored.id, stored);
}
}
}
private async persistToken(stored: IStoredApiToken): Promise<void> {
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
}
}

View File

@@ -0,0 +1,271 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import type {
IStoredRoute,
IRouteOverride,
IMergedRoute,
IRouteWarning,
} from '../../ts_interfaces/data/route-management.js';
const ROUTES_PREFIX = '/config-api/routes/';
const OVERRIDES_PREFIX = '/config-api/overrides/';
export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>();
private overrides = new Map<string, IRouteOverride>();
private warnings: IRouteWarning[] = [];
constructor(
private storageManager: StorageManager,
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
) {}
/**
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
*/
public async initialize(): Promise<void> {
await this.loadStoredRoutes();
await this.loadOverrides();
this.computeWarnings();
this.logWarnings();
await this.applyRoutes();
}
// =========================================================================
// Merged view
// =========================================================================
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
const merged: IMergedRoute[] = [];
// Hardcoded routes
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
merged.push({
route,
source: 'hardcoded',
enabled: override ? override.enabled : true,
overridden: !!override,
});
}
// Programmatic routes
for (const stored of this.storedRoutes.values()) {
merged.push({
route: stored.route,
source: 'programmatic',
enabled: stored.enabled,
overridden: false,
storedRouteId: stored.id,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
});
}
return { routes: merged, warnings: [...this.warnings] };
}
// =========================================================================
// Programmatic route CRUD
// =========================================================================
public async createRoute(
route: plugins.smartproxy.IRouteConfig,
createdBy: string,
enabled = true,
): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
// Ensure route has a name
if (!route.name) {
route.name = `programmatic-${id.slice(0, 8)}`;
}
const stored: IStoredRoute = {
id,
route,
enabled,
createdAt: now,
updatedAt: now,
createdBy,
};
this.storedRoutes.set(id, stored);
await this.persistRoute(stored);
await this.applyRoutes();
return id;
}
public async updateRoute(
id: string,
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
): Promise<boolean> {
const stored = this.storedRoutes.get(id);
if (!stored) return false;
if (patch.route) {
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
}
if (patch.enabled !== undefined) {
stored.enabled = patch.enabled;
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
await this.applyRoutes();
return true;
}
public async deleteRoute(id: string): Promise<boolean> {
if (!this.storedRoutes.has(id)) return false;
this.storedRoutes.delete(id);
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
await this.applyRoutes();
return true;
}
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
return this.updateRoute(id, { enabled });
}
// =========================================================================
// Hardcoded route overrides
// =========================================================================
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
const override: IRouteOverride = {
routeName,
enabled,
updatedAt: Date.now(),
updatedBy,
};
this.overrides.set(routeName, override);
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
this.computeWarnings();
await this.applyRoutes();
}
public async removeOverride(routeName: string): Promise<boolean> {
if (!this.overrides.has(routeName)) return false;
this.overrides.delete(routeName);
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
this.computeWarnings();
await this.applyRoutes();
return true;
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadStoredRoutes(): Promise<void> {
const keys = await this.storageManager.list(ROUTES_PREFIX);
for (const key of keys) {
if (!key.endsWith('.json')) continue;
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
if (stored?.id) {
this.storedRoutes.set(stored.id, stored);
}
}
if (this.storedRoutes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
}
}
private async loadOverrides(): Promise<void> {
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
for (const key of keys) {
if (!key.endsWith('.json')) continue;
const override = await this.storageManager.getJSON<IRouteOverride>(key);
if (override?.routeName) {
this.overrides.set(override.routeName, override);
}
}
if (this.overrides.size > 0) {
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
}
}
private async persistRoute(stored: IStoredRoute): Promise<void> {
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
}
// =========================================================================
// Private: warnings
// =========================================================================
private computeWarnings(): void {
this.warnings = [];
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
// Check overrides
for (const [routeName, override] of this.overrides) {
if (!hardcodedNames.has(routeName)) {
this.warnings.push({
type: 'orphaned-override',
routeName,
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
});
} else if (!override.enabled) {
this.warnings.push({
type: 'disabled-hardcoded',
routeName,
message: `Route '${routeName}' is disabled via API override`,
});
}
}
// Check disabled programmatic routes
for (const stored of this.storedRoutes.values()) {
if (!stored.enabled) {
const name = stored.route.name || stored.id;
this.warnings.push({
type: 'disabled-programmatic',
routeName: name,
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
});
}
}
}
private logWarnings(): void {
for (const w of this.warnings) {
logger.log('warn', w.message);
}
}
// =========================================================================
// Private: apply merged routes to SmartProxy
// =========================================================================
private async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add enabled hardcoded routes (respecting overrides)
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
if (override && !override.enabled) {
continue; // Skip disabled hardcoded route
}
enabledRoutes.push(route);
}
// Add enabled programmatic routes
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
enabledRoutes.push(stored.route);
}
}
await smartProxy.updateRoutes(enabledRoutes);
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
}
}

View File

@@ -1,2 +1,4 @@
// Export validation tools only // Export validation tools only
export * from './validator.js'; export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';

View File

@@ -10,4 +10,7 @@ export * from './classes.dcrouter.js';
// RADIUS module // RADIUS module
export * from './radius/index.js'; export * from './radius/index.js';
// Remote Ingress module
export * from './remoteingress/index.js';
export const runCli = async () => {}; export const runCli = async () => {};

View File

@@ -1,5 +1,6 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
// Map NODE_ENV to valid TEnvironment // Map NODE_ENV to valid TEnvironment
const nodeEnv = process.env.NODE_ENV || 'production'; const nodeEnv = process.env.NODE_ENV || 'production';
@@ -10,8 +11,11 @@ const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
'production': 'production' 'production': 'production'
}; };
// Default Smartlog instance // In-memory log buffer for the OpsServer UI
const baseLogger = new plugins.smartlog.Smartlog({ export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
// Default Smartlog instance (exported so OpsServer can add push destinations)
export const baseLogger = new plugins.smartlog.Smartlog({
logContext: { logContext: {
environment: envMap[nodeEnv] || 'production', environment: envMap[nodeEnv] || 'production',
runtime: 'node', runtime: 'node',
@@ -19,6 +23,9 @@ const baseLogger = new plugins.smartlog.Smartlog({
} }
}); });
// Wire the buffer destination so all logs are captured
baseLogger.addLogDestination(logBuffer);
// Extended logger compatible with the original enhanced logger API // Extended logger compatible with the original enhanced logger API
class StandardLogger { class StandardLogger {
private defaultContext: Record<string, any> = {}; private defaultContext: Record<string, any> = {};

View File

@@ -1,9 +1,11 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { DcRouter } from '../classes.dcrouter.js'; import { DcRouter } from '../classes.dcrouter.js';
import { MetricsCache } from './classes.metricscache.js'; import { MetricsCache } from './classes.metricscache.js';
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
import { logger } from '../logger.js';
export class MetricsManager { export class MetricsManager {
private logger: plugins.smartlog.Smartlog; private metricsLogger: plugins.smartlog.Smartlog;
private smartMetrics: plugins.smartmetrics.SmartMetrics; private smartMetrics: plugins.smartmetrics.SmartMetrics;
private dcRouter: DcRouter; private dcRouter: DcRouter;
private resetInterval?: NodeJS.Timeout; private resetInterval?: NodeJS.Timeout;
@@ -35,8 +37,13 @@ export class MetricsManager {
lastResetDate: new Date().toDateString(), lastResetDate: new Date().toDateString(),
queryTimestamps: [] as number[], // Track query timestamps for rate calculation queryTimestamps: [] as number[], // Track query timestamps for rate calculation
responseTimes: [] as number[], // Track response times in ms responseTimes: [] as number[], // Track response times in ms
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
}; };
// Per-minute time-series buckets for charts
private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
private dnsMinuteBuckets = new Map<number, { queries: number }>();
// Track security-specific metrics // Track security-specific metrics
private securityMetrics = { private securityMetrics = {
blockedIPs: 0, blockedIPs: 0,
@@ -50,15 +57,15 @@ export class MetricsManager {
constructor(dcRouter: DcRouter) { constructor(dcRouter: DcRouter) {
this.dcRouter = dcRouter; this.dcRouter = dcRouter;
// Create a new Smartlog instance for metrics // Create a Smartlog instance for SmartMetrics (requires its own instance)
this.logger = new plugins.smartlog.Smartlog({ this.metricsLogger = new plugins.smartlog.Smartlog({
logContext: { logContext: {
environment: 'production', environment: 'production',
runtime: 'node', runtime: 'node',
zone: 'dcrouter-metrics', zone: 'dcrouter-metrics',
} }
}); });
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter'); this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
// Initialize metrics cache with 500ms TTL // Initialize metrics cache with 500ms TTL
this.metricsCache = new MetricsCache(500); this.metricsCache = new MetricsCache(500);
} }
@@ -90,6 +97,7 @@ export class MetricsManager {
this.dnsMetrics.topDomains.clear(); this.dnsMetrics.topDomains.clear();
this.dnsMetrics.queryTimestamps = []; this.dnsMetrics.queryTimestamps = [];
this.dnsMetrics.responseTimes = []; this.dnsMetrics.responseTimes = [];
this.dnsMetrics.recentQueries = [];
this.dnsMetrics.lastResetDate = currentDate; this.dnsMetrics.lastResetDate = currentDate;
} }
@@ -102,9 +110,12 @@ export class MetricsManager {
this.securityMetrics.incidents = []; this.securityMetrics.incidents = [];
this.securityMetrics.lastResetDate = currentDate; this.securityMetrics.lastResetDate = currentDate;
} }
// Prune old time-series buckets every minute (don't wait for lazy query)
this.pruneOldBuckets();
}, 60000); // Check every minute }, 60000); // Check every minute
this.logger.log('info', 'MetricsManager started'); logger.log('info', 'MetricsManager started');
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
@@ -115,7 +126,13 @@ export class MetricsManager {
} }
this.smartMetrics.stop(); this.smartMetrics.stop();
this.logger.log('info', 'MetricsManager stopped');
// Clear caches and time-series buckets on shutdown
this.metricsCache.clear();
this.emailMinuteBuckets.clear();
this.dnsMinuteBuckets.clear();
logger.log('info', 'MetricsManager stopped');
} }
// Get server metrics from SmartMetrics and SmartProxy // Get server metrics from SmartMetrics and SmartProxy
@@ -139,8 +156,8 @@ export class MetricsManager {
actualUsagePercentage: smartMetricsData.memoryPercentage, actualUsagePercentage: smartMetricsData.memoryPercentage,
}, },
cpuUsage: { cpuUsage: {
user: parseFloat(smartMetricsData.cpuUsageText || '0'), user: smartMetricsData.cpuPercentage,
system: 0, // SmartMetrics doesn't separate user/system system: 0,
}, },
activeConnections: proxyStats ? proxyStats.activeConnections : 0, activeConnections: proxyStats ? proxyStats.activeConnections : 0,
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0, totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
@@ -223,13 +240,39 @@ export class MetricsManager {
queryTypes: this.dnsMetrics.queryTypes, queryTypes: this.dnsMetrics.queryTypes,
averageResponseTime: Math.round(avgResponseTime), averageResponseTime: Math.round(avgResponseTime),
activeDomains: this.dnsMetrics.topDomains.size, activeDomains: this.dnsMetrics.topDomains.size,
recentQueries: this.dnsMetrics.recentQueries.slice(),
}; };
}); });
} }
/**
* Sync security metrics from the SecurityLogger singleton (last 24h).
* Called before returning security stats so counters reflect real events.
*/
private syncFromSecurityLogger(): void {
try {
const securityLogger = SecurityLogger.getInstance();
const summary = securityLogger.getEventsSummary(86400000); // last 24h
this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
this.securityMetrics.authFailures =
summary.byType[SecurityEventType.AUTHENTICATION] || 0;
this.securityMetrics.blockedIPs =
(summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
(summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
} catch {
// SecurityLogger may not be initialized yet — ignore
}
}
// Get security metrics // Get security metrics
public async getSecurityStats() { public async getSecurityStats() {
return this.metricsCache.get('securityStats', () => { return this.metricsCache.get('securityStats', () => {
// Sync counters from the real SecurityLogger events
this.syncFromSecurityLogger();
// Get recent incidents (last 20) // Get recent incidents (last 20)
const recentIncidents = this.securityMetrics.incidents.slice(-20); const recentIncidents = this.securityMetrics.incidents.slice(-20);
@@ -275,10 +318,19 @@ export class MetricsManager {
// Email event tracking methods // Email event tracking methods
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void { public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
this.emailMetrics.sentToday++; this.emailMetrics.sentToday++;
this.incrementEmailBucket('sent');
if (recipient) { if (recipient) {
const count = this.emailMetrics.recipients.get(recipient) || 0; const count = this.emailMetrics.recipients.get(recipient) || 0;
this.emailMetrics.recipients.set(recipient, count + 1); this.emailMetrics.recipients.set(recipient, count + 1);
// Cap recipients map to prevent unbounded growth within a day
if (this.emailMetrics.recipients.size > this.MAX_TOP_DOMAINS) {
const sorted = Array.from(this.emailMetrics.recipients.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8));
this.emailMetrics.recipients = new Map(sorted);
}
} }
if (deliveryTimeMs) { if (deliveryTimeMs) {
@@ -303,6 +355,7 @@ export class MetricsManager {
public trackEmailReceived(sender?: string): void { public trackEmailReceived(sender?: string): void {
this.emailMetrics.receivedToday++; this.emailMetrics.receivedToday++;
this.incrementEmailBucket('received');
this.emailMetrics.recentActivity.push({ this.emailMetrics.recentActivity.push({
timestamp: Date.now(), timestamp: Date.now(),
@@ -318,6 +371,7 @@ export class MetricsManager {
public trackEmailFailed(recipient?: string, reason?: string): void { public trackEmailFailed(recipient?: string, reason?: string): void {
this.emailMetrics.failedToday++; this.emailMetrics.failedToday++;
this.incrementEmailBucket('failed');
this.emailMetrics.recentActivity.push({ this.emailMetrics.recentActivity.push({
timestamp: Date.now(), timestamp: Date.now(),
@@ -351,8 +405,21 @@ export class MetricsManager {
} }
// DNS event tracking methods // DNS event tracking methods
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void { public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void {
this.dnsMetrics.totalQueries++; this.dnsMetrics.totalQueries++;
this.incrementDnsBucket();
// Store recent query entry
this.dnsMetrics.recentQueries.push({
timestamp: Date.now(),
domain,
type: queryType,
answered: answered ?? true,
responseTimeMs: responseTimeMs ?? 0,
});
if (this.dnsMetrics.recentQueries.length > 100) {
this.dnsMetrics.recentQueries.shift();
}
if (cacheHit) { if (cacheHit) {
this.dnsMetrics.cacheHits++; this.dnsMetrics.cacheHits++;
@@ -539,4 +606,90 @@ export class MetricsManager {
}; };
}, 200); // Use 200ms cache for more frequent updates }, 200); // Use 200ms cache for more frequent updates
} }
// --- Time-series helpers ---
private static minuteKey(ts: number = Date.now()): number {
return Math.floor(ts / 60000) * 60000;
}
private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
const key = MetricsManager.minuteKey();
let bucket = this.emailMinuteBuckets.get(key);
if (!bucket) {
bucket = { sent: 0, received: 0, failed: 0 };
this.emailMinuteBuckets.set(key, bucket);
}
bucket[field]++;
}
private incrementDnsBucket(): void {
const key = MetricsManager.minuteKey();
let bucket = this.dnsMinuteBuckets.get(key);
if (!bucket) {
bucket = { queries: 0 };
this.dnsMinuteBuckets.set(key, bucket);
}
bucket.queries++;
}
private pruneOldBuckets(): void {
const cutoff = Date.now() - 86400000; // 24h
for (const key of this.emailMinuteBuckets.keys()) {
if (key < cutoff) this.emailMinuteBuckets.delete(key);
}
for (const key of this.dnsMinuteBuckets.keys()) {
if (key < cutoff) this.dnsMinuteBuckets.delete(key);
}
}
/**
* Get email time-series data for the last N hours, aggregated per minute.
*/
public getEmailTimeSeries(hours: number = 24): {
sent: Array<{ timestamp: number; value: number }>;
received: Array<{ timestamp: number; value: number }>;
failed: Array<{ timestamp: number; value: number }>;
} {
this.pruneOldBuckets();
const cutoff = Date.now() - hours * 3600000;
const sent: Array<{ timestamp: number; value: number }> = [];
const received: Array<{ timestamp: number; value: number }> = [];
const failed: Array<{ timestamp: number; value: number }> = [];
const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
.filter((k) => k >= cutoff)
.sort((a, b) => a - b);
for (const key of sortedKeys) {
const bucket = this.emailMinuteBuckets.get(key)!;
sent.push({ timestamp: key, value: bucket.sent });
received.push({ timestamp: key, value: bucket.received });
failed.push({ timestamp: key, value: bucket.failed });
}
return { sent, received, failed };
}
/**
* Get DNS time-series data for the last N hours, aggregated per minute.
*/
public getDnsTimeSeries(hours: number = 24): {
queries: Array<{ timestamp: number; value: number }>;
} {
this.pruneOldBuckets();
const cutoff = Date.now() - hours * 3600000;
const queries: Array<{ timestamp: number; value: number }> = [];
const sortedKeys = Array.from(this.dnsMinuteBuckets.keys())
.filter((k) => k >= cutoff)
.sort((a, b) => a - b);
for (const key of sortedKeys) {
const bucket = this.dnsMinuteBuckets.get(key)!;
queries.push({ timestamp: key, value: bucket.queries });
}
return { queries };
}
} }

View File

@@ -19,6 +19,9 @@ export class OpsServer {
private radiusHandler: handlers.RadiusHandler; private radiusHandler: handlers.RadiusHandler;
private emailOpsHandler: handlers.EmailOpsHandler; private emailOpsHandler: handlers.EmailOpsHandler;
private certificateHandler: handlers.CertificateHandler; private certificateHandler: handlers.CertificateHandler;
private remoteIngressHandler: handlers.RemoteIngressHandler;
private routeManagementHandler: handlers.RouteManagementHandler;
private apiTokenHandler: handlers.ApiTokenHandler;
constructor(dcRouterRefArg: DcRouter) { constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg; this.dcRouterRef = dcRouterRefArg;
@@ -59,6 +62,9 @@ export class OpsServer {
this.radiusHandler = new handlers.RadiusHandler(this); this.radiusHandler = new handlers.RadiusHandler(this);
this.emailOpsHandler = new handlers.EmailOpsHandler(this); this.emailOpsHandler = new handlers.EmailOpsHandler(this);
this.certificateHandler = new handlers.CertificateHandler(this); this.certificateHandler = new handlers.CertificateHandler(this);
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized'); console.log('✅ OpsServer TypedRequest handlers initialized');
} }

View File

@@ -0,0 +1,96 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class ApiTokenHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Token management requires admin JWT only (tokens cannot manage tokens).
*/
private async requireAdmin(identity?: interfaces.data.IIdentity): Promise<string> {
if (!identity?.jwt) {
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ identity });
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError('admin access required');
}
return identity.userId;
}
private registerHandlers(): void {
// Create API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
'createApiToken',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await manager.createToken(
dataArg.name,
dataArg.scopes,
dataArg.expiresInDays ?? null,
userId,
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
),
);
// List API tokens
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { tokens: [] };
}
return { tokens: manager.listTokens() };
},
),
);
// Revoke API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const ok = await manager.revokeToken(dataArg.id);
return { success: ok, message: ok ? undefined : 'Token not found' };
},
),
);
// Toggle API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const ok = await manager.toggleToken(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Token not found' };
},
),
);
}
}

View File

@@ -23,24 +23,75 @@ export class CertificateHandler {
) )
); );
// Reprovision Certificate // Legacy route-based reprovision (backward compat)
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate', 'reprovisionCertificate',
async (dataArg) => { async (dataArg) => {
return this.reprovisionCertificate(dataArg.routeName); return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
return this.reprovisionCertificateDomain(dataArg.domain);
}
)
);
// Delete certificate
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
return this.deleteCertificate(dataArg.domain);
}
)
);
// Export certificate
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
return this.exportCertificate(dataArg.domain);
}
)
);
// Import certificate
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
return this.importCertificate(dataArg.cert);
} }
) )
); );
} }
/**
* Build domain-centric certificate overview.
* Instead of one row per route, we produce one row per unique domain.
*/
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> { private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy; const smartProxy = dcRouter.smartProxy;
if (!smartProxy) return []; if (!smartProxy) return [];
const routes = smartProxy.routeManager.getRoutes(); const routes = smartProxy.routeManager.getRoutes();
const certificates: interfaces.requests.ICertificateInfo[] = [];
// Phase 1: Collect unique domains with their associated route info
const domainMap = new Map<string, {
routeNames: string[];
source: interfaces.requests.TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
canReprovision: boolean;
}>();
for (const route of routes) { for (const route of routes) {
if (!route.name) continue; if (!route.name) continue;
@@ -58,7 +109,6 @@ export class CertificateHandler {
// Determine source // Determine source
let source: interfaces.requests.TCertificateSource = 'none'; let source: interfaces.requests.TCertificateSource = 'none';
if (tls.certificate === 'auto') { if (tls.certificate === 'auto') {
// Check if a certProvisionFunction is configured
if ((smartProxy.settings as any).certProvisionFunction) { if ((smartProxy.settings as any).certProvisionFunction) {
source = 'provision-function'; source = 'provision-function';
} else { } else {
@@ -68,15 +118,44 @@ export class CertificateHandler {
source = 'static'; source = 'static';
} }
// Start with unknown status const canReprovision = source === 'acme' || source === 'provision-function';
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
for (const domain of routeDomains) {
const existing = domainMap.get(domain);
if (existing) {
// Add this route name to the existing domain entry
if (!existing.routeNames.includes(route.name)) {
existing.routeNames.push(route.name);
}
// Upgrade source if more specific
if (existing.source === 'none' && source !== 'none') {
existing.source = source;
existing.canReprovision = canReprovision;
}
} else {
domainMap.set(domain, {
routeNames: [route.name],
source,
tlsMode,
canReprovision,
});
}
}
}
// Phase 2: Resolve status for each unique domain
const certificates: interfaces.requests.ICertificateInfo[] = [];
for (const [domain, info] of domainMap) {
let status: interfaces.requests.TCertificateStatus = 'unknown'; let status: interfaces.requests.TCertificateStatus = 'unknown';
let expiryDate: string | undefined; let expiryDate: string | undefined;
let issuedAt: string | undefined; let issuedAt: string | undefined;
let issuer: string | undefined; let issuer: string | undefined;
let error: string | undefined; let error: string | undefined;
// Check event-based status from DcRouter's certificateStatusMap // Check event-based status from certificateStatusMap (now keyed by domain)
const eventStatus = dcRouter.certificateStatusMap.get(route.name); const eventStatus = dcRouter.certificateStatusMap.get(domain);
if (eventStatus) { if (eventStatus) {
status = eventStatus.status; status = eventStatus.status;
expiryDate = eventStatus.expiryDate; expiryDate = eventStatus.expiryDate;
@@ -87,10 +166,10 @@ export class CertificateHandler {
} }
} }
// Try Rust-side certificate status if no event data // Try SmartProxy certificate status if no event data
if (status === 'unknown') { if (status === 'unknown' && info.routeNames.length > 0) {
try { try {
const rustStatus = await smartProxy.getCertificateStatus(route.name); const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
if (rustStatus) { if (rustStatus) {
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate; if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
if (rustStatus.issuer) issuer = rustStatus.issuer; if (rustStatus.issuer) issuer = rustStatus.issuer;
@@ -104,7 +183,36 @@ export class CertificateHandler {
} }
} }
// Compute status from expiry date if we have one and status is still valid/unknown // Check persisted cert data from StorageManager
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (!certData) {
// Also check certStore path (proxy-certs)
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
}
if (certData?.validUntil) {
expiryDate = new Date(certData.validUntil).toISOString();
if (certData.created) {
issuedAt = new Date(certData.created).toISOString();
}
issuer = 'smartacme-dns-01';
} else if (certData?.publicKey) {
// certStore has the cert — parse PEM for expiry
try {
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
expiryDate = new Date(x509.validTo).toISOString();
issuedAt = new Date(x509.validFrom).toISOString();
} catch { /* PEM parsing failed */ }
status = 'valid';
issuer = 'cert-store';
} else if (certData) {
status = 'valid';
issuer = 'cert-store';
}
}
// Compute status from expiry date
if (expiryDate && (status === 'valid' || status === 'unknown')) { if (expiryDate && (status === 'valid' || status === 'unknown')) {
const expiry = new Date(expiryDate); const expiry = new Date(expiryDate);
const now = new Date(); const now = new Date();
@@ -120,23 +228,36 @@ export class CertificateHandler {
} }
// Static certs with no other info default to 'valid' // Static certs with no other info default to 'valid'
if (source === 'static' && status === 'unknown') { if (info.source === 'static' && status === 'unknown') {
status = 'valid'; status = 'valid';
} }
const canReprovision = source === 'acme' || source === 'provision-function'; // ACME/provision-function routes with no cert data are still provisioning
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
status = 'provisioning';
}
// Phase 3: Attach backoff info
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
if (dcRouter.certProvisionScheduler) {
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
if (bi) {
backoffInfo = bi;
}
}
certificates.push({ certificates.push({
routeName: route.name, domain,
domains: routeDomains, routeNames: info.routeNames,
status, status,
source, source: info.source,
tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough', tlsMode: info.tlsMode,
expiryDate, expiryDate,
issuer, issuer,
issuedAt, issuedAt,
error, error,
canReprovision, canReprovision: info.canReprovision,
backoffInfo,
}); });
} }
@@ -166,7 +287,10 @@ export class CertificateHandler {
return summary; return summary;
} }
private async reprovisionCertificate(routeName: string): Promise<{ success: boolean; message?: string }> { /**
* Legacy route-based reprovisioning
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy; const smartProxy = dcRouter.smartProxy;
@@ -176,11 +300,208 @@ export class CertificateHandler {
try { try {
await smartProxy.provisionCertificate(routeName); await smartProxy.provisionCertificate(routeName);
// Clear event-based status so it gets refreshed // Clear event-based status for domains in this route
dcRouter.certificateStatusMap.delete(routeName); for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` }; return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err) { } catch (err) {
return { success: false, message: err.message || 'Failed to reprovision certificate' }; return { success: false, message: err.message || 'Failed to reprovision certificate' };
} }
} }
/**
* Domain-based reprovisioning — clears backoff first, then triggers provision
*/
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear backoff for this domain (user override)
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
// Clear status map entry so it gets refreshed
dcRouter.certificateStatusMap.delete(domain);
// Try to provision via SmartAcme directly
if (dcRouter.smartAcme) {
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}` };
}
}
// Fallback: try provisioning via the first matching route
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length > 0) {
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}` };
}
}
return { success: false, message: `No routes found for domain '${domain}'` };
}
/**
* Delete certificate data for a domain from storage
*/
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
// Delete from all known storage paths
const paths = [
`/proxy-certs/${domain}`,
`/proxy-certs/${cleanDomain}`,
`/certs/${cleanDomain}`,
];
for (const path of paths) {
try {
await dcRouter.storageManager.delete(path);
} catch {
// Path may not exist — ignore
}
}
// Clear from in-memory status map
dcRouter.certificateStatusMap.delete(domain);
// Clear backoff info
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
return { success: true, message: `Certificate data deleted for '${domain}'` };
}
/**
* Export certificate data for a domain as ICert-shaped JSON
*/
private async exportCertificate(domain: string): Promise<{
success: boolean;
cert?: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
message?: string;
}> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
// Try SmartAcme /certs/ path first (has full ICert fields)
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certData && certData.publicKey && certData.privateKey) {
return {
success: true,
cert: {
id: certData.id || plugins.crypto.randomUUID(),
domainName: certData.domainName || domain,
created: certData.created || Date.now(),
validUntil: certData.validUntil || 0,
privateKey: certData.privateKey,
publicKey: certData.publicKey,
csr: certData.csr || '',
},
};
}
// Fallback: try /proxy-certs/ with original domain
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
if (!certData || !certData.publicKey) {
// Try with clean domain
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
}
if (certData && certData.publicKey && certData.privateKey) {
return {
success: true,
cert: {
id: plugins.crypto.randomUUID(),
domainName: domain,
created: certData.validFrom || Date.now(),
validUntil: certData.validUntil || 0,
privateKey: certData.privateKey,
publicKey: certData.publicKey,
csr: '',
},
};
}
return { success: false, message: `No certificate data found for '${domain}'` };
}
/**
* Import a certificate from ICert-shaped JSON
*/
private async importCertificate(cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
}): Promise<{ success: boolean; message?: string }> {
// Validate PEM content
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
}
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
}
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
// Save to /certs/ (SmartAcme-compatible path)
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
validUntil: cert.validUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr || '',
});
// Also save to /proxy-certs/ (proxy-cert format)
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
domain: cert.domainName,
publicKey: cert.publicKey,
privateKey: cert.privateKey,
ca: undefined,
validUntil: cert.validUntil,
validFrom: cert.created,
});
// Update in-memory status map
dcRouter.certificateStatusMap.set(cert.domainName, {
status: 'valid',
source: 'static',
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
routeNames: [],
});
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
}
} }

View File

@@ -1,4 +1,5 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
@@ -17,7 +18,7 @@ export class ConfigHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration', 'getConfiguration',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
const config = await this.getConfiguration(dataArg.section); const config = await this.getConfiguration();
return { return {
config, config,
section: dataArg.section, section: dataArg.section,
@@ -27,82 +28,163 @@ export class ConfigHandler {
); );
} }
private async getConfiguration(section?: string): Promise<{ private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
email: {
enabled: boolean;
ports: number[];
maxMessageSize: number;
rateLimits: {
perMinute: number;
perHour: number;
perDay: number;
};
domains?: string[];
};
dns: {
enabled: boolean;
port: number;
nameservers: string[];
caching: boolean;
ttl: number;
};
proxy: {
enabled: boolean;
httpPort: number;
httpsPort: number;
maxConnections: number;
};
security: {
blockList: string[];
rateLimit: boolean;
spamDetection: boolean;
tlsRequired: boolean;
};
}> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const opts = dcRouter.options;
const resolvedPaths = dcRouter.resolvedPaths;
// Get email domains if email server is configured // --- System ---
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction
? 'custom'
: opts.storage?.fsPath
? 'filesystem'
: 'memory';
const system: interfaces.requests.IConfigData['system'] = {
baseDir: resolvedPaths.dcrouterHomeDir,
dataDir: resolvedPaths.dataDir,
publicIp: opts.publicIp || null,
proxyIps: opts.proxyIps || [],
uptime: Math.floor(process.uptime()),
storageBackend,
storagePath: opts.storage?.fsPath || null,
};
// --- SmartProxy ---
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
if (opts.smartProxyConfig?.acme) {
const acme = opts.smartProxyConfig.acme;
acmeInfo = {
enabled: acme.enabled !== false,
accountEmail: acme.accountEmail || '',
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays || 30,
};
}
let routeCount = 0;
if (dcRouter.routeConfigManager) {
try {
const merged = await dcRouter.routeConfigManager.getMergedRoutes();
routeCount = merged.routes.length;
} catch {
routeCount = opts.smartProxyConfig?.routes?.length || 0;
}
} else if (opts.smartProxyConfig?.routes) {
routeCount = opts.smartProxyConfig.routes.length;
}
const smartProxy: interfaces.requests.IConfigData['smartProxy'] = {
enabled: !!dcRouter.smartProxy,
routeCount,
acme: acmeInfo,
};
// --- Email ---
let emailDomains: string[] = []; let emailDomains: string[] = [];
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) { if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains(); emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
} else if (dcRouter.options.emailConfig?.domains) { } else if (opts.emailConfig?.domains) {
// Fallback: get domains from email config options emailDomains = opts.emailConfig.domains.map((d: any) =>
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
typeof d === 'string' ? d : d.domain typeof d === 'string' ? d : d.domain
); );
} }
let portMapping: Record<string, number> | null = null;
if (opts.emailPortConfig?.portMapping) {
portMapping = {};
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
portMapping[String(ext)] = int as number;
}
}
const email: interfaces.requests.IConfigData['email'] = {
enabled: !!dcRouter.emailServer,
ports: opts.emailConfig?.ports || [],
portMapping,
hostname: opts.emailConfig?.hostname || null,
domains: emailDomains,
emailRouteCount: opts.emailConfig?.routes?.length || 0,
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
};
// --- DNS ---
const dnsRecords = (opts.dnsRecords || []).map(r => ({
name: r.name,
type: r.type,
value: r.value,
ttl: r.ttl,
}));
const dns: interfaces.requests.IConfigData['dns'] = {
enabled: !!dcRouter.dnsServer,
port: 53,
nsDomains: opts.dnsNsDomains || [],
scopes: opts.dnsScopes || [],
recordCount: dnsRecords.length,
records: dnsRecords,
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
};
// --- TLS ---
let tlsSource: 'acme' | 'static' | 'none' = 'none';
if (opts.tls?.certPath && opts.tls?.keyPath) {
tlsSource = 'static';
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
tlsSource = 'acme';
}
const tls: interfaces.requests.IConfigData['tls'] = {
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
domain: opts.tls?.domain || null,
source: tlsSource,
certPath: opts.tls?.certPath || null,
keyPath: opts.tls?.keyPath || null,
};
// --- Cache ---
const cacheConfig = opts.cacheConfig;
const cache: interfaces.requests.IConfigData['cache'] = {
enabled: cacheConfig?.enabled !== false,
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
dbName: cacheConfig?.dbName || 'dcrouter',
defaultTTLDays: cacheConfig?.defaultTTLDays || 30,
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1,
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {},
};
// --- RADIUS ---
const radiusCfg = opts.radiusConfig;
const radius: interfaces.requests.IConfigData['radius'] = {
enabled: !!dcRouter.radiusServer,
authPort: radiusCfg?.authPort || null,
acctPort: radiusCfg?.acctPort || null,
bindAddress: radiusCfg?.bindAddress || null,
clientCount: radiusCfg?.clients?.length || 0,
vlanDefaultVlan: radiusCfg?.vlanAssignment?.defaultVlan ?? null,
vlanAllowUnknownMacs: radiusCfg?.vlanAssignment?.allowUnknownMacs ?? null,
vlanMappingCount: radiusCfg?.vlanAssignment?.mappings?.length || 0,
};
// --- Remote Ingress ---
const riCfg = opts.remoteIngressConfig;
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
enabled: !!dcRouter.remoteIngressManager,
tunnelPort: riCfg?.tunnelPort || null,
hubDomain: riCfg?.hubDomain || null,
tlsConfigured: !!(riCfg?.tls?.certPath && riCfg?.tls?.keyPath),
};
return { return {
email: { system,
enabled: !!dcRouter.emailServer, smartProxy,
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [], email,
maxMessageSize: 10 * 1024 * 1024, // 10MB default dns,
rateLimits: { tls,
perMinute: 10, cache,
perHour: 100, radius,
perDay: 1000, remoteIngress,
},
domains: emailDomains,
},
dns: {
enabled: !!dcRouter.dnsServer,
port: 53,
nameservers: dcRouter.options.dnsNsDomains || [],
caching: true,
ttl: 300,
},
proxy: {
enabled: !!dcRouter.smartProxy,
httpPort: 80,
httpsPort: 443,
maxConnections: 1000,
},
security: {
blockList: [],
rateLimit: true,
spamDetection: true,
tlsRequired: false,
},
}; };
} }
} }

View File

@@ -1,7 +1,6 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
import { SecurityLogger } from '../../security/index.js';
export class EmailOpsHandler { export class EmailOpsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -13,68 +12,24 @@ export class EmailOpsHandler {
} }
private registerHandlers(): void { private registerHandlers(): void {
// Get Queued Emails Handler // Get All Emails Handler
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getQueuedEmails', 'getAllEmails',
async (dataArg) => { async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer; const emails = this.getAllQueueEmails();
if (!emailServer?.deliveryQueue) { return { emails };
return { items: [], total: 0 };
}
const queue = emailServer.deliveryQueue;
const stats = queue.getStats();
// Get all queue items and filter by status if provided
const items = this.getQueueItems(
dataArg.status,
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: stats.queueSize,
};
} }
) )
); );
// Get Sent Emails Handler // Get Email Detail Handler
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getSentEmails', 'getEmailDetail',
async (dataArg) => { async (dataArg) => {
const items = this.getQueueItems( const email = this.getEmailDetail(dataArg.emailId);
'delivered', return { email };
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length, // Note: total would ideally come from a counter
};
}
)
);
// Get Failed Emails Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
'getFailedEmails',
async (dataArg) => {
const items = this.getQueueItems(
'failed',
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length,
};
} }
) )
); );
@@ -101,17 +56,12 @@ export class EmailOpsHandler {
} }
try { try {
// Re-enqueue the failed email by creating a new queue entry
// with the same data but reset attempt count
const newQueueId = await queue.enqueue( const newQueueId = await queue.enqueue(
item.processingResult, item.processingResult,
item.processingMode, item.processingMode,
item.route item.route
); );
// Optionally remove the old failed entry
await queue.removeItem(dataArg.emailId); await queue.removeItem(dataArg.emailId);
return { success: true, newQueueId }; return { success: true, newQueueId };
} catch (error) { } catch (error) {
return { return {
@@ -122,197 +72,199 @@ export class EmailOpsHandler {
} }
) )
); );
// Get Security Incidents Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
'getSecurityIncidents',
async (dataArg) => {
const securityLogger = SecurityLogger.getInstance();
const filter: {
level?: any;
type?: any;
} = {};
if (dataArg.level) {
filter.level = dataArg.level;
}
if (dataArg.type) {
filter.type = dataArg.type;
}
const incidents = securityLogger.getRecentEvents(
dataArg.limit || 100,
Object.keys(filter).length > 0 ? filter : undefined
);
return {
incidents: incidents.map(event => ({
timestamp: event.timestamp,
level: event.level as interfaces.requests.TSecurityLogLevel,
type: event.type as interfaces.requests.TSecurityEventType,
message: event.message,
details: event.details,
ipAddress: event.ipAddress,
userId: event.userId,
sessionId: event.sessionId,
emailId: event.emailId,
domain: event.domain,
action: event.action,
result: event.result,
success: event.success,
})),
total: incidents.length,
};
}
)
);
// Get Bounce Records Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
'getBounceRecords',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { records: [], suppressionList: [], total: 0 };
}
// Use smartmta's public API for bounce/suppression data
const suppressionList = emailServer.getSuppressionList();
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
// Create bounce records from the available data
const records: interfaces.requests.IBounceRecord[] = [];
for (const email of hardBouncedAddresses) {
const bounceInfo = emailServer.getBounceHistory(email);
if (bounceInfo) {
records.push({
id: `bounce-${email}`,
recipient: email,
sender: '',
domain: email.split('@')[1] || '',
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
timestamp: (bounceInfo as any).lastBounce,
processed: true,
});
}
}
// Apply limit and offset
const limit = dataArg.limit || 50;
const offset = dataArg.offset || 0;
const paginatedRecords = records.slice(offset, offset + limit);
return {
records: paginatedRecords,
suppressionList,
total: records.length,
};
}
)
);
// Remove from Suppression List Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
'removeFromSuppressionList',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { success: false, error: 'Email server not available' };
}
try {
emailServer.removeFromSuppressionList(dataArg.email);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
};
}
}
)
);
} }
/** /**
* Helper method to get queue items with filtering and pagination * Get all queue items mapped to catalog IEmail format
*/ */
private getQueueItems( private getAllQueueEmails(): interfaces.requests.IEmail[] {
status?: interfaces.requests.TEmailQueueStatus,
limit: number = 50,
offset: number = 0
): interfaces.requests.IEmailQueueItem[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer; const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) { if (!emailServer?.deliveryQueue) {
return []; return [];
} }
const queue = emailServer.deliveryQueue; const queue = emailServer.deliveryQueue;
const items: interfaces.requests.IEmailQueueItem[] = [];
// Access the internal queue map via reflection
// This is necessary because the queue doesn't expose iteration methods
const queueMap = (queue as any).queue as Map<string, any>; const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) { if (!queueMap) {
return []; return [];
} }
// Filter and convert items const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) { for (const [id, item] of queueMap.entries()) {
// Apply status filter if provided emails.push(this.mapQueueItemToEmail(item));
if (status && item.status !== status) {
continue;
}
// Extract email details from processingResult if available
const processingResult = item.processingResult;
let from = '';
let to: string[] = [];
let subject = '';
if (processingResult) {
// Check if it's an Email object or raw email data
if (processingResult.email) {
from = processingResult.email.from || '';
to = processingResult.email.to || [];
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = processingResult.to || [];
subject = processingResult.subject || '';
}
}
items.push({
id: item.id,
processingMode: item.processingMode,
status: item.status,
attempts: item.attempts,
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
lastError: item.lastError,
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
from,
to,
subject,
});
} }
// Sort by createdAt descending (newest first) // Sort by createdAt descending (newest first)
items.sort((a, b) => b.createdAt - a.createdAt); emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination return emails;
return items.slice(offset, offset + limit); }
/**
* Get a single email detail by ID
*/
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return null;
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(emailId);
if (!item) {
return null;
}
return this.mapQueueItemToEmailDetail(item);
}
/**
* Map a queue item to catalog IEmail format
*/
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
const processingResult = item.processingResult;
let from = '';
let to = '';
let subject = '';
let messageId = '';
let size = '0 B';
if (processingResult) {
if (processingResult.email) {
from = processingResult.email.from || '';
to = (processingResult.email.to || [])[0] || '';
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = (processingResult.to || [])[0] || '';
subject = processingResult.subject || '';
}
// Try to get messageId
if (typeof processingResult.getMessageId === 'function') {
try {
messageId = processingResult.getMessageId() || '';
} catch {
messageId = '';
}
}
// Compute approximate size
const textLen = processingResult.text?.length || 0;
const htmlLen = processingResult.html?.length || 0;
let attachSize = 0;
if (typeof processingResult.getAttachmentsSize === 'function') {
try {
attachSize = processingResult.getAttachmentsSize() || 0;
} catch {
attachSize = 0;
}
}
size = this.formatSize(textLen + htmlLen + attachSize);
}
// Map queue status to catalog TEmailStatus
const status = this.mapStatus(item.status);
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
return {
id: item.id,
direction: 'outbound' as interfaces.requests.TEmailDirection,
status,
from,
to,
subject,
timestamp: new Date(createdAt).toISOString(),
messageId,
size,
};
}
/**
* Map a queue item to catalog IEmailDetail format
*/
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
const base = this.mapQueueItemToEmail(item);
const processingResult = item.processingResult;
let toList: string[] = [];
let cc: string[] = [];
let headers: Record<string, string> = {};
let body = '';
if (processingResult) {
if (processingResult.email) {
toList = processingResult.email.to || [];
cc = processingResult.email.cc || [];
} else {
toList = processingResult.to || [];
cc = processingResult.cc || [];
}
headers = processingResult.headers || {};
body = processingResult.html || processingResult.text || '';
}
return {
...base,
toList,
cc,
smtpLog: [],
connectionInfo: {
sourceIp: '',
sourceHostname: '',
destinationIp: '',
destinationPort: 0,
tlsVersion: '',
tlsCipher: '',
authenticated: false,
authMethod: '',
authUser: '',
},
authenticationResults: {
spf: 'none',
spfDomain: '',
dkim: 'none',
dkimDomain: '',
dmarc: 'none',
dmarcPolicy: '',
},
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
headers,
body,
};
}
/**
* Map queue status to catalog TEmailStatus
*/
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
switch (queueStatus) {
case 'pending':
case 'processing':
return 'pending';
case 'delivered':
return 'delivered';
case 'failed':
return 'bounced';
case 'deferred':
return 'deferred';
default:
return 'pending';
}
}
/**
* Format byte size to human-readable string
*/
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
} }

View File

@@ -6,3 +6,6 @@ export * from './stats.handler.js';
export * from './radius.handler.js'; export * from './radius.handler.js';
export * from './email-ops.handler.js'; export * from './email-ops.handler.js';
export * from './certificate.handler.js'; export * from './certificate.handler.js';
export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';

View File

@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
import { logBuffer, baseLogger } from '../../logger.js';
export class LogsHandler { export class LogsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -9,6 +10,7 @@ export class LogsHandler {
// Add this handler's router to the parent // Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
this.setupLogPushDestination();
} }
private registerHandlers(): void { private registerHandlers(): void {
@@ -64,6 +66,32 @@ export class LogsHandler {
); );
} }
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
switch (smartlogLevel) {
case 'silly':
case 'debug':
return 'debug';
case 'warn':
return 'warn';
case 'error':
return 'error';
default:
return 'info';
}
}
private static deriveCategory(
zone?: string,
message?: string
): 'smtp' | 'dns' | 'security' | 'system' | 'email' {
const msg = (message || '').toLowerCase();
if (msg.includes('[security:') || msg.includes('security')) return 'security';
if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email';
if (zone === 'dns' || msg.includes('dns')) return 'dns';
if (msg.includes('smtp')) return 'smtp';
return 'system';
}
private async getRecentLogs( private async getRecentLogs(
level?: 'error' | 'warn' | 'info' | 'debug', level?: 'error' | 'warn' | 'info' | 'debug',
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email', category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
@@ -78,9 +106,39 @@ export class LogsHandler {
message: string; message: string;
metadata?: any; metadata?: any;
}>> { }>> {
// TODO: Implement actual log retrieval from storage or logger // Compute a timestamp cutoff from timeRange
// For now, return mock data let since: number | undefined;
const mockLogs: Array<{ if (timeRange) {
const rangeMs: Record<string, number> = {
'1h': 3600000,
'6h': 21600000,
'24h': 86400000,
'7d': 604800000,
'30d': 2592000000,
};
since = Date.now() - (rangeMs[timeRange] || 86400000);
}
// Map the UI level to smartlog levels for filtering
const smartlogLevels: string[] | undefined = level
? level === 'debug'
? ['debug', 'silly']
: level === 'info'
? ['info', 'ok', 'success', 'note', 'lifecycle']
: [level]
: undefined;
// Fetch a larger batch from buffer, then apply category filter client-side
const rawEntries = logBuffer.getEntries({
level: smartlogLevels as any,
search,
since,
limit: limit * 3, // over-fetch to compensate for category filtering
offset: 0,
});
// Map ILogPackage → UI log format and apply category filter
const mapped: Array<{
timestamp: number; timestamp: number;
level: 'debug' | 'info' | 'warn' | 'error'; level: 'debug' | 'info' | 'warn' | 'error';
category: 'smtp' | 'dns' | 'security' | 'system' | 'email'; category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
@@ -88,32 +146,68 @@ export class LogsHandler {
metadata?: any; metadata?: any;
}> = []; }> = [];
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email']; for (const pkg of rawEntries) {
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug']; const uiLevel = LogsHandler.mapLogLevel(pkg.level);
const now = Date.now(); const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message);
// Generate some mock log entries if (category && uiCategory !== category) continue;
for (let i = 0; i < 50; i++) {
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
// Filter by requested criteria mapped.push({
if (level && mockLevel !== level) continue; timestamp: pkg.timestamp,
if (category && mockCategory !== category) continue; level: uiLevel,
category: uiCategory,
mockLogs.push({ message: pkg.message,
timestamp: now - (i * 60000), // 1 minute apart metadata: pkg.data,
level: mockLevel,
category: mockCategory,
message: `Sample log message ${i} from ${mockCategory}`,
metadata: {
requestId: plugins.uuid.v4(),
},
}); });
if (mapped.length >= limit) break;
} }
// Apply pagination return mapped;
return mockLogs.slice(offset, offset + limit); }
/**
* Add a log destination to the base logger that pushes entries
* to all connected ops_dashboard TypedSocket clients.
*/
private setupLogPushDestination(): void {
const opsServerRef = this.opsServerRef;
baseLogger.addLogDestination({
async handleLog(logPackage: any) {
// Access the TypedSocket server instance from OpsServer
const typedsocket = opsServerRef.server?.typedserver?.typedsocket;
if (!typedsocket) return;
let connections: any[];
try {
connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
} catch {
return;
}
if (connections.length === 0) return;
const entry: interfaces.data.ILogEntry = {
timestamp: logPackage.timestamp || Date.now(),
level: LogsHandler.mapLogLevel(logPackage.level),
category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message),
message: logPackage.message,
metadata: logPackage.data,
};
for (const conn of connections) {
try {
const push = typedsocket.createTypedRequest<interfaces.requests.IReq_PushLogEntry>(
'pushLogEntry',
conn,
);
push.fire({ entry }).catch(() => {}); // fire-and-forget
} catch {
// connection may have closed
}
}
},
});
} }
private setupLogStream( private setupLogStream(
@@ -148,7 +242,7 @@ export class LogsHandler {
} }
// For follow mode, simulate real-time log streaming // For follow mode, simulate real-time log streaming
intervalId = setInterval(() => { intervalId = setInterval(async () => {
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email']; const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug']; const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
@@ -171,7 +265,13 @@ export class LogsHandler {
const logData = JSON.stringify(logEntry); const logData = JSON.stringify(logEntry);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
virtualStream.sendData(encoder.encode(logData)); try {
await virtualStream.sendData(encoder.encode(logData));
} catch {
// Stream closed or errored — clean up to prevent interval leak
clearInterval(intervalId!);
intervalId = null;
}
}, 2000); // Send a log every 2 seconds }, 2000); // Send a log every 2 seconds
// TODO: Hook into actual logger events // TODO: Hook into actual logger events

View File

@@ -0,0 +1,222 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class RemoteIngressHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get all remote ingress edges
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
'getRemoteIngresses',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { edges: [] };
}
// Return edges without secrets, enriched with effective listen ports and breakdown
const edges = manager.getAllEdges().map((e) => {
const breakdown = manager.getPortBreakdown(e);
return {
...e,
secret: '********', // Never expose secrets via API
effectiveListenPorts: manager.getEffectiveListenPorts(e),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
};
});
return { edges };
},
),
);
// Create a new remote ingress edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return {
success: false,
edge: null as any,
};
}
const edge = await manager.createEdge(
dataArg.name,
dataArg.listenPorts || [],
dataArg.tags,
dataArg.autoDerivePorts ?? true,
);
// Sync allowed edges with the hub
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, edge };
},
),
);
// Delete a remote ingress edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };
}
const deleted = await manager.deleteEdge(dataArg.id);
if (deleted && tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return {
success: deleted,
message: deleted ? undefined : 'Edge not found',
};
},
),
);
// Update a remote ingress edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, edge: null as any };
}
const edge = await manager.updateEdge(dataArg.id, {
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
enabled: dataArg.enabled,
tags: dataArg.tags,
});
if (!edge) {
return { success: false, edge: null as any };
}
// Sync allowed edges — ports, tags, or enabled may have changed
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
const breakdown = manager.getPortBreakdown(edge);
return {
success: true,
edge: {
...edge,
secret: '********',
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
},
};
},
),
);
// Regenerate secret for an edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, secret: '' };
}
const secret = await manager.regenerateSecret(dataArg.id);
if (!secret) {
return { success: false, secret: '' };
}
// Sync allowed edges since secret changed
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, secret };
},
),
);
// Get runtime status of all edges
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus',
async (dataArg, toolsArg) => {
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!tunnelManager) {
return { statuses: [] };
}
return { statuses: tunnelManager.getEdgeStatuses() };
},
),
);
// Get a connection token for an edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
'getRemoteIngressConnectionToken',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };
}
const edge = manager.getEdge(dataArg.edgeId);
if (!edge) {
return { success: false, message: 'Edge not found' };
}
if (!edge.enabled) {
return { success: false, message: 'Edge is disabled' };
}
const hubHost = dataArg.hubHost
|| this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.hubDomain;
if (!hubHost) {
return {
success: false,
message: 'No hub hostname configured. Set hubDomain in remoteIngressConfig or provide hubHost.',
};
}
const hubPort = this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.tunnelPort ?? 8443;
const token = plugins.remoteingress.encodeConnectionToken({
hubHost,
hubPort,
edgeId: edge.id,
secret: edge.secret,
});
return { success: true, token };
},
),
);
}
}

View File

@@ -0,0 +1,163 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class RouteManagementHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Validate auth: JWT identity OR API token with required scope.
* Returns a userId string on success, throws on failure.
*/
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
// Try JWT identity first
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
// Try API token
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get merged routes
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMergedRoutes>(
'getMergedRoutes',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:read');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { routes: [], warnings: [] };
}
return manager.getMergedRoutes();
},
),
);
// Create route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
'createRoute',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true);
return { success: true, storedRouteId: id };
},
),
);
// Update route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRoute>(
'updateRoute',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any,
enabled: dataArg.enabled,
});
return { success: ok, message: ok ? undefined : 'Route not found' };
},
),
);
// Delete route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRoute>(
'deleteRoute',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.deleteRoute(dataArg.id);
return { success: ok, message: ok ? undefined : 'Route not found' };
},
),
);
// Set override on a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
return { success: true };
},
),
);
// Remove override from a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.removeOverride(dataArg.routeName);
return { success: ok, message: ok ? undefined : 'Override not found' };
},
),
);
// Toggle programmatic route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Route not found' };
},
),
);
}
}

View File

@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js'; import { MetricsManager } from '../../monitoring/index.js';
import { SecurityLogger } from '../../security/classes.securitylogger.js';
export class StatsHandler { export class StatsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -203,6 +204,11 @@ export class StatsHandler {
if (sections.email) { if (sections.email) {
promises.push( promises.push(
this.collectEmailStats().then(stats => { this.collectEmailStats().then(stats => {
// Get time-series data from MetricsManager
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
? this.opsServerRef.dcRouterRef.metricsManager.getEmailTimeSeries(24)
: undefined;
metrics.email = { metrics.email = {
sent: stats.sentToday, sent: stats.sentToday,
received: stats.receivedToday, received: stats.receivedToday,
@@ -212,6 +218,7 @@ export class StatsHandler {
averageDeliveryTime: 0, averageDeliveryTime: 0,
deliveryRate: stats.deliveryRate, deliveryRate: stats.deliveryRate,
bounceRate: stats.bounceRate, bounceRate: stats.bounceRate,
timeSeries,
}; };
}) })
); );
@@ -220,6 +227,11 @@ export class StatsHandler {
if (sections.dns) { if (sections.dns) {
promises.push( promises.push(
this.collectDnsStats().then(stats => { this.collectDnsStats().then(stats => {
// Get time-series data from MetricsManager
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
? this.opsServerRef.dcRouterRef.metricsManager.getDnsTimeSeries(24)
: undefined;
metrics.dns = { metrics.dns = {
totalQueries: stats.totalQueries, totalQueries: stats.totalQueries,
cacheHits: stats.cacheHits, cacheHits: stats.cacheHits,
@@ -228,6 +240,8 @@ export class StatsHandler {
activeDomains: stats.topDomains.length, activeDomains: stats.topDomains.length,
averageResponseTime: 0, averageResponseTime: 0,
queryTypes: stats.queryTypes, queryTypes: stats.queryTypes,
timeSeries,
recentQueries: stats.recentQueries,
}; };
}) })
); );
@@ -236,6 +250,19 @@ export class StatsHandler {
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) { if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push( promises.push(
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => { this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
// Get recent events from the SecurityLogger singleton
const securityLogger = SecurityLogger.getInstance();
const recentEvents = securityLogger.getRecentEvents(50).map((evt) => ({
timestamp: evt.timestamp,
level: evt.level,
type: evt.type,
message: evt.message,
details: evt.details,
ipAddress: evt.ipAddress,
domain: evt.domain,
success: evt.success,
}));
metrics.security = { metrics.security = {
blockedIPs: stats.blockedIPs, blockedIPs: stats.blockedIPs,
reputationScores: {}, reputationScores: {},
@@ -244,6 +271,7 @@ export class StatsHandler {
phishingDetected: stats.phishingDetected, phishingDetected: stats.phishingDetected,
authenticationFailures: stats.authFailures, authenticationFailures: stats.authFailures,
suspiciousActivities: stats.totalThreatsBlocked, suspiciousActivities: stats.totalThreatsBlocked,
recentEvents,
}; };
}) })
); );
@@ -395,6 +423,7 @@ export class StatsHandler {
count: number; count: number;
}>; }>;
queryTypes: { [key: string]: number }; queryTypes: { [key: string]: number };
recentQueries?: Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>;
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats }; domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
}> { }> {
// Get metrics from MetricsManager if available // Get metrics from MetricsManager if available
@@ -408,6 +437,7 @@ export class StatsHandler {
cacheHitRate: dnsStats.cacheHitRate, cacheHitRate: dnsStats.cacheHitRate,
topDomains: dnsStats.topDomains, topDomains: dnsStats.topDomains,
queryTypes: dnsStats.queryTypes, queryTypes: dnsStats.queryTypes,
recentQueries: dnsStats.recentQueries,
}; };
} }

View File

@@ -1,7 +1,6 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
// Base directories // Code/asset paths (not affected by baseDir)
export const baseDir = process.cwd();
export const packageDir = plugins.path.join( export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url), plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../' '../'
@@ -20,35 +19,37 @@ export const dataDir = process.env.DATA_DIR
// Default TsmDB path for CacheDb // Default TsmDB path for CacheDb
export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb'); export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb');
// MTA directories // DNS records directory (only surviving MTA directory reference)
export const keysDir = plugins.path.join(dataDir, 'keys');
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns'); export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
export const sentEmailsDir = plugins.path.join(dataDir, 'emails', 'sent');
export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received');
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
// Email template directories /**
export const emailTemplatesDir = plugins.path.join(dataDir, 'templates', 'email'); * Resolve all data paths from a given baseDir.
export const MtaAttachmentsDir = plugins.path.join(dataDir, 'attachments'); // For email attachments * When no baseDir is provided, falls back to ~/.serve.zone/dcrouter.
* Specific overrides (e.g. DATA_DIR env) take precedence.
// Configuration path */
export const configPath = process.env.CONFIG_PATH export function resolvePaths(baseDir?: string) {
? process.env.CONFIG_PATH const root = baseDir ?? plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
: plugins.path.join(baseDir, 'config.json'); const resolvedDataDir = process.env.DATA_DIR ?? plugins.path.join(root, 'data');
return {
// Create directories if they don't exist dcrouterHomeDir: root,
export function ensureDirectories() { dataDir: resolvedDataDir,
// Ensure data directories defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
plugins.fsUtils.ensureDirSync(dataDir); defaultStoragePath: plugins.path.join(root, 'storage'),
plugins.fsUtils.ensureDirSync(keysDir); dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
plugins.fsUtils.ensureDirSync(dnsRecordsDir); };
plugins.fsUtils.ensureDirSync(sentEmailsDir); }
plugins.fsUtils.ensureDirSync(receivedEmailsDir);
plugins.fsUtils.ensureDirSync(failedEmailsDir); /**
plugins.fsUtils.ensureDirSync(logsDir); * Ensure only the data directories that are actually used exist.
*/
// Ensure email template directories export function ensureDataDirectories(resolvedPaths: ReturnType<typeof resolvePaths>) {
plugins.fsUtils.ensureDirSync(emailTemplatesDir); plugins.fsUtils.ensureDirSync(resolvedPaths.dataDir);
plugins.fsUtils.ensureDirSync(MtaAttachmentsDir); plugins.fsUtils.ensureDirSync(resolvedPaths.dnsRecordsDir);
}
/**
* Legacy wrapper — delegates to ensureDataDirectories with module-level defaults.
*/
export function ensureDirectories() {
ensureDataDirectories(resolvePaths());
} }

View File

@@ -23,9 +23,11 @@ export {
// @serve.zone scope // @serve.zone scope
import * as servezoneInterfaces from '@serve.zone/interfaces'; import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as remoteingress from '@serve.zone/remoteingress';
export { export {
servezoneInterfaces servezoneInterfaces,
remoteingress,
} }
// @api.global scope // @api.global scope

View File

@@ -100,6 +100,14 @@ export class VlanManager {
// Cache the result // Cache the result
this.normalizedMacCache.set(mac, normalized); this.normalizedMacCache.set(mac, normalized);
// Prevent unbounded cache growth
if (this.normalizedMacCache.size > 10000) {
const iterator = this.normalizedMacCache.keys();
for (let i = 0; i < 1000; i++) {
this.normalizedMacCache.delete(iterator.next().value);
}
}
return normalized; return normalized;
} }

146
ts/readme.md Normal file
View File

@@ -0,0 +1,146 @@
# @serve.zone/dcrouter
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Installation
```bash
pnpm add @serve.zone/dcrouter
```
## Usage
```typescript
import { DcRouter } from '@serve.zone/dcrouter';
const router = new DcRouter({
smartProxyConfig: {
routes: [
{
name: 'web-app',
match: { domains: ['example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
}
],
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
}
});
await router.start();
// OpsServer dashboard at http://localhost:3000
// Graceful shutdown
await router.stop();
```
## Module Structure
```
ts/
├── index.ts # Main exports (DcRouter, re-exported smartmta types)
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
├── logger.ts # Structured logging utility
├── paths.ts # Centralized data directory paths
├── plugins.ts # All dependency imports
├── cache/ # Cache database (smartdata + LocalTsmDb)
│ ├── classes.cachedb.ts # CacheDb singleton
│ ├── classes.cachecleaner.ts # TTL-based cleanup
│ └── documents/ # Cached document models
├── config/ # Configuration utilities
├── errors/ # Error classes and retry logic
├── monitoring/ # MetricsManager (SmartMetrics integration)
├── opsserver/ # OpsServer dashboard + API handlers
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
│ └── handlers/ # TypedRequest handlers by domain
│ ├── admin.handler.ts # Auth (login/logout/verify)
│ ├── stats.handler.ts # Statistics + health
│ ├── config.handler.ts # Configuration (read-only)
│ ├── logs.handler.ts # Log retrieval
│ ├── email.handler.ts # Email operations
│ ├── certificate.handler.ts # Certificate management
│ ├── radius.handler.ts # RADIUS management
│ └── remoteingress.handler.ts # Remote ingress edge + token management
├── radius/ # RADIUS server integration
├── remoteingress/ # Remote ingress hub integration
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
├── security/ # Security utilities
├── sms/ # SMS integration
└── storage/ # StorageManager (filesystem/custom/memory)
```
## Exports
```typescript
// Main class
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
// Re-exported from smartmta
export { UnifiedEmailServer } from '@push.rocks/smartmta';
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
// RADIUS
export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
// Remote Ingress
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
```
## Key Classes
### `DcRouter`
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
| Config Section | Service Started | Package |
|----------------|----------------|---------|
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
### `RemoteIngressManager`
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
### `TunnelManager`
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -0,0 +1,258 @@
import * as plugins from '../plugins.js';
import type { StorageManager } from '../storage/classes.storagemanager.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
const STORAGE_PREFIX = '/remote-ingress/';
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
function extractPorts(portRange: number | number[] | Array<{ from: number; to: number }>): number[] {
const ports = new Set<number>();
if (typeof portRange === 'number') {
ports.add(portRange);
} else if (Array.isArray(portRange)) {
for (const entry of portRange) {
if (typeof entry === 'number') {
ports.add(entry);
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
for (let p = entry.from; p <= entry.to; p++) {
ports.add(p);
}
}
}
}
return [...ports].sort((a, b) => a - b);
}
/**
* Manages CRUD for remote ingress edge registrations.
* Persists edge configs via StorageManager and provides
* the allowed edges list for the Rust hub.
*/
export class RemoteIngressManager {
private storageManager: StorageManager;
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
constructor(storageManager: StorageManager) {
this.storageManager = storageManager;
}
/**
* Load all edge registrations from storage into memory.
*/
public async initialize(): Promise<void> {
const keys = await this.storageManager.list(STORAGE_PREFIX);
for (const key of keys) {
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
if (edge) {
// Migration: old edges without autoDerivePorts default to true
if ((edge as any).autoDerivePorts === undefined) {
edge.autoDerivePorts = true;
await this.storageManager.setJSON(key, edge);
}
this.edges.set(edge.id, edge);
}
}
}
/**
* Store the current route configs for port derivation.
*/
public setRoutes(routes: IDcRouterRouteConfig[]): void {
this.routes = routes;
}
/**
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
* When edgeFilter is absent, the route applies to all edges.
*/
public derivePortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
const ports = new Set<number>();
for (const route of this.routes) {
if (!route.remoteIngress?.enabled) continue;
// Apply edge filter if present
const filter = route.remoteIngress.edgeFilter;
if (filter && filter.length > 0) {
const idMatch = filter.includes(edgeId);
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
if (!idMatch && !tagMatch) continue;
}
// Extract ports from the route match
if (route.match?.ports) {
for (const p of extractPorts(route.match.ports)) {
ports.add(p);
}
}
}
return [...ports].sort((a, b) => a - b);
}
/**
* Get the effective listen ports for an edge.
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
*/
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
const manualPorts = edge.listenPorts || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags);
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
}
/**
* Get manual and derived port breakdown for an edge (used in API responses).
* Derived ports exclude any ports already present in the manual list.
*/
public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } {
const manual = edge.listenPorts || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return { manual, derived: [] };
const manualSet = new Set(manual);
const allDerived = this.derivePortsForEdge(edge.id, edge.tags);
const derived = allDerived.filter((p) => !manualSet.has(p));
return { manual, derived };
}
/**
* Create a new edge registration.
*/
public async createEdge(
name: string,
listenPorts: number[] = [],
tags?: string[],
autoDerivePorts: boolean = true,
): Promise<IRemoteIngress> {
const id = plugins.uuid.v4();
const secret = plugins.crypto.randomBytes(32).toString('hex');
const now = Date.now();
const edge: IRemoteIngress = {
id,
name,
secret,
listenPorts,
enabled: true,
autoDerivePorts,
tags: tags || [],
createdAt: now,
updatedAt: now,
};
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
this.edges.set(id, edge);
return edge;
}
/**
* Get an edge by ID.
*/
public getEdge(id: string): IRemoteIngress | undefined {
return this.edges.get(id);
}
/**
* Get all edge registrations.
*/
public getAllEdges(): IRemoteIngress[] {
return Array.from(this.edges.values());
}
/**
* Update an edge registration.
*/
public async updateEdge(
id: string,
updates: {
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
tags?: string[];
},
): Promise<IRemoteIngress | null> {
const edge = this.edges.get(id);
if (!edge) {
return null;
}
if (updates.name !== undefined) edge.name = updates.name;
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
this.edges.set(id, edge);
return edge;
}
/**
* Delete an edge registration.
*/
public async deleteEdge(id: string): Promise<boolean> {
if (!this.edges.has(id)) {
return false;
}
await this.storageManager.delete(`${STORAGE_PREFIX}${id}`);
this.edges.delete(id);
return true;
}
/**
* Regenerate the secret for an edge.
*/
public async regenerateSecret(id: string): Promise<string | null> {
const edge = this.edges.get(id);
if (!edge) {
return null;
}
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
this.edges.set(id, edge);
return edge.secret;
}
/**
* Verify an edge's secret using constant-time comparison.
*/
public verifySecret(id: string, secret: string): boolean {
const edge = this.edges.get(id);
if (!edge) {
return false;
}
const expected = Buffer.from(edge.secret);
const provided = Buffer.from(secret);
if (expected.length !== provided.length) {
return false;
}
return plugins.crypto.timingSafeEqual(expected, provided);
}
/**
* Get the list of allowed edges (enabled only) for the Rust hub.
*/
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[] }> {
const result: Array<{ id: string; secret: string; listenPorts: number[] }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
result.push({
id: edge.id,
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
});
}
}
return result;
}
}

View File

@@ -0,0 +1,122 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
export interface ITunnelManagerConfig {
tunnelPort?: number;
targetHost?: string;
}
/**
* Manages the RemoteIngressHub instance and tracks connected edge statuses.
*/
export class TunnelManager {
private hub: InstanceType<typeof plugins.remoteingress.RemoteIngressHub>;
private manager: RemoteIngressManager;
private config: ITunnelManagerConfig;
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
this.manager = manager;
this.config = config;
this.hub = new plugins.remoteingress.RemoteIngressHub();
// Listen for edge connect/disconnect events
this.hub.on('edgeConnected', (data: { edgeId: string }) => {
const existing = this.edgeStatuses.get(data.edgeId);
this.edgeStatuses.set(data.edgeId, {
edgeId: data.edgeId,
connected: true,
publicIp: existing?.publicIp ?? null,
activeTunnels: 0,
lastHeartbeat: Date.now(),
connectedAt: Date.now(),
});
});
this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
this.edgeStatuses.delete(data.edgeId);
});
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing) {
existing.activeTunnels++;
existing.lastHeartbeat = Date.now();
}
});
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing && existing.activeTunnels > 0) {
existing.activeTunnels--;
}
});
}
/**
* Start the tunnel hub and load allowed edges.
*/
public async start(): Promise<void> {
await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
});
// Send allowed edges to the hub
await this.syncAllowedEdges();
}
/**
* Stop the tunnel hub.
*/
public async stop(): Promise<void> {
await this.hub.stop();
this.edgeStatuses.clear();
}
/**
* Sync allowed edges from the manager to the hub.
* Call this after creating/deleting/updating edges.
*/
public async syncAllowedEdges(): Promise<void> {
const edges = this.manager.getAllowedEdges();
await this.hub.updateAllowedEdges(edges);
}
/**
* Get runtime statuses for all known edges.
*/
public getEdgeStatuses(): IRemoteIngressStatus[] {
return Array.from(this.edgeStatuses.values());
}
/**
* Get status for a specific edge.
*/
public getEdgeStatus(edgeId: string): IRemoteIngressStatus | undefined {
return this.edgeStatuses.get(edgeId);
}
/**
* Get the count of connected edges.
*/
public getConnectedCount(): number {
let count = 0;
for (const status of this.edgeStatuses.values()) {
if (status.connected) count++;
}
return count;
}
/**
* Get the total number of active tunnels across all edges.
*/
public getTotalActiveTunnels(): number {
let total = 0;
for (const status of this.edgeStatuses.values()) {
total += status.activeTunnels;
}
return total;
}
}

View File

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

View File

@@ -1,5 +1,4 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { Email, type Core } from '@push.rocks/smartmta'; import { Email, type Core } from '@push.rocks/smartmta';
type IAttachment = Core.IAttachment; type IAttachment = Core.IAttachment;

View File

@@ -378,7 +378,7 @@ export class StorageManager {
*/ */
async getJSON<T = any>(key: string): Promise<T | null> { async getJSON<T = any>(key: string): Promise<T | null> {
const value = await this.get(key); const value = await this.get(key);
if (value === null) { if (value === null || value.trim() === '') {
return null; return null;
} }

View File

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

View File

@@ -0,0 +1,57 @@
import type { IRouteConfig } from '@push.rocks/smartproxy';
/**
* A stored remote ingress edge registration.
*/
export interface IRemoteIngress {
id: string;
name: string;
secret: string;
listenPorts: number[];
enabled: boolean;
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
autoDerivePorts: boolean;
tags?: string[];
createdAt: number;
updatedAt: number;
/** Effective ports (union of manual + derived) — only present in API responses. */
effectiveListenPorts?: number[];
/** Ports explicitly set by the user — only present in API responses. */
manualPorts?: number[];
/** Ports auto-derived from route configs — only present in API responses. */
derivedPorts?: number[];
}
/**
* Runtime status of a remote ingress edge.
*/
export interface IRemoteIngressStatus {
edgeId: string;
connected: boolean;
publicIp: string | null;
activeTunnels: number;
lastHeartbeat: number | null;
connectedAt: number | null;
}
/**
* Route-level remote ingress configuration.
* When attached to a route, signals that traffic for this route
* should be accepted from remote edge nodes.
*/
export interface IRouteRemoteIngress {
/** Whether this route receives traffic from edge nodes */
enabled: boolean;
/** Optional filter: only edges whose id or tags match get this route's ports.
* When absent, the route applies to all edges. */
edgeFilter?: string[];
}
/**
* Extended route config used within dcrouter.
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
* SmartProxy ignores unknown properties at runtime.
*/
export type IDcRouterRouteConfig = IRouteConfig & {
remoteIngress?: IRouteRemoteIngress;
};

View File

@@ -0,0 +1,83 @@
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Route Management Data Types
// ============================================================================
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage';
/**
* A merged route combining hardcoded and programmatic sources.
*/
export interface IMergedRoute {
route: IRouteConfig;
source: 'hardcoded' | 'programmatic';
enabled: boolean;
overridden: boolean;
storedRouteId?: string;
createdAt?: number;
updatedAt?: number;
}
/**
* A warning generated during route merge/startup.
*/
export interface IRouteWarning {
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
routeName: string;
message: string;
}
/**
* Public info about an API token (never includes the hash).
*/
export interface IApiTokenInfo {
id: string;
name: string;
scopes: TApiTokenScope[];
createdAt: number;
expiresAt: number | null;
lastUsedAt: number | null;
enabled: boolean;
}
// ============================================================================
// Storage Schemas (persisted via StorageManager)
// ============================================================================
/**
* A programmatic route stored in /config-api/routes/{id}.json
*/
export interface IStoredRoute {
id: string;
route: IRouteConfig;
enabled: boolean;
createdAt: number;
updatedAt: number;
createdBy: string;
}
/**
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
*/
export interface IRouteOverride {
routeName: string;
enabled: boolean;
updatedAt: number;
updatedBy: string;
}
/**
* A stored API token, stored in /config-api/tokens/{id}.json
*/
export interface IStoredApiToken {
id: string;
name: string;
tokenHash: string;
scopes: TApiTokenScope[];
createdAt: number;
expiresAt: number | null;
lastUsedAt: number | null;
createdBy: string;
enabled: boolean;
}

View File

@@ -26,6 +26,11 @@ export interface IServerStats {
}; };
} }
export interface ITimeSeriesPoint {
timestamp: number;
value: number;
}
export interface IEmailStats { export interface IEmailStats {
sent: number; sent: number;
received: number; received: number;
@@ -35,6 +40,11 @@ export interface IEmailStats {
averageDeliveryTime: number; averageDeliveryTime: number;
deliveryRate: number; deliveryRate: number;
bounceRate: number; bounceRate: number;
timeSeries?: {
sent: ITimeSeriesPoint[];
received: ITimeSeriesPoint[];
failed: ITimeSeriesPoint[];
};
} }
export interface IDnsStats { export interface IDnsStats {
@@ -47,6 +57,16 @@ export interface IDnsStats {
queryTypes: { queryTypes: {
[key: string]: number; [key: string]: number;
}; };
timeSeries?: {
queries: ITimeSeriesPoint[];
};
recentQueries?: Array<{
timestamp: number;
domain: string;
type: string;
answered: boolean;
responseTimeMs: number;
}>;
} }
export interface IRateLimitInfo { export interface IRateLimitInfo {
@@ -58,6 +78,17 @@ export interface IRateLimitInfo {
blocked: boolean; blocked: boolean;
} }
export interface ISecurityEvent {
timestamp: number;
level: string;
type: string;
message: string;
details?: any;
ipAddress?: string;
domain?: string;
success?: boolean;
}
export interface ISecurityMetrics { export interface ISecurityMetrics {
blockedIPs: string[]; blockedIPs: string[];
reputationScores: { reputationScores: {
@@ -68,6 +99,7 @@ export interface ISecurityMetrics {
phishingDetected: number; phishingDetected: number;
authenticationFailures: number; authenticationFailures: number;
suspiciousActivities: number; suspiciousActivities: number;
recentEvents?: ISecurityEvent[];
} }
export interface ILogEntry { export interface ILogEntry {

View File

@@ -82,6 +82,14 @@ interface IIdentity {
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints | | `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
| `ILogEntry` | Timestamp, level, category, message, metadata | | `ILogEntry` | Timestamp, level, category, message, metadata |
#### Remote Ingress Interfaces
| Interface | Description |
|-----------|-------------|
| `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 |
### Request Interfaces (`requests`) ### Request Interfaces (`requests`)
TypedRequest interfaces for the OpsServer API, organized by domain: TypedRequest interfaces for the OpsServer API, organized by domain:
@@ -128,6 +136,51 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
| `IReq_GetBounceRecords` | `getBounceRecords` | Bounce records | | `IReq_GetBounceRecords` | `getBounceRecords` | Bounce records |
| `IReq_RemoveFromSuppressionList` | `removeFromSuppressionList` | Unsuppress an address | | `IReq_RemoveFromSuppressionList` | `removeFromSuppressionList` | Unsuppress an address |
#### 🔐 Certificates
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetCertificateOverview` | `getCertificateOverview` | Domain-centric certificate status |
| `IReq_ReprovisionCertificate` | `reprovisionCertificate` | Reprovision by route name (legacy) |
| `IReq_ReprovisionCertificateDomain` | `reprovisionCertificateDomain` | Reprovision by domain (preferred) |
| `IReq_ImportCertificate` | `importCertificate` | Import a certificate |
| `IReq_ExportCertificate` | `exportCertificate` | Export a certificate |
| `IReq_DeleteCertificate` | `deleteCertificate` | Delete a certificate |
#### Certificate Types
```typescript
type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
interface ICertificateInfo {
domain: string;
routeNames: string[];
status: TCertificateStatus;
source: TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
expiryDate?: string;
issuer?: string;
issuedAt?: string;
error?: string;
canReprovision: boolean;
backoffInfo?: {
failures: number;
retryAfter?: string;
lastError?: string;
};
}
```
#### 🌍 Remote Ingress
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_CreateRemoteIngress` | `createRemoteIngress` | Register a new edge node |
| `IReq_DeleteRemoteIngress` | `deleteRemoteIngress` | Remove an edge registration |
| `IReq_UpdateRemoteIngress` | `updateRemoteIngress` | Update edge settings |
| `IReq_RegenerateRemoteIngressSecret` | `regenerateRemoteIngressSecret` | Issue a new secret |
| `IReq_GetRemoteIngresses` | `getRemoteIngresses` | List all edge registrations |
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
#### 📡 RADIUS #### 📡 RADIUS
| Interface | Method | Description | | Interface | Method | Description |
|-----------|--------|-------------| |-----------|--------|-------------|
@@ -152,7 +205,7 @@ import { data, requests } from '@serve.zone/dcrouter-interfaces';
// 1. Login // 1. Login
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>( const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
'https://your-dcrouter:3000/typedrequest', 'https://your-dcrouter:3000/typedrequest',
'adminLogin' 'adminLoginWithUsernameAndPassword'
); );
const loginResponse = await loginClient.fire({ const loginResponse = await loginClient.fire({
@@ -168,19 +221,35 @@ const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMet
); );
const metrics = await metricsClient.fire({ identity }); const metrics = await metricsClient.fire({ identity });
console.log('Server:', metrics.serverStats); console.log('Server:', metrics.metrics.server);
console.log('Email:', metrics.emailStats); console.log('Email:', metrics.metrics.email);
console.log('DNS:', metrics.dnsStats);
console.log('Security:', metrics.securityMetrics);
// 3. Check email queues // 3. Check certificate status
const queueClient = new typedrequest.TypedRequest<requests.IReq_GetQueuedEmails>( const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
'https://your-dcrouter:3000/typedrequest', 'https://your-dcrouter:3000/typedrequest',
'getQueuedEmails' 'getCertificateOverview'
); );
const queued = await queueClient.fire({ identity }); const certs = await certClient.fire({ identity });
console.log('Queued emails:', queued.emails.length); console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
// 4. List remote ingress edges
const edgesClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngresses>(
'https://your-dcrouter:3000/typedrequest',
'getRemoteIngresses'
);
const edges = await edgesClient.fire({ identity });
console.log('Registered edges:', edges.edges.length);
// 5. Generate a connection token for an edge
const tokenClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngressConnectionToken>(
'https://your-dcrouter:3000/typedrequest',
'getRemoteIngressConnectionToken'
);
const tokenResponse = await tokenClient.fire({ identity, edgeId: edges.edges[0].id });
console.log('Connection token:', tokenResponse.token);
``` ```
## License and Legal Information ## License and Legal Information

View File

@@ -0,0 +1,83 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js';
// ============================================================================
// API Token Management Endpoints
// ============================================================================
/**
* Create a new API token. Returns the raw token value once (never shown again).
* Admin JWT only — tokens cannot create tokens.
*/
export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateApiToken
> {
method: 'createApiToken';
request: {
identity?: authInterfaces.IIdentity;
name: string;
scopes: TApiTokenScope[];
expiresInDays?: number | null;
};
response: {
success: boolean;
tokenId?: string;
tokenValue?: string;
message?: string;
};
}
/**
* List all API tokens (without hashes).
*/
export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListApiTokens
> {
method: 'listApiTokens';
request: {
identity?: authInterfaces.IIdentity;
};
response: {
tokens: IApiTokenInfo[];
};
}
/**
* Revoke (delete) an API token.
*/
export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RevokeApiToken
> {
method: 'revokeApiToken';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Enable or disable an API token.
*/
export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ToggleApiToken
> {
method: 'toggleApiToken';
request: {
identity?: authInterfaces.IIdentity;
id: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}

View File

@@ -5,8 +5,8 @@ export type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisionin
export type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none'; export type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
export interface ICertificateInfo { export interface ICertificateInfo {
routeName: string; domain: string;
domains: string[]; routeNames: string[];
status: TCertificateStatus; status: TCertificateStatus;
source: TCertificateSource; source: TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough'; tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
@@ -15,6 +15,11 @@ export interface ICertificateInfo {
issuedAt?: string; // ISO string issuedAt?: string; // ISO string
error?: string; // if status === 'failed' error?: string; // if status === 'failed'
canReprovision: boolean; // true for acme/provision-function routes canReprovision: boolean; // true for acme/provision-function routes
backoffInfo?: {
failures: number;
retryAfter?: string; // ISO string
lastError?: string;
};
} }
export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfaces.implementsTR<
@@ -38,6 +43,7 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
}; };
} }
// Legacy route-based reprovision (kept for backward compat)
export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_ReprovisionCertificate IReq_ReprovisionCertificate
@@ -52,3 +58,84 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa
message?: string; message?: string;
}; };
} }
// Domain-based reprovision (preferred)
export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ReprovisionCertificateDomain
> {
method: 'reprovisionCertificateDomain';
request: {
identity?: authInterfaces.IIdentity;
domain: string;
};
response: {
success: boolean;
message?: string;
};
}
// Delete a certificate by domain
export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteCertificate
> {
method: 'deleteCertificate';
request: {
identity?: authInterfaces.IIdentity;
domain: string;
};
response: {
success: boolean;
message?: string;
};
}
// Export a certificate as ICert JSON
export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ExportCertificate
> {
method: 'exportCertificate';
request: {
identity?: authInterfaces.IIdentity;
domain: string;
};
response: {
success: boolean;
cert?: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
message?: string;
};
}
// Import a certificate from ICert JSON
export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ImportCertificate
> {
method: 'importCertificate';
request: {
identity?: authInterfaces.IIdentity;
cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
};
response: {
success: boolean;
message?: string;
};
}

View File

@@ -1,6 +1,78 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js'; import * as authInterfaces from '../data/auth.js';
export interface IConfigData {
system: {
baseDir: string;
dataDir: string;
publicIp: string | null;
proxyIps: string[];
uptime: number;
storageBackend: 'filesystem' | 'custom' | 'memory';
storagePath: string | null;
};
smartProxy: {
enabled: boolean;
routeCount: number;
acme: {
enabled: boolean;
accountEmail: string;
useProduction: boolean;
autoRenew: boolean;
renewThresholdDays: number;
} | null;
};
email: {
enabled: boolean;
ports: number[];
portMapping: Record<string, number> | null;
hostname: string | null;
domains: string[];
emailRouteCount: number;
receivedEmailsPath: string | null;
};
dns: {
enabled: boolean;
port: number;
nsDomains: string[];
scopes: string[];
recordCount: number;
records: Array<{ name: string; type: string; value: string; ttl?: number }>;
dnsChallenge: boolean;
};
tls: {
contactEmail: string | null;
domain: string | null;
source: 'acme' | 'static' | 'none';
certPath: string | null;
keyPath: string | null;
};
cache: {
enabled: boolean;
storagePath: string | null;
dbName: string | null;
defaultTTLDays: number;
cleanupIntervalHours: number;
ttlConfig: Record<string, number>;
};
radius: {
enabled: boolean;
authPort: number | null;
acctPort: number | null;
bindAddress: string | null;
clientCount: number;
vlanDefaultVlan: number | null;
vlanAllowUnknownMacs: boolean | null;
vlanMappingCount: number;
};
remoteIngress: {
enabled: boolean;
tunnelPort: number | null;
hubDomain: string | null;
tlsConfigured: boolean;
};
}
// Get Configuration (read-only) // Get Configuration (read-only)
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -12,7 +84,7 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
section?: string; section?: string;
}; };
response: { response: {
config: any; config: IConfigData;
section?: string; section?: string;
}; };
} }

View File

@@ -2,162 +2,93 @@ import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js'; import * as authInterfaces from '../data/auth.js';
// ============================================================================ // ============================================================================
// Email Queue Item Interface (matches backend IQueueItem) // Catalog-compatible email types (matches @serve.zone/catalog IEmail/IEmailDetail)
// ============================================================================ // ============================================================================
export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred'; export type TEmailStatus = 'delivered' | 'bounced' | 'rejected' | 'deferred' | 'pending';
export type TEmailDirection = 'inbound' | 'outbound';
export interface IEmailQueueItem { export interface IEmail {
id: string; id: string;
processingMode: 'forward' | 'mta' | 'process'; direction: TEmailDirection;
status: TEmailQueueStatus; status: TEmailStatus;
attempts: number; from: string;
nextAttempt: number; // timestamp to: string;
lastError?: string; subject: string;
createdAt: number; // timestamp timestamp: string;
updatedAt: number; // timestamp messageId: string;
deliveredAt?: number; // timestamp size: string;
// Email details extracted from processingResult }
from?: string;
to?: string[]; export interface ISmtpLogEntry {
subject?: string; timestamp: string;
direction: 'client' | 'server';
command: string;
responseCode?: number;
}
export interface IConnectionInfo {
sourceIp: string;
sourceHostname: string;
destinationIp: string;
destinationPort: number;
tlsVersion: string;
tlsCipher: string;
authenticated: boolean;
authMethod: string;
authUser: string;
}
export interface IAuthenticationResults {
spf: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none';
spfDomain: string;
dkim: 'pass' | 'fail' | 'none';
dkimDomain: string;
dmarc: 'pass' | 'fail' | 'none';
dmarcPolicy: string;
}
export interface IEmailDetail extends IEmail {
toList: string[];
cc?: string[];
smtpLog: ISmtpLogEntry[];
connectionInfo: IConnectionInfo;
authenticationResults: IAuthenticationResults;
rejectionReason?: string;
bounceMessage?: string;
headers: Record<string, string>;
body: string;
} }
// ============================================================================ // ============================================================================
// Bounce Record Interface (matches backend BounceRecord) // Get All Emails Request
// ============================================================================ // ============================================================================
export type TBounceType = export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implementsTR<
| 'invalid_recipient'
| 'domain_not_found'
| 'mailbox_full'
| 'mailbox_inactive'
| 'blocked'
| 'spam_related'
| 'policy_related'
| 'server_unavailable'
| 'temporary_failure'
| 'quota_exceeded'
| 'network_error'
| 'timeout'
| 'auto_response'
| 'challenge_response'
| 'unknown';
export type TBounceCategory = 'hard' | 'soft' | 'auto_response' | 'unknown';
export interface IBounceRecord {
id: string;
originalEmailId?: string;
recipient: string;
sender: string;
domain: string;
subject?: string;
bounceType: TBounceType;
bounceCategory: TBounceCategory;
timestamp: number;
smtpResponse?: string;
diagnosticCode?: string;
statusCode?: string;
processed: boolean;
retryCount?: number;
nextRetryTime?: number;
}
// ============================================================================
// Security Incident Interface (matches backend ISecurityEvent)
// ============================================================================
export type TSecurityLogLevel = 'info' | 'warn' | 'error' | 'critical';
export type TSecurityEventType =
| 'authentication'
| 'access_control'
| 'email_validation'
| 'email_processing'
| 'email_forwarding'
| 'email_delivery'
| 'dkim'
| 'spf'
| 'dmarc'
| 'rate_limit'
| 'rate_limiting'
| 'spam'
| 'malware'
| 'connection'
| 'data_exposure'
| 'configuration'
| 'ip_reputation'
| 'rejected_connection';
export interface ISecurityIncident {
timestamp: number;
level: TSecurityLogLevel;
type: TSecurityEventType;
message: string;
details?: any;
ipAddress?: string;
userId?: string;
sessionId?: string;
emailId?: string;
domain?: string;
action?: string;
result?: string;
success?: boolean;
}
// ============================================================================
// Get Queued Emails Request
// ============================================================================
export interface IReq_GetQueuedEmails extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetQueuedEmails IReq_GetAllEmails
> { > {
method: 'getQueuedEmails'; method: 'getAllEmails';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
status?: TEmailQueueStatus;
limit?: number;
offset?: number;
}; };
response: { response: {
items: IEmailQueueItem[]; emails: IEmail[];
total: number;
}; };
} }
// ============================================================================ // ============================================================================
// Get Sent Emails Request // Get Email Detail Request
// ============================================================================ // ============================================================================
export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSentEmails IReq_GetEmailDetail
> { > {
method: 'getSentEmails'; method: 'getEmailDetail';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
limit?: number; emailId: string;
offset?: number;
}; };
response: { response: {
items: IEmailQueueItem[]; email: IEmailDetail | null;
total: number;
};
}
// ============================================================================
// Get Failed Emails Request
// ============================================================================
export interface IReq_GetFailedEmails extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetFailedEmails
> {
method: 'getFailedEmails';
request: {
identity?: authInterfaces.IIdentity;
limit?: number;
offset?: number;
};
response: {
items: IEmailQueueItem[];
total: number;
}; };
} }
@@ -179,61 +110,3 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
error?: string; error?: string;
}; };
} }
// ============================================================================
// Get Security Incidents Request
// ============================================================================
export interface IReq_GetSecurityIncidents extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityIncidents
> {
method: 'getSecurityIncidents';
request: {
identity?: authInterfaces.IIdentity;
type?: TSecurityEventType;
level?: TSecurityLogLevel;
limit?: number;
};
response: {
incidents: ISecurityIncident[];
total: number;
};
}
// ============================================================================
// Get Bounce Records Request
// ============================================================================
export interface IReq_GetBounceRecords extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetBounceRecords
> {
method: 'getBounceRecords';
request: {
identity?: authInterfaces.IIdentity;
limit?: number;
offset?: number;
};
response: {
records: IBounceRecord[];
suppressionList: string[];
total: number;
};
}
// ============================================================================
// Remove from Suppression List Request
// ============================================================================
export interface IReq_RemoveFromSuppressionList extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RemoveFromSuppressionList
> {
method: 'removeFromSuppressionList';
request: {
identity?: authInterfaces.IIdentity;
email: string;
};
response: {
success: boolean;
error?: string;
};
}

View File

@@ -6,3 +6,6 @@ export * from './combined.stats.js';
export * from './radius.js'; export * from './radius.js';
export * from './email-ops.js'; export * from './email-ops.js';
export * from './certificate.js'; export * from './certificate.js';
export * from './remoteingress.js';
export * from './route-management.js';
export * from './api-tokens.js';

View File

@@ -42,3 +42,15 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
logStream: plugins.typedrequestInterfaces.IVirtualStream; logStream: plugins.typedrequestInterfaces.IVirtualStream;
}; };
} }
// Push Log Entry (server → client via TypedSocket)
export interface IReq_PushLogEntry extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushLogEntry
> {
method: 'pushLogEntry';
request: {
entry: statsInterfaces.ILogEntry;
};
response: {};
}

View File

@@ -0,0 +1,140 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IRemoteIngress, IRemoteIngressStatus } from '../data/remoteingress.js';
// ============================================================================
// Remote Ingress Edge Management
// ============================================================================
/**
* Create a new remote ingress edge registration.
*/
export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateRemoteIngress
> {
method: 'createRemoteIngress';
request: {
identity?: authInterfaces.IIdentity;
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
tags?: string[];
};
response: {
success: boolean;
edge: IRemoteIngress;
};
}
/**
* Delete a remote ingress edge registration.
*/
export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteRemoteIngress
> {
method: 'deleteRemoteIngress';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Update a remote ingress edge registration.
*/
export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateRemoteIngress
> {
method: 'updateRemoteIngress';
request: {
identity?: authInterfaces.IIdentity;
id: string;
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
tags?: string[];
};
response: {
success: boolean;
edge: IRemoteIngress;
};
}
/**
* Regenerate the secret for a remote ingress edge.
*/
export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RegenerateRemoteIngressSecret
> {
method: 'regenerateRemoteIngressSecret';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
secret: string;
};
}
/**
* Get all remote ingress edge registrations.
*/
export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRemoteIngresses
> {
method: 'getRemoteIngresses';
request: {
identity?: authInterfaces.IIdentity;
};
response: {
edges: IRemoteIngress[];
};
}
/**
* Get runtime status of all remote ingress edges.
*/
export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRemoteIngressStatus
> {
method: 'getRemoteIngressStatus';
request: {
identity?: authInterfaces.IIdentity;
};
response: {
statuses: IRemoteIngressStatus[];
};
}
/**
* Get a connection token for a remote ingress edge.
* The token is a single opaque base64url string that encodes hubHost, hubPort, edgeId, and secret.
*/
export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRemoteIngressConnectionToken
> {
method: 'getRemoteIngressConnectionToken';
request: {
identity?: authInterfaces.IIdentity;
edgeId: string;
hubHost?: string;
};
response: {
success: boolean;
token?: string;
message?: string;
};
}

View File

@@ -0,0 +1,146 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Route Management Endpoints
// ============================================================================
/**
* Get all merged routes (hardcoded + programmatic) with warnings.
*/
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetMergedRoutes
> {
method: 'getMergedRoutes';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
routes: IMergedRoute[];
warnings: IRouteWarning[];
};
}
/**
* Create a new programmatic route.
*/
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateRoute
> {
method: 'createRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
route: IRouteConfig;
enabled?: boolean;
};
response: {
success: boolean;
storedRouteId?: string;
message?: string;
};
}
/**
* Update a programmatic route.
*/
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateRoute
> {
method: 'updateRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
route?: Partial<IRouteConfig>;
enabled?: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Delete a programmatic route.
*/
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteRoute
> {
method: 'deleteRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Set an override on a hardcoded route (disable/enable by name).
*/
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SetRouteOverride
> {
method: 'setRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove an override from a hardcoded route (restore default behavior).
*/
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RemoveRouteOverride
> {
method: 'removeRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Toggle a programmatic route on/off by id.
*/
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ToggleRoute
> {
method: 'toggleRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,9 @@ export * from './ops-view-network.js';
export * from './ops-view-emails.js'; export * from './ops-view-emails.js';
export * from './ops-view-logs.js'; export * from './ops-view-logs.js';
export * from './ops-view-config.js'; export * from './ops-view-config.js';
export * from './ops-view-routes.js';
export * from './ops-view-apitokens.js';
export * from './ops-view-security.js'; export * from './ops-view-security.js';
export * from './ops-view-certificates.js'; export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './shared/index.js'; export * from './shared/index.js';

View File

@@ -18,8 +18,11 @@ import { OpsViewNetwork } from './ops-view-network.js';
import { OpsViewEmails } from './ops-view-emails.js'; import { OpsViewEmails } from './ops-view-emails.js';
import { OpsViewLogs } from './ops-view-logs.js'; import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewConfig } from './ops-view-config.js'; import { OpsViewConfig } from './ops-view-config.js';
import { OpsViewRoutes } from './ops-view-routes.js';
import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './ops-view-security.js'; import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js'; import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
@customElement('ops-dashboard') @customElement('ops-dashboard')
export class OpsDashboard extends DeesElement { export class OpsDashboard extends DeesElement {
@@ -40,32 +43,54 @@ export class OpsDashboard extends DeesElement {
private viewTabs = [ private viewTabs = [
{ {
name: 'Overview', name: 'Overview',
iconName: 'lucide:layoutDashboard',
element: OpsViewOverview, element: OpsViewOverview,
}, },
{
name: 'Configuration',
iconName: 'lucide:settings',
element: OpsViewConfig,
},
{ {
name: 'Network', name: 'Network',
iconName: 'lucide:network',
element: OpsViewNetwork, element: OpsViewNetwork,
}, },
{ {
name: 'Emails', name: 'Emails',
iconName: 'lucide:mail',
element: OpsViewEmails, element: OpsViewEmails,
}, },
{ {
name: 'Logs', name: 'Logs',
iconName: 'lucide:scrollText',
element: OpsViewLogs, element: OpsViewLogs,
}, },
{ {
name: 'Configuration', name: 'Routes',
element: OpsViewConfig, iconName: 'lucide:route',
element: OpsViewRoutes,
},
{
name: 'ApiTokens',
iconName: 'lucide:key',
element: OpsViewApiTokens,
}, },
{ {
name: 'Security', name: 'Security',
iconName: 'lucide:shield',
element: OpsViewSecurity, element: OpsViewSecurity,
}, },
{ {
name: 'Certificates', name: 'Certificates',
iconName: 'lucide:badgeCheck',
element: OpsViewCertificates, element: OpsViewCertificates,
}, },
{
name: 'RemoteIngress',
iconName: 'lucide:globe',
element: OpsViewRemoteIngress,
},
]; ];
/** /**

View File

@@ -0,0 +1,285 @@
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
type TApiTokenScope = interfaces.data.TApiTokenScope;
@customElement('ops-view-apitokens')
export class OpsViewApiTokens extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
apiTokens: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
constructor() {
super();
const sub = appstate.routeManagementStatePart
.select((s) => s)
.subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(sub);
// Re-fetch tokens when user logs in (fixes race condition where
// the view is created before authentication completes)
const loginSub = appstate.loginStatePart
.select((s) => s.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.apiTokensContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.scopePill {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')};
color: ${cssManager.bdTheme('#0369a1', '#0af')};
margin-right: 4px;
margin-bottom: 2px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.statusBadge.active {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.expired {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
`,
];
public render(): TemplateResult {
const { apiTokens } = this.routeState;
return html`
<ops-sectionheading>API Tokens</ops-sectionheading>
<div class="apiTokensContainer">
<dees-table
.heading1=${'API Tokens'}
.heading2=${'Manage programmatic access tokens'}
.data=${apiTokens}
.dataName=${'token'}
.searchable=${true}
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
name: token.name,
scopes: this.renderScopePills(token.scopes),
status: this.renderStatusBadge(token),
created: new Date(token.createdAt).toLocaleDateString(),
expires: token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : 'Never',
lastUsed: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : 'Never',
})}
.dataActions=${[
{
name: 'Create Token',
iconName: 'lucide:plus',
type: ['header'],
actionFunc: async () => {
await this.showCreateTokenDialog();
},
},
{
name: 'Enable',
iconName: 'lucide:play',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleApiTokenAction,
{ id: token.id, enabled: true },
);
},
},
{
name: 'Disable',
iconName: 'lucide:pause',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleApiTokenAction,
{ id: token.id, enabled: false },
);
},
},
{
name: 'Revoke',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await appstate.routeManagementStatePart.dispatchAction(
appstate.revokeApiTokenAction,
token.id,
);
},
},
]}
></dees-table>
</div>
`;
}
private renderScopePills(scopes: TApiTokenScope[]): TemplateResult {
return html`<div style="display: flex; flex-wrap: wrap; gap: 2px;">${scopes.map(
(s) => html`<span class="scopePill">${s}</span>`,
)}</div>`;
}
private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult {
if (!token.enabled) {
return html`<span class="statusBadge disabled">Disabled</span>`;
}
if (token.expiresAt && token.expiresAt < Date.now()) {
return html`<span class="statusBadge expired">Expired</span>`;
}
return html`<span class="statusBadge active">Active</span>`;
}
private async showCreateTokenDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
const allScopes: TApiTokenScope[] = [
'routes:read',
'routes:write',
'config:read',
'tokens:read',
'tokens:manage',
];
await DeesModal.createAndShow({
heading: 'Create API Token',
content: html`
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
The token value will be shown once after creation. Copy it immediately.
</div>
<dees-form>
<dees-input-text .key=${'name'} .label=${'Token Name'} .required=${true}></dees-input-text>
<dees-input-tags
.key=${'scopes'}
.label=${'Token Scopes'}
.value=${['routes:read', 'routes:write']}
.suggestions=${allScopes}
.required=${true}
></dees-input-tags>
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:key',
action: async (modalArg: any) => {
const contentEl = modalArg.shadowRoot?.querySelector('.content');
const form = contentEl?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
if (!formData.name) return;
// dees-input-tags is not in dees-form's FORM_INPUT_TYPES, so collectFormData() won't
// include it. Query the tags input directly and call getValue().
const tagsInput = form.querySelector('dees-input-tags') as any;
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
const scopes = rawScopes
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
const expiresInDays = formData.expiresInDays
? parseInt(formData.expiresInDays, 10)
: null;
await modalArg.destroy();
try {
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays);
if (response.success && response.tokenValue) {
// Refresh the list first so it's ready when user dismisses the modal
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
// Show the token value in a new modal
await DeesModal.createAndShow({
heading: 'Token Created',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Copy this token now. It will not be shown again.</p>
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
</div>
</div>
`,
menuOptions: [
{
name: 'Done',
iconName: 'lucide:check',
action: async (m: any) => await m.destroy(),
},
],
});
}
} catch (error) {
console.error('Failed to create token:', error);
}
},
},
],
});
}
async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
}
}

View File

@@ -94,13 +94,13 @@ export class OpsViewCertificates extends DeesElement {
color: ${cssManager.bdTheme('#374151', '#d1d5db')}; color: ${cssManager.bdTheme('#374151', '#d1d5db')};
} }
.domainPills { .routePills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
} }
.domainPill { .routePill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 2px 8px; padding: 2px 8px;
@@ -125,6 +125,17 @@ export class OpsViewCertificates extends DeesElement {
white-space: nowrap; white-space: nowrap;
} }
.backoffIndicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
padding: 2px 6px;
border-radius: 4px;
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
}
.expiryInfo { .expiryInfo {
font-size: 12px; font-size: 12px;
} }
@@ -164,7 +175,7 @@ export class OpsViewCertificates extends DeesElement {
title: 'Total Certificates', title: 'Total Certificates',
value: summary.total, value: summary.total,
type: 'number', type: 'number',
icon: 'shieldHalved', icon: 'lucide:ShieldHalf',
color: '#3b82f6', color: '#3b82f6',
}, },
{ {
@@ -172,7 +183,7 @@ export class OpsViewCertificates extends DeesElement {
title: 'Valid', title: 'Valid',
value: summary.valid, value: summary.valid,
type: 'number', type: 'number',
icon: 'check', icon: 'lucide:Check',
color: '#22c55e', color: '#22c55e',
}, },
{ {
@@ -180,7 +191,7 @@ export class OpsViewCertificates extends DeesElement {
title: 'Expiring Soon', title: 'Expiring Soon',
value: summary.expiring, value: summary.expiring,
type: 'number', type: 'number',
icon: 'clock', icon: 'lucide:Clock',
color: '#f59e0b', color: '#f59e0b',
}, },
{ {
@@ -188,7 +199,7 @@ export class OpsViewCertificates extends DeesElement {
title: 'Failed / Expired', title: 'Failed / Expired',
value: summary.failed + summary.expired, value: summary.failed + summary.expired,
type: 'number', type: 'number',
icon: 'triangleExclamation', icon: 'lucide:TriangleAlert',
color: '#ef4444', color: '#ef4444',
}, },
]; ];
@@ -200,7 +211,7 @@ export class OpsViewCertificates extends DeesElement {
.gridActions=${[ .gridActions=${[
{ {
name: 'Refresh', name: 'Refresh',
iconName: 'arrowsRotate', iconName: 'lucide:RefreshCw',
action: async () => { action: async () => {
await appstate.certificateStatePart.dispatchAction( await appstate.certificateStatePart.dispatchAction(
appstate.fetchCertificateOverviewAction, appstate.fetchCertificateOverviewAction,
@@ -218,19 +229,76 @@ export class OpsViewCertificates extends DeesElement {
<dees-table <dees-table
.data=${this.certState.certificates} .data=${this.certState.certificates}
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({ .displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
Route: cert.routeName, Domain: cert.domain,
Domains: this.renderDomainPills(cert.domains), Routes: this.renderRoutePills(cert.routeNames),
Status: this.renderStatusBadge(cert.status), Status: this.renderStatusBadge(cert.status),
Source: this.renderSourceBadge(cert.source), Source: this.renderSourceBadge(cert.source),
Expires: this.renderExpiry(cert.expiryDate), Expires: this.renderExpiry(cert.expiryDate),
Error: cert.error Error: cert.backoffInfo
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>` ? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
: '', : cert.error
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
: '',
})} })}
.dataActions=${[ .dataActions=${[
{
name: 'Import Certificate',
iconName: 'lucide:upload',
type: ['header'],
actionFunc: async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Import Certificate',
content: html`
<dees-form>
<dees-input-fileupload
key="certJsonFile"
label="Certificate JSON (.tsclass.cert.json)"
accept=".json"
.multiple=${false}
required
></dees-input-fileupload>
</dees-form>
`,
menuOptions: [
{
name: 'Import',
iconName: 'lucide:upload',
action: async (modal) => {
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
const form = modal.shadowRoot.querySelector('dees-form') as any;
const formData = await form.collectFormData();
const files = formData.certJsonFile;
if (!files || files.length === 0) {
DeesToast.show({ message: 'Please select a JSON file.', type: 'warning', duration: 3000 });
return;
}
const file = files[0];
const text = await file.text();
const cert = JSON.parse(text);
if (!cert.domainName || !cert.publicKey || !cert.privateKey) {
DeesToast.show({ message: 'Invalid cert JSON: missing domainName, publicKey, or privateKey.', type: 'error', duration: 4000 });
return;
}
await appstate.certificateStatePart.dispatchAction(
appstate.importCertificateAction,
cert,
);
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 });
}
},
},
],
});
},
},
{ {
name: 'Reprovision', name: 'Reprovision',
iconName: 'arrowsRotate', iconName: 'lucide:RefreshCw',
type: ['inRow'], type: ['inRow'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const cert = actionData.item; const cert = actionData.item;
@@ -245,25 +313,82 @@ export class OpsViewCertificates extends DeesElement {
} }
await appstate.certificateStatePart.dispatchAction( await appstate.certificateStatePart.dispatchAction(
appstate.reprovisionCertificateAction, appstate.reprovisionCertificateAction,
cert.routeName, cert.domain,
); );
const { DeesToast } = await import('@design.estate/dees-catalog'); const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ DeesToast.show({
message: `Reprovisioning triggered for ${cert.routeName}`, message: `Reprovisioning triggered for ${cert.domain}`,
type: 'success', type: 'success',
duration: 3000, duration: 3000,
}); });
}, },
}, },
{
name: 'Export',
iconName: 'lucide:download',
type: ['inRow', 'contextmenu'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const { DeesToast } = await import('@design.estate/dees-catalog');
const cert = actionData.item;
try {
const response = await appstate.fetchCertificateExport(cert.domain);
if (response.success && response.cert) {
const safeDomain = cert.domain.replace(/\*/g, '_wildcard');
this.downloadJsonFile(`${safeDomain}.tsclass.cert.json`, response.cert);
DeesToast.show({ message: `Certificate exported for ${cert.domain}`, type: 'success', duration: 3000 });
} 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 });
}
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
type: ['inRow', 'contextmenu'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const cert = actionData.item;
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Delete Certificate: ${cert.domain}`,
content: html`
<div style="padding: 20px; font-size: 14px;">
<p>Are you sure you want to delete the certificate data for <strong>${cert.domain}</strong>?</p>
<p style="color: #f59e0b; margin-top: 12px;">Note: The certificate may remain in proxy memory until the next restart or reprovisioning.</p>
</div>
`,
menuOptions: [
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modal) => {
try {
await appstate.certificateStatePart.dispatchAction(
appstate.deleteCertificateAction,
cert.domain,
);
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 });
}
},
},
],
});
},
},
{ {
name: 'View Details', name: 'View Details',
iconName: 'magnifyingGlass', iconName: 'fa:magnifyingGlass',
type: ['doubleClick', 'contextmenu'], type: ['doubleClick', 'contextmenu'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const cert = actionData.item; const cert = actionData.item;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({ await DeesModal.createAndShow({
heading: `Certificate: ${cert.routeName}`, heading: `Certificate: ${cert.domain}`,
content: html` content: html`
<div style="padding: 20px;"> <div style="padding: 20px;">
<dees-dataview-codebox <dees-dataview-codebox
@@ -275,10 +400,10 @@ export class OpsViewCertificates extends DeesElement {
`, `,
menuOptions: [ menuOptions: [
{ {
name: 'Copy Route Name', name: 'Copy Domain',
iconName: 'copy', iconName: 'lucide:Copy',
action: async () => { action: async () => {
await navigator.clipboard.writeText(cert.routeName); await navigator.clipboard.writeText(cert.domain);
}, },
}, },
], ],
@@ -287,7 +412,7 @@ export class OpsViewCertificates extends DeesElement {
}, },
]} ]}
heading1="Certificate Status" heading1="Certificate Status"
heading2="TLS certificates across all routes" heading2="TLS certificates by domain"
searchable searchable
.pagination=${true} .pagination=${true}
.paginationSize=${50} .paginationSize=${50}
@@ -296,14 +421,27 @@ export class OpsViewCertificates extends DeesElement {
`; `;
} }
private renderDomainPills(domains: string[]): TemplateResult { private downloadJsonFile(filename: string, data: any): void {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
private renderRoutePills(routeNames: string[]): TemplateResult {
const maxShow = 3; const maxShow = 3;
const visible = domains.slice(0, maxShow); const visible = routeNames.slice(0, maxShow);
const remaining = domains.length - maxShow; const remaining = routeNames.length - maxShow;
return html` return html`
<span class="domainPills"> <span class="routePills">
${visible.map((d) => html`<span class="domainPill">${d}</span>`)} ${visible.map((r) => html`<span class="routePill">${r}</span>`)}
${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''} ${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
</span> </span>
`; `;
@@ -352,4 +490,16 @@ export class OpsViewCertificates extends DeesElement {
</span> </span>
`; `;
} }
private formatRetryTime(retryAfter?: string): string {
if (!retryAfter) return 'soon';
const retryDate = new Date(retryAfter);
const now = new Date();
const diffMs = retryDate.getTime() - now.getTime();
if (diffMs <= 0) return 'now';
const diffMin = Math.ceil(diffMs / 60000);
if (diffMin < 60) return `in ${diffMin}m`;
const diffHours = Math.ceil(diffMin / 60);
return `in ${diffHours}h`;
}
} }

View File

@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import { import {
DeesElement, DeesElement,
@@ -12,6 +13,8 @@ import {
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog';
@customElement('ops-view-config') @customElement('ops-view-config')
export class OpsViewConfig extends DeesElement { export class OpsViewConfig extends DeesElement {
@state() @state()
@@ -35,165 +38,19 @@ export class OpsViewConfig extends DeesElement {
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css` css`
.configSection {
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
margin-bottom: 24px;
overflow: hidden;
}
.sectionHeader {
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
padding: 16px 24px;
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
display: flex;
justify-content: space-between;
align-items: center;
}
.sectionTitle {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
display: flex;
align-items: center;
gap: 12px;
}
.sectionTitle dees-icon {
font-size: 20px;
color: ${cssManager.bdTheme('#666', '#888')};
}
.sectionContent {
padding: 24px;
}
.configField {
margin-bottom: 20px;
}
.configField:last-child {
margin-bottom: 0;
}
.fieldLabel {
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#666', '#999')};
margin-bottom: 8px;
display: block;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fieldValue {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
color: ${cssManager.bdTheme('#333', '#ccc')};
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
padding: 10px 14px;
border-radius: 6px;
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.fieldValue.empty {
color: ${cssManager.bdTheme('#999', '#666')};
font-style: italic;
}
.nestedFields {
margin-left: 16px;
padding-left: 16px;
border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
/* Status badge styles */
.statusBadge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.statusBadge.enabled {
background: ${cssManager.bdTheme('#d4edda', '#1a3d1a')};
color: ${cssManager.bdTheme('#155724', '#66cc66')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#f8d7da', '#3d1a1a')};
color: ${cssManager.bdTheme('#721c24', '#cc6666')};
}
.statusBadge dees-icon {
font-size: 14px;
}
/* Array/list display */
.arrayItems {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.arrayItem {
display: inline-flex;
align-items: center;
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
padding: 4px 12px;
border-radius: 16px;
font-size: 13px;
font-family: 'Consolas', 'Monaco', monospace;
}
.arrayCount {
font-size: 12px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-bottom: 8px;
}
/* Numeric value formatting */
.numericValue {
font-weight: 600;
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
}
.errorMessage {
background: ${cssManager.bdTheme('#fee', '#4a1f1f')};
border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')};
border-radius: 4px;
padding: 16px;
color: ${cssManager.bdTheme('#c00', '#ff6666')};
margin: 16px 0;
}
.loadingMessage { .loadingMessage {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
color: ${cssManager.bdTheme('#666', '#999')}; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
} }
.infoNote { .errorMessage {
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')}; background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.1)')};
border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')}; border: 1px solid ${cssManager.bdTheme('#fecaca', 'rgba(239,68,68,0.3)')};
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
margin-bottom: 24px; color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
color: ${cssManager.bdTheme('#004085', '#88ccff')}; margin: 16px 0;
display: flex;
align-items: center;
gap: 12px;
}
.infoNote dees-icon {
font-size: 20px;
flex-shrink: 0;
} }
`, `,
]; ];
@@ -202,185 +59,266 @@ export class OpsViewConfig extends DeesElement {
return html` return html`
<ops-sectionheading>Configuration</ops-sectionheading> <ops-sectionheading>Configuration</ops-sectionheading>
${this.configState.isLoading ? html` ${this.configState.isLoading
<div class="loadingMessage"> ? html`
<dees-spinner></dees-spinner> <div class="loadingMessage">
<p>Loading configuration...</p> <dees-spinner></dees-spinner>
</div> <p>Loading configuration...</p>
` : this.configState.error ? html`
<div class="errorMessage">
Error loading configuration: ${this.configState.error}
</div>
` : this.configState.config ? html`
<div class="infoNote">
<dees-icon icon="lucide:info"></dees-icon>
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span>
</div>
${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)}
${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)}
${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)}
${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)}
` : html`
<div class="errorMessage">No configuration loaded</div>
`}
`;
}
private renderConfigSection(key: string, title: string, icon: string, config: any) {
const isEnabled = config?.enabled ?? false;
return html`
<div class="configSection">
<div class="sectionHeader">
<h3 class="sectionTitle">
<dees-icon icon="${icon}"></dees-icon>
${title}
</h3>
${this.renderStatusBadge(isEnabled)}
</div>
<div class="sectionContent">
${config ? this.renderConfigFields(config) : html`
<div class="fieldValue empty">Not configured</div>
`}
</div>
</div>
`;
}
private renderStatusBadge(enabled: boolean): TemplateResult {
return enabled
? html`<span class="statusBadge enabled"><dees-icon icon="lucide:check"></dees-icon>Enabled</span>`
: html`<span class="statusBadge disabled"><dees-icon icon="lucide:x"></dees-icon>Disabled</span>`;
}
private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
if (!config || typeof config !== 'object') {
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
}
return Object.entries(config).map(([key, value]) => {
const fieldName = prefix ? `${prefix}.${key}` : key;
const displayName = this.formatFieldName(key);
// Handle boolean values with badges
if (typeof value === 'boolean') {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
${this.renderStatusBadge(value)}
</div>
`;
}
// Handle arrays
if (Array.isArray(value)) {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
${this.renderArrayValue(value, key)}
</div>
`;
}
// Handle nested objects
if (typeof value === 'object' && value !== null) {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
<div class="nestedFields">
${this.renderConfigFields(value, fieldName)}
</div> </div>
</div> `
`; : this.configState.error
} ? html`
<div class="errorMessage">
// Handle primitive values Error loading configuration: ${this.configState.error}
return html` </div>
<div class="configField"> `
<label class="fieldLabel">${displayName}</label> : this.configState.config
<div class="fieldValue">${this.formatValue(value, key)}</div> ? this.renderConfig()
</div> : html`<div class="errorMessage">No configuration loaded</div>`}
`;
});
}
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult {
if (arr.length === 0) {
return html`<div class="fieldValue empty">None configured</div>`;
}
// Determine if we should show as pills/tags
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number');
if (showAsPills) {
const itemLabel = this.getArrayItemLabel(fieldKey, arr.length);
return html`
<div class="arrayCount">${arr.length} ${itemLabel}</div>
<div class="arrayItems">
${arr.map(item => html`<span class="arrayItem">${item}</span>`)}
</div>
`;
}
// For complex arrays, show as JSON
return html`
<div class="fieldValue">
${arr.length} items configured
</div>
`; `;
} }
private getArrayItemLabel(fieldKey: string, count: number): string { private renderConfig(): TemplateResult {
const labels: Record<string, [string, string]> = { const cfg = this.configState.config!;
ports: ['port', 'ports'],
domains: ['domain', 'domains'],
nameservers: ['nameserver', 'nameservers'],
blockList: ['IP', 'IPs'],
};
const label = labels[fieldKey] || ['item', 'items']; return html`
return count === 1 ? label[0] : label[1]; <sz-config-overview
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
@navigate=${(e: CustomEvent) => {
if (e.detail?.view) {
appRouter.navigateToView(e.detail.view);
}
}}
>
${this.renderSystemSection(cfg.system)}
${this.renderSmartProxySection(cfg.smartProxy)}
${this.renderEmailSection(cfg.email)}
${this.renderDnsSection(cfg.dns)}
${this.renderTlsSection(cfg.tls)}
${this.renderCacheSection(cfg.cache)}
${this.renderRadiusSection(cfg.radius)}
${this.renderRemoteIngressSection(cfg.remoteIngress)}
</sz-config-overview>
`;
} }
private formatFieldName(key: string): string { private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
// Convert camelCase to readable format const fields: IConfigField[] = [
return key { key: 'Base Directory', value: sys.baseDir },
.replace(/([A-Z])/g, ' $1') { key: 'Data Directory', value: sys.dataDir },
.replace(/^./, str => str.toUpperCase()) { key: 'Public IP', value: sys.publicIp },
.trim(); { key: 'Proxy IPs', value: sys.proxyIps.length > 0 ? sys.proxyIps : null, type: 'pills' },
{ key: 'Uptime', value: this.formatUptime(sys.uptime) },
{ key: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
{ key: 'Storage Path', value: sys.storagePath },
];
return html`
<sz-config-section
title="System"
subtitle="Base paths and infrastructure"
icon="lucide:server"
status="enabled"
.fields=${fields}
></sz-config-section>
`;
} }
private formatValue(value: any, fieldKey?: string): string | TemplateResult { private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult {
if (value === null || value === undefined) { const fields: IConfigField[] = [
return html`<span class="empty">Not set</span>`; { key: 'Route Count', value: proxy.routeCount },
];
if (proxy.acme) {
fields.push(
{ key: 'ACME Enabled', value: proxy.acme.enabled, type: 'boolean' },
{ key: 'Account Email', value: proxy.acme.accountEmail || null },
{ key: 'Use Production', value: proxy.acme.useProduction, type: 'boolean' },
{ key: 'Auto Renew', value: proxy.acme.autoRenew, type: 'boolean' },
{ key: 'Renew Threshold', value: `${proxy.acme.renewThresholdDays} days` },
);
} }
if (typeof value === 'number') { const actions: IConfigSectionAction[] = [
// Format bytes { label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) { ];
return html`<span class="numericValue">${this.formatBytes(value)}</span>`;
}
// Format time values
if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) {
return html`<span class="numericValue">${value} seconds</span>`;
}
// Format port numbers
if (fieldKey?.toLowerCase().includes('port')) {
return html`<span class="numericValue">${value}</span>`;
}
// Format counts with separators
return html`<span class="numericValue">${value.toLocaleString()}</span>`;
}
return String(value); return html`
<sz-config-section
title="SmartProxy"
subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
icon="lucide:network"
.status=${proxy.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
} }
private formatBytes(bytes: number): string { private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult {
if (bytes === 0) return '0 B'; const fields: IConfigField[] = [
const k = 1024; { key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; { key: 'Hostname', value: email.hostname },
const i = Math.floor(Math.log(bytes) / Math.log(k)); { key: 'Domains', value: email.domains.length > 0 ? email.domains : null, type: 'pills' },
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; { key: 'Email Routes', value: email.emailRouteCount },
{ key: 'Received Emails Path', value: email.receivedEmailsPath },
];
if (email.portMapping) {
const mappingStr = Object.entries(email.portMapping)
.map(([ext, int]) => `${ext}${int}`)
.join(', ');
fields.splice(1, 0, { key: 'Port Mapping', value: mappingStr, type: 'code' });
}
const actions: IConfigSectionAction[] = [
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
];
return html`
<sz-config-section
title="Email Server"
subtitle="SMTP email handling with smartmta"
icon="lucide:mail"
.status=${email.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
}
private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Port', value: dns.port },
{ key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' },
{ key: 'Scopes', value: dns.scopes.length > 0 ? dns.scopes : null, type: 'pills' },
{ key: 'Record Count', value: dns.recordCount },
{ key: 'DNS Challenge', value: dns.dnsChallenge, type: 'boolean' },
];
return html`
<sz-config-section
title="DNS Server"
subtitle="Authoritative DNS with smartdns"
icon="lucide:globe"
.status=${dns.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
></sz-config-section>
`;
}
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Contact Email', value: tls.contactEmail },
{ key: 'Domain', value: tls.domain },
{ key: 'Source', value: tls.source, type: 'badge' },
{ key: 'Certificate Path', value: tls.certPath },
{ key: 'Key Path', value: tls.keyPath },
];
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
const actions: IConfigSectionAction[] = [
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
];
return html`
<sz-config-section
title="TLS / Certificates"
subtitle="Certificate management and ACME"
icon="lucide:shield-check"
.status=${status as any}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
}
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Storage Path', value: cache.storagePath },
{ key: 'DB Name', value: cache.dbName },
{ key: 'Default TTL', value: `${cache.defaultTTLDays} days` },
{ key: 'Cleanup Interval', value: `${cache.cleanupIntervalHours} hours` },
];
if (cache.ttlConfig && Object.keys(cache.ttlConfig).length > 0) {
for (const [key, val] of Object.entries(cache.ttlConfig)) {
fields.push({ key: `TTL: ${key}`, value: `${val} days` });
}
}
return html`
<sz-config-section
title="Cache Database"
subtitle="Persistent caching with smartdata"
icon="lucide:database"
.status=${cache.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
></sz-config-section>
`;
}
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Auth Port', value: radius.authPort },
{ key: 'Accounting Port', value: radius.acctPort },
{ key: 'Bind Address', value: radius.bindAddress },
{ key: 'Client Count', value: radius.clientCount },
];
if (radius.vlanDefaultVlan !== null) {
fields.push(
{ key: 'Default VLAN', value: radius.vlanDefaultVlan },
{ key: 'Allow Unknown MACs', value: radius.vlanAllowUnknownMacs, type: 'boolean' },
{ key: 'VLAN Mappings', value: radius.vlanMappingCount },
);
}
const status = radius.enabled ? 'enabled' : 'not-configured';
return html`
<sz-config-section
title="RADIUS Server"
subtitle="Network authentication and VLAN assignment"
icon="lucide:wifi"
.status=${status as any}
.fields=${fields}
></sz-config-section>
`;
}
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain },
{ key: 'TLS Configured', value: ri.tlsConfigured, type: 'boolean' },
];
const actions: IConfigSectionAction[] = [
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
];
return html`
<sz-config-section
title="Remote Ingress"
subtitle="Edge tunnel nodes"
icon="lucide:cloud"
.status=${ri.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
}
private formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
} }
} }

View File

@@ -1,8 +1,8 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element'; import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../dist_ts_interfaces/index.js';
import { appRouter } from '../router.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -10,67 +10,30 @@ declare global {
} }
} }
type TEmailFolder = 'queued' | 'sent' | 'failed' | 'received' | 'security';
@customElement('ops-view-emails') @customElement('ops-view-emails')
export class OpsViewEmails extends DeesElement { export class OpsViewEmails extends DeesElement {
@state() @state()
accessor selectedFolder: TEmailFolder = 'queued'; accessor emails: interfaces.requests.IEmail[] = [];
@state() @state()
accessor queuedEmails: interfaces.requests.IEmailQueueItem[] = []; accessor selectedEmail: interfaces.requests.IEmailDetail | null = null;
@state() @state()
accessor sentEmails: interfaces.requests.IEmailQueueItem[] = []; accessor currentView: 'list' | 'detail' = 'list';
@state()
accessor failedEmails: interfaces.requests.IEmailQueueItem[] = [];
@state()
accessor securityIncidents: interfaces.requests.ISecurityIncident[] = [];
@state()
accessor selectedEmail: interfaces.requests.IEmailQueueItem | null = null;
@state()
accessor selectedIncident: interfaces.requests.ISecurityIncident | null = null;
@state()
accessor showCompose = false;
@state() @state()
accessor isLoading = false; accessor isLoading = false;
@state()
accessor searchTerm = '';
@state()
accessor emailDomains: string[] = [];
private stateSubscription: any; private stateSubscription: any;
constructor() {
super();
this.loadData();
this.loadEmailDomains();
}
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
// Subscribe to state changes
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => { this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
this.queuedEmails = state.queuedEmails; this.emails = state.emails;
this.sentEmails = state.sentEmails;
this.failedEmails = state.failedEmails;
this.securityIncidents = state.securityIncidents;
this.isLoading = state.isLoading; this.isLoading = state.isLoading;
// Sync folder from state (e.g., when URL changes)
if (state.currentView !== this.selectedFolder) {
this.selectedFolder = state.currentView as TEmailFolder;
this.loadFolderData(state.currentView as TEmailFolder);
}
}); });
// Initial fetch
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchAllEmailsAction, null);
} }
async disconnectedCallback() { async disconnectedCallback() {
@@ -89,730 +52,58 @@ export class OpsViewEmails extends DeesElement {
height: 100%; height: 100%;
} }
.emailLayout { .viewContainer {
display: flex;
gap: 16px;
height: 100%; height: 100%;
min-height: 600px;
}
.sidebar {
flex-shrink: 0;
width: 280px;
}
.mainArea {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.emailToolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.searchBox {
flex: 1;
min-width: 200px;
max-width: 400px;
}
.emailList {
flex: 1;
overflow: hidden;
}
.emailPreview {
flex: 1;
display: flex;
flex-direction: column;
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
overflow: hidden;
}
.emailHeader {
padding: 24px;
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.emailSubject {
font-size: 24px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.emailMeta {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.emailMetaRow {
display: flex;
gap: 8px;
}
.emailMetaLabel {
font-weight: 600;
min-width: 80px;
}
.emailBody {
flex: 1;
padding: 24px;
overflow-y: auto;
font-size: 15px;
line-height: 1.6;
}
.emailActions {
display: flex;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: ${cssManager.bdTheme('#999', '#666')};
}
.emptyIcon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.emptyText {
font-size: 18px;
}
.status-pending {
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
}
.status-processing {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.status-delivered {
color: ${cssManager.bdTheme('#10b981', '#34d399')};
}
.status-failed {
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.status-deferred {
color: ${cssManager.bdTheme('#f97316', '#fb923c')};
}
.severity-info {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.severity-warn {
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
}
.severity-error {
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.severity-critical {
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
font-weight: bold;
}
.incidentDetails {
padding: 24px;
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
}
.incidentHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.incidentTitle {
font-size: 20px;
font-weight: 600;
}
.incidentMeta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.incidentField {
padding: 12px;
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
border-radius: 6px;
}
.incidentFieldLabel {
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-bottom: 4px;
}
.incidentFieldValue {
font-size: 14px;
word-break: break-all;
} }
`, `,
]; ];
public render() { public render() {
if (this.selectedEmail) {
return this.renderEmailDetail();
}
if (this.selectedIncident) {
return this.renderIncidentDetail();
}
return html` return html`
<ops-sectionheading>Email Operations</ops-sectionheading> <ops-sectionheading>Email Operations</ops-sectionheading>
<div class="viewContainer">
<!-- Toolbar --> ${this.currentView === 'detail' && this.selectedEmail
<div class="emailToolbar" style="margin-bottom: 16px;"> ? html`
<dees-button @click=${() => this.openComposeModal()} type="highlighted"> <sz-mta-detail-view
<dees-icon icon="lucide:penLine" slot="iconSlot"></dees-icon> .email=${this.selectedEmail}
Compose @back=${this.handleBack}
</dees-button> ></sz-mta-detail-view>
`
<dees-input-text : html`
class="searchBox" <sz-mta-list-view
placeholder="Search..." .emails=${this.emails}
.value=${this.searchTerm} @email-click=${this.handleEmailClick}
@input=${(e: Event) => this.searchTerm = (e.target as any).value} ></sz-mta-list-view>
> `
<dees-icon icon="lucide:search" slot="iconSlot"></dees-icon> }
</dees-input-text>
<dees-button @click=${() => this.refreshData()}>
${this.isLoading ? html`<dees-spinner slot="iconSlot" size="small"></dees-spinner>` : html`<dees-icon slot="iconSlot" icon="lucide:refreshCw"></dees-icon>`}
Refresh
</dees-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<dees-button-group>
<dees-button
@click=${() => this.selectFolder('queued')}
.type=${this.selectedFolder === 'queued' ? 'highlighted' : 'normal'}
>
Queued ${this.queuedEmails.length > 0 ? `(${this.queuedEmails.length})` : ''}
</dees-button>
<dees-button
@click=${() => this.selectFolder('sent')}
.type=${this.selectedFolder === 'sent' ? 'highlighted' : 'normal'}
>
Sent
</dees-button>
<dees-button
@click=${() => this.selectFolder('failed')}
.type=${this.selectedFolder === 'failed' ? 'highlighted' : 'normal'}
>
Failed ${this.failedEmails.length > 0 ? `(${this.failedEmails.length})` : ''}
</dees-button>
<dees-button
@click=${() => this.selectFolder('security')}
.type=${this.selectedFolder === 'security' ? 'highlighted' : 'normal'}
>
Security ${this.securityIncidents.length > 0 ? `(${this.securityIncidents.length})` : ''}
</dees-button>
</dees-button-group>
</div>
</div> </div>
${this.renderContent()}
`; `;
} }
private renderContent() { private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
switch (this.selectedFolder) { const emailSummary = e.detail;
case 'queued': try {
return this.renderEmailTable(this.queuedEmails, 'Queued Emails', 'Emails waiting to be delivered'); const context = appstate.loginStatePart.getState();
case 'sent': const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
return this.renderEmailTable(this.sentEmails, 'Sent Emails', 'Successfully delivered emails'); interfaces.requests.IReq_GetEmailDetail
case 'failed': >('/typedrequest', 'getEmailDetail');
return this.renderEmailTable(this.failedEmails, 'Failed Emails', 'Emails that failed to deliver', true);
case 'security':
return this.renderSecurityIncidents();
default:
return this.renderEmptyState('Select a folder');
}
}
private renderEmailTable( const response = await request.fire({
emails: interfaces.requests.IEmailQueueItem[], identity: context.identity,
heading1: string, emailId: emailSummary.id,
heading2: string,
showResend = false
) {
const filteredEmails = this.filterEmails(emails);
if (filteredEmails.length === 0) {
return this.renderEmptyState(`No emails in ${this.selectedFolder}`);
}
const actions = [
{
name: 'View Details',
iconName: 'lucide:eye',
type: ['doubleClick', 'inRow'] as any,
actionFunc: async (actionData: any) => {
this.selectedEmail = actionData.item;
}
}
];
if (showResend) {
actions.push({
name: 'Resend',
iconName: 'lucide:send',
type: ['inRow'] as any,
actionFunc: async (actionData: any) => {
await this.resendEmail(actionData.item.id);
}
}); });
}
return html` if (response.email) {
<dees-table this.selectedEmail = response.email;
.data=${filteredEmails} this.currentView = 'detail';
.displayFunction=${(email: interfaces.requests.IEmailQueueItem) => ({
'Status': html`<span class="status-${email.status}">${email.status}</span>`,
'From': email.from || 'N/A',
'To': email.to?.join(', ') || 'N/A',
'Subject': email.subject || 'No subject',
'Attempts': email.attempts,
'Created': this.formatDate(email.createdAt),
})}
.dataActions=${actions}
.selectionMode=${'single'}
heading1=${heading1}
heading2=${`${filteredEmails.length} emails - ${heading2}`}
></dees-table>
`;
}
private renderSecurityIncidents() {
const incidents = this.securityIncidents;
if (incidents.length === 0) {
return this.renderEmptyState('No security incidents');
}
return html`
<dees-table
.data=${incidents}
.displayFunction=${(incident: interfaces.requests.ISecurityIncident) => ({
'Severity': html`<span class="severity-${incident.level}">${incident.level.toUpperCase()}</span>`,
'Type': incident.type,
'Message': incident.message,
'IP': incident.ipAddress || 'N/A',
'Domain': incident.domain || 'N/A',
'Time': this.formatDate(incident.timestamp),
})}
.dataActions=${[
{
name: 'View Details',
iconName: 'lucide:eye',
type: ['doubleClick', 'inRow'],
actionFunc: async (actionData: any) => {
this.selectedIncident = actionData.item;
}
}
]}
.selectionMode=${'single'}
heading1="Security Incidents"
heading2=${`${incidents.length} incidents`}
></dees-table>
`;
}
private renderEmailDetail() {
if (!this.selectedEmail) return '';
return html`
<ops-sectionheading>Email Details</ops-sectionheading>
<div class="emailLayout">
<div class="sidebar">
<dees-windowbox>
<dees-button @click=${() => this.selectedEmail = null} type="secondary" style="width: 100%;">
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
Back to List
</dees-button>
</dees-windowbox>
</div>
<div class="mainArea">
<div class="emailPreview">
<div class="emailHeader">
<div class="emailSubject">${this.selectedEmail.subject || 'No subject'}</div>
<div class="emailMeta">
<div class="emailMetaRow">
<span class="emailMetaLabel">Status:</span>
<span class="status-${this.selectedEmail.status}">${this.selectedEmail.status}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">From:</span>
<span>${this.selectedEmail.from || 'N/A'}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">To:</span>
<span>${this.selectedEmail.to?.join(', ') || 'N/A'}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">Mode:</span>
<span>${this.selectedEmail.processingMode}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">Attempts:</span>
<span>${this.selectedEmail.attempts}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">Created:</span>
<span>${new Date(this.selectedEmail.createdAt).toLocaleString()}</span>
</div>
${this.selectedEmail.deliveredAt ? html`
<div class="emailMetaRow">
<span class="emailMetaLabel">Delivered:</span>
<span>${new Date(this.selectedEmail.deliveredAt).toLocaleString()}</span>
</div>
` : ''}
${this.selectedEmail.lastError ? html`
<div class="emailMetaRow">
<span class="emailMetaLabel">Last Error:</span>
<span style="color: #ef4444;">${this.selectedEmail.lastError}</span>
</div>
` : ''}
</div>
</div>
<div class="emailActions">
${this.selectedEmail.status === 'failed' ? html`
<dees-button @click=${() => this.resendEmail(this.selectedEmail!.id)} type="highlighted">
<dees-icon icon="lucide:send" slot="iconSlot"></dees-icon>
Resend
</dees-button>
` : ''}
<dees-button @click=${() => this.selectedEmail = null}>
<dees-icon icon="lucide:x" slot="iconSlot"></dees-icon>
Close
</dees-button>
</div>
</div>
</div>
</div>
`;
}
private renderIncidentDetail() {
if (!this.selectedIncident) return '';
const incident = this.selectedIncident;
return html`
<ops-sectionheading>Security Incident Details</ops-sectionheading>
<div style="margin-bottom: 16px;">
<dees-button @click=${() => this.selectedIncident = null} type="secondary">
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
Back to List
</dees-button>
</div>
<div class="incidentDetails">
<div class="incidentHeader">
<div>
<div class="incidentTitle">${incident.message}</div>
<div style="margin-top: 8px; color: #666;">
${new Date(incident.timestamp).toLocaleString()}
</div>
</div>
<span class="severity-${incident.level}" style="font-size: 16px; padding: 4px 12px; background: rgba(0,0,0,0.1); border-radius: 4px;">
${incident.level.toUpperCase()}
</span>
</div>
<div class="incidentMeta">
<div class="incidentField">
<div class="incidentFieldLabel">Type</div>
<div class="incidentFieldValue">${incident.type}</div>
</div>
${incident.ipAddress ? html`
<div class="incidentField">
<div class="incidentFieldLabel">IP Address</div>
<div class="incidentFieldValue">${incident.ipAddress}</div>
</div>
` : ''}
${incident.domain ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Domain</div>
<div class="incidentFieldValue">${incident.domain}</div>
</div>
` : ''}
${incident.emailId ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Email ID</div>
<div class="incidentFieldValue">${incident.emailId}</div>
</div>
` : ''}
${incident.userId ? html`
<div class="incidentField">
<div class="incidentFieldLabel">User ID</div>
<div class="incidentFieldValue">${incident.userId}</div>
</div>
` : ''}
${incident.action ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Action</div>
<div class="incidentFieldValue">${incident.action}</div>
</div>
` : ''}
${incident.result ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Result</div>
<div class="incidentFieldValue">${incident.result}</div>
</div>
` : ''}
${incident.success !== undefined ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Success</div>
<div class="incidentFieldValue">${incident.success ? 'Yes' : 'No'}</div>
</div>
` : ''}
</div>
${incident.details ? html`
<div style="margin-top: 24px;">
<div class="incidentFieldLabel" style="margin-bottom: 8px;">Details</div>
<pre style="background: #1a1a1a; color: #e5e5e5; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px;">
${JSON.stringify(incident.details, null, 2)}
</pre>
</div>
` : ''}
</div>
`;
}
private renderEmptyState(message: string) {
return html`
<div class="emptyState">
<dees-icon class="emptyIcon" icon="lucide:inbox"></dees-icon>
<div class="emptyText">${message}</div>
</div>
`;
}
private async openComposeModal() {
const { DeesModal } = await import('@design.estate/dees-catalog');
// Ensure domains are loaded before opening modal
if (this.emailDomains.length === 0) {
await this.loadEmailDomains();
}
await DeesModal.createAndShow({
heading: 'New Email',
width: 'large',
content: html`
<div>
<dees-form @formData=${async (e: CustomEvent) => {
await this.sendEmail(e.detail);
const modals = document.querySelectorAll('dees-modal');
modals.forEach(m => (m as any).destroy?.());
}}>
<div style="display: flex; gap: 8px; align-items: flex-end;">
<dees-input-text
key="fromUsername"
label="From"
placeholder="username"
.value=${'admin'}
required
style="flex: 1;"
></dees-input-text>
<span style="padding-bottom: 12px; font-size: 18px; color: #666;">@</span>
<dees-input-dropdown
key="fromDomain"
label=" "
.options=${this.emailDomains.length > 0
? this.emailDomains.map(domain => ({ key: domain, value: domain }))
: [{ key: 'dcrouter.local', value: 'dcrouter.local' }]}
.selectedKey=${this.emailDomains[0] || 'dcrouter.local'}
required
style="flex: 1;"
></dees-input-dropdown>
</div>
<dees-input-tags
key="to"
label="To"
placeholder="Enter recipient email addresses..."
required
></dees-input-tags>
<dees-input-tags
key="cc"
label="CC"
placeholder="Enter CC recipients..."
></dees-input-tags>
<dees-input-text
key="subject"
label="Subject"
placeholder="Enter email subject..."
required
></dees-input-text>
<dees-input-wysiwyg
key="body"
label="Message"
outputFormat="html"
></dees-input-wysiwyg>
</dees-form>
</div>
`,
menuOptions: [
{
name: 'Send',
iconName: 'lucide:send',
action: async (modalArg) => {
const form = modalArg.shadowRoot?.querySelector('dees-form') as any;
form?.submit();
}
},
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg) => await modalArg.destroy()
}
]
});
}
private filterEmails(emails: interfaces.requests.IEmailQueueItem[]): interfaces.requests.IEmailQueueItem[] {
if (!this.searchTerm) {
return emails;
}
const search = this.searchTerm.toLowerCase();
return emails.filter(e =>
(e.subject?.toLowerCase().includes(search)) ||
(e.from?.toLowerCase().includes(search)) ||
(e.to?.some(t => t.toLowerCase().includes(search)))
);
}
private selectFolder(folder: TEmailFolder) {
// Use router for navigation to update URL
appRouter.navigateToEmailFolder(folder);
// Clear selections
this.selectedEmail = null;
this.selectedIncident = null;
}
private formatDate(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = diff / (1000 * 60 * 60);
if (hours < 24) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (hours < 168) { // 7 days
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
}
private async loadData() {
this.isLoading = true;
await this.loadFolderData(this.selectedFolder);
this.isLoading = false;
}
private async loadFolderData(folder: TEmailFolder) {
switch (folder) {
case 'queued':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchQueuedEmailsAction, null);
break;
case 'sent':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSentEmailsAction, null);
break;
case 'failed':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchFailedEmailsAction, null);
break;
case 'security':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSecurityIncidentsAction, null);
break;
}
}
private async loadEmailDomains() {
try {
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
const config = appstate.configStatePart.getState().config;
if (config?.email?.domains && Array.isArray(config.email.domains) && config.email.domains.length > 0) {
this.emailDomains = config.email.domains;
} else {
this.emailDomains = ['dcrouter.local'];
} }
} catch (error) { } catch (error) {
console.error('Failed to load email domains:', error); console.error('Failed to fetch email detail:', error);
this.emailDomains = ['dcrouter.local'];
} }
} }
private async refreshData() { private handleBack() {
this.isLoading = true; this.selectedEmail = null;
await this.loadFolderData(this.selectedFolder); this.currentView = 'list';
this.isLoading = false;
}
private async sendEmail(formData: any) {
try {
console.log('Sending email:', formData);
// TODO: Implement actual email sending via API
// For now, just log the data
const fromEmail = `${formData.fromUsername || 'admin'}@${formData.fromDomain || this.emailDomains[0] || 'dcrouter.local'}`;
console.log('From:', fromEmail);
console.log('To:', formData.to);
console.log('Subject:', formData.subject);
} catch (error: any) {
console.error('Failed to send email', error);
}
}
private async resendEmail(emailId: string) {
try {
await appstate.emailOpsStatePart.dispatchAction(appstate.resendEmailAction, emailId);
this.selectedEmail = null;
} catch (error) {
console.error('Failed to resend email:', error);
}
} }
} }

View File

@@ -1,4 +1,3 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
@@ -20,6 +19,8 @@ export class OpsViewLogs extends DeesElement {
filters: {}, filters: {},
}; };
private lastPushedCount = 0;
constructor() { constructor() {
super(); super();
const subscription = appstate.logStatePart const subscription = appstate.logStatePart
@@ -33,175 +34,76 @@ export class OpsViewLogs extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css` css``,
.controls {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
}
.logContainer {
background: ${cssManager.bdTheme('#f8f9fa', '#1e1e1e')};
border-radius: 8px;
padding: 16px;
max-height: 600px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
.logEntry {
margin-bottom: 8px;
line-height: 1.5;
}
.logTimestamp {
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
margin-right: 8px;
}
.logLevel {
font-weight: bold;
margin-right: 8px;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
}
.logLevel.debug {
color: ${cssManager.bdTheme('#6a9955', '#6a9955')};
background: ${cssManager.bdTheme('rgba(106, 153, 85, 0.1)', 'rgba(106, 153, 85, 0.1)')};
}
.logLevel.info {
color: ${cssManager.bdTheme('#569cd6', '#569cd6')};
background: ${cssManager.bdTheme('rgba(86, 156, 214, 0.1)', 'rgba(86, 156, 214, 0.1)')};
}
.logLevel.warn {
color: ${cssManager.bdTheme('#ce9178', '#ce9178')};
background: ${cssManager.bdTheme('rgba(206, 145, 120, 0.1)', 'rgba(206, 145, 120, 0.1)')};
}
.logLevel.error {
color: ${cssManager.bdTheme('#f44747', '#f44747')};
background: ${cssManager.bdTheme('rgba(244, 71, 71, 0.1)', 'rgba(244, 71, 71, 0.1)')};
}
.logCategory {
color: ${cssManager.bdTheme('#c586c0', '#c586c0')};
margin-right: 8px;
}
.logMessage {
color: ${cssManager.bdTheme('#333', '#d4d4d4')};
}
.noLogs {
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
text-align: center;
padding: 40px;
}
`,
]; ];
public render() { public render() {
return html` return html`
<ops-sectionheading>Logs</ops-sectionheading> <ops-sectionheading>Logs</ops-sectionheading>
<div class="controls"> <dees-chart-log
<div class="filterGroup"> .label=${'Application Logs'}
<dees-button .autoScroll=${true}
@click=${() => this.fetchLogs()} .maxEntries=${2000}
> .showMetrics=${true}
Refresh Logs ></dees-chart-log>
</dees-button>
<dees-button
@click=${() => this.toggleStreaming()}
.type=${this.logState.isStreaming ? 'highlighted' : 'normal'}
>
${this.logState.isStreaming ? 'Stop Streaming' : 'Start Streaming'}
</dees-button>
</div>
<div class="filterGroup">
<label>Level:</label>
<dees-input-dropdown
.options=${['all', 'debug', 'info', 'warn', 'error']}
.selectedOption=${'all'}
@selectedOption=${(e) => this.updateFilter('level', e.detail)}
></dees-input-dropdown>
</div>
<div class="filterGroup">
<label>Category:</label>
<dees-input-dropdown
.options=${['all', 'smtp', 'dns', 'security', 'system', 'email']}
.selectedOption=${'all'}
@selectedOption=${(e) => this.updateFilter('category', e.detail)}
></dees-input-dropdown>
</div>
<div class="filterGroup">
<label>Limit:</label>
<dees-input-dropdown
.options=${['50', '100', '200', '500']}
.selectedOption=${'100'}
@selectedOption=${(e) => this.updateFilter('limit', e.detail)}
></dees-input-dropdown>
</div>
</div>
<div class="logContainer">
${this.logState.recentLogs.length > 0 ?
this.logState.recentLogs.map(log => html`
<div class="logEntry">
<span class="logTimestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
<span class="logLevel ${log.level}">${log.level.toUpperCase()}</span>
<span class="logCategory">[${log.category}]</span>
<span class="logMessage">${log.message}</span>
</div>
`) : html`
<div class="noLogs">No logs to display</div>
`
}
</div>
`; `;
} }
private async fetchLogs() { async connectedCallback() {
const filters = this.getActiveFilters(); super.connectedCallback();
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { this.lastPushedCount = 0;
limit: filters.limit || 100, // Only fetch if state is empty (streaming will handle new entries)
level: filters.level as 'debug' | 'info' | 'warn' | 'error' | undefined, if (this.logState.recentLogs.length === 0) {
category: filters.category as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined, await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
}); }
} }
private updateFilter(type: string, value: string) { async updated(changedProperties: Map<string, any>) {
if (value === 'all') { super.updated(changedProperties);
value = undefined; if (changedProperties.has('logState')) {
this.pushLogsToChart();
}
}
private async pushLogsToChart() {
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
if (!chartLog) return;
// Ensure the chart element has finished its own initialization
await chartLog.updateComplete;
// Wait for xterm terminal to finish initializing (CDN load)
if (!chartLog.terminalReady) {
await new Promise<void>((resolve) => {
const check = () => {
if (chartLog.terminalReady) { resolve(); return; }
setTimeout(check, 50);
};
check();
});
} }
// Update filters then fetch logs const allEntries = this.getMappedLogEntries();
this.fetchLogs(); if (this.lastPushedCount === 0 && allEntries.length > 0) {
// Initial load: push all entries
chartLog.updateLog(allEntries);
this.lastPushedCount = allEntries.length;
} else if (allEntries.length > this.lastPushedCount) {
// Incremental: only push new entries
const newEntries = allEntries.slice(this.lastPushedCount);
chartLog.updateLog(newEntries);
this.lastPushedCount = allEntries.length;
}
} }
private getActiveFilters() { private getMappedLogEntries() {
return { return this.logState.recentLogs.map((log) => ({
level: this.logState.filters.level?.[0], timestamp: new Date(log.timestamp).toISOString(),
category: this.logState.filters.category?.[0], level: log.level as 'debug' | 'info' | 'warn' | 'error',
limit: 100, message: log.message,
}; source: log.category,
}));
} }
private toggleStreaming() {
// TODO: Implement log streaming with VirtualStream
console.log('Streaming toggle not yet implemented');
}
} }

View File

@@ -47,9 +47,6 @@ export class OpsViewNetwork extends DeesElement {
private lastChartUpdate = 0; private lastChartUpdate = 0;
private chartUpdateThreshold = 1000; // Minimum ms between chart updates private chartUpdateThreshold = 1000; // Minimum ms between chart updates
private lastTrafficUpdateTime = 0;
private trafficUpdateInterval = 1000; // Update every 1 second
private requestCountHistory = new Map<number, number>(); // Track requests per time bucket
private trafficUpdateTimer: any = null; private trafficUpdateTimer: any = null;
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
private historyLoaded = false; // Whether server-side throughput history has been loaded private historyLoaded = false; // Whether server-side throughput history has been loaded
@@ -106,8 +103,6 @@ export class OpsViewNetwork extends DeesElement {
this.trafficDataIn = [...emptyData]; this.trafficDataIn = [...emptyData];
this.trafficDataOut = emptyData.map(point => ({ ...point })); this.trafficDataOut = emptyData.map(point => ({ ...point }));
this.lastTrafficUpdateTime = now;
} }
/** /**
@@ -287,7 +282,7 @@ export class OpsViewNetwork extends DeesElement {
.dataActions=${[ .dataActions=${[
{ {
name: 'View Details', name: 'View Details',
iconName: 'magnifyingGlass', iconName: 'fa:magnifyingGlass',
type: ['inRow', 'doubleClick', 'contextmenu'], type: ['inRow', 'doubleClick', 'contextmenu'],
actionFunc: async (actionData) => { actionFunc: async (actionData) => {
await this.showRequestDetails(actionData.item); await this.showRequestDetails(actionData.item);
@@ -336,7 +331,7 @@ export class OpsViewNetwork extends DeesElement {
menuOptions: [ menuOptions: [
{ {
name: 'Copy Request ID', name: 'Copy Request ID',
iconName: 'copy', iconName: 'lucide:Copy',
action: async () => { action: async () => {
await navigator.clipboard.writeText(request.id); await navigator.clipboard.writeText(request.id);
} }
@@ -413,11 +408,7 @@ export class OpsViewNetwork extends DeesElement {
const throughput = this.calculateThroughput(); const throughput = this.calculateThroughput();
const activeConnections = this.statsState.serverStats?.activeConnections || 0; const activeConnections = this.statsState.serverStats?.activeConnections || 0;
// Track requests/sec history for the trend sparkline // Build trend data from pre-computed history (mutated in updateNetworkData, not here)
this.requestsPerSecHistory.push(reqPerSec);
if (this.requestsPerSecHistory.length > 20) {
this.requestsPerSecHistory.shift();
}
const trendData = [...this.requestsPerSecHistory]; const trendData = [...this.requestsPerSecHistory];
while (trendData.length < 20) { while (trendData.length < 20) {
trendData.unshift(0); trendData.unshift(0);
@@ -429,13 +420,13 @@ export class OpsViewNetwork extends DeesElement {
title: 'Active Connections', title: 'Active Connections',
value: activeConnections, value: activeConnections,
type: 'number', type: 'number',
icon: 'plug', icon: 'lucide:Plug',
color: activeConnections > 100 ? '#f59e0b' : '#22c55e', color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`, description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
actions: [ actions: [
{ {
name: 'View Details', name: 'View Details',
iconName: 'magnifyingGlass', iconName: 'fa:magnifyingGlass',
action: async () => { action: async () => {
}, },
}, },
@@ -446,7 +437,7 @@ export class OpsViewNetwork extends DeesElement {
title: 'Requests/sec', title: 'Requests/sec',
value: reqPerSec, value: reqPerSec,
type: 'trend', type: 'trend',
icon: 'chartLine', icon: 'lucide:ChartLine',
color: '#3b82f6', color: '#3b82f6',
trendData: trendData, trendData: trendData,
description: `Total: ${this.formatNumber(this.networkState.requestsTotal || 0)} requests`, description: `Total: ${this.formatNumber(this.networkState.requestsTotal || 0)} requests`,
@@ -457,7 +448,7 @@ export class OpsViewNetwork extends DeesElement {
value: this.formatBitsPerSecond(throughput.in), value: this.formatBitsPerSecond(throughput.in),
unit: '', unit: '',
type: 'number', type: 'number',
icon: 'download', icon: 'lucide:Download',
color: '#22c55e', color: '#22c55e',
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.in || 0)}`, description: `Total: ${this.formatBytes(this.networkState.totalBytes?.in || 0)}`,
}, },
@@ -467,7 +458,7 @@ export class OpsViewNetwork extends DeesElement {
value: this.formatBitsPerSecond(throughput.out), value: this.formatBitsPerSecond(throughput.out),
unit: '', unit: '',
type: 'number', type: 'number',
icon: 'upload', icon: 'lucide:Upload',
color: '#8b5cf6', color: '#8b5cf6',
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.out || 0)}`, description: `Total: ${this.formatBytes(this.networkState.totalBytes?.out || 0)}`,
}, },
@@ -480,7 +471,7 @@ export class OpsViewNetwork extends DeesElement {
.gridActions=${[ .gridActions=${[
{ {
name: 'Export Data', name: 'Export Data',
iconName: 'fileExport', iconName: 'lucide:FileOutput',
action: async () => { action: async () => {
console.log('Export feature coming soon'); console.log('Export feature coming soon');
}, },
@@ -529,6 +520,13 @@ export class OpsViewNetwork extends DeesElement {
} }
private async updateNetworkData() { private async updateNetworkData() {
// Track requests/sec history for the trend sparkline (moved out of render)
const reqPerSec = this.networkState.requestsPerSecond || 0;
this.requestsPerSecHistory.push(reqPerSec);
if (this.requestsPerSecHistory.length > 20) {
this.requestsPerSecHistory.shift();
}
// Only update if connections changed significantly // Only update if connections changed significantly
const newConnectionCount = this.networkState.connections.length; const newConnectionCount = this.networkState.connections.length;
const oldConnectionCount = this.networkRequests.length; const oldConnectionCount = this.networkRequests.length;
@@ -602,16 +600,13 @@ export class OpsViewNetwork extends DeesElement {
y: Math.round(throughputOutMbps * 10) / 10 y: Math.round(throughputOutMbps * 10) / 10
}; };
// Efficient array updates - modify in place when possible // In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
if (this.trafficDataIn.length >= 60) { if (this.trafficDataIn.length >= 60) {
// Remove oldest and add newest this.trafficDataIn.shift();
this.trafficDataIn = [...this.trafficDataIn.slice(1), newDataPointIn]; this.trafficDataOut.shift();
this.trafficDataOut = [...this.trafficDataOut.slice(1), newDataPointOut];
} else {
// Still filling up the initial data
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
} }
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
this.lastChartUpdate = now; this.lastChartUpdate = now;
} }

View File

@@ -26,14 +26,36 @@ export class OpsViewOverview extends DeesElement {
error: null, error: null,
}; };
@state()
accessor logState: appstate.ILogState = {
recentLogs: [],
isStreaming: false,
filters: {},
};
constructor() { constructor() {
super(); super();
const subscription = appstate.statsStatePart const statsSub = appstate.statsStatePart
.select((stateArg) => stateArg) .select((stateArg) => stateArg)
.subscribe((statsState) => { .subscribe((statsState) => {
this.statsState = statsState; this.statsState = statsState;
}); });
this.rxSubscriptions.push(subscription); this.rxSubscriptions.push(statsSub);
const logSub = appstate.logStatePart
.select((stateArg) => stateArg)
.subscribe((logState) => {
this.logState = logState;
});
this.rxSubscriptions.push(logSub);
}
async connectedCallback() {
super.connectedCallback();
// Ensure logs are fetched for the overview charts
if (this.logState.recentLogs.length === 0) {
appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
}
} }
public static styles = [ public static styles = [
@@ -96,10 +118,24 @@ export class OpsViewOverview extends DeesElement {
${this.renderDnsStats()} ${this.renderDnsStats()}
<div class="chartGrid"> <div class="chartGrid">
<dees-chart-area .label=${'Email Traffic (24h)'} .data=${[]}></dees-chart-area> <dees-chart-area
<dees-chart-area .label=${'DNS Queries (24h)'} .data=${[]}></dees-chart-area> .label=${'Email Traffic (24h)'}
<dees-chart-log .label=${'Recent Events'} .data=${[]}></dees-chart-log> .series=${this.getEmailTrafficSeries()}
<dees-chart-log .label=${'Security Alerts'} .data=${[]}></dees-chart-log> .yAxisFormatter=${(val: number) => `${val}`}
></dees-chart-area>
<dees-chart-area
.label=${'DNS Queries (24h)'}
.series=${this.getDnsQuerySeries()}
.yAxisFormatter=${(val: number) => `${val}`}
></dees-chart-area>
<dees-chart-log
.label=${'Recent Events'}
.logEntries=${this.getRecentEventEntries()}
></dees-chart-log>
<dees-chart-log
.label=${'DNS Queries'}
.logEntries=${this.getDnsQueryEntries()}
></dees-chart-log>
</div> </div>
`} `}
`; `;
@@ -163,7 +199,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Server Status', title: 'Server Status',
value: this.statsState.serverStats.uptime ? 'Online' : 'Offline', value: this.statsState.serverStats.uptime ? 'Online' : 'Offline',
type: 'text', type: 'text',
icon: 'server', icon: 'lucide:Server',
color: this.statsState.serverStats.uptime ? '#22c55e' : '#ef4444', color: this.statsState.serverStats.uptime ? '#22c55e' : '#ef4444',
description: `Uptime: ${this.formatUptime(this.statsState.serverStats.uptime)}`, description: `Uptime: ${this.formatUptime(this.statsState.serverStats.uptime)}`,
}, },
@@ -172,7 +208,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Active Connections', title: 'Active Connections',
value: this.statsState.serverStats.activeConnections, value: this.statsState.serverStats.activeConnections,
type: 'number', type: 'number',
icon: 'networkWired', icon: 'lucide:Network',
color: '#3b82f6', color: '#3b82f6',
description: `Total: ${this.statsState.serverStats.totalConnections}`, description: `Total: ${this.statsState.serverStats.totalConnections}`,
}, },
@@ -181,7 +217,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Throughput In', title: 'Throughput In',
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesInPerSecond || 0), value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesInPerSecond || 0),
type: 'text', type: 'text',
icon: 'download', icon: 'lucide:Download',
color: '#22c55e', color: '#22c55e',
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesIn || 0)}`, description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesIn || 0)}`,
}, },
@@ -190,7 +226,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Throughput Out', title: 'Throughput Out',
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesOutPerSecond || 0), value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesOutPerSecond || 0),
type: 'text', type: 'text',
icon: 'upload', icon: 'lucide:Upload',
color: '#8b5cf6', color: '#8b5cf6',
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesOut || 0)}`, description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesOut || 0)}`,
}, },
@@ -199,7 +235,7 @@ export class OpsViewOverview extends DeesElement {
title: 'CPU Usage', title: 'CPU Usage',
value: cpuUsage, value: cpuUsage,
type: 'gauge', type: 'gauge',
icon: 'microchip', icon: 'lucide:Cpu',
gaugeOptions: { gaugeOptions: {
min: 0, min: 0,
max: 100, max: 100,
@@ -215,7 +251,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Memory Usage', title: 'Memory Usage',
value: memoryUsage, value: memoryUsage,
type: 'percentage', type: 'percentage',
icon: 'memory', icon: 'lucide:MemoryStick',
color: memoryUsage > 80 ? '#ef4444' : memoryUsage > 60 ? '#f59e0b' : '#22c55e', color: memoryUsage > 80 ? '#ef4444' : memoryUsage > 60 ? '#f59e0b' : '#22c55e',
description: this.statsState.serverStats.memoryUsage.actualUsageBytes !== undefined && this.statsState.serverStats.memoryUsage.maxMemoryMB !== undefined description: this.statsState.serverStats.memoryUsage.actualUsageBytes !== undefined && this.statsState.serverStats.memoryUsage.maxMemoryMB !== undefined
? `${this.formatBytes(this.statsState.serverStats.memoryUsage.actualUsageBytes)} / ${this.formatBytes(this.statsState.serverStats.memoryUsage.maxMemoryMB * 1024 * 1024)}` ? `${this.formatBytes(this.statsState.serverStats.memoryUsage.actualUsageBytes)} / ${this.formatBytes(this.statsState.serverStats.memoryUsage.maxMemoryMB * 1024 * 1024)}`
@@ -229,7 +265,7 @@ export class OpsViewOverview extends DeesElement {
.gridActions=${[ .gridActions=${[
{ {
name: 'Refresh', name: 'Refresh',
iconName: 'arrowsRotate', iconName: 'lucide:RefreshCw',
action: async () => { action: async () => {
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
}, },
@@ -251,7 +287,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Emails Sent', title: 'Emails Sent',
value: this.statsState.emailStats.sent, value: this.statsState.emailStats.sent,
type: 'number', type: 'number',
icon: 'paperPlane', icon: 'lucide:Send',
color: '#22c55e', color: '#22c55e',
description: `Delivery rate: ${(deliveryRate * 100).toFixed(1)}%`, description: `Delivery rate: ${(deliveryRate * 100).toFixed(1)}%`,
}, },
@@ -260,7 +296,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Emails Received', title: 'Emails Received',
value: this.statsState.emailStats.received, value: this.statsState.emailStats.received,
type: 'number', type: 'number',
icon: 'envelope', icon: 'lucide:Mail',
color: '#3b82f6', color: '#3b82f6',
}, },
{ {
@@ -268,7 +304,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Queued', title: 'Queued',
value: this.statsState.emailStats.queued, value: this.statsState.emailStats.queued,
type: 'number', type: 'number',
icon: 'clock', icon: 'lucide:Clock',
color: '#f59e0b', color: '#f59e0b',
description: 'Pending delivery', description: 'Pending delivery',
}, },
@@ -277,7 +313,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Failed', title: 'Failed',
value: this.statsState.emailStats.failed, value: this.statsState.emailStats.failed,
type: 'number', type: 'number',
icon: 'triangleExclamation', icon: 'lucide:TriangleAlert',
color: '#ef4444', color: '#ef4444',
description: `Bounce rate: ${(bounceRate * 100).toFixed(1)}%`, description: `Bounce rate: ${(bounceRate * 100).toFixed(1)}%`,
}, },
@@ -300,7 +336,7 @@ export class OpsViewOverview extends DeesElement {
title: 'DNS Queries', title: 'DNS Queries',
value: this.statsState.dnsStats.totalQueries, value: this.statsState.dnsStats.totalQueries,
type: 'number', type: 'number',
icon: 'globe', icon: 'lucide:Globe',
color: '#3b82f6', color: '#3b82f6',
description: 'Total queries handled', description: 'Total queries handled',
}, },
@@ -309,7 +345,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Cache Hit Rate', title: 'Cache Hit Rate',
value: cacheHitRate, value: cacheHitRate,
type: 'percentage', type: 'percentage',
icon: 'database', icon: 'lucide:Database',
color: cacheHitRate > 80 ? '#22c55e' : cacheHitRate > 60 ? '#f59e0b' : '#ef4444', color: cacheHitRate > 80 ? '#22c55e' : cacheHitRate > 60 ? '#f59e0b' : '#ef4444',
description: `${this.statsState.dnsStats.cacheHits} hits / ${this.statsState.dnsStats.cacheMisses} misses`, description: `${this.statsState.dnsStats.cacheHits} hits / ${this.statsState.dnsStats.cacheMisses} misses`,
}, },
@@ -318,7 +354,7 @@ export class OpsViewOverview extends DeesElement {
title: 'Active Domains', title: 'Active Domains',
value: this.statsState.dnsStats.activeDomains, value: this.statsState.dnsStats.activeDomains,
type: 'number', type: 'number',
icon: 'sitemap', icon: 'lucide:Network',
color: '#8b5cf6', color: '#8b5cf6',
}, },
{ {
@@ -327,7 +363,7 @@ export class OpsViewOverview extends DeesElement {
value: this.statsState.dnsStats.averageResponseTime.toFixed(1), value: this.statsState.dnsStats.averageResponseTime.toFixed(1),
unit: 'ms', unit: 'ms',
type: 'number', type: 'number',
icon: 'clockRotateLeft', icon: 'lucide:History',
color: this.statsState.dnsStats.averageResponseTime < 50 ? '#22c55e' : '#f59e0b', color: this.statsState.dnsStats.averageResponseTime < 50 ? '#22c55e' : '#f59e0b',
}, },
]; ];
@@ -337,4 +373,52 @@ export class OpsViewOverview extends DeesElement {
<dees-statsgrid .tiles=${tiles}></dees-statsgrid> <dees-statsgrid .tiles=${tiles}></dees-statsgrid>
`; `;
} }
// --- Chart data helpers ---
private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
return this.logState.recentLogs.map((log) => ({
timestamp: new Date(log.timestamp).toISOString(),
level: log.level as 'debug' | 'info' | 'warn' | 'error',
message: log.message,
source: log.category,
}));
}
private getSecurityAlertEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
const events: any[] = this.statsState.securityMetrics?.recentEvents || [];
return events.map((evt: any) => ({
timestamp: new Date(evt.timestamp).toISOString(),
level: evt.level === 'critical' || evt.level === 'error' ? 'error' as const : evt.level === 'warn' ? 'warn' as const : 'info' as const,
message: evt.message,
source: evt.type,
}));
}
private getDnsQueryEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
const queries: any[] = (this.statsState.dnsStats as any)?.recentQueries || [];
return queries.map((q: any) => ({
timestamp: new Date(q.timestamp).toISOString(),
level: q.answered ? 'info' as const : 'warn' as const,
message: `${q.type} ${q.domain} (${q.responseTimeMs}ms)`,
source: 'dns',
}));
}
private getEmailTrafficSeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
const ts = this.statsState.emailStats?.timeSeries;
if (!ts) return [];
return [
{ name: 'Sent', color: '#22c55e', data: (ts.sent || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
{ name: 'Received', color: '#3b82f6', data: (ts.received || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
];
}
private getDnsQuerySeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
const ts = this.statsState.dnsStats?.timeSeries;
if (!ts) return [];
return [
{ name: 'Queries', color: '#8b5cf6', data: (ts.queries || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
];
}
} }

View File

@@ -0,0 +1,467 @@
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-remoteingress': OpsViewRemoteIngress;
}
}
@customElement('ops-view-remoteingress')
export class OpsViewRemoteIngress extends DeesElement {
@state()
accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState();
constructor() {
super();
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
this.riState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.remoteIngressStatePart.dispatchAction(appstate.fetchRemoteIngressAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.remoteIngressContainer {
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.connected {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disconnected {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.secretDialog {
padding: 16px;
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
border-radius: 8px;
margin-bottom: 16px;
}
.secretDialog code {
display: block;
padding: 8px 12px;
background: ${cssManager.bdTheme('#1f2937', '#111827')};
color: #10b981;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
word-break: break-all;
margin: 8px 0;
user-select: all;
}
.secretDialog .warning {
font-size: 12px;
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
margin-top: 8px;
}
.portsDisplay {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.portBadge {
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')};
}
.portBadge.manual {
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
}
.portBadge.derived {
background: ${cssManager.bdTheme('#ecfdf5', '#022c22')};
color: ${cssManager.bdTheme('#047857', '#34d399')};
border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')};
}
`,
];
render(): TemplateResult {
const totalEdges = this.riState.edges.length;
const connectedEdges = this.riState.statuses.filter(s => s.connected).length;
const disconnectedEdges = totalEdges - connectedEdges;
const activeTunnels = this.riState.statuses.reduce((sum, s) => sum + s.activeTunnels, 0);
const statsTiles: IStatsTile[] = [
{
id: 'totalEdges',
title: 'Total Edges',
type: 'number',
value: totalEdges,
icon: 'lucide:server',
description: 'Registered edge nodes',
color: '#3b82f6',
},
{
id: 'connectedEdges',
title: 'Connected',
type: 'number',
value: connectedEdges,
icon: 'lucide:link',
description: 'Currently connected edges',
color: '#10b981',
},
{
id: 'disconnectedEdges',
title: 'Disconnected',
type: 'number',
value: disconnectedEdges,
icon: 'lucide:unlink',
description: 'Offline edge nodes',
color: disconnectedEdges > 0 ? '#ef4444' : '#6b7280',
},
{
id: 'activeTunnels',
title: 'Active Tunnels',
type: 'number',
value: activeTunnels,
icon: 'lucide:cable',
description: 'Active client connections',
color: '#8b5cf6',
},
];
return html`
<ops-sectionheading>Remote Ingress</ops-sectionheading>
${this.riState.newEdgeId ? html`
<div class="secretDialog">
<strong>Edge created successfully!</strong>
<div class="warning">Copy the connection token now. Use it with edge.start({ token: '...' }).</div>
<dees-button
@click=${async () => {
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
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);
} else {
const textarea = document.createElement('textarea');
textarea.value = response.token;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
DeesToast.show({ message: 'Connection token copied!', type: 'success', duration: 3000 });
} 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 });
}
}}
>Copy Connection Token</dees-button>
<dees-button
@click=${() => appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeIdAction, null)}
>Dismiss</dees-button>
</div>
` : ''}
<div class="remoteIngressContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Edge Nodes'}
.heading2=${'Manage remote ingress edge registrations'}
.data=${this.riState.edges}
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
name: edge.name,
status: this.getEdgeStatusHtml(edge),
publicIp: this.getEdgePublicIp(edge.id),
ports: this.getPortsHtml(edge),
tunnels: this.getEdgeTunnelCount(edge.id),
lastHeartbeat: this.getLastHeartbeat(edge.id),
})}
.dataActions=${[
{
name: 'Create Edge Node',
iconName: 'lucide:plus',
type: ['header'],
actionFunc: async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
const modal = await DeesModal.createAndShow({
heading: 'Create Edge Node',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Additional Manual Ports (comma-separated, optional)'}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
const name = formData.name;
if (!name) return;
const portsStr = formData.listenPorts?.trim();
const listenPorts = portsStr
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: undefined;
const autoDerivePorts = formData.autoDerivePorts !== false;
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.createRemoteIngressAction,
{ name, listenPorts, autoDerivePorts, tags },
);
await modalArg.destroy();
},
},
],
});
},
},
{
name: 'Enable',
iconName: 'lucide:play',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.toggleRemoteIngressAction,
{ id: edge.id, enabled: true },
);
},
},
{
name: 'Disable',
iconName: 'lucide:pause',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.toggleRemoteIngressAction,
{ id: edge.id, enabled: false },
);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Edit Edge: ${edge.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports (comma-separated)'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated)'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Save',
iconName: 'lucide:check',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
const portsStr = formData.listenPorts?.trim();
const listenPorts = portsStr
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: [];
const autoDerivePorts = formData.autoDerivePorts !== false;
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
await appstate.remoteIngressStatePart.dispatchAction(
appstate.updateRemoteIngressAction,
{
id: edge.id,
name: formData.name || edge.name,
listenPorts,
autoDerivePorts,
tags,
},
);
await modalArg.destroy();
},
},
],
});
},
},
{
name: 'Regenerate Secret',
iconName: 'lucide:key',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.regenerateRemoteIngressSecretAction,
edge.id,
);
},
},
{
name: 'Copy Token',
iconName: 'lucide:ClipboardCopy',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
const response = await appstate.fetchConnectionToken(edge.id);
if (response.success && response.token) {
// Use clipboard API with fallback for non-HTTPS contexts
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(response.token);
} else {
const textarea = document.createElement('textarea');
textarea.value = response.token;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
DeesToast.show({ message: `Connection token copied for ${edge.name}`, type: 'success', duration: 3000 });
} 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 });
}
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const edge = actionData.item as interfaces.data.IRemoteIngress;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.deleteRemoteIngressAction,
edge.id,
);
},
},
]}
></dees-table>
</div>
`;
}
private getEdgeStatus(edgeId: string): interfaces.data.IRemoteIngressStatus | undefined {
return this.riState.statuses.find(s => s.edgeId === edgeId);
}
private getEdgeStatusHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
if (!edge.enabled) {
return html`<span class="statusBadge disabled">Disabled</span>`;
}
const status = this.getEdgeStatus(edge.id);
if (status?.connected) {
return html`<span class="statusBadge connected">Connected</span>`;
}
return html`<span class="statusBadge disconnected">Disconnected</span>`;
}
private getEdgePublicIp(edgeId: string): string {
const status = this.getEdgeStatus(edgeId);
return status?.publicIp || '-';
}
private getPortsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
const manualPorts = edge.manualPorts || [];
const derivedPorts = edge.derivedPorts || [];
if (manualPorts.length === 0 && derivedPorts.length === 0) {
return html`<span style="color: var(--text-muted, #6b7280); font-size: 12px;">none</span>`;
}
return html`<div class="portsDisplay">${manualPorts.map(p => html`<span class="portBadge manual">${p}</span>`)}${derivedPorts.map(p => html`<span class="portBadge derived">${p}</span>`)}${derivedPorts.length > 0 ? html`<span style="font-size: 11px; color: var(--text-muted, #6b7280); align-self: center;">(auto)</span>` : ''}</div>`;
}
private getEdgeTunnelCount(edgeId: string): number {
const status = this.getEdgeStatus(edgeId);
return status?.activeTunnels || 0;
}
private getLastHeartbeat(edgeId: string): string {
const status = this.getEdgeStatus(edgeId);
if (!status?.lastHeartbeat) return '-';
const ago = Date.now() - status.lastHeartbeat;
if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`;
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
return `${Math.floor(ago / 3600000)}h ago`;
}
}

View File

@@ -0,0 +1,389 @@
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ops-view-routes')
export class OpsViewRoutes extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
apiTokens: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
constructor() {
super();
const sub = appstate.routeManagementStatePart
.select((s) => s)
.subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(sub);
// Re-fetch routes when user logs in (fixes race condition where
// the view is created before authentication completes)
const loginSub = appstate.loginStatePart
.select((s) => s.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.routesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.warnings-bar {
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
border-radius: 8px;
padding: 12px 16px;
}
.warning-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
color: ${cssManager.bdTheme('#b45309', '#fa0')};
}
.warning-icon {
flex-shrink: 0;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: ${cssManager.bdTheme('#6b7280', '#666')};
}
.empty-state p {
margin: 8px 0;
}
`,
];
public render(): TemplateResult {
const { mergedRoutes, warnings } = this.routeState;
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
const statsTiles: IStatsTile[] = [
{
id: 'totalRoutes',
title: 'Total Routes',
type: 'number',
value: mergedRoutes.length,
icon: 'lucide:route',
description: 'All configured routes',
color: '#3b82f6',
},
{
id: 'hardcoded',
title: 'Hardcoded',
type: 'number',
value: hardcodedCount,
icon: 'lucide:lock',
description: 'Routes from constructor config',
color: '#8b5cf6',
},
{
id: 'programmatic',
title: 'Programmatic',
type: 'number',
value: programmaticCount,
icon: 'lucide:code',
description: 'Routes added via API',
color: '#0ea5e9',
},
{
id: 'disabled',
title: 'Disabled',
type: 'number',
value: disabledCount,
icon: 'lucide:pauseCircle',
description: 'Currently disabled routes',
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
},
];
// Map merged routes to sz-route-list-view format
const szRoutes = mergedRoutes.map((mr) => {
const tags = [...(mr.route.tags || [])];
tags.push(mr.source);
if (!mr.enabled) tags.push('disabled');
if (mr.overridden) tags.push('overridden');
return {
...mr.route,
enabled: mr.enabled,
tags,
id: mr.storedRouteId || mr.route.name || undefined,
};
});
return html`
<ops-sectionheading>Route Management</ops-sectionheading>
<div class="routesContainer">
<dees-statsgrid
.tiles=${statsTiles}
.gridActions=${[
{
name: 'Add Route',
iconName: 'lucide:plus',
action: () => this.showCreateRouteDialog(),
},
{
name: 'Refresh',
iconName: 'lucide:refreshCw',
action: () => this.refreshData(),
},
]}
></dees-statsgrid>
${warnings.length > 0
? html`
<div class="warnings-bar">
${warnings.map(
(w) => html`
<div class="warning-item">
<span class="warning-icon">&#9888;</span>
<span>${w.message}</span>
</div>
`,
)}
</div>
`
: ''}
${szRoutes.length > 0
? html`
<sz-route-list-view
.routes=${szRoutes}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
></sz-route-list-view>
`
: html`
<div class="empty-state">
<p>No routes configured</p>
<p>Add a programmatic route or check your constructor configuration.</p>
</div>
`}
</div>
`;
}
private async handleRouteClick(e: CustomEvent) {
const clickedRoute = e.detail;
if (!clickedRoute) return;
// Find the corresponding merged route
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog');
if (merged.source === 'hardcoded') {
const menuOptions = merged.enabled
? [
{
name: 'Disable Route',
iconName: 'lucide:pause',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: false },
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
]
: [
{
name: 'Enable Route',
iconName: 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: true },
);
await modalArg.destroy();
},
},
{
name: 'Remove Override',
iconName: 'lucide:undo',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.removeRouteOverrideAction,
merged.route.name!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
];
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
</div>
`,
menuOptions,
});
} else {
// Programmatic route
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
</div>
`,
menuOptions: [
{
name: merged.enabled ? 'Disable' : 'Enable',
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleRouteAction,
{ id: merged.storedRouteId!, enabled: !merged.enabled },
);
await modalArg.destroy();
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction,
merged.storedRouteId!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
}
}
private async showCreateRouteDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Add Programmatic Route',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, optional)'}></dees-input-text>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .value=${'localhost'} .required=${true}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .required=${true}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
if (!formData.name || !formData.ports) return;
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
const domains = formData.domains
? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean)
: undefined;
const route: any = {
name: formData.name,
match: {
ports,
...(domains && domains.length > 0 ? { domains } : {}),
},
action: {
type: 'forward',
targets: [
{
host: formData.targetHost || 'localhost',
port: parseInt(formData.targetPort, 10),
},
],
},
};
await appstate.routeManagementStatePart.dispatchAction(
appstate.createRouteAction,
{ route },
);
await modalArg.destroy();
},
},
],
});
}
private refreshData() {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
}

View File

@@ -250,13 +250,20 @@ export class OpsViewSecurity extends DeesElement {
const threatLevel = this.calculateThreatLevel(metrics); const threatLevel = this.calculateThreatLevel(metrics);
const threatScore = this.getThreatScore(metrics); const threatScore = this.getThreatScore(metrics);
// Derive active sessions from recent successful auth events (last hour)
const allEvents: any[] = metrics.recentEvents || [];
const oneHourAgo = Date.now() - 3600000;
const recentAuthSuccesses = allEvents.filter(
(evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo
).length;
const tiles: IStatsTile[] = [ const tiles: IStatsTile[] = [
{ {
id: 'threatLevel', id: 'threatLevel',
title: 'Threat Level', title: 'Threat Level',
value: threatScore, value: threatScore,
type: 'gauge', type: 'gauge',
icon: 'shield', icon: 'lucide:Shield',
gaugeOptions: { gaugeOptions: {
min: 0, min: 0,
max: 100, max: 100,
@@ -271,27 +278,27 @@ export class OpsViewSecurity extends DeesElement {
{ {
id: 'blockedThreats', id: 'blockedThreats',
title: 'Blocked Threats', title: 'Blocked Threats',
value: metrics.blockedIPs.length + metrics.spamDetected, value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected,
type: 'number', type: 'number',
icon: 'userShield', icon: 'lucide:ShieldCheck',
color: '#ef4444', color: '#ef4444',
description: 'Total threats blocked today', description: 'Total threats blocked today',
}, },
{ {
id: 'activeSessions', id: 'activeSessions',
title: 'Active Sessions', title: 'Active Sessions',
value: 0, value: recentAuthSuccesses,
type: 'number', type: 'number',
icon: 'users', icon: 'lucide:Users',
color: '#22c55e', color: '#22c55e',
description: 'Current authenticated sessions', description: 'Authenticated in last hour',
}, },
{ {
id: 'authFailures', id: 'authFailures',
title: 'Auth Failures', title: 'Auth Failures',
value: metrics.authenticationFailures, value: metrics.authenticationFailures,
type: 'number', type: 'number',
icon: 'lockOpen', icon: 'lucide:LockOpen',
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b', color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
description: 'Failed login attempts today', description: 'Failed login attempts today',
}, },
@@ -349,27 +356,41 @@ export class OpsViewSecurity extends DeesElement {
} }
private renderAuthentication(metrics: any) { private renderAuthentication(metrics: any) {
// Derive auth events from recentEvents
const allEvents: any[] = metrics.recentEvents || [];
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
const tiles: IStatsTile[] = [ const tiles: IStatsTile[] = [
{ {
id: 'authFailures', id: 'authFailures',
title: 'Authentication Failures', title: 'Authentication Failures',
value: metrics.authenticationFailures, value: metrics.authenticationFailures,
type: 'number', type: 'number',
icon: 'lockOpen', icon: 'lucide:LockOpen',
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b', color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
description: 'Failed authentication attempts today', description: 'Failed authentication attempts today',
}, },
{ {
id: 'successfulLogins', id: 'successfulLogins',
title: 'Successful Logins', title: 'Successful Logins',
value: 0, value: successfulLogins,
type: 'number', type: 'number',
icon: 'lock', icon: 'lucide:Lock',
color: '#22c55e', color: '#22c55e',
description: 'Successful logins today', description: 'Successful logins today',
}, },
]; ];
// Map auth events to login history table data
const loginHistory = authEvents.map((evt: any) => ({
timestamp: evt.timestamp,
username: evt.details?.username || 'unknown',
ipAddress: evt.ipAddress || 'unknown',
success: evt.success ?? false,
reason: evt.success ? '' : evt.message || 'Authentication failed',
}));
return html` return html`
<dees-statsgrid <dees-statsgrid
.tiles=${tiles} .tiles=${tiles}
@@ -380,7 +401,7 @@ export class OpsViewSecurity extends DeesElement {
<dees-table <dees-table
.heading1=${'Login History'} .heading1=${'Login History'}
.heading2=${'Recent authentication attempts'} .heading2=${'Recent authentication attempts'}
.data=${[]} .data=${loginHistory}
.displayFunction=${(item) => ({ .displayFunction=${(item) => ({
'Time': new Date(item.timestamp).toLocaleString(), 'Time': new Date(item.timestamp).toLocaleString(),
'Username': item.username, 'Username': item.username,
@@ -399,7 +420,7 @@ export class OpsViewSecurity extends DeesElement {
title: 'Malware Detection', title: 'Malware Detection',
value: metrics.malwareDetected, value: metrics.malwareDetected,
type: 'number', type: 'number',
icon: 'virusSlash', icon: 'lucide:BugOff',
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e', color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
description: 'Malware detected', description: 'Malware detected',
}, },
@@ -408,7 +429,7 @@ export class OpsViewSecurity extends DeesElement {
title: 'Phishing Detection', title: 'Phishing Detection',
value: metrics.phishingDetected, value: metrics.phishingDetected,
type: 'number', type: 'number',
icon: 'fishFins', icon: 'lucide:Fish',
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e', color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
description: 'Phishing attempts detected', description: 'Phishing attempts detected',
}, },
@@ -417,7 +438,7 @@ export class OpsViewSecurity extends DeesElement {
title: 'Suspicious Activities', title: 'Suspicious Activities',
value: metrics.suspiciousActivities, value: metrics.suspiciousActivities,
type: 'number', type: 'number',
icon: 'triangleExclamation', icon: 'lucide:TriangleAlert',
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b', color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
description: 'Suspicious activities detected', description: 'Suspicious activities detected',
}, },
@@ -426,7 +447,7 @@ export class OpsViewSecurity extends DeesElement {
title: 'Spam Detection', title: 'Spam Detection',
value: metrics.spamDetected, value: metrics.spamDetected,
type: 'number', type: 'number',
icon: 'ban', icon: 'lucide:Ban',
color: '#f59e0b', color: '#f59e0b',
description: 'Spam emails blocked', description: 'Spam emails blocked',
}, },
@@ -483,48 +504,38 @@ export class OpsViewSecurity extends DeesElement {
private getThreatScore(metrics: any): number { private getThreatScore(metrics: any): number {
// Simple scoring algorithm // Simple scoring algorithm
let score = 100; let score = 100;
score -= metrics.blockedIPs.length * 2; const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0);
score -= metrics.authenticationFailures * 1; score -= blockedCount * 2;
score -= metrics.spamDetected * 0.5; score -= (metrics.authenticationFailures || 0) * 1;
score -= metrics.malwareDetected * 3; score -= (metrics.spamDetected || 0) * 0.5;
score -= metrics.phishingDetected * 3; score -= (metrics.malwareDetected || 0) * 3;
score -= metrics.suspiciousActivities * 2; score -= (metrics.phishingDetected || 0) * 3;
score -= (metrics.suspiciousActivities || 0) * 2;
return Math.max(0, Math.min(100, Math.round(score))); return Math.max(0, Math.min(100, Math.round(score)));
} }
private getSecurityEvents(metrics: any): any[] { private getSecurityEvents(metrics: any): any[] {
// Mock data - in real implementation, this would come from the server const events: any[] = metrics.recentEvents || [];
return [ return events.map((evt: any) => ({
{ timestamp: evt.timestamp,
timestamp: Date.now() - 1000 * 60 * 5, event: evt.message,
event: 'Multiple failed login attempts', severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info',
severity: 'warning', details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type,
details: 'IP: 192.168.1.100', }));
},
{
timestamp: Date.now() - 1000 * 60 * 15,
event: 'SPF check failed',
severity: 'medium',
details: 'Domain: example.com',
},
{
timestamp: Date.now() - 1000 * 60 * 30,
event: 'IP blocked due to spam',
severity: 'high',
details: 'IP: 10.0.0.1',
},
];
} }
private async clearBlockedIPs() { private async clearBlockedIPs() {
console.log('Clear blocked IPs'); // SmartProxy manages IP blocking — not yet exposed via API
alert('Clearing blocked IPs is not yet supported from the UI.');
} }
private async unblockIP(ip: string) { private async unblockIP(ip: string) {
console.log('Unblock IP:', ip); // SmartProxy manages IP blocking — not yet exposed via API
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
} }
private async saveEmailSecuritySettings() { private async saveEmailSecuritySettings() {
console.log('Save email security settings'); // Config is read-only from the UI for now
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
} }
} }

View File

@@ -2,9 +2,17 @@
import * as deesElement from '@design.estate/dees-element'; import * as deesElement from '@design.estate/dees-element';
import * as deesCatalog from '@design.estate/dees-catalog'; import * as deesCatalog from '@design.estate/dees-catalog';
// @serve.zone scope
import * as szCatalog from '@serve.zone/catalog';
// TypedSocket for real-time push communication
import * as typedsocket from '@api.global/typedsocket';
export { export {
deesElement, deesElement,
deesCatalog deesCatalog,
szCatalog,
typedsocket,
} }
// domtools gives us TypedRequest and other utilities // domtools gives us TypedRequest and other utilities

View File

@@ -34,6 +34,22 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Security** — Security incidents from email processing - **Security** — Security incidents from email processing
- Bounce record management and suppression list controls - Bounce record management and suppression list controls
### 🔐 Certificate Management
- Domain-centric certificate overview with status indicators
- Certificate source tracking (ACME, provision function, static)
- Expiry date monitoring and alerts
- Per-domain backoff status for failed provisions
- One-click reprovisioning per domain
- Certificate import, export, and deletion
### 🌍 Remote Ingress Management
- Edge node registration with name, ports, and tags
- Real-time connection status (connected/disconnected/disabled)
- Public IP and active tunnel count per edge
- Auto-derived port display with manual/derived breakdown
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
- Enable/disable, edit, secret regeneration, and delete actions
### 📜 Log Viewer ### 📜 Log Viewer
- Real-time log streaming - Real-time log streaming
- Filter by log level (error, warning, info, debug) - Filter by log level (error, warning, info, debug)
@@ -77,6 +93,8 @@ ts_web/
├── ops-view-overview.ts # Overview statistics ├── ops-view-overview.ts # Overview statistics
├── ops-view-network.ts # Network monitoring ├── ops-view-network.ts # Network monitoring
├── ops-view-emails.ts # Email queue management ├── ops-view-emails.ts # Email queue management
├── ops-view-certificates.ts # Certificate overview & reprovisioning
├── ops-view-remoteingress.ts # Remote ingress edge management
├── ops-view-logs.ts # Log viewer ├── ops-view-logs.ts # Log viewer
├── ops-view-config.ts # Configuration display ├── ops-view-config.ts # Configuration display
├── ops-view-security.ts # Security dashboard ├── ops-view-security.ts # Security dashboard
@@ -98,6 +116,8 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
| `logStatePart` | Soft | Recent logs, streaming status, filters | | `logStatePart` | Soft | Recent logs, streaming status, filters |
| `networkStatePart` | Soft | Connections, IPs, throughput rates | | `networkStatePart` | Soft | Connections, IPs, throughput rates |
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list | | `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
### Actions ### Actions
@@ -120,6 +140,23 @@ fetchSecurityIncidentsAction() // Security events
fetchBounceRecordsAction() // Bounce records fetchBounceRecordsAction() // Bounce records
resendEmailAction(emailId) // Re-queue failed email resendEmailAction(emailId) // Re-queue failed email
removeFromSuppressionAction(email) // Remove from suppression list removeFromSuppressionAction(email) // Remove from suppression list
// Certificates
fetchCertificateOverviewAction() // All certificates with summary
reprovisionCertificateAction(domain) // Reprovision a certificate
deleteCertificateAction(domain) // Delete a certificate
importCertificateAction(cert) // Import a certificate
fetchCertificateExport(domain) // Export (standalone function)
// Remote Ingress
fetchRemoteIngressAction() // Edges + statuses
createRemoteIngressAction(data) // Create new edge
updateRemoteIngressAction(data) // Update edge settings
deleteRemoteIngressAction(id) // Remove edge
regenerateRemoteIngressSecretAction(id) // New secret
toggleRemoteIngressAction(id, enabled) // Enable/disable
clearNewEdgeSecretAction() // Dismiss secret banner
fetchConnectionToken(edgeId) // Get connection token (standalone function)
``` ```
### Client-Side Routing ### Client-Side Routing
@@ -132,6 +169,8 @@ removeFromSuppressionAction(email) // Remove from suppression list
/emails/sent → Sent emails /emails/sent → Sent emails
/emails/failed → Failed emails /emails/failed → Failed emails
/emails/security → Security incidents /emails/security → Security incidents
/certificates → Certificate management
/remoteingress → Remote ingress edge management
/logs → Log viewer /logs → Log viewer
/configuration → System configuration /configuration → System configuration
/security → Security dashboard /security → Security dashboard

View File

@@ -3,11 +3,9 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'] as const; export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
export type TValidView = typeof validViews[number]; export type TValidView = typeof validViews[number];
export type TValidEmailFolder = typeof validEmailFolders[number];
class AppRouter { class AppRouter {
private router: InstanceType<typeof SmartRouter>; private router: InstanceType<typeof SmartRouter>;
@@ -27,31 +25,10 @@ class AppRouter {
} }
private setupRoutes(): void { private setupRoutes(): void {
// Main views
for (const view of validViews) { for (const view of validViews) {
if (view === 'emails') { this.router.on(`/${view}`, async () => {
// Email root - default to queued this.updateViewState(view);
this.router.on('/emails', async () => { });
this.updateViewState('emails');
this.updateEmailFolder('queued');
});
// Email with folder parameter
this.router.on('/emails/:folder', async (routeInfo) => {
const folder = routeInfo.params.folder as string;
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
this.updateViewState('emails');
this.updateEmailFolder(folder as TValidEmailFolder);
} else {
// Invalid folder, redirect to queued
this.navigateTo('/emails/queued');
}
});
} else {
this.router.on(`/${view}`, async () => {
this.updateViewState(view);
});
}
} }
// Root redirect // Root redirect
@@ -61,60 +38,32 @@ class AppRouter {
} }
private setupStateSync(): void { private setupStateSync(): void {
// Sync URL when state changes programmatically (not from router)
appstate.uiStatePart.state.subscribe((uiState) => { appstate.uiStatePart.state.subscribe((uiState) => {
if (this.suppressStateUpdate) return; if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const expectedPath = this.getExpectedPath(uiState.activeView); const expectedPath = `/${uiState.activeView}`;
// Only update URL if it doesn't match current state if (currentPath !== expectedPath) {
if (!currentPath.startsWith(expectedPath)) {
this.suppressStateUpdate = true; this.suppressStateUpdate = true;
if (uiState.activeView === 'emails') { this.router.pushUrl(expectedPath);
const emailState = appstate.emailOpsStatePart.getState();
this.router.pushUrl(`/emails/${emailState.currentView}`);
} else {
this.router.pushUrl(`/${uiState.activeView}`);
}
this.suppressStateUpdate = false; this.suppressStateUpdate = false;
} }
}); });
} }
private getExpectedPath(view: string): string {
if (view === 'emails') {
return '/emails';
}
return `/${view}`;
}
private handleInitialRoute(): void { private handleInitialRoute(): void {
const path = window.location.pathname; const path = window.location.pathname;
if (!path || path === '/') { if (!path || path === '/') {
// Redirect root to overview
this.router.pushUrl('/overview'); this.router.pushUrl('/overview');
} else { } else {
// Parse current path and update state
const segments = path.split('/').filter(Boolean); const segments = path.split('/').filter(Boolean);
const view = segments[0]; const view = segments[0];
if (validViews.includes(view as TValidView)) { if (validViews.includes(view as TValidView)) {
this.updateViewState(view as TValidView); this.updateViewState(view as TValidView);
if (view === 'emails' && segments[1]) {
const folder = segments[1];
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
this.updateEmailFolder(folder as TValidEmailFolder);
} else {
this.updateEmailFolder('queued');
}
} else if (view === 'emails') {
this.updateEmailFolder('queued');
}
} else { } else {
// Invalid view, redirect to overview
this.router.pushUrl('/overview'); this.router.pushUrl('/overview');
} }
} }
@@ -132,18 +81,6 @@ class AppRouter {
this.suppressStateUpdate = false; this.suppressStateUpdate = false;
} }
private updateEmailFolder(folder: TValidEmailFolder): void {
this.suppressStateUpdate = true;
const currentState = appstate.emailOpsStatePart.getState();
if (currentState.currentView !== folder) {
appstate.emailOpsStatePart.setState({
...currentState,
currentView: folder as appstate.IEmailOpsState['currentView'],
});
}
this.suppressStateUpdate = false;
}
public navigateTo(path: string): void { public navigateTo(path: string): void {
this.router.pushUrl(path); this.router.pushUrl(path);
} }
@@ -156,22 +93,10 @@ class AppRouter {
} }
} }
public navigateToEmailFolder(folder: string): void {
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
this.navigateTo(`/emails/${folder}`);
} else {
this.navigateTo('/emails/queued');
}
}
public getCurrentView(): string { public getCurrentView(): string {
return appstate.uiStatePart.getState().activeView; return appstate.uiStatePart.getState().activeView;
} }
public getCurrentEmailFolder(): string {
return appstate.emailOpsStatePart.getState().currentView;
}
public destroy(): void { public destroy(): void {
this.router.destroy(); this.router.destroy();
this.initialized = false; this.initialized = false;