Compare commits

...

58 Commits

Author SHA1 Message Date
670b67eecf v11.0.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-03-04 07:32:50 +00:00
174af5cf86 fix(): no changes 2026-03-04 07:32:50 +00:00
a1f5e45e94 v11.0.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-03-04 07:31:37 +00:00
d06165bd0c fix(): no changes detected 2026-03-04 07:31:37 +00:00
8f3c6fdf23 v11.0.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-03-04 07:30:26 +00:00
106ef2919e fix(dcrouter): no changes detected; no files were modified 2026-03-04 07:30:26 +00:00
3d7fd233cf v11.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-03-04 01:11:19 +00:00
34d40f7370 fix(auth): treat expired JWTs as no identity, improve logout and token verification flow, and bump deps 2026-03-04 01:11:19 +00:00
89b9d01628 v11.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-03-03 21:39:20 +00:00
ed3964e892 BREAKING CHANGE(opsserver): Require authentication for OpsServer endpoints, split handlers into authenticated view/admin routers, and make identity required on many TypedRequest interfaces 2026-03-03 21:39:20 +00:00
baab152fd3 v10.1.9
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-03 16:19:42 +00:00
9baf09ff61 fix(deps): bump @push.rocks/smartproxy to ^25.9.1 2026-03-03 16:19:42 +00:00
71f23302d3 v10.1.8
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-03 11:49:28 +00:00
ecbaab3000 fix(deps): bump dependencies: @push.rocks/smartmetrics to ^3.0.2, @push.rocks/smartproxy to ^25.9.0, @serve.zone/remoteingress to ^4.4.0 2026-03-03 11:49:28 +00:00
8cb1f3c12d v10.1.7
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-03 07:29:03 +00:00
c7d7f92759 fix(ops-view-apitokens): use correct lucide icon name for roll/rotate actions in API tokens view 2026-03-03 07:29:03 +00:00
02e1b9231f v10.1.6
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-02 22:32:21 +00:00
4ec4dd2bdb fix(ts_web): use actionContext for dispatches in web state actions and bump @push.rocks/smartstate to ^2.2.0 2026-03-02 22:32:21 +00:00
aa543160e2 v10.1.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-03-02 15:06:26 +00:00
94fa0f04d8 fix(monitoring): use a per-second ring buffer for DNS query metrics, improve DNS logging rate limiting and security event aggregation, and bump smartmta dependency 2026-03-02 15:06:26 +00:00
17deb481e0 v10.1.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-03-02 12:37:44 +00:00
e452ffd38e fix(no-changes): no changes detected; no version bump required 2026-03-02 12:37:44 +00:00
865b4a53e6 v10.1.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-03-02 09:43:08 +00:00
c07f3975e9 fix(deps): bump @api.global/typedrequest to ^3.2.7 2026-03-02 09:43:08 +00:00
476505537a v10.1.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-03-01 00:44:01 +00:00
74ad5cec90 fix(core): improve shutdown cleanup, socket/stream robustness, and memory/cache handling 2026-03-01 00:44:01 +00:00
59a3f7978e v10.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-27 10:29:20 +00:00
7dc976b59e fix(ops-view-apitokens): replace lucide:refresh-cw with lucide:rotate-cw for Roll action icon 2026-02-27 10:29:20 +00:00
345effee13 v10.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-27 10:24:20 +00:00
dee6897931 feat(api-tokens): add ability to roll (regenerate) API token secrets and UI to display the newly generated token once 2026-02-27 10:24:20 +00:00
56f41d70b3 v10.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-27 00:04:24 +00:00
8f570ae8a0 BREAKING CHANGE(remote-ingress): replace tlsConfigured boolean with tlsMode (custom | acme | self-signed) and compute TLS mode server-side 2026-02-27 00:04:24 +00:00
e58e24a92d v9.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-26 23:50:40 +00:00
12070bc7b5 feat(remoteingress): add TLS certificate resolution and passthrough for RemoteIngress tunnel 2026-02-26 23:50:40 +00:00
37d62c51f3 v9.2.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-26 23:15:00 +00:00
ea9427d46b feat(remoteingress): expose connected edge IPs and detected public IP; resolve proxy IPs from SmartProxy and improve ops UI 2026-02-26 23:15:00 +00:00
bc77321752 v9.1.10
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-26 21:34:01 +00:00
65aa546c1c fix(deps): bump @push.rocks/smartproxy to ^25.8.5 2026-02-26 21:34:01 +00:00
54484518dc v9.1.9
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-26 17:53:45 +00:00
6fe1247d4d fix(deps(smartmta)): bump @push.rocks/smartmta to ^5.3.0 2026-02-26 17:53:45 +00:00
e59d80a3b3 v9.1.8
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-26 17:42:06 +00:00
6c4feba711 fix(deps): bump @serve.zone/remoteingress to ^4.1.0 2026-02-26 17:42:05 +00:00
006a9af20c v9.1.7
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-26 17:34:54 +00:00
dfb3b0ac37 fix(dcrouter): bump @push.rocks/smartproxy to ^25.8.4 and remove custom smartProxy timeout/connection lifetime settings from dcrouter 2026-02-26 17:34:54 +00:00
44c1a3a928 v9.1.6
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-26 17:14:52 +00:00
0c4e28455e fix(cleanup): prevent event listener and log stream leaks, tighten smartProxy connection timeouts, and improve graceful shutdown behavior 2026-02-26 17:14:51 +00:00
cfc4cf378f v9.1.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-26 12:49:53 +00:00
a09e69a28b fix(remoteingress): Reconcile tunnel manager edge statuses with authoritative Rust hub periodically; update active tunnel counts and heartbeats, add missed edges, remove stale entries, and clear reconcile interval on stop 2026-02-26 12:49:53 +00:00
82dd19e274 v9.1.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-25 00:16:50 +00:00
c1d8afdbf7 fix(deps): bump @push.rocks/smartproxy to ^25.8.1 2026-02-25 00:16:50 +00:00
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
48 changed files with 2563 additions and 2089 deletions

View File

@@ -0,0 +1,6 @@
[ 95ms] 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)
[ 992ms] [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,5 @@
[ 329ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 727ms] [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
[ 260513ms] [ERROR] method: >>adminLoginWithUsernameAndPassword<< got an ERROR: "login failed" with data undefined @ http://localhost:3000/bundle.js:13
[ 260514ms] [ERROR] Login failed: Ns @ http://localhost:3000/bundle.js:38066
[ 260518ms] [WARNING] FontAwesome icon not found: circle-xmark @ http://localhost:3000/bundle.js:1203

View File

@@ -0,0 +1,3 @@
[ 397ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 657ms] [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
[ 24180ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203

View File

@@ -0,0 +1,15 @@
[ 916ms] [ERROR] method: >>getCombinedMetrics<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
[ 972ms] [ERROR] method: >>getConfiguration<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
[ 973ms] [ERROR] method: >>getRecentLogs<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
[ 990ms] K2
[ 1024ms] [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
[ 37030ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/typedserver/devtools:16227
[ 37031ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 37923ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 37923ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 39699ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 39699ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 44287ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 44288ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 53685ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 53685ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251

View File

@@ -0,0 +1,90 @@
[ 1146ms] [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
[ 26151ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
[ 257684ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/bundle.js:38066
[ 257684ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/typedserver/devtools:16227
[ 257684ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
[ 257685ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 258151ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 258500ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 258500ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 258568ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
[ 258568ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
[ 259149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 260149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 260245ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
[ 260245ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
[ 260324ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 260324ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 261149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 262149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 263149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 263917ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 263917ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 264149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 264781ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
[ 264781ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
[ 265169ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 266149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 267149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 268149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 269149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 270149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 271149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 272149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 272565ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 272565ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 273149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 273647ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
[ 273647ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
[ 274149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 275149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 276149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 277149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 278149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 279149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 280149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 281149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 282149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 283149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 284149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 285149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 286149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 287149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 288150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 289149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 290149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 290179ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
[ 290179ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
[ 291147ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 291147ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 291149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 292149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 293149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 294149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 295149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 296149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 297149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 298149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 299149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 300149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 301149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 302149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 303149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 304149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 305149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 306149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 307149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 308149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 309149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 310149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 311149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 312150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 313149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 314149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 315149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 316149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 317149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 318150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 319149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 320149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
[ 321149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -1,5 +1,222 @@
# Changelog # Changelog
## 2026-03-04 - 11.0.4 - fix()
no changes
- No files changed in the provided diff; no release or version bump required.
## 2026-03-04 - 11.0.3 - fix()
no changes detected
- Diff shows no file changes; no code changes to release.
## 2026-03-04 - 11.0.2 - fix(dcrouter)
no changes detected; no files were modified
- diff was empty
- no source or package changes detected
## 2026-03-04 - 11.0.1 - fix(auth)
treat expired JWTs as no identity, improve logout and token verification flow, and bump deps
- App: getActionContext now treats expired JWTs as null to avoid using stale identities for requests.
- Logout action always clears local login state; server-side adminLogout is attempted only when a valid identity exists.
- Dashboard: verify persisted JWT with server (verifyIdentity) on startup; if verification fails, clear state and show login.
- Auto-refresh: on combined refresh failure, detect auth-related errors (invalid/unauthorized/401), dispatch logout and reload to force re-login.
- Deps: bumped devDependencies @git.zone/tstest (^3.2.0) and @git.zone/tswatch (^3.2.5); added runtime dependency @push.rocks/lik (^6.2.2).
- Tests/artifacts: added Playwright console logs and page screenshots (test artifacts) to the commit.
## 2026-03-03 - 11.0.0 - BREAKING CHANGE(opsserver)
Require authentication for OpsServer endpoints, split handlers into authenticated view/admin routers, and make identity required on many TypedRequest interfaces
- Added viewRouter and adminRouter to OpsServer and wired middleware to enforce identity/admin checks (requireValidIdentity, requireAdminIdentity).
- Moved handlers to appropriate routers (viewRouter for read endpoints, adminRouter for write/admin endpoints) instead of registering on the unauthenticated main typedrouter.
- Made identity a required field on numerous ts_interfaces request types (breaking change to request typings).
- Refactored ApiTokenHandler to register directly on adminRouter and use dataArg.identity.userId (no per-handler admin checks needed thanks to middleware).
- Updated tests: added admin login to obtain identity, adjusted protected endpoint tests to expect rejection when unauthenticated, and adapted other tests to pass identity where required.
- Added IReq_GetNetworkStats request/response typings to ts_interfaces/requests/stats.ts.
- Bumped dependencies: @api.global/typedrequest ^3.3.0 and @api.global/typedserver ^8.4.2.
## 2026-03-03 - 10.1.9 - fix(deps)
bump @push.rocks/smartproxy to ^25.9.1
- Updated package.json dependency @push.rocks/smartproxy from ^25.9.0 to ^25.9.1
- No other code changes; current package version is 10.1.8, recommend a patch release
## 2026-03-03 - 10.1.8 - fix(deps)
bump dependencies: @push.rocks/smartmetrics to ^3.0.2, @push.rocks/smartproxy to ^25.9.0, @serve.zone/remoteingress to ^4.4.0
- @push.rocks/smartmetrics: 3.0.1 -> 3.0.2 (patch)
- @push.rocks/smartproxy: 25.8.5 -> 25.9.0 (minor)
- @serve.zone/remoteingress: 4.3.0 -> 4.4.0 (minor)
## 2026-03-03 - 10.1.7 - fix(ops-view-apitokens)
use correct lucide icon name for roll/rotate actions in API tokens view
- Updated iconName from 'lucide:rotate-cw' to 'lucide:rotateCw' in ts_web/elements/ops-view-apitokens.ts (two occurrences) to match lucide icon naming and ensure icons render correctly
- Non-functional UI fix; no API or behavior changes
## 2026-03-02 - 10.1.6 - fix(ts_web)
use actionContext for dispatches in web state actions and bump @push.rocks/smartstate to ^2.2.0
- Action handlers in ts_web/appstate.ts now accept an actionContext parameter and call await actionContext.dispatch(...) instead of using statePartArg.dispatchAction(...).
- Handlers return the awaited dispatch result (ensuring callers receive refreshed state) instead of returning the previous statePartArg.getState().
- Dependency bumped in package.json: @push.rocks/smartstate from ^2.1.1 to ^2.2.0.
- Playwright artifacts (logs and page screenshots) were added under .playwright-mcp.
## 2026-03-02 - 10.1.5 - fix(monitoring)
use a per-second ring buffer for DNS query metrics, improve DNS logging rate limiting and security event aggregation, and bump smartmta dependency
- Replace unbounded query timestamp array with a fixed-size per-second Int32Array ring buffer (300s) to calculate queries-per-second with O(1) updates and bounded memory
- Add incrementQueryRing and getQueryRingSum helpers to correctly zero stale slots and sum recent seconds
- Change metrics cache interval from 200ms to 1000ms to better match dashboard polling and reduce update frequency
- Refactor DNS adaptive logging to use per-second counters (dnsLogWindowSecond / dnsLogWindowCount) instead of timestamp arrays to avoid per-query array filtering and improve rate limiting accuracy; reset counters on flush
- Security logger: avoid mutating source when sorting/filtering, and implement single-pass aggregation with optional time-window filtering for byLevel/byType/top lists
- Bump dependency @push.rocks/smartmta from ^5.3.0 to ^5.3.1
## 2026-03-02 - 10.1.4 - fix(no-changes)
no changes detected; no version bump required
- package version is 10.1.3
- git diff contains no changes
## 2026-03-02 - 10.1.3 - fix(deps)
bump @api.global/typedrequest to ^3.2.7
- Updated @api.global/typedrequest from ^3.2.6 to ^3.2.7 in package.json
- Dependency patch bump only — no source code changes detected
- Current package version 10.1.2 -> recommended next version 10.1.3 (patch)
## 2026-03-01 - 10.1.2 - fix(core)
improve shutdown cleanup, socket/stream robustness, and memory/cache handling
- Reset security singletons and CacheDb on shutdown to allow GC (SecurityLogger, ContentScanner, IPReputationChecker, CacheDb).
- Add DNS socket 'error' handler and only destroy socket when not already destroyed to avoid uncaught exceptions.
- Move pruning of dnsMetrics.queryTimestamps to a periodic interval to avoid O(n) work on every query.
- Debounce IPReputationChecker cache saves (save timer + reset on instance reset) to reduce IO and prevent duplicate saves.
- Fix virtualStream send timeout handling by keeping/clearing a timeout handle to avoid leaks and hung promises.
- Add memory store eviction in StorageManager to cap entries (MAX_MEMORY_ENTRIES) and evict oldest entries when exceeded.
- Add terminal-ready timeout in ops-view-logs to avoid blocking UI initialization if xterm CDN fails to initialize.
- Bump dev dependency @types/node and push.rocks/smartstate versions.
## 2026-02-27 - 10.1.1 - fix(ops-view-apitokens)
replace lucide:refresh-cw with lucide:rotate-cw for Roll action icon
- Updated ts_web/elements/ops-view-apitokens.ts: changed iconName in two locations to 'lucide:rotate-cw' for the Roll/Roll Token actions.
- UI-only change — no functional or API behavior modified.
- Current package version is 10.1.0; recommended patch bump to 10.1.1.
## 2026-02-27 - 10.1.0 - feat(api-tokens)
add ability to roll (regenerate) API token secrets and UI to display the newly generated token once
- Server: added ApiTokenManager.rollToken(id) to regenerate a token secret, update its hash, persist it and log the action.
- Server: added opsserver handler 'rollApiToken' which requires admin identity and returns the new raw token value (shown once) or error messages.
- API: added typed request interface IReq_RollApiToken for the rollApiToken RPC.
- Web: added appstate.rollApiToken wrapper to call the new typed request.
- UI: ops-view-apitokens updated with a 'Roll' action and a modal flow to confirm rolling, call the API, refresh token list, and present the new token value to copy (token value is shown only once).
- Security: operation is admin-only and the raw token is returned only once after rolling.
## 2026-02-27 - 10.0.0 - BREAKING CHANGE(remote-ingress)
replace tlsConfigured boolean with tlsMode ('custom' | 'acme' | 'self-signed') and compute TLS mode server-side
- Server: compute remoteIngress.tlsMode = 'custom' when custom certPath/keyPath provided; else attempt to detect ACME by checking stored certs for hubDomain; default to 'self-signed' as fallback.
- API: replaced remoteIngress.tlsConfigured:boolean with tlsMode:'custom'|'acme'|'self-signed' — this is a breaking change for consumers of the config API.
- UI: ops view updated to display TLS Mode as a badge instead of a boolean "TLS Configured" field.
- Action required: update clients and integrations to read remoteIngress.tlsMode instead of tlsConfigured.
## 2026-02-26 - 9.3.0 - feat(remoteingress)
add TLS certificate resolution and passthrough for RemoteIngress tunnel
- Resolve TLS certs for the RemoteIngress tunnel with priority: explicit certPath/keyPath files → stored ACME cert for hubDomain → fallback to self-signed
- Expose tls option on ITunnelManagerConfig and forward certPem/keyPem into hub.start so the hub can use the provided TLS materials
- Add logging for cert selection and file read failures
- Bump dependency @serve.zone/remoteingress from ^4.2.0 to ^4.3.0
## 2026-02-26 - 9.2.0 - feat(remoteingress)
expose connected edge IPs and detected public IP; resolve proxy IPs from SmartProxy and improve ops UI
- Add detectedPublicIp to DC Router and populate it when a configured or auto-discovered public IP is chosen
- Use dcRouter.detectedPublicIp as a fallback for system.publicIp in the config handler
- Resolve proxy IPs from SmartProxy runtime settings when opts.proxyIps is not provided
- TunnelManager: capture peerAddr on edgeConnected and from Rust heartbeats, store per-edge publicIp, and add getConnectedEdgeIps()
- Expose connectedEdgeIps in the config API and return it in remoteIngress config
- Ops UI: show Connected Edge IPs, annotate 127.0.0.1 proxy IP as 'Remote Ingress' when applicable, and refresh remote ingress data during combined refresh when viewing remoteingress
- Bump dependency @serve.zone/remoteingress to ^4.2.0
## 2026-02-26 - 9.1.10 - fix(deps)
bump @push.rocks/smartproxy to ^25.8.5
- package.json: @push.rocks/smartproxy version updated from ^25.8.4 to ^25.8.5
- No other files changed
## 2026-02-26 - 9.1.9 - fix(deps(smartmta))
bump @push.rocks/smartmta to ^5.3.0
- Updated @push.rocks/smartmta from ^5.2.6 to ^5.3.0 in package.json
- Patch release recommended (no source code changes)
## 2026-02-26 - 9.1.8 - fix(deps)
bump @serve.zone/remoteingress to ^4.1.0
- Updated dependency @serve.zone/remoteingress from ^4.0.1 to ^4.1.0 in package.json
- Non-breaking dependency update; recommend patch version bump
## 2026-02-26 - 9.1.7 - fix(dcrouter)
bump @push.rocks/smartproxy to ^25.8.4 and remove custom smartProxy timeout/connection lifetime settings from dcrouter
- Bumped dependency @push.rocks/smartproxy from ^25.8.3 to ^25.8.4 in package.json
- Removed explicit smartProxy options: socketTimeout, inactivityTimeout, keepAliveInactivityMultiplier, extendedKeepAliveLifetime, and maxConnectionLifetime from ts/classes.dcrouter.ts
## 2026-02-26 - 9.1.6 - fix(cleanup)
prevent event listener and log stream leaks, tighten smartProxy connection timeouts, and improve graceful shutdown behavior
- Tightened smartProxy connection timeouts and lifetimes (5m socketTimeout, 10m inactivityTimeout, keep-alive multiplier, 1h extendedKeepAliveLifetime, 4h maxConnectionLifetime).
- Remove event listeners before stopping services to avoid leaks (smartProxy, emailServer, dnsServer, remote ingress hub).
- OpsServer.stop now invokes logsHandler.cleanup to tear down active log streams and avoid duplicate push destinations.
- LogsHandler rewritten to use a module-level singleton push destination, track active stream stop callbacks, add cleanup(), guard against hung VirtualStream.sendData with a 10s timeout, and ensure intervals are cleared on stop.
- updateSmartProxyConfig removes listeners on the old instance before stopping it.
- Dependency bumps: @api.global/typedsocket ^4.1.2, @push.rocks/smartdata ^7.1.0, @push.rocks/smartmta ^5.2.6, @push.rocks/smartproxy ^25.8.3.
## 2026-02-26 - 9.1.5 - fix(remoteingress)
Reconcile tunnel manager edge statuses with authoritative Rust hub periodically; update active tunnel counts and heartbeats, add missed edges, remove stale entries, and clear reconcile interval on stop
- Add reconcile() to sync TS-side edgeStatuses with hub.getStatus and overwrite activeTunnels with the authoritative activeStreams.
- Start a periodic reconcile (setInterval every 15s) and store the interval handle on the tunnel manager.
- Clear the reconcile interval in stop() to avoid background timers; remove edgeStatuses entries that are no longer connected in Rust.
- Bump dependency @serve.zone/remoteingress from ^4.0.0 to ^4.0.1.
## 2026-02-25 - 9.1.4 - fix(deps)
bump @push.rocks/smartproxy to ^25.8.1
- Updated package.json dependency @push.rocks/smartproxy from ^25.8.0 to ^25.8.1
## 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) ## 2026-02-23 - 9.0.0 - BREAKING CHANGE(opsserver)
Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "9.0.0", "version": "11.0.4",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -20,44 +20,45 @@
}, },
"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.2.0",
"@git.zone/tswatch": "^3.1.0", "@git.zone/tswatch": "^3.2.5",
"@types/node": "^25.3.0" "@types/node": "^25.3.3"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.2.6", "@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.0", "@api.global/typedserver": "^8.4.2",
"@api.global/typedsocket": "^4.1.0", "@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.43.2", "@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.1.6",
"@push.rocks/lik": "^6.2.2",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.1.3", "@push.rocks/smartacme": "^9.1.3",
"@push.rocks/smartdata": "^7.0.15", "@push.rocks/smartdata": "^7.1.0",
"@push.rocks/smartdns": "^7.9.0", "@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.1", "@push.rocks/smartmetrics": "^3.0.2",
"@push.rocks/smartmongo": "^5.1.0", "@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.2.2", "@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.7.9", "@push.rocks/smartproxy": "^25.9.1",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.0.30", "@push.rocks/smartstate": "^2.2.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@serve.zone/catalog": "^2.5.0", "@serve.zone/catalog": "^2.5.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.0.0", "@serve.zone/remoteingress": "^4.4.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"

2937
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,27 +4,44 @@ import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js'; import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter; let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => { tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({ testDcRouter = new DcRouter({
// Minimal config for testing // Minimal config for testing
cacheConfig: { enabled: false }, cacheConfig: { enabled: false },
}); });
await testDcRouter.start(); await testDcRouter.start();
expect(testDcRouter.opsServer).toBeInstanceOf(Object); expect(testDcRouter.opsServer).toBeInstanceOf(Object);
}); });
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'adminLoginWithUsernameAndPassword'
);
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
});
tap.test('should respond to health status request', async () => { tap.test('should respond to health status request', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>( const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3000/typedrequest', 'http://localhost:3000/typedrequest',
'getHealthStatus' 'getHealthStatus'
); );
const response = await healthRequest.fire({ const response = await healthRequest.fire({
detailed: false identity: adminIdentity,
detailed: false,
}); });
expect(response).toHaveProperty('health'); expect(response).toHaveProperty('health');
expect(response.health.healthy).toBeTrue(); expect(response.health.healthy).toBeTrue();
expect(response.health.services).toHaveProperty('OpsServer'); expect(response.health.services).toHaveProperty('OpsServer');
@@ -35,11 +52,12 @@ tap.test('should respond to server statistics request', async () => {
'http://localhost:3000/typedrequest', 'http://localhost:3000/typedrequest',
'getServerStatistics' 'getServerStatistics'
); );
const response = await statsRequest.fire({ const response = await statsRequest.fire({
includeHistory: false identity: adminIdentity,
includeHistory: false,
}); });
expect(response).toHaveProperty('stats'); expect(response).toHaveProperty('stats');
expect(response.stats).toHaveProperty('uptime'); expect(response.stats).toHaveProperty('uptime');
expect(response.stats).toHaveProperty('cpuUsage'); expect(response.stats).toHaveProperty('cpuUsage');
@@ -51,9 +69,11 @@ tap.test('should respond to configuration request', async () => {
'http://localhost:3000/typedrequest', 'http://localhost:3000/typedrequest',
'getConfiguration' 'getConfiguration'
); );
const response = await configRequest.fire({}); const response = await configRequest.fire({
identity: adminIdentity,
});
expect(response).toHaveProperty('config'); expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system'); expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy'); expect(response.config).toHaveProperty('smartProxy');
@@ -70,19 +90,34 @@ tap.test('should handle log retrieval request', async () => {
'http://localhost:3000/typedrequest', 'http://localhost:3000/typedrequest',
'getRecentLogs' 'getRecentLogs'
); );
const response = await logsRequest.fire({ const response = await logsRequest.fire({
limit: 10 identity: adminIdentity,
limit: 10,
}); });
expect(response).toHaveProperty('logs'); expect(response).toHaveProperty('logs');
expect(response).toHaveProperty('total'); expect(response).toHaveProperty('total');
expect(response).toHaveProperty('hasMore'); expect(response).toHaveProperty('hasMore');
expect(response.logs).toBeArray(); expect(response.logs).toBeArray();
}); });
tap.test('should reject unauthenticated requests', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3000/typedrequest',
'getHealthStatus'
);
try {
await healthRequest.fire({} as any);
expect(true).toBeFalse(); // Should not reach here
} catch (error) {
expect(error).toBeTruthy();
}
});
tap.test('should stop DCRouter', async () => { tap.test('should stop DCRouter', async () => {
await testDcRouter.stop(); await testDcRouter.stop();
}); });
export default tap.start(); export default tap.start();

View File

@@ -82,28 +82,31 @@ tap.test('should reject verify identity with invalid JWT', async () => {
} }
}); });
tap.test('should allow access to public endpoints without auth', async () => { tap.test('should reject protected endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>( const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3000/typedrequest', 'http://localhost:3000/typedrequest',
'getHealthStatus' 'getHealthStatus'
); );
// No identity provided try {
const response = await healthRequest.fire({}); // No identity provided — should be rejected
await healthRequest.fire({} as any);
expect(response).toHaveProperty('health'); expect(true).toBeFalse(); // Should not reach here
expect(response.health.healthy).toBeTrue(); } catch (error) {
console.log('Public endpoint accessible without auth'); expect(error).toBeTruthy();
console.log('Protected endpoint correctly rejects unauthenticated request');
}
}); });
tap.test('should allow read-only config access', async () => { tap.test('should allow authenticated access to protected endpoints', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>( const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3000/typedrequest', 'http://localhost:3000/typedrequest',
'getConfiguration' 'getConfiguration'
); );
// Config is read-only and doesn't require auth const response = await configRequest.fire({
const response = await configRequest.fire({}); identity: adminIdentity,
});
expect(response).toHaveProperty('config'); expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system'); expect(response.config).toHaveProperty('system');
@@ -114,7 +117,7 @@ tap.test('should allow read-only config access', async () => {
expect(response.config).toHaveProperty('cache'); expect(response.config).toHaveProperty('cache');
expect(response.config).toHaveProperty('radius'); expect(response.config).toHaveProperty('radius');
expect(response.config).toHaveProperty('remoteIngress'); expect(response.config).toHaveProperty('remoteIngress');
console.log('Configuration read successfully'); console.log('Authenticated access to config successful');
}); });
tap.test('should stop DCRouter', async () => { tap.test('should stop DCRouter', async () => {

View File

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

View File

@@ -23,6 +23,7 @@ import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js'; import { RouteConfigManager, ApiTokenManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
export interface IDcRouterOptions { export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -217,8 +218,12 @@ export class DcRouter {
public routeConfigManager?: RouteConfigManager; public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager; public apiTokenManager?: ApiTokenManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null;
// DNS query logging rate limiter state // DNS query logging rate limiter state
private dnsLogWindow: number[] = []; private dnsLogWindowSecond: number = 0; // epoch second of current window
private dnsLogWindowCount: number = 0; // queries logged this second
private dnsBatchCount: number = 0; private dnsBatchCount: number = 0;
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null; private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -897,12 +902,27 @@ export class DcRouter {
} }
this.dnsBatchTimer = null; this.dnsBatchTimer = null;
this.dnsBatchCount = 0; this.dnsBatchCount = 0;
this.dnsLogWindow = []; this.dnsLogWindowSecond = 0;
this.dnsLogWindowCount = 0;
} }
await this.opsServer.stop(); await this.opsServer.stop();
try { try {
// Remove event listeners before stopping services to prevent leaks
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
}
if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
}
if (this.dnsServer) {
this.dnsServer.removeAllListeners();
}
// Stop all services in parallel for faster shutdown // Stop all services in parallel for faster shutdown
await Promise.all([ await Promise.all([
// Stop cache cleaner if running // Stop cache cleaner if running
@@ -939,6 +959,7 @@ export class DcRouter {
// 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 => logger.log('error', 'Error stopping CacheDb', { error: String(err) })); await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
CacheDb.resetInstance();
} }
// Clear backoff cache in cert scheduler // Clear backoff cache in cert scheduler
@@ -962,6 +983,11 @@ export class DcRouter {
this.apiTokenManager = undefined; this.apiTokenManager = undefined;
this.certificateStatusMap.clear(); this.certificateStatusMap.clear();
// Reset security singletons to allow GC
SecurityLogger.resetInstance();
ContentScanner.resetInstance();
IPReputationChecker.resetInstance();
logger.log('info', 'All DcRouter services stopped'); logger.log('info', 'All DcRouter services stopped');
} catch (error) { } catch (error) {
logger.log('error', 'Error during DcRouter shutdown', { error: String(error) }); logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
@@ -976,10 +1002,11 @@ export class DcRouter {
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> { public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
// Stop existing SmartProxy if running // Stop existing SmartProxy if running
if (this.smartProxy) { if (this.smartProxy) {
this.smartProxy.removeAllListeners();
await this.smartProxy.stop(); await this.smartProxy.stop();
this.smartProxy = undefined; this.smartProxy = undefined;
} }
// Update configuration // Update configuration
this.options.smartProxyConfig = config; this.options.smartProxyConfig = config;
@@ -1103,6 +1130,11 @@ export class DcRouter {
try { try {
// Stop the unified email server which contains all components // Stop the unified email server which contains all components
if (this.emailServer) { if (this.emailServer) {
// Remove listeners before stopping to prevent leaks on config update cycles
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
await this.emailServer.stop(); await this.emailServer.stop();
logger.log('info', 'Unified email server stopped'); logger.log('info', 'Unified email server stopped');
this.emailServer = undefined; this.emailServer = undefined;
@@ -1282,11 +1314,14 @@ export class DcRouter {
} }
// Adaptive logging: individual logs up to 2/sec, then batch // Adaptive logging: individual logs up to 2/sec, then batch
const now = Date.now(); const nowSec = Math.floor(Date.now() / 1000);
this.dnsLogWindow = this.dnsLogWindow.filter(t => now - t < 1000); if (nowSec !== this.dnsLogWindowSecond) {
this.dnsLogWindowSecond = nowSec;
this.dnsLogWindowCount = 0;
}
if (this.dnsLogWindow.length < 2) { if (this.dnsLogWindowCount < 2) {
this.dnsLogWindow.push(now); this.dnsLogWindowCount++;
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', '); 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' }); logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
} else { } else {
@@ -1340,15 +1375,25 @@ export class DcRouter {
return; return;
} }
// Prevent uncaught exception from socket 'error' events
socket.on('error', (err) => {
logger.log('error', `DNS socket error: ${err.message}`);
if (!socket.destroyed) {
socket.destroy();
}
});
logger.log('debug', 'DNS socket handler: passing socket to DnsServer'); logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
try { try {
// Use the built-in socket handler from smartdns // Use the built-in socket handler from smartdns
// This handles HTTP/2, DoH protocol, etc. // This handles HTTP/2, DoH protocol, etc.
await (this.dnsServer as any).handleHttpsSocket(socket); await (this.dnsServer as any).handleHttpsSocket(socket);
} catch (error) { } catch (error) {
logger.log('error', `DNS socket handler error: ${error.message}`); logger.log('error', `DNS socket handler error: ${error.message}`);
socket.destroy(); if (!socket.destroyed) {
socket.destroy();
}
} }
}; };
} }
@@ -1554,6 +1599,7 @@ export class DcRouter {
} else if (this.options.publicIp) { } else if (this.options.publicIp) {
// Use explicitly configured public IP // Use explicitly configured public IP
publicIp = this.options.publicIp; publicIp = this.options.publicIp;
this.detectedPublicIp = publicIp;
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`); logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
} else { } else {
// Auto-discover public IP using smartnetwork // Auto-discover public IP using smartnetwork
@@ -1564,6 +1610,7 @@ export class DcRouter {
if (publicIps.v4) { if (publicIps.v4) {
publicIp = publicIps.v4; publicIp = publicIps.v4;
this.detectedPublicIp = publicIp;
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`); logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
} else { } else {
logger.log('warn', 'Could not auto-discover public IPv4 address'); logger.log('warn', 'Could not auto-discover public IPv4 address');
@@ -1689,10 +1736,42 @@ export class DcRouter {
const currentRoutes = this.options.smartProxyConfig?.routes || []; const currentRoutes = this.options.smartProxyConfig?.routes || [];
this.remoteIngressManager.setRoutes(currentRoutes as any[]); this.remoteIngressManager.setRoutes(currentRoutes as any[]);
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
const riCfg = this.options.remoteIngressConfig;
let tlsConfig: { certPem: string; keyPem: string } | undefined;
// Priority 1: Explicit cert/key file paths
if (riCfg.tls?.certPath && riCfg.tls?.keyPath) {
try {
const certPem = plugins.fs.readFileSync(riCfg.tls.certPath, 'utf8');
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
tlsConfig = { certPem, keyPem };
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
} catch (err) {
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`);
}
}
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
if (!tlsConfig && riCfg.hubDomain) {
try {
const stored = await this.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
if (stored?.publicKey && stored?.privateKey) {
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
}
} catch { /* no stored cert, fall through */ }
}
if (!tlsConfig) {
logger.log('info', 'No TLS cert configured for RemoteIngress tunnel — using auto-generated self-signed');
}
// Create and start the tunnel manager // Create and start the tunnel manager
this.tunnelManager = new TunnelManager(this.remoteIngressManager, { this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443, tunnelPort: riCfg.tunnelPort ?? 8443,
targetHost: '127.0.0.1', targetHost: '127.0.0.1',
tls: tlsConfig,
}); });
await this.tunnelManager.start(); await this.tunnelManager.start();

View File

@@ -122,6 +122,24 @@ export class ApiTokenManager {
return true; return true;
} }
/**
* Roll (regenerate) a token's secret while keeping its identity.
* Returns the new raw token value (shown once).
*/
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
const stored = this.tokens.get(id);
if (!stored) return null;
const randomBytes = plugins.crypto.randomBytes(32);
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
return { id, rawToken };
}
/** /**
* Enable or disable a token. * Enable or disable a token.
*/ */

View File

@@ -35,7 +35,9 @@ export class MetricsManager {
queryTypes: {} as Record<string, number>, queryTypes: {} as Record<string, number>,
topDomains: new Map<string, number>(), topDomains: new Map<string, number>(),
lastResetDate: new Date().toDateString(), lastResetDate: new Date().toDateString(),
queryTimestamps: [] as number[], // Track query timestamps for rate calculation // Per-second query count ring buffer (300 entries = 5 minutes)
queryRing: new Int32Array(300),
queryRingLastSecond: 0, // last epoch second that was written
responseTimes: [] as number[], // Track response times in ms responseTimes: [] as number[], // Track response times in ms
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>, recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
}; };
@@ -95,12 +97,13 @@ export class MetricsManager {
this.dnsMetrics.cacheMisses = 0; this.dnsMetrics.cacheMisses = 0;
this.dnsMetrics.queryTypes = {}; this.dnsMetrics.queryTypes = {};
this.dnsMetrics.topDomains.clear(); this.dnsMetrics.topDomains.clear();
this.dnsMetrics.queryTimestamps = []; this.dnsMetrics.queryRing.fill(0);
this.dnsMetrics.queryRingLastSecond = 0;
this.dnsMetrics.responseTimes = []; this.dnsMetrics.responseTimes = [];
this.dnsMetrics.recentQueries = []; this.dnsMetrics.recentQueries = [];
this.dnsMetrics.lastResetDate = currentDate; this.dnsMetrics.lastResetDate = currentDate;
} }
if (currentDate !== this.securityMetrics.lastResetDate) { if (currentDate !== this.securityMetrics.lastResetDate) {
this.securityMetrics.blockedIPs = 0; this.securityMetrics.blockedIPs = 0;
this.securityMetrics.authFailures = 0; this.securityMetrics.authFailures = 0;
@@ -141,16 +144,16 @@ export class MetricsManager {
const smartMetricsData = await this.smartMetrics.getMetrics(); const smartMetricsData = await this.smartMetrics.getMetrics();
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null; const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null; const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
return { return {
uptime: process.uptime(), uptime: process.uptime(),
startTime: Date.now() - (process.uptime() * 1000), startTime: Date.now() - (process.uptime() * 1000),
memoryUsage: { memoryUsage: {
heapUsed: process.memoryUsage().heapUsed, heapUsed,
heapTotal: process.memoryUsage().heapTotal, heapTotal,
external: process.memoryUsage().external, external,
rss: process.memoryUsage().rss, rss,
// Add SmartMetrics memory data
maxMemoryMB: this.smartMetrics.maxMemoryMB, maxMemoryMB: this.smartMetrics.maxMemoryMB,
actualUsageBytes: smartMetricsData.memoryUsageBytes, actualUsageBytes: smartMetricsData.memoryUsageBytes,
actualUsagePercentage: smartMetricsData.memoryPercentage, actualUsagePercentage: smartMetricsData.memoryPercentage,
@@ -219,11 +222,8 @@ export class MetricsManager {
.slice(0, 10) .slice(0, 10)
.map(([domain, count]) => ({ domain, count })); .map(([domain, count]) => ({ domain, count }));
// Calculate queries per second from recent timestamps // Calculate queries per second from ring buffer (sum last 60 seconds)
const now = Date.now(); const queriesPerSecond = this.getQueryRingSum(60) / 60;
const oneMinuteAgo = now - 60000;
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
const queriesPerSecond = recentQueries.length / 60;
// Calculate average response time // Calculate average response time
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0 const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
@@ -427,12 +427,8 @@ export class MetricsManager {
this.dnsMetrics.cacheMisses++; this.dnsMetrics.cacheMisses++;
} }
// Track query timestamp // Increment per-second query counter in ring buffer
this.dnsMetrics.queryTimestamps.push(Date.now()); this.incrementQueryRing();
// Keep only timestamps from last 5 minutes
const fiveMinutesAgo = Date.now() - 300000;
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
// Track response time if provided // Track response time if provided
if (responseTimeMs) { if (responseTimeMs) {
@@ -604,7 +600,7 @@ export class MetricsManager {
requestsPerSecond, requestsPerSecond,
requestsTotal, requestsTotal,
}; };
}, 200); // Use 200ms cache for more frequent updates }, 1000); // 1s cache — matches typical dashboard poll interval
} }
// --- Time-series helpers --- // --- Time-series helpers ---
@@ -633,6 +629,63 @@ export class MetricsManager {
bucket.queries++; bucket.queries++;
} }
/**
* Increment the per-second query counter in the ring buffer.
* Zeros any stale slots between the last write and the current second.
*/
private incrementQueryRing(): void {
const currentSecond = Math.floor(Date.now() / 1000);
const ring = this.dnsMetrics.queryRing;
const last = this.dnsMetrics.queryRingLastSecond;
if (last === 0) {
// First call — zero and anchor
ring.fill(0);
this.dnsMetrics.queryRingLastSecond = currentSecond;
ring[currentSecond % ring.length] = 1;
return;
}
const gap = currentSecond - last;
if (gap >= ring.length) {
// Entire ring is stale — clear all
ring.fill(0);
} else if (gap > 0) {
// Zero slots from (last+1) to currentSecond (inclusive)
for (let s = last + 1; s <= currentSecond; s++) {
ring[s % ring.length] = 0;
}
}
this.dnsMetrics.queryRingLastSecond = currentSecond;
ring[currentSecond % ring.length]++;
}
/**
* Sum query counts from the ring buffer for the last N seconds.
*/
private getQueryRingSum(seconds: number): number {
const currentSecond = Math.floor(Date.now() / 1000);
const ring = this.dnsMetrics.queryRing;
const last = this.dnsMetrics.queryRingLastSecond;
if (last === 0) return 0;
// First, zero stale slots so reads are accurate even without writes
const gap = currentSecond - last;
if (gap >= ring.length) return 0; // all data is stale
let sum = 0;
const limit = Math.min(seconds, ring.length);
for (let i = 0; i < limit; i++) {
const sec = currentSecond - i;
if (sec < last - (ring.length - 1)) break; // slot is from older cycle
if (sec > last) continue; // no writes yet for this second
sum += ring[sec % ring.length];
}
return sum;
}
private pruneOldBuckets(): void { private pruneOldBuckets(): void {
const cutoff = Date.now() - 86400000; // 24h const cutoff = Date.now() - 86400000; // 24h
for (const key of this.emailMinuteBuckets.keys()) { for (const key of this.emailMinuteBuckets.keys()) {

View File

@@ -2,14 +2,20 @@ import type DcRouter from '../classes.dcrouter.js';
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import * as handlers from './handlers/index.js'; import * as handlers from './handlers/index.js';
import * as interfaces from '../../ts_interfaces/index.js';
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
export class OpsServer { export class OpsServer {
public dcRouterRef: DcRouter; public dcRouterRef: DcRouter;
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer; public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
// TypedRouter for OpsServer-specific handlers // Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// Auth-enforced routers — middleware validates identity before any handler runs
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
// Handler instances // Handler instances
public adminHandler: handlers.AdminHandler; public adminHandler: handlers.AdminHandler;
private configHandler: handlers.ConfigHandler; private configHandler: handlers.ConfigHandler;
@@ -25,7 +31,7 @@ export class OpsServer {
constructor(dcRouterRefArg: DcRouter) { constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg; this.dcRouterRef = dcRouterRefArg;
// Add our typedrouter to the dcRouter's main typedrouter // Add our typedrouter to the dcRouter's main typedrouter
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter); this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
} }
@@ -51,10 +57,25 @@ export class OpsServer {
* Set up all TypedRequest handlers * Set up all TypedRequest handlers
*/ */
private async setupHandlers(): Promise<void> { private async setupHandlers(): Promise<void> {
// Instantiate all handlers - they self-register with the typedrouter // AdminHandler must be initialized first (JWT setup needed for guards)
this.adminHandler = new handlers.AdminHandler(this); this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize(); // JWT needs async initialization await this.adminHandler.initialize();
// viewRouter middleware: requires valid identity (any logged-in user)
this.viewRouter.addMiddleware(async (typedRequest) => {
await requireValidIdentity(this.adminHandler, typedRequest.request);
});
// adminRouter middleware: requires admin identity
this.adminRouter.addMiddleware(async (typedRequest) => {
await requireAdminIdentity(this.adminHandler, typedRequest.request);
});
// Connect auth routers to the main typedrouter
this.typedrouter.addTypedRouter(this.viewRouter);
this.typedrouter.addTypedRouter(this.adminRouter);
// Instantiate all handlers — they self-register with the appropriate router
this.configHandler = new handlers.ConfigHandler(this); this.configHandler = new handlers.ConfigHandler(this);
this.logsHandler = new handlers.LogsHandler(this); this.logsHandler = new handlers.LogsHandler(this);
this.securityHandler = new handlers.SecurityHandler(this); this.securityHandler = new handlers.SecurityHandler(this);
@@ -70,6 +91,10 @@ export class OpsServer {
} }
public async stop() { public async stop() {
// Clean up log handler streams and push destination before stopping the server
if (this.logsHandler) {
this.logsHandler.cleanup();
}
if (this.server) { if (this.server) {
await this.server.stop(); await this.server.stop();
} }

View File

@@ -3,34 +3,20 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
export class ApiTokenHandler { export class ApiTokenHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); 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 { private registerHandlers(): void {
// All token management endpoints register directly on adminRouter
// (middleware enforces admin JWT check, so no per-handler requireAdmin needed)
const router = this.opsServerRef.adminRouter;
// Create API token // Create API token
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
'createApiToken', 'createApiToken',
async (dataArg) => { async (dataArg) => {
const userId = await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager; const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) { if (!manager) {
return { success: false, message: 'Token management not initialized' }; return { success: false, message: 'Token management not initialized' };
@@ -39,7 +25,7 @@ export class ApiTokenHandler {
dataArg.name, dataArg.name,
dataArg.scopes, dataArg.scopes,
dataArg.expiresInDays ?? null, dataArg.expiresInDays ?? null,
userId, dataArg.identity.userId,
); );
return { success: true, tokenId: result.id, tokenValue: result.rawToken }; return { success: true, tokenId: result.id, tokenValue: result.rawToken };
}, },
@@ -47,11 +33,10 @@ export class ApiTokenHandler {
); );
// List API tokens // List API tokens
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens', 'listApiTokens',
async (dataArg) => { async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager; const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) { if (!manager) {
return { tokens: [] }; return { tokens: [] };
@@ -62,11 +47,10 @@ export class ApiTokenHandler {
); );
// Revoke API token // Revoke API token
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken', 'revokeApiToken',
async (dataArg) => { async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager; const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) { if (!manager) {
return { success: false, message: 'Token management not initialized' }; return { success: false, message: 'Token management not initialized' };
@@ -77,12 +61,29 @@ export class ApiTokenHandler {
), ),
); );
// Roll API token
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
'rollApiToken',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await manager.rollToken(dataArg.id);
if (!result) {
return { success: false, message: 'Token not found' };
}
return { success: true, tokenValue: result.rawToken };
},
),
);
// Toggle API token // Toggle API token
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken', 'toggleApiToken',
async (dataArg) => { async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager; const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) { if (!manager) {
return { success: false, message: 'Token management not initialized' }; return { success: false, message: 'Token management not initialized' };

View File

@@ -3,16 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
export class CertificateHandler { export class CertificateHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
} }
private registerHandlers(): void { private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get Certificate Overview // Get Certificate Overview
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
'getCertificateOverview', 'getCertificateOverview',
async (dataArg) => { async (dataArg) => {
@@ -23,8 +25,10 @@ export class CertificateHandler {
) )
); );
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Legacy route-based reprovision (backward compat) // Legacy route-based reprovision (backward compat)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate', 'reprovisionCertificate',
async (dataArg) => { async (dataArg) => {
@@ -34,7 +38,7 @@ export class CertificateHandler {
); );
// Domain-based reprovision (preferred) // Domain-based reprovision (preferred)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain', 'reprovisionCertificateDomain',
async (dataArg) => { async (dataArg) => {
@@ -44,7 +48,7 @@ export class CertificateHandler {
); );
// Delete certificate // Delete certificate
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate', 'deleteCertificate',
async (dataArg) => { async (dataArg) => {
@@ -54,7 +58,7 @@ export class CertificateHandler {
); );
// Export certificate // Export certificate
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate', 'exportCertificate',
async (dataArg) => { async (dataArg) => {
@@ -64,7 +68,7 @@ export class CertificateHandler {
); );
// Import certificate // Import certificate
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate', 'importCertificate',
async (dataArg) => { async (dataArg) => {

View File

@@ -4,17 +4,16 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
export class ConfigHandler { export class ConfigHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
} }
private registerHandlers(): void { private registerHandlers(): void {
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Get Configuration Handler (read-only) // Get Configuration Handler (read-only)
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration', 'getConfiguration',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -40,11 +39,20 @@ export class ConfigHandler {
? 'filesystem' ? 'filesystem'
: 'memory'; : 'memory';
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
let proxyIps = opts.proxyIps || [];
if (proxyIps.length === 0 && dcRouter.smartProxy) {
const spSettings = (dcRouter.smartProxy as any).settings;
if (spSettings?.proxyIPs?.length > 0) {
proxyIps = spSettings.proxyIPs;
}
}
const system: interfaces.requests.IConfigData['system'] = { const system: interfaces.requests.IConfigData['system'] = {
baseDir: resolvedPaths.dcrouterHomeDir, baseDir: resolvedPaths.dcrouterHomeDir,
dataDir: resolvedPaths.dataDir, dataDir: resolvedPaths.dataDir,
publicIp: opts.publicIp || null, publicIp: opts.publicIp || dcRouter.detectedPublicIp || null,
proxyIps: opts.proxyIps || [], proxyIps,
uptime: Math.floor(process.uptime()), uptime: Math.floor(process.uptime()),
storageBackend, storageBackend,
storagePath: opts.storage?.fsPath || null, storagePath: opts.storage?.fsPath || null,
@@ -169,11 +177,27 @@ export class ConfigHandler {
// --- Remote Ingress --- // --- Remote Ingress ---
const riCfg = opts.remoteIngressConfig; const riCfg = opts.remoteIngressConfig;
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
tlsMode = 'custom';
} else if (riCfg?.hubDomain) {
try {
const stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
if (stored?.publicKey && stored?.privateKey) {
tlsMode = 'acme';
}
} catch { /* no stored cert */ }
}
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = { const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
enabled: !!dcRouter.remoteIngressManager, enabled: !!dcRouter.remoteIngressManager,
tunnelPort: riCfg?.tunnelPort || null, tunnelPort: riCfg?.tunnelPort || null,
hubDomain: riCfg?.hubDomain || null, hubDomain: riCfg?.hubDomain || null,
tlsConfigured: !!(riCfg?.tls?.certPath && riCfg?.tls?.keyPath), tlsMode,
connectedEdgeIps,
}; };
return { return {

View File

@@ -3,17 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
export class EmailOpsHandler { export class EmailOpsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
} }
private registerHandlers(): void { private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get All Emails Handler // Get All Emails Handler
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getAllEmails', 'getAllEmails',
async (dataArg) => { async (dataArg) => {
@@ -24,7 +25,7 @@ export class EmailOpsHandler {
); );
// Get Email Detail Handler // Get Email Detail Handler
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail', 'getEmailDetail',
async (dataArg) => { async (dataArg) => {
@@ -34,8 +35,10 @@ export class EmailOpsHandler {
) )
); );
// ---- Write endpoints (adminRouter) ----
// Resend Failed Email Handler // Resend Failed Email Handler
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
'resendEmail', 'resendEmail',
async (dataArg) => { async (dataArg) => {

View File

@@ -3,19 +3,40 @@ 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'; import { logBuffer, baseLogger } from '../../logger.js';
// Module-level singleton: the log push destination is added once and reuses
// the current OpsServer reference so it survives OpsServer restarts without
// accumulating duplicate destinations.
let logPushDestinationInstalled = false;
let currentOpsServerRef: OpsServer | null = null;
export class LogsHandler { export class LogsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); private activeStreamStops: Set<() => void> = new Set();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
this.setupLogPushDestination(); this.setupLogPushDestination();
} }
/**
* Clean up all active log streams and deactivate the push destination.
* Called when OpsServer stops.
*/
public cleanup(): void {
// Stop all active follow-mode log streams
for (const stop of this.activeStreamStops) {
stop();
}
this.activeStreamStops.clear();
// Deactivate the push destination (it stays registered but becomes a no-op)
currentOpsServerRef = null;
}
private registerHandlers(): void { private registerHandlers(): void {
// All log endpoints register directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Get Recent Logs Handler // Get Recent Logs Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
'getRecentLogs', 'getRecentLogs',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -27,24 +48,24 @@ export class LogsHandler {
dataArg.search, dataArg.search,
dataArg.timeRange dataArg.timeRange
); );
return { return {
logs, logs,
total: logs.length, // TODO: Implement proper total count total: logs.length,
hasMore: false, // TODO: Implement proper pagination hasMore: false,
}; };
} }
) )
); );
// Get Log Stream Handler // Get Log Stream Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
'getLogStream', 'getLogStream',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
// Create a virtual stream for log streaming // Create a virtual stream for log streaming
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>(); const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
// Set up log streaming // Set up log streaming
const streamLogs = this.setupLogStream( const streamLogs = this.setupLogStream(
virtualStream, virtualStream,
@@ -52,20 +73,21 @@ export class LogsHandler {
dataArg.filters?.category, dataArg.filters?.category,
dataArg.follow dataArg.follow
); );
// Start streaming // Start streaming
streamLogs.start(); streamLogs.start();
// VirtualStream handles cleanup automatically // Track the stop function so we can clean up on shutdown
this.activeStreamStops.add(streamLogs.stop);
return { return {
logStream: virtualStream as any, // Cast to IVirtualStream interface logStream: virtualStream as any,
}; };
} }
) )
); );
} }
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' { private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
switch (smartlogLevel) { switch (smartlogLevel) {
case 'silly': case 'silly':
@@ -165,18 +187,30 @@ export class LogsHandler {
return mapped; return mapped;
} }
/** /**
* Add a log destination to the base logger that pushes entries * Add a log destination to the base logger that pushes entries
* to all connected ops_dashboard TypedSocket clients. * to all connected ops_dashboard TypedSocket clients.
*
* Uses a module-level singleton so the destination is added only once,
* even across OpsServer restart cycles. The destination reads
* `currentOpsServerRef` dynamically so it always uses the active server.
*/ */
private setupLogPushDestination(): void { private setupLogPushDestination(): void {
const opsServerRef = this.opsServerRef; // Update the module-level reference so the existing destination uses the new server
currentOpsServerRef = this.opsServerRef;
if (logPushDestinationInstalled) {
return; // destination already registered — just updated the ref
}
logPushDestinationInstalled = true;
baseLogger.addLogDestination({ baseLogger.addLogDestination({
async handleLog(logPackage: any) { async handleLog(logPackage: any) {
// Access the TypedSocket server instance from OpsServer const opsServer = currentOpsServerRef;
const typedsocket = opsServerRef.server?.typedserver?.typedsocket; if (!opsServer) return;
const typedsocket = opsServer.server?.typedserver?.typedsocket;
if (!typedsocket) return; if (!typedsocket) return;
let connections: any[]; let connections: any[];
@@ -220,8 +254,18 @@ export class LogsHandler {
stop: () => void; stop: () => void;
} { } {
let intervalId: NodeJS.Timeout | null = null; let intervalId: NodeJS.Timeout | null = null;
let stopped = false;
let logIndex = 0; let logIndex = 0;
const stop = () => {
stopped = true;
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
this.activeStreamStops.delete(stop);
};
const start = () => { const start = () => {
if (!follow) { if (!follow) {
// Send existing logs and close // Send existing logs and close
@@ -236,13 +280,19 @@ export class LogsHandler {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
virtualStream.sendData(encoder.encode(logData)); virtualStream.sendData(encoder.encode(logData));
}); });
// VirtualStream doesn't have end() method - it closes automatically
}); });
return; return;
} }
// For follow mode, simulate real-time log streaming // For follow mode, simulate real-time log streaming
intervalId = setInterval(async () => { intervalId = setInterval(async () => {
if (stopped) {
// Guard: clear interval if stop() was called between ticks
clearInterval(intervalId!);
intervalId = null;
return;
}
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email']; const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug']; const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
@@ -266,30 +316,25 @@ export class LogsHandler {
const logData = JSON.stringify(logEntry); const logData = JSON.stringify(logEntry);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
try { try {
await virtualStream.sendData(encoder.encode(logData)); // Use a timeout to detect hung streams (sendData can hang if the
// VirtualStream's keepAlive loop has ended)
let timeoutHandle: ReturnType<typeof setTimeout>;
await Promise.race([
virtualStream.sendData(encoder.encode(logData)).then((result) => {
clearTimeout(timeoutHandle);
return result;
}),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
}),
]);
} catch { } catch {
// Stream closed or errored — clean up to prevent interval leak // Stream closed, errored, or timed out — clean up
clearInterval(intervalId!); stop();
intervalId = null;
} }
}, 2000); // Send a log every 2 seconds }, 2000);
// TODO: Hook into actual logger events
// logger.on('log', (logEntry) => {
// if (matchesCriteria(logEntry, level, service)) {
// virtualStream.sendData(formatLogEntry(logEntry));
// }
// });
}; };
const stop = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
// TODO: Unhook from logger events
};
return { start, stop }; return { start, stop };
} }
} }

View File

@@ -3,21 +3,19 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
export class RadiusHandler { export class RadiusHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
} }
private registerHandlers(): void { private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ======================================================================== // ========================================================================
// RADIUS Client Management // RADIUS Client Management
// ======================================================================== // ========================================================================
// Get all RADIUS clients // Get all RADIUS clients (read)
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
'getRadiusClients', 'getRadiusClients',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -40,8 +38,8 @@ export class RadiusHandler {
) )
); );
// Add or update a RADIUS client // Add or update a RADIUS client (write)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
'setRadiusClient', 'setRadiusClient',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -61,8 +59,8 @@ export class RadiusHandler {
) )
); );
// Remove a RADIUS client // Remove a RADIUS client (write)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
'removeRadiusClient', 'removeRadiusClient',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -85,8 +83,8 @@ export class RadiusHandler {
// VLAN Mapping Management // VLAN Mapping Management
// ======================================================================== // ========================================================================
// Get all VLAN mappings // Get all VLAN mappings (read)
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
'getVlanMappings', 'getVlanMappings',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -121,8 +119,8 @@ export class RadiusHandler {
) )
); );
// Add or update a VLAN mapping // Add or update a VLAN mapping (write)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
'setVlanMapping', 'setVlanMapping',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -153,8 +151,8 @@ export class RadiusHandler {
) )
); );
// Remove a VLAN mapping // Remove a VLAN mapping (write)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
'removeVlanMapping', 'removeVlanMapping',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -174,8 +172,8 @@ export class RadiusHandler {
) )
); );
// Update VLAN configuration // Update VLAN configuration (write)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
'updateVlanConfig', 'updateVlanConfig',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -206,8 +204,8 @@ export class RadiusHandler {
) )
); );
// Test VLAN assignment // Test VLAN assignment (read)
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
'testVlanAssignment', 'testVlanAssignment',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -240,8 +238,8 @@ export class RadiusHandler {
// Accounting / Session Management // Accounting / Session Management
// ======================================================================== // ========================================================================
// Get active sessions // Get active sessions (read)
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
'getRadiusSessions', 'getRadiusSessions',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -289,8 +287,8 @@ export class RadiusHandler {
) )
); );
// Disconnect a session // Disconnect a session (write)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
'disconnectRadiusSession', 'disconnectRadiusSession',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -314,8 +312,8 @@ export class RadiusHandler {
) )
); );
// Get accounting summary // Get accounting summary (read)
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
'getRadiusAccountingSummary', 'getRadiusAccountingSummary',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -351,8 +349,8 @@ export class RadiusHandler {
// Statistics // Statistics
// ======================================================================== // ========================================================================
// Get RADIUS statistics // Get RADIUS statistics (read)
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
'getRadiusStatistics', 'getRadiusStatistics',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {

View File

@@ -3,16 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
export class RemoteIngressHandler { export class RemoteIngressHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
} }
private registerHandlers(): void { private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get all remote ingress edges // Get all remote ingress edges
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
'getRemoteIngresses', 'getRemoteIngresses',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -36,8 +38,10 @@ export class RemoteIngressHandler {
), ),
); );
// ---- Write endpoints (adminRouter) ----
// Create a new remote ingress edge // Create a new remote ingress edge
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress', 'createRemoteIngress',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -69,7 +73,7 @@ export class RemoteIngressHandler {
); );
// Delete a remote ingress edge // Delete a remote ingress edge
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress', 'deleteRemoteIngress',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -94,7 +98,7 @@ export class RemoteIngressHandler {
); );
// Update a remote ingress edge // Update a remote ingress edge
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress', 'updateRemoteIngress',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -138,7 +142,7 @@ export class RemoteIngressHandler {
); );
// Regenerate secret for an edge // Regenerate secret for an edge
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret', 'regenerateRemoteIngressSecret',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -164,8 +168,8 @@ export class RemoteIngressHandler {
), ),
); );
// Get runtime status of all edges // Get runtime status of all edges (read)
this.typedrouter.addTypedHandler( viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus', 'getRemoteIngressStatus',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -178,8 +182,8 @@ export class RemoteIngressHandler {
), ),
); );
// Get a connection token for an edge // Get a connection token for an edge (write — exposes secret)
this.typedrouter.addTypedHandler( adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
'getRemoteIngressConnectionToken', 'getRemoteIngressConnectionToken',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {

View File

@@ -4,17 +4,16 @@ import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js'; import { MetricsManager } from '../../monitoring/index.js';
export class SecurityHandler { export class SecurityHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
} }
private registerHandlers(): void { private registerHandlers(): void {
// All security endpoints register directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Security Metrics Handler // Security Metrics Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
'getSecurityMetrics', 'getSecurityMetrics',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -40,7 +39,7 @@ export class SecurityHandler {
); );
// Active Connections Handler // Active Connections Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
'getActiveConnections', 'getActiveConnections',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -77,8 +76,8 @@ export class SecurityHandler {
); );
// Network Stats Handler - provides comprehensive network metrics // Network Stats Handler - provides comprehensive network metrics
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats', 'getNetworkStats',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
// Get network stats from MetricsManager if available // Get network stats from MetricsManager if available
@@ -121,7 +120,7 @@ export class SecurityHandler {
); );
// Rate Limit Status Handler // Rate Limit Status Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
'getRateLimitStatus', 'getRateLimitStatus',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {

View File

@@ -5,17 +5,16 @@ import { MetricsManager } from '../../monitoring/index.js';
import { SecurityLogger } from '../../security/classes.securitylogger.js'; import { SecurityLogger } from '../../security/classes.securitylogger.js';
export class StatsHandler { export class StatsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
} }
private registerHandlers(): void { private registerHandlers(): void {
// All stats endpoints register directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Server Statistics Handler // Server Statistics Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
'getServerStatistics', 'getServerStatistics',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -38,7 +37,7 @@ export class StatsHandler {
); );
// Email Statistics Handler // Email Statistics Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
'getEmailStatistics', 'getEmailStatistics',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -77,7 +76,7 @@ export class StatsHandler {
); );
// DNS Statistics Handler // DNS Statistics Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
'getDnsStatistics', 'getDnsStatistics',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -114,7 +113,7 @@ export class StatsHandler {
); );
// Queue Status Handler // Queue Status Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
'getQueueStatus', 'getQueueStatus',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -142,7 +141,7 @@ export class StatsHandler {
); );
// Health Status Handler // Health Status Handler
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
'getHealthStatus', 'getHealthStatus',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
@@ -167,7 +166,7 @@ export class StatsHandler {
); );
// Combined Metrics Handler - More efficient for frontend polling // Combined Metrics Handler - More efficient for frontend polling
this.typedrouter.addTypedHandler( router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
'getCombinedMetrics', 'getCombinedMetrics',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {

View File

@@ -22,16 +22,17 @@ export async function passGuards<T extends { identity?: any }>(
} }
/** /**
* Helper to check admin identity in handlers * Helper to check admin identity in handlers and middleware.
* Accepts both optional and required identity for flexibility.
*/ */
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>( export async function requireAdminIdentity(
adminHandler: AdminHandler, adminHandler: AdminHandler,
dataArg: T dataArg: { identity?: interfaces.data.IIdentity }
): Promise<void> { ): Promise<void> {
if (!dataArg.identity) { if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided'); throw new plugins.typedrequest.TypedResponseError('No identity provided');
} }
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity }); const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) { if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Admin access required'); throw new plugins.typedrequest.TypedResponseError('Admin access required');
@@ -39,16 +40,17 @@ export async function requireAdminIdentity<T extends { identity?: interfaces.dat
} }
/** /**
* Helper to check valid identity in handlers * Helper to check valid identity in handlers and middleware.
* Accepts both optional and required identity for flexibility.
*/ */
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>( export async function requireValidIdentity(
adminHandler: AdminHandler, adminHandler: AdminHandler,
dataArg: T dataArg: { identity?: interfaces.data.IIdentity }
): Promise<void> { ): Promise<void> {
if (!dataArg.identity) { if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided'); throw new plugins.typedrequest.TypedResponseError('No identity provided');
} }
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity }); const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) { if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required'); throw new plugins.typedrequest.TypedResponseError('Valid identity required');

View File

@@ -5,6 +5,10 @@ import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
export interface ITunnelManagerConfig { export interface ITunnelManagerConfig {
tunnelPort?: number; tunnelPort?: number;
targetHost?: string; targetHost?: string;
tls?: {
certPem?: string;
keyPem?: string;
};
} }
/** /**
@@ -15,6 +19,7 @@ export class TunnelManager {
private manager: RemoteIngressManager; private manager: RemoteIngressManager;
private config: ITunnelManagerConfig; private config: ITunnelManagerConfig;
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map(); private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) { constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
this.manager = manager; this.manager = manager;
@@ -22,12 +27,11 @@ export class TunnelManager {
this.hub = new plugins.remoteingress.RemoteIngressHub(); this.hub = new plugins.remoteingress.RemoteIngressHub();
// Listen for edge connect/disconnect events // Listen for edge connect/disconnect events
this.hub.on('edgeConnected', (data: { edgeId: string }) => { this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
const existing = this.edgeStatuses.get(data.edgeId);
this.edgeStatuses.set(data.edgeId, { this.edgeStatuses.set(data.edgeId, {
edgeId: data.edgeId, edgeId: data.edgeId,
connected: true, connected: true,
publicIp: existing?.publicIp ?? null, publicIp: data.peerAddr || null,
activeTunnels: 0, activeTunnels: 0,
lastHeartbeat: Date.now(), lastHeartbeat: Date.now(),
connectedAt: Date.now(), connectedAt: Date.now(),
@@ -61,20 +65,73 @@ export class TunnelManager {
await this.hub.start({ await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443, tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1', targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
}); });
// Send allowed edges to the hub // Send allowed edges to the hub
await this.syncAllowedEdges(); await this.syncAllowedEdges();
// Periodically reconcile with authoritative Rust hub status
this.reconcileInterval = setInterval(() => {
this.reconcile().catch(() => {});
}, 15_000);
} }
/** /**
* Stop the tunnel hub. * Stop the tunnel hub.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (this.reconcileInterval) {
clearInterval(this.reconcileInterval);
this.reconcileInterval = null;
}
// Remove event listeners before stopping to prevent leaks
this.hub.removeAllListeners();
await this.hub.stop(); await this.hub.stop();
this.edgeStatuses.clear(); this.edgeStatuses.clear();
} }
/**
* Reconcile TS-side edge statuses with the authoritative Rust hub status.
* Overwrites event-derived activeTunnels with the real activeStreams count.
*/
private async reconcile(): Promise<void> {
const hubStatus = await this.hub.getStatus();
if (!hubStatus || !hubStatus.connectedEdges) return;
const rustEdgeIds = new Set<string>();
for (const rustEdge of hubStatus.connectedEdges) {
rustEdgeIds.add(rustEdge.edgeId);
const existing = this.edgeStatuses.get(rustEdge.edgeId);
if (existing) {
existing.activeTunnels = rustEdge.activeStreams;
existing.lastHeartbeat = Date.now();
// Update peer address if available from Rust hub
if (rustEdge.peerAddr) {
existing.publicIp = rustEdge.peerAddr;
}
} else {
// Missed edgeConnected event — add entry
this.edgeStatuses.set(rustEdge.edgeId, {
edgeId: rustEdge.edgeId,
connected: true,
publicIp: rustEdge.peerAddr || null,
activeTunnels: rustEdge.activeStreams,
lastHeartbeat: Date.now(),
connectedAt: rustEdge.connectedAt * 1000,
});
}
}
// Remove entries for edges no longer connected in Rust (missed edgeDisconnected)
for (const edgeId of this.edgeStatuses.keys()) {
if (!rustEdgeIds.has(edgeId)) {
this.edgeStatuses.delete(edgeId);
}
}
}
/** /**
* Sync allowed edges from the manager to the hub. * Sync allowed edges from the manager to the hub.
* Call this after creating/deleting/updating edges. * Call this after creating/deleting/updating edges.
@@ -109,6 +166,19 @@ export class TunnelManager {
return count; return count;
} }
/**
* Get the public IPs of all connected edges.
*/
public getConnectedEdgeIps(): string[] {
const ips: string[] = [];
for (const status of this.edgeStatuses.values()) {
if (status.connected && status.publicIp) {
ips.push(status.publicIp);
}
}
return ips;
}
/** /**
* Get the total number of active tunnels across all edges. * Get the total number of active tunnels across all edges.
*/ */

View File

@@ -182,7 +182,14 @@ export class ContentScanner {
} }
return ContentScanner.instance; return ContentScanner.instance;
} }
/**
* Reset the singleton instance (for shutdown/testing)
*/
public static resetInstance(): void {
ContentScanner.instance = undefined;
}
/** /**
* Scan an email for malicious content * Scan an email for malicious content
* @param email The email to scan * @param email The email to scan

View File

@@ -65,6 +65,8 @@ export class IPReputationChecker {
private reputationCache: LRUCache<string, IReputationResult>; private reputationCache: LRUCache<string, IReputationResult>;
private options: Required<IIPReputationOptions>; private options: Required<IIPReputationOptions>;
private storageManager?: any; // StorageManager instance private storageManager?: any; // StorageManager instance
private saveCacheTimer: ReturnType<typeof setTimeout> | null = null;
private static readonly SAVE_CACHE_DEBOUNCE_MS = 30_000;
// Default DNSBL servers // Default DNSBL servers
private static readonly DEFAULT_DNSBL_SERVERS = [ private static readonly DEFAULT_DNSBL_SERVERS = [
@@ -143,7 +145,20 @@ export class IPReputationChecker {
} }
return IPReputationChecker.instance; return IPReputationChecker.instance;
} }
/**
* Reset the singleton instance (for shutdown/testing)
*/
public static resetInstance(): void {
if (IPReputationChecker.instance) {
if (IPReputationChecker.instance.saveCacheTimer) {
clearTimeout(IPReputationChecker.instance.saveCacheTimer);
IPReputationChecker.instance.saveCacheTimer = null;
}
}
IPReputationChecker.instance = undefined;
}
/** /**
* Check an IP address's reputation * Check an IP address's reputation
* @param ip IP address to check * @param ip IP address to check
@@ -213,12 +228,9 @@ export class IPReputationChecker {
// Update cache with result // Update cache with result
this.reputationCache.set(ip, result); this.reputationCache.set(ip, result);
// Save cache if enabled // Schedule debounced cache save if enabled
if (this.options.enableLocalCache) { if (this.options.enableLocalCache) {
// Fire and forget the save operation this.debouncedSaveCache();
this.saveCache().catch(error => {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
});
} }
// Log the reputation check // Log the reputation check
@@ -447,6 +459,21 @@ export class IPReputationChecker {
}); });
} }
/**
* Schedule a debounced cache save (at most once per SAVE_CACHE_DEBOUNCE_MS)
*/
private debouncedSaveCache(): void {
if (this.saveCacheTimer) {
return; // already scheduled
}
this.saveCacheTimer = setTimeout(() => {
this.saveCacheTimer = null;
this.saveCache().catch(error => {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
});
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
}
/** /**
* Save cache to disk or storage manager * Save cache to disk or storage manager
*/ */

View File

@@ -83,7 +83,14 @@ export class SecurityLogger {
} }
return SecurityLogger.instance; return SecurityLogger.instance;
} }
/**
* Reset the singleton instance (for shutdown/testing)
*/
public static resetInstance(): void {
SecurityLogger.instance = undefined;
}
/** /**
* Log a security event * Log a security event
* @param event The security event to log * @param event The security event to log
@@ -155,8 +162,9 @@ export class SecurityLogger {
} }
} }
// Return most recent events up to limit // Return most recent events up to limit (slice first to avoid mutating source)
return filteredEvents return filteredEvents
.slice()
.sort((a, b) => b.timestamp - a.timestamp) .sort((a, b) => b.timestamp - a.timestamp)
.slice(0, limit); .slice(0, limit);
} }
@@ -242,58 +250,46 @@ export class SecurityLogger {
topIPs: Array<{ ip: string; count: number }>; topIPs: Array<{ ip: string; count: number }>;
topDomains: Array<{ domain: string; count: number }>; topDomains: Array<{ domain: string; count: number }>;
} { } {
// Filter by time window if provided const cutoff = timeWindow ? Date.now() - timeWindow : 0;
let events = this.securityEvents;
if (timeWindow) { // Initialize counters
const cutoff = Date.now() - timeWindow; const byLevel = {} as Record<SecurityLogLevel, number>;
events = events.filter(e => e.timestamp >= cutoff); for (const level of Object.values(SecurityLogLevel)) {
byLevel[level] = 0;
}
const byType = {} as Record<SecurityEventType, number>;
for (const type of Object.values(SecurityEventType)) {
byType[type] = 0;
} }
// Count by level
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
acc[level] = events.filter(e => e.level === level).length;
return acc;
}, {} as Record<SecurityLogLevel, number>);
// Count by type
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
acc[type] = events.filter(e => e.type === type).length;
return acc;
}, {} as Record<SecurityEventType, number>);
// Count by IP
const ipCounts = new Map<string, number>(); const ipCounts = new Map<string, number>();
events.forEach(e => { const domainCounts = new Map<string, number>();
// Single pass over all events
let total = 0;
for (const e of this.securityEvents) {
if (cutoff && e.timestamp < cutoff) continue;
total++;
byLevel[e.level]++;
byType[e.type]++;
if (e.ipAddress) { if (e.ipAddress) {
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1); ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
} }
});
// Count by domain
const domainCounts = new Map<string, number>();
events.forEach(e => {
if (e.domain) { if (e.domain) {
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1); domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
} }
}); }
// Sort and limit top entries // Sort and limit top entries
const topIPs = Array.from(ipCounts.entries()) const topIPs = Array.from(ipCounts.entries())
.map(([ip, count]) => ({ ip, count })) .map(([ip, count]) => ({ ip, count }))
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
.slice(0, 10); .slice(0, 10);
const topDomains = Array.from(domainCounts.entries()) const topDomains = Array.from(domainCounts.entries())
.map(([domain, count]) => ({ domain, count })) .map(([domain, count]) => ({ domain, count }))
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
.slice(0, 10); .slice(0, 10);
return { return { total, byLevel, byType, topIPs, topDomains };
total: events.length,
byLevel,
byType,
topIPs,
topDomains
};
} }
} }

View File

@@ -30,6 +30,7 @@ export type StorageBackend = 'filesystem' | 'custom' | 'memory';
* Provides unified key-value storage with multiple backend support * Provides unified key-value storage with multiple backend support
*/ */
export class StorageManager { export class StorageManager {
private static readonly MAX_MEMORY_ENTRIES = 10_000;
private backend: StorageBackend; private backend: StorageBackend;
private memoryStore: Map<string, string> = new Map(); private memoryStore: Map<string, string> = new Map();
private config: IStorageConfig; private config: IStorageConfig;
@@ -227,6 +228,11 @@ export class StorageManager {
case 'memory': { case 'memory': {
this.memoryStore.set(key, value); this.memoryStore.set(key, value);
// Evict oldest entries if memory store exceeds limit
while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) {
const firstKey = this.memoryStore.keys().next().value;
this.memoryStore.delete(firstKey);
}
break; break;
} }

View File

@@ -16,7 +16,7 @@ export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.impl
> { > {
method: 'createApiToken'; method: 'createApiToken';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
name: string; name: string;
scopes: TApiTokenScope[]; scopes: TApiTokenScope[];
expiresInDays?: number | null; expiresInDays?: number | null;
@@ -38,7 +38,7 @@ export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.imple
> { > {
method: 'listApiTokens'; method: 'listApiTokens';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
tokens: IApiTokenInfo[]; tokens: IApiTokenInfo[];
@@ -54,7 +54,7 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
> { > {
method: 'revokeApiToken'; method: 'revokeApiToken';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
id: string; id: string;
}; };
response: { response: {
@@ -63,6 +63,26 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
}; };
} }
/**
* Roll (regenerate) an API token's secret. Returns the new raw token value once.
* Admin JWT only.
*/
export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RollApiToken
> {
method: 'rollApiToken';
request: {
identity: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
tokenValue?: string;
message?: string;
};
}
/** /**
* Enable or disable an API token. * Enable or disable an API token.
*/ */
@@ -72,7 +92,7 @@ export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.impl
> { > {
method: 'toggleApiToken'; method: 'toggleApiToken';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
id: string; id: string;
enabled: boolean; enabled: boolean;
}; };

View File

@@ -28,7 +28,7 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
> { > {
method: 'getCertificateOverview'; method: 'getCertificateOverview';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
certificates: ICertificateInfo[]; certificates: ICertificateInfo[];
@@ -50,7 +50,7 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa
> { > {
method: 'reprovisionCertificate'; method: 'reprovisionCertificate';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
routeName: string; routeName: string;
}; };
response: { response: {
@@ -66,7 +66,7 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
> { > {
method: 'reprovisionCertificateDomain'; method: 'reprovisionCertificateDomain';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
domain: string; domain: string;
}; };
response: { response: {
@@ -82,7 +82,7 @@ export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.i
> { > {
method: 'deleteCertificate'; method: 'deleteCertificate';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
domain: string; domain: string;
}; };
response: { response: {
@@ -98,7 +98,7 @@ export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.i
> { > {
method: 'exportCertificate'; method: 'exportCertificate';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
domain: string; domain: string;
}; };
response: { response: {
@@ -123,7 +123,7 @@ export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.i
> { > {
method: 'importCertificate'; method: 'importCertificate';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
cert: { cert: {
id: string; id: string;
domainName: string; domainName: string;

View File

@@ -69,7 +69,8 @@ export interface IConfigData {
enabled: boolean; enabled: boolean;
tunnelPort: number | null; tunnelPort: number | null;
hubDomain: string | null; hubDomain: string | null;
tlsConfigured: boolean; tlsMode: 'custom' | 'acme' | 'self-signed';
connectedEdgeIps: string[];
}; };
} }
@@ -80,7 +81,7 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
> { > {
method: 'getConfiguration'; method: 'getConfiguration';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
section?: string; section?: string;
}; };
response: { response: {

View File

@@ -68,7 +68,7 @@ export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implem
> { > {
method: 'getAllEmails'; method: 'getAllEmails';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
emails: IEmail[]; emails: IEmail[];
@@ -84,7 +84,7 @@ export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.impl
> { > {
method: 'getEmailDetail'; method: 'getEmailDetail';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
emailId: string; emailId: string;
}; };
response: { response: {
@@ -101,7 +101,7 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
> { > {
method: 'resendEmail'; method: 'resendEmail';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
emailId: string; emailId: string;
}; };
response: { response: {

View File

@@ -9,7 +9,7 @@ export interface IReq_GetRecentLogs extends plugins.typedrequestInterfaces.imple
> { > {
method: 'getRecentLogs'; method: 'getRecentLogs';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
level?: 'debug' | 'info' | 'warn' | 'error'; level?: 'debug' | 'info' | 'warn' | 'error';
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email'; category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
limit?: number; limit?: number;
@@ -31,7 +31,7 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
> { > {
method: 'getLogStream'; method: 'getLogStream';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
follow?: boolean; follow?: boolean;
filters?: { filters?: {
level?: string[]; level?: string[];

View File

@@ -14,7 +14,7 @@ export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.im
> { > {
method: 'getRadiusClients'; method: 'getRadiusClients';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
clients: Array<{ clients: Array<{
@@ -35,7 +35,7 @@ export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.imp
> { > {
method: 'setRadiusClient'; method: 'setRadiusClient';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
client: { client: {
name: string; name: string;
ipRange: string; ipRange: string;
@@ -59,7 +59,7 @@ export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.
> { > {
method: 'removeRadiusClient'; method: 'removeRadiusClient';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
name: string; name: string;
}; };
response: { response: {
@@ -81,7 +81,7 @@ export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.imp
> { > {
method: 'getVlanMappings'; method: 'getVlanMappings';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
mappings: Array<{ mappings: Array<{
@@ -108,7 +108,7 @@ export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.impl
> { > {
method: 'setVlanMapping'; method: 'setVlanMapping';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
mapping: { mapping: {
mac: string; mac: string;
vlan: number; vlan: number;
@@ -139,7 +139,7 @@ export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.i
> { > {
method: 'removeVlanMapping'; method: 'removeVlanMapping';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
mac: string; mac: string;
}; };
response: { response: {
@@ -157,7 +157,7 @@ export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.im
> { > {
method: 'updateVlanConfig'; method: 'updateVlanConfig';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
defaultVlan?: number; defaultVlan?: number;
allowUnknownMacs?: boolean; allowUnknownMacs?: boolean;
}; };
@@ -179,7 +179,7 @@ export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.
> { > {
method: 'testVlanAssignment'; method: 'testVlanAssignment';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
mac: string; mac: string;
}; };
response: { response: {
@@ -207,7 +207,7 @@ export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.i
> { > {
method: 'getRadiusSessions'; method: 'getRadiusSessions';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
filter?: { filter?: {
username?: string; username?: string;
nasIpAddress?: string; nasIpAddress?: string;
@@ -243,7 +243,7 @@ export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterf
> { > {
method: 'disconnectRadiusSession'; method: 'disconnectRadiusSession';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
sessionId: string; sessionId: string;
reason?: string; reason?: string;
}; };
@@ -262,7 +262,7 @@ export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInt
> { > {
method: 'getRadiusAccountingSummary'; method: 'getRadiusAccountingSummary';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
startTime: number; startTime: number;
endTime: number; endTime: number;
}; };
@@ -296,7 +296,7 @@ export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces
> { > {
method: 'getRadiusStatistics'; method: 'getRadiusStatistics';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
stats: { stats: {

View File

@@ -15,7 +15,7 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
> { > {
method: 'createRemoteIngress'; method: 'createRemoteIngress';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
name: string; name: string;
listenPorts?: number[]; listenPorts?: number[];
autoDerivePorts?: boolean; autoDerivePorts?: boolean;
@@ -36,7 +36,7 @@ export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces
> { > {
method: 'deleteRemoteIngress'; method: 'deleteRemoteIngress';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
id: string; id: string;
}; };
response: { response: {
@@ -54,7 +54,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
> { > {
method: 'updateRemoteIngress'; method: 'updateRemoteIngress';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
id: string; id: string;
name?: string; name?: string;
listenPorts?: number[]; listenPorts?: number[];
@@ -77,7 +77,7 @@ export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequest
> { > {
method: 'regenerateRemoteIngressSecret'; method: 'regenerateRemoteIngressSecret';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
id: string; id: string;
}; };
response: { response: {
@@ -95,7 +95,7 @@ export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.
> { > {
method: 'getRemoteIngresses'; method: 'getRemoteIngresses';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
edges: IRemoteIngress[]; edges: IRemoteIngress[];
@@ -111,7 +111,7 @@ export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfa
> { > {
method: 'getRemoteIngressStatus'; method: 'getRemoteIngressStatus';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
}; };
response: { response: {
statuses: IRemoteIngressStatus[]; statuses: IRemoteIngressStatus[];
@@ -128,7 +128,7 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
> { > {
method: 'getRemoteIngressConnectionToken'; method: 'getRemoteIngressConnectionToken';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
edgeId: string; edgeId: string;
hubHost?: string; hubHost?: string;
}; };

View File

@@ -9,7 +9,7 @@ export interface IReq_GetServerStatistics extends plugins.typedrequestInterfaces
> { > {
method: 'getServerStatistics'; method: 'getServerStatistics';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
includeHistory?: boolean; includeHistory?: boolean;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d'; timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
}; };
@@ -29,7 +29,7 @@ export interface IReq_GetEmailStatistics extends plugins.typedrequestInterfaces.
> { > {
method: 'getEmailStatistics'; method: 'getEmailStatistics';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d'; timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
domain?: string; domain?: string;
includeDetails?: boolean; includeDetails?: boolean;
@@ -49,7 +49,7 @@ export interface IReq_GetDnsStatistics extends plugins.typedrequestInterfaces.im
> { > {
method: 'getDnsStatistics'; method: 'getDnsStatistics';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d'; timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
domain?: string; domain?: string;
includeQueryTypes?: boolean; includeQueryTypes?: boolean;
@@ -69,7 +69,7 @@ export interface IReq_GetRateLimitStatus extends plugins.typedrequestInterfaces.
> { > {
method: 'getRateLimitStatus'; method: 'getRateLimitStatus';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
domain?: string; domain?: string;
ip?: string; ip?: string;
includeBlocked?: boolean; includeBlocked?: boolean;
@@ -91,7 +91,7 @@ export interface IReq_GetSecurityMetrics extends plugins.typedrequestInterfaces.
> { > {
method: 'getSecurityMetrics'; method: 'getSecurityMetrics';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d'; timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
includeDetails?: boolean; includeDetails?: boolean;
}; };
@@ -112,7 +112,7 @@ export interface IReq_GetActiveConnections extends plugins.typedrequestInterface
> { > {
method: 'getActiveConnections'; method: 'getActiveConnections';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
protocol?: 'smtp' | 'smtps' | 'http' | 'https'; protocol?: 'smtp' | 'smtps' | 'http' | 'https';
state?: string; state?: string;
}; };
@@ -137,7 +137,7 @@ export interface IReq_GetQueueStatus extends plugins.typedrequestInterfaces.impl
> { > {
method: 'getQueueStatus'; method: 'getQueueStatus';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
queueName?: string; queueName?: string;
}; };
response: { response: {
@@ -153,10 +153,31 @@ export interface IReq_GetHealthStatus extends plugins.typedrequestInterfaces.imp
> { > {
method: 'getHealthStatus'; method: 'getHealthStatus';
request: { request: {
identity?: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
detailed?: boolean; detailed?: boolean;
}; };
response: { response: {
health: statsInterfaces.IHealthStatus; health: statsInterfaces.IHealthStatus;
}; };
}
// Network Stats (raw SmartProxy network data)
export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetNetworkStats
> {
method: 'getNetworkStats';
request: {
identity: authInterfaces.IIdentity;
};
response: {
connectionsByIP: Array<{ ip: string; count: number }>;
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
topIPs: Array<{ ip: string; count: number }>;
totalDataTransferred: { bytesIn: number; bytesOut: number };
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
throughputByIP: Array<{ ip: string; in: number; out: number }>;
requestsPerSecond: number;
requestsTotal: number;
};
} }

View File

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

View File

@@ -238,9 +238,12 @@ interface IActionContext {
} }
const getActionContext = (): IActionContext => { const getActionContext = (): IActionContext => {
return { const identity = loginStatePart.getState().identity;
identity: loginStatePart.getState().identity, // Treat expired JWTs as no identity — prevents stale persisted sessions from firing requests
}; if (identity && identity.expiresAt && identity.expiresAt < Date.now()) {
return { identity: null };
}
return { identity };
}; };
// Login Action // Login Action
@@ -271,24 +274,23 @@ export const loginAction = loginStatePart.createAction<{
} }
}); });
// Logout Action // Logout Action — always clears state, even if identity is expired/missing
export const logoutAction = loginStatePart.createAction(async (statePartArg) => { export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
if (!context.identity) return statePartArg.getState();
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< // Try to notify server, but don't block logout if identity is missing/expired
interfaces.requests.IReq_AdminLogout if (context.identity) {
>('/typedrequest', 'adminLogout'); const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLogout
try { >('/typedrequest', 'adminLogout');
await typedRequest.fire({ try {
identity: context.identity, await typedRequest.fire({ identity: context.identity });
}); } catch (error) {
} catch (error) { console.error('Logout error:', error);
console.error('Logout error:', error); }
} }
// Clear login state regardless // Always clear login state
return { return {
identity: null, identity: null,
isLoggedIn: false, isLoggedIn: false,
@@ -298,8 +300,8 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
// Fetch All Stats Action - Using combined endpoint for efficiency // Fetch All Stats Action - Using combined endpoint for efficiency
export const fetchAllStatsAction = statsStatePart.createAction(async (statePartArg) => { export const fetchAllStatsAction = statsStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
// Use combined metrics endpoint - single request instead of 4 // Use combined metrics endpoint - single request instead of 4
@@ -340,8 +342,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
// Fetch Configuration Action (read-only) // Fetch Configuration Action (read-only)
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => { export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -373,6 +375,7 @@ export const fetchRecentLogsAction = logStatePart.createAction<{
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email'; category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg) => {
const context = getActionContext(); const context = getActionContext();
if (!context.identity) return statePartArg.getState();
const logsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const logsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetRecentLogs interfaces.requests.IReq_GetRecentLogs
@@ -448,8 +451,8 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
// Fetch Network Stats Action // Fetch Network Stats Action
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg) => { export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
// Fetch active connections using the existing endpoint // Fetch active connections using the existing endpoint
@@ -522,6 +525,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => { export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -554,6 +558,7 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => { export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -581,7 +586,7 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
}); });
export const reprovisionCertificateAction = certificateStatePart.createAction<string>( export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
async (statePartArg, domain) => { async (statePartArg, domain, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -596,8 +601,7 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
}); });
// Re-fetch overview after reprovisioning // Re-fetch overview after reprovisioning
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); return await actionContext.dispatch(fetchCertificateOverviewAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -608,7 +612,7 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
); );
export const deleteCertificateAction = certificateStatePart.createAction<string>( export const deleteCertificateAction = certificateStatePart.createAction<string>(
async (statePartArg, domain) => { async (statePartArg, domain, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -623,8 +627,7 @@ export const deleteCertificateAction = certificateStatePart.createAction<string>
}); });
// Re-fetch overview after deletion // Re-fetch overview after deletion
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); return await actionContext.dispatch(fetchCertificateOverviewAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -643,7 +646,7 @@ export const importCertificateAction = certificateStatePart.createAction<{
publicKey: string; publicKey: string;
csr: string; csr: string;
}>( }>(
async (statePartArg, cert) => { async (statePartArg, cert, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -658,8 +661,7 @@ export const importCertificateAction = certificateStatePart.createAction<{
}); });
// Re-fetch overview after import // Re-fetch overview after import
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); return await actionContext.dispatch(fetchCertificateOverviewAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -700,6 +702,7 @@ export async function fetchConnectionToken(edgeId: string) {
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => { export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -737,7 +740,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
listenPorts?: number[]; listenPorts?: number[];
autoDerivePorts?: boolean; autoDerivePorts?: boolean;
tags?: string[]; tags?: string[];
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -756,7 +759,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
if (response.success) { if (response.success) {
// Refresh the list // Refresh the list
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); await actionContext.dispatch(fetchRemoteIngressAction, null);
return { return {
...statePartArg.getState(), ...statePartArg.getState(),
@@ -774,7 +777,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
}); });
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>( export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
async (statePartArg, edgeId) => { async (statePartArg, edgeId, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -788,8 +791,7 @@ export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<str
id: edgeId, id: edgeId,
}); });
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); return await actionContext.dispatch(fetchRemoteIngressAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -805,7 +807,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
listenPorts?: number[]; listenPorts?: number[];
autoDerivePorts?: boolean; autoDerivePorts?: boolean;
tags?: string[]; tags?: string[];
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -823,8 +825,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
tags: dataArg.tags, tags: dataArg.tags,
}); });
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); return await actionContext.dispatch(fetchRemoteIngressAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -877,7 +878,7 @@ export const clearNewEdgeIdAction = remoteIngressStatePart.createAction(
export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
id: string; id: string;
enabled: boolean; enabled: boolean;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -892,8 +893,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); return await actionContext.dispatch(fetchRemoteIngressAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -909,6 +909,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => { export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -939,7 +940,7 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
export const createRouteAction = routeManagementStatePart.createAction<{ export const createRouteAction = routeManagementStatePart.createAction<{
route: any; route: any;
enabled?: boolean; enabled?: boolean;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -954,8 +955,7 @@ export const createRouteAction = routeManagementStatePart.createAction<{
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); return await actionContext.dispatch(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -965,7 +965,7 @@ export const createRouteAction = routeManagementStatePart.createAction<{
}); });
export const deleteRouteAction = routeManagementStatePart.createAction<string>( export const deleteRouteAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeId) => { async (statePartArg, routeId, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -979,8 +979,7 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
id: routeId, id: routeId,
}); });
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); return await actionContext.dispatch(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -993,7 +992,7 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
export const toggleRouteAction = routeManagementStatePart.createAction<{ export const toggleRouteAction = routeManagementStatePart.createAction<{
id: string; id: string;
enabled: boolean; enabled: boolean;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -1008,8 +1007,7 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); return await actionContext.dispatch(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -1021,7 +1019,7 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
export const setRouteOverrideAction = routeManagementStatePart.createAction<{ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
routeName: string; routeName: string;
enabled: boolean; enabled: boolean;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -1036,8 +1034,7 @@ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); return await actionContext.dispatch(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -1047,7 +1044,7 @@ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
}); });
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>( export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeName) => { async (statePartArg, routeName, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -1061,8 +1058,7 @@ export const removeRouteOverrideAction = routeManagementStatePart.createAction<s
routeName, routeName,
}); });
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); return await actionContext.dispatch(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -1079,6 +1075,7 @@ export const removeRouteOverrideAction = routeManagementStatePart.createAction<s
export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => { export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
if (!context.identity) return currentState;
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1115,8 +1112,20 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT
}); });
} }
export async function rollApiToken(id: string) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RollApiToken
>('/typedrequest', 'rollApiToken');
return request.fire({
identity: context.identity,
id,
});
}
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>( export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
async (statePartArg, tokenId) => { async (statePartArg, tokenId, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -1130,8 +1139,7 @@ export const revokeApiTokenAction = routeManagementStatePart.createAction<string
id: tokenId, id: tokenId,
}); });
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null); return await actionContext.dispatch(fetchApiTokensAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -1144,7 +1152,7 @@ export const revokeApiTokenAction = routeManagementStatePart.createAction<string
export const toggleApiTokenAction = routeManagementStatePart.createAction<{ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
id: string; id: string;
enabled: boolean; enabled: boolean;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg, actionContext) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
@@ -1159,8 +1167,7 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null); return await actionContext.dispatch(fetchApiTokensAction, null);
return statePartArg.getState();
} catch (error) { } catch (error) {
return { return {
...currentState, ...currentState,
@@ -1221,8 +1228,9 @@ async function disconnectSocket() {
// Combined refresh action for efficient polling // Combined refresh action for efficient polling
async function dispatchCombinedRefreshAction() { async function dispatchCombinedRefreshAction() {
const context = getActionContext(); const context = getActionContext();
if (!context.identity) return;
const currentView = uiStatePart.getState().activeView; const currentView = uiStatePart.getState().activeView;
try { try {
// Always fetch basic stats for dashboard widgets // Always fetch basic stats for dashboard widgets
const combinedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const combinedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1321,8 +1329,23 @@ async function dispatchCombinedRefreshAction() {
console.error('Certificate refresh failed:', error); console.error('Certificate refresh failed:', error);
} }
} }
// Refresh remote ingress data if on remoteingress view
if (currentView === 'remoteingress') {
try {
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
} catch (error) {
console.error('Remote ingress refresh failed:', error);
}
}
} catch (error) { } catch (error) {
console.error('Combined refresh failed:', error); console.error('Combined refresh failed:', error);
// If the error looks like an auth failure (invalid JWT), force re-login
const errMsg = String(error);
if (errMsg.includes('invalid') || errMsg.includes('unauthorized') || errMsg.includes('401')) {
await loginStatePart.dispatchAction(logoutAction, null);
window.location.reload();
}
} }
} }

View File

@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { appRouter } from '../router.js'; import { appRouter } from '../router.js';
import { import {
@@ -43,42 +44,52 @@ export class OpsDashboard extends DeesElement {
private viewTabs = [ private viewTabs = [
{ {
name: 'Overview', name: 'Overview',
iconName: 'lucide:layoutDashboard',
element: OpsViewOverview, element: OpsViewOverview,
}, },
{ {
name: 'Configuration', name: 'Configuration',
iconName: 'lucide:settings',
element: OpsViewConfig, 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: 'Routes', name: 'Routes',
iconName: 'lucide:route',
element: OpsViewRoutes, element: OpsViewRoutes,
}, },
{ {
name: 'ApiTokens', name: 'ApiTokens',
iconName: 'lucide:key',
element: OpsViewApiTokens, element: OpsViewApiTokens,
}, },
{ {
name: 'Security', name: 'Security',
iconName: 'lucide:shield',
element: OpsViewSecurity, element: OpsViewSecurity,
}, },
{ {
name: 'Certificates', name: 'Certificates',
iconName: 'lucide:badgeCheck',
element: OpsViewCertificates, element: OpsViewCertificates,
}, },
{ {
name: 'RemoteIngress', name: 'RemoteIngress',
iconName: 'lucide:globe',
element: OpsViewRemoteIngress, element: OpsViewRemoteIngress,
}, },
]; ];
@@ -208,13 +219,27 @@ export class OpsDashboard extends DeesElement {
// Handle initial state - check if we have a stored session that's still valid // Handle initial state - check if we have a stored session that's still valid
const loginState = appstate.loginStatePart.getState(); const loginState = appstate.loginStatePart.getState();
if (loginState.identity?.jwt) { if (loginState.identity?.jwt) {
// Verify JWT hasn't expired
if (loginState.identity.expiresAt > Date.now()) { if (loginState.identity.expiresAt > Date.now()) {
// JWT still valid, restore logged-in state // Client-side expiry looks valid — verify with server (keypair may have changed)
this.loginState = loginState; try {
await simpleLogin.switchToSlottedContent(); const verifyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); interfaces.requests.IReq_VerifyIdentity
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); >('/typedrequest', 'verifyIdentity');
const response = await verifyRequest.fire({ identity: loginState.identity });
if (response.valid) {
// JWT confirmed valid by server
this.loginState = loginState;
await simpleLogin.switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else {
// Server rejected the JWT — clear state, show login
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
} catch {
// Server unreachable or error — clear state, show login
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
} else { } else {
// JWT expired, clear the stored state // JWT expired, clear the stored state
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);

View File

@@ -152,6 +152,15 @@ export class OpsViewApiTokens extends DeesElement {
); );
}, },
}, },
{
name: 'Roll',
iconName: 'lucide:rotateCw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await this.showRollTokenDialog(token);
},
},
{ {
name: 'Revoke', name: 'Revoke',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
@@ -279,6 +288,60 @@ export class OpsViewApiTokens extends DeesElement {
}); });
} }
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Roll Token Secret',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>This will regenerate the secret for <strong>${token.name}</strong>. The old token value will stop working immediately.</p>
</div>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Roll Token',
iconName: 'lucide:rotateCw',
action: async (modalArg: any) => {
await modalArg.destroy();
try {
const response = await appstate.rollApiToken(token.id);
if (response.success && response.tokenValue) {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
await DeesModal.createAndShow({
heading: 'Token Rolled',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Copy this token now. It will not be shown again.</p>
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
</div>
</div>
`,
menuOptions: [
{
name: 'Done',
iconName: 'lucide:check',
action: async (m: any) => await m.destroy(),
},
],
});
}
} catch (error) {
console.error('Failed to roll token:', error);
}
},
},
],
});
}
async firstUpdated() { async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
} }

View File

@@ -103,11 +103,20 @@ export class OpsViewConfig extends DeesElement {
} }
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult { private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
// Annotate proxy IPs with source hint when Remote Ingress is active
const ri = this.configState.config?.remoteIngress;
let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null;
if (proxyIpValues && ri?.enabled && proxyIpValues.includes('127.0.0.1')) {
proxyIpValues = proxyIpValues.map(ip =>
ip === '127.0.0.1' ? '127.0.0.1 (Remote Ingress)' : ip
);
}
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Base Directory', value: sys.baseDir }, { key: 'Base Directory', value: sys.baseDir },
{ key: 'Data Directory', value: sys.dataDir }, { key: 'Data Directory', value: sys.dataDir },
{ key: 'Public IP', value: sys.publicIp }, { key: 'Public IP', value: sys.publicIp },
{ key: 'Proxy IPs', value: sys.proxyIps.length > 0 ? sys.proxyIps : null, type: 'pills' }, { key: 'Proxy IPs', value: proxyIpValues, type: 'pills' },
{ key: 'Uptime', value: this.formatUptime(sys.uptime) }, { key: 'Uptime', value: this.formatUptime(sys.uptime) },
{ key: 'Storage Backend', value: sys.storageBackend, type: 'badge' }, { key: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
{ key: 'Storage Path', value: sys.storagePath }, { key: 'Storage Path', value: sys.storagePath },
@@ -291,7 +300,8 @@ export class OpsViewConfig extends DeesElement {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Tunnel Port', value: ri.tunnelPort }, { key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain }, { key: 'Hub Domain', value: ri.hubDomain },
{ key: 'TLS Configured', value: ri.tlsConfigured, type: 'boolean' }, { key: 'TLS Mode', value: ri.tlsMode, type: 'badge' },
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
]; ];
const actions: IConfigSectionAction[] = [ const actions: IConfigSectionAction[] = [

View File

@@ -76,8 +76,15 @@ export class OpsViewLogs extends DeesElement {
// Wait for xterm terminal to finish initializing (CDN load) // Wait for xterm terminal to finish initializing (CDN load)
if (!chartLog.terminalReady) { if (!chartLog.terminalReady) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
let attempts = 0;
const maxAttempts = 200; // 200 * 50ms = 10 seconds
const check = () => { const check = () => {
if (chartLog.terminalReady) { resolve(); return; } if (chartLog.terminalReady) { resolve(); return; }
if (++attempts >= maxAttempts) {
console.warn('ops-view-logs: terminal ready timeout after 10s');
resolve(); // resolve gracefully to avoid blocking
return;
}
setTimeout(check, 50); setTimeout(check, 50);
}; };
check(); check();