Compare commits

...

398 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
2bd5e5c7c5 v9.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 21:34:50 +00:00
4d6ac81c59 BREAKING CHANGE(opsserver): Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections 2026-02-23 21:34:50 +00:00
2ebe0de92d v8.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 12:40:26 +00:00
f5028ffb60 feat(route-management): add programmatic route management API with API tokens and admin UI 2026-02-23 12:40:26 +00:00
90016d1217 v8.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-22 00:45:01 +00:00
48d3d1218f BREAKING CHANGE(email-ops): migrate email operations to catalog-compatible email model and simplify UI/router 2026-02-22 00:45:01 +00:00
4759c4f011 v7.4.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 23:36:10 +00:00
0fbd8d1cdd fix(logging): add adaptive rate-limited DNS query logging, flush pending DNS logs on shutdown, and enhance email delivery logging 2026-02-21 23:36:10 +00:00
447cf44d68 v7.4.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 18:56:44 +00:00
82ce17a941 fix(monitoring,remoteingress,web): Prune old metrics buckets periodically, clear metrics caches on shutdown, simplify edge disconnect handling, and optimize network view data updates 2026-02-21 18:56:44 +00:00
15da996e70 v7.4.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 18:13:10 +00:00
582e19e6a6 fix(dcrouter): replace console logging with structured logger, improve metrics logging, add terminal-ready wait in ops UI, bump dees-catalog patch 2026-02-21 18:13:10 +00:00
79765d6729 v7.4.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 14:02:48 +00:00
ffc93eb9d3 feat(opsserver): add real-time log push to ops dashboard and recent DNS query tracking 2026-02-21 14:02:48 +00:00
1337a4905a v7.3.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-20 15:37:15 +00:00
c7418d9e1a feat(dcrouter): Wire DNS server query events to MetricsManager for time-series tracking and bump @push.rocks/smartdns to ^7.9.0 2026-02-20 15:37:15 +00:00
2a94ffd4c9 v7.2.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-20 12:55:20 +00:00
b2fe6caf33 feat(logs): replace custom logs list with dees-chart-log component and push logs to chart, add log mapping and lifecycle sync, and bump smartlog dependency 2026-02-20 12:55:20 +00:00
822bbc1957 v7.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 17:23:43 +00:00
eacddc7ce1 feat(ops/monitoring): add in-memory log buffer, metrics time-series and ops UI integration 2026-02-19 17:23:43 +00:00
dc6ce341bd v7.0.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 14:36:11 +00:00
1aadc93f92 fix(monitoring): Use smartMetrics cpuPercentage for cpuUsage.user and update smartmetrics and smartproxy dependencies 2026-02-19 14:36:11 +00:00
8fdcd479d6 v7.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 10:16:23 +00:00
d24dde8eff BREAKING CHANGE(deps): bump dependencies: @serve.zone/remoteingress to ^4.0.0 (breaking), @push.rocks/smartproxy to ^25.7.6, @types/node to ^25.3.0 2026-02-19 10:16:23 +00:00
40a34073e9 v6.13.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-19 08:33:41 +00:00
9ac297c197 fix(runtime): prevent memory leaks and improve shutdown/stream handling across services 2026-02-19 08:33:41 +00:00
ddd0662fb8 v6.13.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 22:56:18 +00:00
11bc0dde6c fix(dcrouter): enable PROXY protocol v1 handling for SmartProxy when remoteIngress is enabled to preserve client IPs 2026-02-18 22:56:18 +00:00
610d691244 v6.13.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 21:35:18 +00:00
c88410ea53 feat(remoteingress): include listenPorts for allowed edges sent to the Rust hub and always resync allowed edges when edge properties change 2026-02-18 21:35:18 +00:00
9cbdd24281 v6.12.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 18:47:18 +00:00
dce1de8c4b feat(remote-ingress): add Remote Ingress hub integration, OpsServer UI, APIs, and docs 2026-02-18 18:47:18 +00:00
86e6c4f600 v6.11.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 06:05:46 +00:00
0618755236 feat(remoteingress): add ability to generate remote ingress connection tokens and UI copy action; add hubDomain config option; update remoteingress dependency to ^3.1.1 2026-02-18 06:05:46 +00:00
b21f3385e1 v6.10.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 17:49:12 +00:00
dd61e0c962 feat(ops-view-certificates): Make Export and Delete actions available inline (inRow) as well as in the context menu; bump @design.estate/dees-catalog to ^3.43.0 2026-02-17 17:49:12 +00:00
ac3a42fc41 v6.9.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 16:28:33 +00:00
c23f16149c feat(certificates): add certificate import, export, and deletion support (server handlers, request types, and UI) 2026-02-17 16:28:33 +00:00
529a4bae00 v6.8.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 14:17:18 +00:00
49606ae007 feat(remote-ingress): support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI 2026-02-17 14:17:18 +00:00
31a6510d8b v6.7.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 11:56:54 +00:00
b5e760ae07 feat(remote-ingress): Support auto-derived effective listen ports, make listenPorts optional, add toggle action and refine remote ingress creation/management UI 2026-02-17 11:56:54 +00:00
ea32babaac v6.6.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 10:57:27 +00:00
a4ddedaf46 fix(icons): standardize icon identifiers to lucide-prefixed names across operational views 2026-02-17 10:57:27 +00:00
7ce09c53ca v6.6.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 10:55:31 +00:00
69be2295f1 feat(remoteingress): derive effective remote ingress listen ports from route configs and expose them via ops API 2026-02-17 10:55:31 +00:00
018efa32f6 v6.5.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 22:42:30 +00:00
2530918dc6 v6.4.5
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 17:47:43 +00:00
0b09ea1573 fix(remoteingress): mark remote ingress data actions as row actions and bump @design.estate/dees-catalog dependency 2026-02-16 17:47:43 +00:00
21157477b4 v6.4.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 14:50:44 +00:00
fcf36e5cd5 fix(deps): bump @push.rocks/smartproxy to ^25.7.3 2026-02-16 14:50:44 +00:00
f5740fa565 v6.4.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:44:38 +00:00
4a9fba53a9 fix(deps): bump @push.rocks/smartproxy to ^25.7.2 2026-02-16 13:44:38 +00:00
da61adc9a2 v6.4.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:32:24 +00:00
616066ffd0 fix(smartproxy): bump @push.rocks/smartproxy to ^25.7.1 2026-02-16 13:32:24 +00:00
bd5cccb405 v6.4.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:16:50 +00:00
fbade85cda fix(deps): bump dependencies: @push.rocks/smartproxy to ^25.7.0 and @serve.zone/remoteingress to ^3.0.2 2026-02-16 13:16:50 +00:00
9060d26f3a v6.4.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 11:25:16 +00:00
c889141ec3 feat(remoteingress): add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI 2026-02-16 11:25:16 +00:00
fb472f353c v6.3.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:52:38 +00:00
090bd747e1 feat(dcrouter): add configurable baseDir and centralized path resolution; use resolved data paths for storage, cache and DNS 2026-02-16 09:52:38 +00:00
4d77a94bbb v6.2.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:02:57 +00:00
7f5284b10f fix(deps): bump @push.rocks/smartproxy to ^25.5.0 2026-02-16 09:02:57 +00:00
9cd5db2d81 v6.2.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 02:50:25 +00:00
de0b7d1fe0 fix(dcrouter): persist proxy certificate validity dates and improve certificate status initialization 2026-02-16 02:50:25 +00:00
4e32745a8f v6.2.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 01:58:39 +00:00
121573de2f fix(certs): Populate certificate status for cert-store-loaded certificates after SmartProxy startup and check proxy-certs in opsserver certificate handler 2026-02-16 01:58:39 +00:00
cd957526e2 v6.2.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:56:41 +00:00
7aa5f07731 fix(smartacme,storage): Respect wildcard domain requests when retrieving certificates and treat empty/whitespace storage values as null in getJSON 2026-02-16 00:56:41 +00:00
5b6f7b30c3 v6.2.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:26:35 +00:00
18cc21a49e feat(ts_web): add Certificate Management documentation and ops-view-certificates reference 2026-02-16 00:26:35 +00:00
46fa2f6ade v6.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:22:23 +00:00
0a6315f177 feat(certs): integrate smartacme v9 for ACME certificate provisioning and add certificate management features, docs, dashboard views, API endpoints, and per-domain backoff scheduler 2026-02-16 00:22:23 +00:00
841f99e19d v6.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-15 16:03:13 +00:00
8e9de46cd2 BREAKING CHANGE(certs): Introduce domain-centric certificate provisioning with per-domain exponential backoff and a staggered serial scheduler; add domain-based reprovision API and UI backoff display; change certificate overview API to be domain-first and include backoff info; bump related deps. 2026-02-15 16:03:13 +00:00
2d44528345 v5.5.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 14:27:59 +00:00
28a38252da feat(certs): persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting 2026-02-14 14:27:58 +00:00
dfb268bbfc v5.4.6
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 12:49:57 +00:00
6532c7ff22 fix(deps): bump @push.rocks/smartproxy dependency to ^25.2.2 2026-02-14 12:49:57 +00:00
d2c63cf170 v5.4.5
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 12:33:04 +00:00
09d66e4528 fix(dcrouter): bump patch for release pipeline consistency - no code changes 2026-02-14 12:33:04 +00:00
3078fa9d7b feat(dashboard): use SmartProxy server-side throughput history and per-IP bandwidth in network view 2026-02-14 12:31:44 +00:00
57fbb128e6 v5.4.4
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 11:26:58 +00:00
d73266eeb8 fix(deps): bump @push.rocks/smartproxy to ^25.2.0 2026-02-14 11:26:58 +00:00
2dbdf2d2b1 v5.4.3
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 09:25:59 +00:00
383e0adc23 fix(dependencies): bump @push.rocks/smartproxy to ^25.1.0 2026-02-14 09:25:59 +00:00
d7789f5a44 v5.4.2
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-13 23:16:25 +00:00
2638990667 fix(dcrouter): improve domain pattern matching to support routing-glob and wildcard patterns and use matching logic when resolving routes 2026-02-13 23:16:25 +00:00
c33ecdc26f v5.4.1
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-13 22:03:23 +00:00
b033d80927 fix(network,dcrouter): Always register SmartProxy certificate event handlers and include total bytes + improved connection metrics in network stats/UI 2026-02-13 22:03:23 +00:00
cf5d616769 v5.4.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-13 21:37:52 +00:00
8e722f5ab6 feat(certificates): include certificate source/issuer and Rust-side status checks; pass eventComms into certProvisionFunction and record expiry information 2026-02-13 21:37:52 +00:00
2b75709161 v5.3.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-13 17:05:33 +00:00
c5e2c262b7 feat(certificates): add certificate overview and reprovisioning in ops UI and API; track SmartProxy certificate events 2026-02-13 17:05:33 +00:00
d10896196d v5.2.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-13 14:19:19 +00:00
8be1e87bdc feat(monitoring): add throughput metrics and expose them in ops UI 2026-02-13 14:19:19 +00:00
96cefe984a v5.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-13 12:12:01 +00:00
ca112c3e42 feat(acme): Integrate SmartAcme DNS-01 handling and add certificate provisioning for SmartProxy 2026-02-13 12:12:01 +00:00
85b6c4fa51 v5.0.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-13 00:02:09 +00:00
ee550e6f25 fix(deps): bump @push.rocks/smartdns to ^7.8.1 and @push.rocks/smartmta to ^5.2.2 2026-02-13 00:02:09 +00:00
108a8bb51d v5.0.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-12 22:51:55 +00:00
3c5b26d1c1 fix(deps): bump @push.rocks/smartproxy to ^23.1.4 2026-02-12 22:51:55 +00:00
01fbc3db95 v5.0.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-12 16:27:28 +00:00
8dd9770339 fix(dcrouter): remove legacy handling of emailConfig.routes that added domain-based routes 2026-02-12 16:27:28 +00:00
77842647fd v5.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-02-12 14:20:42 +00:00
a309145829 fix(cache): use user-writable ~/.serve.zone/dcrouter for TsmDB and centralize data path logic 2026-02-12 14:20:42 +00:00
5de8d38b78 v5.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-02-12 13:41:32 +00:00
2d6dbc552e fix(packaging): add files whitelist to package.json and remove Playwright-generated screenshots 2026-02-12 13:41:32 +00:00
f0fae866dc v5.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-02-12 10:15:26 +00:00
87c039a63f fix(docs): update documentation and packaging configuration: document smartmta/smartdns integrations, adjust API method names, and add release registry info 2026-02-12 10:15:26 +00:00
2c875cbb18 v5.0.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-11 17:24:17 +00:00
735464e8e6 fix(deps/tests): bump two dependencies and disable cache in tests 2026-02-11 17:24:17 +00:00
e6a1f50554 v5.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-11 16:32:49 +00:00
530ebbf3e4 BREAKING CHANGE(mta): migrate internal MTA to @push.rocks/smartmta and remove legacy mail/deliverability implementation 2026-02-11 16:32:49 +00:00
048f038e36 v4.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-10 14:41:19 +00:00
e375adb80a fix(smartproxy): upgrade @push.rocks/smartproxy to ^23.1.0 and adapt code/tests for its async getStatistics() API 2026-02-10 14:41:19 +00:00
9d7da5bc25 v4.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-10 11:22:15 +00:00
41fe7a8a47 feat(cache): add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration 2026-02-10 11:22:15 +00:00
f3f1f58b67 v4.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-03 23:26:51 +00:00
9e0e77737b BREAKING CHANGE(config): convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing 2026-02-03 23:26:51 +00:00
5de3344905 v3.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-02 22:21:55 +00:00
ae34314f54 feat(web): determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies 2026-02-02 22:21:55 +00:00
5b473de354 v3.0.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-02 00:36:19 +00:00
1a108fa8b7 BREAKING CHANGE(deps): upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support 2026-02-02 00:36:19 +00:00
badabe753a v2.13.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-01 19:21:37 +00:00
c2d3ace0dd feat(radius): add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers 2026-02-01 19:21:37 +00:00
fcea194cf6 v2.12.6
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-01 18:10:30 +00:00
b90650c660 fix(tests): update tests and test helpers to current email/DNS APIs, use non-privileged ports, and improve robustness and resilience 2026-02-01 18:10:30 +00:00
2206abd04b v2.12.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-01 14:17:54 +00:00
d54831765b fix(mail): migrate filesystem helpers to fsUtils, update DKIM and mail APIs, harden SMTP client, and bump dependencies 2026-02-01 14:17:54 +00:00
dd4ac9fa3d update menu 2025-07-04 18:58:10 +00:00
aed9151998 update 2025-07-04 18:50:15 +00:00
5d4bf4eff8 update 2025-07-03 04:04:43 +00:00
9027125520 update 2025-07-03 01:53:50 +00:00
ee561c0823 update 2025-07-03 01:50:46 +00:00
95cb5d7840 update frontend 2025-07-02 19:18:14 +00:00
2f46b3c9f3 update 2025-07-02 11:33:50 +00:00
7bd94884f4 update 2025-06-29 18:47:44 +00:00
405990563b update UI 2025-06-27 09:28:07 +00:00
bf9f805c71 fix(metrics): fix metrics 2025-06-23 13:24:43 +00:00
28cbf84f97 fix(metrics): fix metrics 2025-06-23 00:19:47 +00:00
d24e51117d fix(metrics): fix metrics 2025-06-22 23:40:02 +00:00
92fde9d0d7 feat: Implement network metrics integration and UI updates for real-time data display 2025-06-20 10:56:53 +00:00
b81bda6ce8 update docs 2025-06-20 00:44:04 +00:00
9b3f5c458d Refactor code structure for improved readability and maintainability 2025-06-20 00:37:29 +00:00
3ba47f9a71 fix: update styles in various components to use dynamic theming and improve layout consistency 2025-06-19 12:14:52 +00:00
2ab2e30336 fix: update dependencies and improve email view layout in OpsViewEmails component 2025-06-17 14:37:05 +00:00
8ce6c88d58 feat: Integrate SmartMetrics for enhanced CPU and memory monitoring in UI 2025-06-12 11:22:18 +00:00
facae93e4b feat: Implement dees-statsgrid in DCRouter UI for enhanced stats visualization
- Added new readme.statsgrid.md outlining the implementation plan for dees-statsgrid component.
- Replaced custom stats cards in ops-view-overview.ts and ops-view-network.ts with dees-statsgrid for better visualization.
- Introduced consistent color scheme for success, warning, error, and info states.
- Enhanced interactive features including click actions, context menus, and real-time updates.
- Developed ops-view-emails.ts for email management with features like composing, searching, and viewing emails.
- Integrated mock data generation for emails and network requests to facilitate testing.
- Added responsive design elements and improved UI consistency across components.
2025-06-12 08:04:30 +00:00
0eb4963247 fix: update @push.rocks/smartproxy to version 19.6.2 and adjust refresh intervals in app state 2025-06-10 16:09:41 +00:00
02dd3c77b5 fix: update @push.rocks/smartproxy to version 19.6.1 and improve socket management in ConnectionManager
feat: enhance MetricsManager with reset interval and top domains tracking
2025-06-09 17:18:50 +00:00
93995d5031 Implement Metrics Manager and Integrate Metrics Collection
- Removed the existing readme.opsserver.md file as it is no longer needed.
- Added a new MetricsManager class to handle metrics collection using @push.rocks/smartmetrics.
- Integrated MetricsManager into the DcRouter and OpsServer classes.
- Updated StatsHandler and SecurityHandler to retrieve metrics from MetricsManager.
- Implemented methods for tracking email, DNS, and security metrics.
- Added connection tracking capabilities to the MetricsManager.
- Created a new readme.metrics.md file outlining the metrics implementation plan.
- Adjusted plugins.ts to include smartmetrics.
- Added a new monitoring directory with classes for metrics management.
- Created readme.module-adjustments.md to document necessary adjustments for SmartProxy and SmartDNS.
2025-06-09 16:03:27 +00:00
554d245c0c 2.12.4
Some checks failed
Docker (tags) / security (push) Failing after 20s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:51:57 +00:00
e3cb35a036 fix(web ui): login 2025-06-08 12:51:48 +00:00
3a95ea9f4e update 2025-06-08 12:39:53 +00:00
99f57dba76 2.12.3
Some checks failed
Docker (tags) / security (push) Failing after 26s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:09:39 +00:00
415e28038d feat: add TypeScript interfaces for authentication and server statistics 2025-06-08 12:09:09 +00:00
7bda406624 2.12.2
Some checks failed
Docker (tags) / security (push) Failing after 28s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:06:21 +00:00
8282610307 2.12.1
Some checks failed
Docker (tags) / security (push) Failing after 30s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:05:44 +00:00
5269c20770 fix(dependencies): update @push.rocks/smartproxy to version 19.5.25 2025-06-08 12:05:40 +00:00
f1fb4c8495 feat: Add operations view components for logs, overview, security, and stats
- Implemented `ops-view-logs` for displaying and filtering logs with streaming capabilities.
- Created `ops-view-overview` to show server, email, DNS statistics, and charts.
- Developed `ops-view-security` for monitoring security metrics, blocked IPs, and authentication attempts.
- Added `ops-view-stats` to present comprehensive statistics on server, email, DNS, and security metrics.
- Introduced shared styles and components including `ops-sectionheading` for consistent UI.
2025-06-08 12:03:17 +00:00
5faca8c1b6 feat(auth): implement JWT-based authentication with admin access controls 2025-06-08 07:19:31 +00:00
61778bdba8 feat(ops-server): implement TypedRouter integration and modular handler classes 2025-06-08 07:04:35 +00:00
ab19130904 feat(ts_interfaces): add TypedRequest interfaces for admin and configuration requests
fix(dependencies): include @api.global/typedrequest-interfaces in package.json
chore(docs): create OpsServer implementation plan in readme.opsserver.md
2025-06-07 17:28:15 +00:00
646aa7106b fix(appstate): update import statement to use plugins module for Smartstate initialization 2025-06-07 16:00:54 +00:00
b0f167f6da feat(appstate): initialize appState with Smartstate from domtools 2025-06-07 11:40:31 +00:00
4d8d802006 fix(dependencies): update @types/node and other dependencies to latest versions 2025-06-07 11:25:03 +00:00
6ee1d6e917 feat(ops-dashboard): implement OpsServer and dashboard component with initial rendering 2025-06-01 19:46:10 +00:00
f877ad9676 fix(dependencies): add ui catalog for minimal local dashboard with statistics. 2025-05-31 16:34:39 +00:00
fe817dde00 feat(logging): add professional startup logging to DcRouter
- Add logStartupSummary() method with clean ASCII art header
- Display service status for SmartProxy, Email, DNS, and Storage
- Show detailed configuration info for each service
- Replace verbose console logs with structured startup summary
2025-05-31 15:58:56 +00:00
272973702e feat(dns): implement DKIM record serving and proactive key generation
- Add loadDkimRecords() method to read DKIM records from JSON files
- Integrate DKIM records into DNS server during startup
- Add initializeDkimForEmailDomains() for proactive DKIM key generation
- Ensure DKIM records are available immediately after server startup
- Update documentation with DKIM implementation status

DKIM records are now automatically loaded from .nogit/data/dns/*.dkimrecord.json
and served via DNS. Keys are generated for all configured email domains at startup.
2025-05-31 12:53:29 +00:00
c776dab2c0 fix(nameservers): fix ip records 2025-05-30 20:11:44 +00:00
74692c4aa5 fix(dns): Fixed Soa records 2025-05-30 19:54:48 +00:00
71183b35c0 fix(dns): register separate handlers for each DNS record to serve multiple records
The previous implementation grouped records by domain and only returned the first
matching record. This prevented serving multiple NS records for a domain, which
caused GoDaddy to reject the nameservers.

Changes:
- Modified registerDnsRecords to register a separate handler for each record
- This works around smartdns limitation where it breaks after first handler match
- Now all NS records are properly served in DNS responses
- Added readme.smartdns.md documenting the underlying issue in smartdns module

The root cause is in smartdns DnsServer which breaks after finding the first
matching handler, preventing multiple records of the same type from being served.
2025-05-30 16:44:10 +00:00
ae73de19b2 fix(dns): update DnsManager to use new DNS configuration properties
The DnsManager was still checking for the old dnsDomain property that was
replaced by dnsNsDomains and dnsScopes in the DNS Architecture Improvements.

Changes:
- Replace dnsDomain checks with dnsNsDomains and dnsScopes validation
- Add check to ensure email domain is included in dnsScopes array
- Update NS delegation check to work with multiple nameservers
- Update error messages to guide users to the new configuration format
2025-05-30 16:26:31 +00:00
a2b413a78f fix(test): repair SMTP test suite after rate limiter integration
The test helper's mock email server was missing the getRateLimiter() method
that was added during the rate limiting feature implementation. This caused
all SMTP tests to fail with "getRateLimiter is not a function" error.

Changes:
- Add getRateLimiter() method to mock email server that returns a mock rate limiter
- Update mock rate limiter method signatures to match actual implementation
- Fix TypeScript type issue with auth options by adding explicit casting
2025-05-30 16:17:02 +00:00
739eeb63aa update 2025-05-30 15:04:12 +00:00
eb26a62a87 fix(config): Update dns config interface within DcRouter 2025-05-30 10:34:50 +00:00
ad0ab6c103 test(dns): add comprehensive tests for DNS record creation
- Add test.dns-manager-creation.ts to verify DNS record creation
- Test MX, SPF, DMARC, and DKIM record registration
- Verify records are stored in StorageManager
- Update readme.hints.md with DNS architecture refactoring notes
2025-05-30 09:29:03 +00:00
37e1ecefd2 refactor(dns): extend DnsValidator to DnsManager with DNS record creation
- Rename DnsValidator to DnsManager to better reflect its expanded responsibilities
- Move DNS record creation logic from UnifiedEmailServer to DnsManager
- Add ensureDnsRecords() method that handles both validation and creation
- Consolidate internal DNS record creation (MX, SPF, DMARC) in one place
- Keep DKIM key generation in UnifiedEmailServer but move DNS registration to DnsManager
- Update all imports and tests to use DnsManager instead of DnsValidator
- Improve code organization and discoverability of DNS functionality
2025-05-30 08:52:07 +00:00
e6251ab655 docs(readme): update documentation with StorageManager and domain configuration features
- Add flexible storage system section with backend examples
- Document email domain configuration with DNS modes
- Update configuration interfaces with storage options
- Add examples for filesystem, custom, and memory storage
- Include data migration examples between backends
- Document storage usage patterns and key structure
- Update test suite documentation with new test categories
2025-05-30 07:06:00 +00:00
53b64025f3 feat(storage): implement StorageManager with filesystem support and component integration
- Add StorageManager with filesystem, custom, and memory backends
- Update DKIMCreator and BounceManager to use StorageManager
- Remove component-level storage warnings (handled by StorageManager)
- Fix list() method for filesystem backend
- Add comprehensive storage and integration tests
- Implement DNS mode switching tests
- Complete Phase 4 testing tasks from plan
2025-05-30 07:00:59 +00:00
40db395591 feat(integration): components now play nicer with each other 2025-05-30 05:30:06 +00:00
2c244c4a9a update 2025-05-29 21:21:59 +00:00
0baf2562b7 fix(plan): create plan for improving email setup. 2025-05-29 21:21:27 +00:00
64da8d9100 test(socket-handler): add comprehensive tests for DNS and email socket-handler functionality
- Add unit tests for DNS route generation and socket handler creation
- Add unit tests for email route generation in both modes
- Add integration tests for combined DNS and email configuration
- Test TLS handling differences between email ports
- Verify socket-handler vs traditional forwarding mode behavior
- All tests pass without requiring actual port binding
- Mark implementation plan as complete with full test coverage
2025-05-29 16:44:34 +00:00
b11fea7334 feat(socket-handler): implement direct socket passing for DNS and email services
- Add socket-handler mode eliminating internal port binding for improved performance
- Add `dnsDomain` config option for automatic DNS-over-HTTPS (DoH) setup
- Add `useSocketHandler` flag to email config for direct socket processing
- Update SmartProxy route generation to support socket-handler actions
- Integrate smartdns with manual HTTPS mode for DoH without port binding
- Add automatic route creation for DNS paths when dnsDomain is configured
- Update documentation with socket-handler configuration and benefits
- Improve resource efficiency by eliminating internal port forwarding
2025-05-29 16:26:19 +00:00
6c8458f63c update 2025-05-28 18:07:07 +00:00
455b0085ec update 2025-05-28 15:32:35 +00:00
2b2fe940c4 fix(test): update tests 2025-05-28 15:24:34 +00:00
e1a7b3e8f7 Complete email router implementation and documentation
- Cleaned up interface definitions to only include implemented features
- Updated readme.md with comprehensive route-based configuration examples
- Added common email routing patterns and troubleshooting guide
- Removed legacy DomainRouter and IDomainRule interfaces
- Updated all imports and exports to use new EmailRouter system
- Verified build and core functionality tests pass

The match/action pattern implementation is now complete and production-ready.
2025-05-28 14:12:50 +00:00
191c4160c1 Complete match/action pattern integration testing
 All integration tests passing
- Route-based forwarding with priority: 5/5 scenarios
- CIDR IP matching: 4/4 test cases
- Authentication-based routing: 3/3 scenarios
- Pattern caching performance: Working
- Dynamic route updates: Working

The match/action pattern implementation is now complete and fully functional.
2025-05-28 13:45:03 +00:00
2e75961d1c feat: implement comprehensive route-based email routing system
Replace legacy domain-rule based routing with flexible route-based system that supports:
- Multi-criteria matching (recipients, senders, IPs, authentication)
- Four action types (forward, process, deliver, reject)
- Moved DKIM signing to delivery phase for signature validity
- Connection pooling for efficient email forwarding
- Pattern caching for improved performance

This provides more granular control over email routing with priority-based matching and comprehensive test coverage.
2025-05-28 13:23:45 +00:00
88099e120a feat: implement route-based email routing system
- Add core interfaces (IEmailRoute, IEmailMatch, IEmailAction, IEmailContext)
- Create EmailRouter class with comprehensive matching capabilities
- Support for recipient/sender patterns, IP/CIDR matching, auth checks
- Add content matching (headers, size, subject, attachments)
- Implement pattern caching for performance
- Update plan with completed steps
2025-05-28 12:07:37 +00:00
77ff948404 update 2025-05-28 11:39:54 +00:00
0e610cba16 update 2025-05-28 11:39:46 +00:00
8d59d617f1 fix(interfaces): Remove legacy interfaces 2025-05-27 21:03:17 +00:00
6aa54d974e fix(mail options): simplify mail options 2025-05-27 19:28:12 +00:00
2aeb52bf13 fix(structure): Unify structure even further 2025-05-27 18:00:14 +00:00
243a45d24c feat(structure): Use unified Email class 2025-05-27 15:38:34 +00:00
cfea44742a update 2025-05-27 15:06:44 +00:00
073c8378c7 update 2025-05-27 14:06:22 +00:00
af408d38c9 feat(plan): simplify structure 2025-05-27 12:56:12 +00:00
c3b14c0f58 update 2025-05-27 10:39:29 +00:00
69304dc839 update 2025-05-26 16:14:49 +00:00
a3721f7a74 update 2025-05-26 14:50:55 +00:00
20583beb35 update 2025-05-26 12:23:19 +00:00
b8ea8f660e update 2025-05-26 10:35:50 +00:00
5a45d6cd45 update 2025-05-26 04:09:29 +00:00
84196f9b13 update 2025-05-25 19:05:43 +00:00
4c9fd22a86 update 2025-05-25 19:02:18 +00:00
5b33623c2d update 2025-05-25 11:18:12 +00:00
58f4a123d2 update 2025-05-24 18:12:08 +00:00
11a2ae6b27 update 2025-05-24 17:00:59 +00:00
4e4c7df558 update 2025-05-24 16:19:19 +00:00
3d669ed9dd update 2025-05-24 14:50:24 +00:00
6e19e30f87 update 2025-05-24 14:39:48 +00:00
dc5c0b2584 update 2025-05-24 13:37:19 +00:00
35712b18bc update 2025-05-24 11:34:05 +00:00
9958c036a0 update 2025-05-24 08:59:30 +00:00
14c9fbdc3c update 2025-05-24 02:38:45 +00:00
4fd3ec2958 update 2025-05-24 02:27:50 +00:00
f2e9ff0a51 update 2025-05-24 01:00:30 +00:00
cb52446f65 update 2025-05-24 00:23:35 +00:00
0907949f8a update 2025-05-23 21:20:39 +00:00
9629329bc2 update 2025-05-23 21:20:32 +00:00
f651cd1c2f update 2025-05-23 20:40:20 +00:00
a7438a7cd6 update 2025-05-23 19:49:25 +00:00
e0f6e3237b update 2025-05-23 19:09:30 +00:00
1b141ec8f3 update 2025-05-23 19:03:44 +00:00
7d28d23bbd update 2025-05-23 08:52:02 +00:00
53f5e30b23 update 2025-05-23 08:17:34 +00:00
7344bf0f70 update 2025-05-23 01:00:37 +00:00
4905595cbb update 2025-05-23 00:06:07 +00:00
f058b2d1e7 update 2025-05-22 23:09:41 +00:00
6fcc3feb73 update 2025-05-22 23:02:51 +00:00
50350bd78d update 2025-05-22 23:02:37 +00:00
f065a9c952 update 2025-05-22 18:38:04 +00:00
72898c67b7 fix(update package naming): Now exported correctly as @serve.zone/dcrouter 2025-05-22 13:10:45 +00:00
ca53816b41 BREAKING_CHANGE(structure): rebrand as dcrouter 2025-05-22 13:03:09 +00:00
ac419e7b79 update 2025-05-22 10:18:02 +00:00
7c0f9b4e44 update 2025-05-22 09:39:31 +00:00
d584f3584c update 2025-05-22 09:22:55 +00:00
a4353b10bb update 2025-05-22 00:38:04 +00:00
b2f25c49b6 update 2025-05-22 00:11:33 +00:00
d3255a7e14 update 2025-05-21 23:37:29 +00:00
2564d0874b update 2025-05-21 21:29:04 +00:00
ca111f4783 update 2025-05-21 19:08:50 +00:00
b6dd281a54 update 2025-05-21 18:52:04 +00:00
645790d0c2 update 2025-05-21 17:33:16 +00:00
535b055664 update 2025-05-21 17:05:42 +00:00
2eeb731669 update 2025-05-21 16:17:17 +00:00
c3ae995372 update 2025-05-21 14:45:17 +00:00
15e7a3032c update 2025-05-21 14:38:58 +00:00
10ab09894b update 2025-05-21 14:28:33 +00:00
38811dbf23 update 2025-05-21 13:42:12 +00:00
3f220996ee update 2025-05-21 12:52:24 +00:00
b0a0078ad0 update 2025-05-21 10:38:22 +00:00
ecb913843c update 2025-05-21 10:00:06 +00:00
162795802f update 2025-05-21 02:17:18 +00:00
b1890f59ee update 2025-05-21 00:12:49 +00:00
5c85188183 update 2025-05-21 00:12:39 +00:00
f37cddf26d update 2025-05-20 19:46:59 +00:00
f3f06ed06d update 2025-05-20 11:04:09 +00:00
07f03eb834 update 2025-05-19 23:43:21 +00:00
e7174e8630 update 2025-05-19 17:34:48 +00:00
186e94c1a2 2.12.0 2025-05-16 15:50:46 +00:00
fb424d814c feat(smartproxy): Update documentation and configuration guides to adopt new route-based SmartProxy architecture 2025-05-16 15:50:46 +00:00
0ad5dfd6ee 2.11.2 2025-05-16 15:26:47 +00:00
fbaafa909b fix(dependencies): Update dependency versions and adjust test imports to use new packages 2025-05-16 15:26:47 +00:00
f1cc7fd340 2.11.1 2025-05-08 13:00:11 +00:00
deec61da42 fix(platform): Update commit info with no functional changes; regenerated commit information. 2025-05-08 13:00:10 +00:00
190ae11667 2.11.0 2025-05-08 12:56:17 +00:00
f4ace3999d feat(platformservice): Expose DcRouter and update package visibility. Changed package.json private flag from true to false to allow public publication, and added export of DcRouter in ts/index.ts for improved API accessibility. 2025-05-08 12:56:17 +00:00
8b857e3d1d update 2025-05-08 12:46:10 +00:00
7aaf8f2595 2.8.9 2025-05-08 10:39:43 +00:00
39b634b6bb fix(types): Fix TypeScript build errors and improve API type safety across platformservice interfaces 2025-05-08 10:39:43 +00:00
4624fdbe10 2.8.6 2025-05-08 10:24:50 +00:00
858794799b fix(tests): fix: Improve test stability by handling race conditions in SenderReputationMonitor and IPWarmupManager. Disable filesystem operations and external DNS lookups during tests by checking NODE_ENV, add proper cleanup of singleton instances and active timeouts to ensure consistent test environment. 2025-05-08 10:24:50 +00:00
cb33dd26d0 2.8.4 2025-05-08 01:37:38 +00:00
d3d197d9d3 fix(mail): refactor(mail): Remove Mailgun references from PlatformService. Update keywords, error messages, and documentation to use MTA exclusively. 2025-05-08 01:37:38 +00:00
0e914a3366 2.8.2 2025-05-08 01:24:03 +00:00
747478f0f9 fix(tests): Fix outdated import paths in test files for dcrouter and ratelimiter modules 2025-05-08 01:24:03 +00:00
b61de33ee0 2.8.1 2025-05-08 01:16:21 +00:00
970c0d5c60 fix(readme): Update readme with consolidated email system improvements and modular directory structure
Clarify that the platform now organizes email functionality into distinct directories (mail/core, mail/delivery, mail/routing, mail/security, mail/services) and update the diagram and key features list accordingly. Adjust code examples to reflect explicit module imports and the use of SzPlatformService.
2025-05-08 01:16:21 +00:00
fe2069c48e update 2025-05-08 01:13:54 +00:00
63781ab1bd 2.8.0 2025-05-08 00:39:43 +00:00
0b155d6925 feat(docs): Update documentation to include consolidated email handling and pattern‑based routing details 2025-05-08 00:39:43 +00:00
076aac27ce 2.7.0 2025-05-08 00:12:36 +00:00
7f84405279 feat(dcrouter): Implement unified email configuration with pattern‐based routing and consolidated email processing. Migrate SMTP forwarding and store‐and‐forward into a single, configuration-driven system that supports glob pattern matching in domain rules. 2025-05-08 00:12:36 +00:00
13ef31c13f 2.6.0 2025-05-07 23:45:20 +00:00
5cf4c0f150 feat(dcrouter): Implement integrated DcRouter with comprehensive SmartProxy configuration, enhanced SMTP processing, and robust store‐and‐forward email routing 2025-05-07 23:45:19 +00:00
04b7552b34 update plan 2025-05-07 23:30:04 +00:00
1528d29b0d 2.5.0 2025-05-07 23:04:54 +00:00
9d895898b1 feat(dcrouter): Enhance DcRouter configuration and update documentation 2025-05-07 23:04:54 +00:00
45be1e0a42 2.4.2 2025-05-07 22:15:08 +00:00
ba39392c1b fix(tests): Update test assertions and singleton instance references in DMARC, integration, and IP warmup manager tests 2025-05-07 22:15:08 +00:00
f704dc78aa 2.4.1 2025-05-07 22:06:55 +00:00
7e931d6c52 fix(tests): Update test assertions and refine service interfaces 2025-05-07 22:06:55 +00:00
630e911589 update 2025-05-07 20:20:17 +00:00
f6377d1973 2.4.0 2025-05-07 17:41:04 +00:00
c852e954c9 feat(email): Enhance email integration by updating @push.rocks/smartmail to ^2.1.0 and improving the entire email stack including validation, DKIM verification, templating, MIME conversion, and attachment handling. 2025-05-07 17:41:04 +00:00
2ee66ef967 update 2025-05-07 14:33:20 +00:00
5ad43470f3 2.3.1 2025-05-04 10:10:07 +00:00
efd64d6304 fix(platformservice): Update dependency versions and refactor import paths for improved compatibility; add initial DcRouter plan documentation. 2025-05-04 10:10:07 +00:00
a29cff2fc5 2.3.0 2025-03-15 16:24:56 +00:00
d161fe4f19 feat(platformservice): Add AIBridge module and refactor service file paths for improved module organization 2025-03-15 16:24:56 +00:00
df9a8ad14e 2.2.1 2025-03-15 16:21:37 +00:00
8ddad6e652 fix(platformservice): Refactor module structure to update import paths and file organization 2025-03-15 16:21:37 +00:00
3d36d3d1c5 2.2.0 2025-03-15 16:14:49 +00:00
329320cd40 feat(plugins): Add smartproxy support by including the @push.rocks/smartproxy dependency and exporting it in the plugins module. 2025-03-15 16:14:49 +00:00
63ecf60543 2.1.0 2025-03-15 16:09:18 +00:00
87917f68fb feat(MTA): Update readme with detailed Mail Transfer Agent usage and examples 2025-03-15 16:09:18 +00:00
018b499010 2.0.0 2025-03-15 16:04:03 +00:00
a4d79c2d01 BREAKING CHANGE(platformservice): Remove deprecated AIBridge module and update email service to use the MTA connector; update dependency versions and adjust build scripts in package.json. 2025-03-15 16:04:03 +00:00
90d3e75963 1.1.2 2025-03-15 14:13:02 +00:00
4887ec9d93 fix(mta): Expose HttpResponse.statusCode and add explicit generic type annotations in DNSManager cache retrieval 2025-03-15 14:13:02 +00:00
983e6cb623 1.1.1 2025-03-15 13:57:21 +00:00
e9b2ec0f59 fix(paths): Update directory paths to use a dedicated data directory and add ensureDirectories function for proper directory creation. 2025-03-15 13:57:21 +00:00
c084de9c78 fix(meta): type improvements 2025-03-15 13:52:48 +00:00
2b207833ce 1.1.0 2025-03-15 13:45:29 +00:00
4dc095e662 feat(mta): Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes 2025-03-15 13:45:29 +00:00
c1311f493f 1.0.11 2024-05-11 12:33:15 +02:00
97cbe6e398 fix(core): update 2024-05-11 12:33:14 +02:00
0bb9c5e1e5 1.0.10 2024-05-11 12:29:04 +02:00
cf90560243 fix(core): update 2024-05-11 12:29:03 +02:00
8def86494a 1.0.9 2024-05-11 12:27:02 +02:00
db46e01f6e fix(core): update 2024-05-11 12:27:01 +02:00
7baf747972 1.0.8 2024-04-01 02:58:27 +02:00
4a17a1073e fix(core): update 2024-04-01 02:58:27 +02:00
8997ded81d 1.0.7 2024-03-19 18:37:25 +01:00
f177d8e9ab fix(core): update 2024-03-19 18:37:24 +01:00
808a9cc856 1.0.6 2024-02-16 20:47:25 +01:00
be1c8d1164 fix(core): update 2024-02-16 20:47:25 +01:00
2ecb2f3aa0 1.0.5 2024-02-16 20:42:26 +01:00
01dcdebda5 fix(core): update 2024-02-16 20:42:26 +01:00
2adcc249de 1.0.4 2024-02-16 13:41:05 +01:00
543e696bfc fix(core): update 2024-02-16 13:41:04 +01:00
796e0204ca 1.0.3 2024-02-16 13:28:40 +01:00
f5a36ab53a fix(core): update 2024-02-16 13:28:40 +01:00
162 changed files with 37427 additions and 6253 deletions

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ dist/
dist_*/
# custom
**/.claude/settings.local.json
.nogit/data/
readme.plan.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

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

1011
changelog.md Normal file

File diff suppressed because it is too large Load Diff

4
cli.child.js Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env node
process.env.CLI_CALL = 'true';
import * as cliTool from './ts/index.js';
cliTool.runCli();

121
html/index.html Normal file
View File

@@ -0,0 +1,121 @@
<!--gitzone default-->
<!-- made by Lossless GmbH -->
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
<!DOCTYPE html>
<html lang="en">
<head>
<!--Lets set some basic meta tags-->
<meta
name="viewport"
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="theme-color" content="#000000" />
<!--Lets make sure we recognize this as an PWA-->
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
<!--Lets load standard fonts-->
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<!--Lets avoid a rescaling flicker due to default body margins-->
<style>
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
position: relative;
background: #000;
margin: 0px;
}
</style>
<script>
projectVersion = '';
</script>
</head>
<body>
<noscript>
<style>
body {
background: #303f9f;
font-family: Inter, Roboto, sans-serif;
color: #ffffff;
}
a {
color: #ffffff;
text-decoration: none;
}
.logo {
margin-top: 100px;
text-align: center;
}
img {
width: 130px;
}
.container {
width: 600px;
margin: auto;
margin-top: 20px;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
overflow: hidden;
border-radius: 3px;
background: #4357d9;
}
.contentHeader {
padding: 20px;
text-align: center;
font-size: 25px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.content {
padding: 20px;
}
.footer {
padding: 10px;
text-align: center;
}
</style>
<div class="logo">
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg" />
</div>
<div class="container">
<div class="contentHeader">We need JavaScript to run properly!</div>
<div class="content">
This site is being built using lit-element (made by Google). This technology works with
JavaScript. Subsequently this website does not work as intended by Lossless GmbH without
JavaScript.
</div>
</div>
<div class="footer">
<a href="https://lossless.gmbh">Legal Info</a> |
<a href="https://lossless.gmbh/privacy">Privacy Policy</a>
</div>
</noscript>
<script type="text/javascript" async defer>
window.revenueEnabled = true;
const runRevenueCheck = async () => {
var e = document.createElement('div');
e.id = '476kjuhzgtr764';
e.style.display = 'none';
document.body.appendChild(e);
if (document.getElementById('476kjuhzgtr764')) {
window.revenueEnabled = true;
} else {
window.revenueEnabled = false;
}
console.log(`revenue enabled: ${window.revenueEnabled}`);
};
runRevenueCheck();
</script>
</body>
<script defer type="module" src="/bundle.js"></script>
</html>

View File

@@ -1,20 +1,75 @@
{
"gitzone": {
"@git.zone/tswatch": {
"watchers": [
{
"name": "dcrouter-dev",
"watch": [
"ts/**/*.ts",
"ts_*/**/*.ts",
"test_watch/devserver.ts"
],
"command": "pnpm run build && tsrun test_watch/devserver.ts",
"restart": true,
"debounce": 500,
"runOnStart": true
}
]
},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild",
"production": true
}
]
},
"@git.zone/cli": {
"projectType": "service",
"module": {
"githost": "gitlab.com",
"gitscope": "serve.zone",
"gitrepo": "platformservice",
"description": "contains the platformservice container with mail, sms, letter, ai services.",
"npmPackagename": "@serve.zone/platformservice",
"gitrepo": "dcrouter",
"description": "A traffic router intended to be gating your datacenter.",
"npmPackagename": "@serve.zone/dcrouter",
"license": "MIT",
"projectDomain": "serve.zone"
"projectDomain": "serve.zone",
"keywords": [
"mail service",
"SMS",
"letter delivery",
"AI services",
"SMTP server",
"mail parsing",
"DKIM",
"traffic router",
"letterXpress",
"OpenAI",
"Anthropic AI",
"DKIM signing",
"mail forwarding",
"SMTP TLS",
"domain management",
"email templating",
"rule management",
"SMTP STARTTLS",
"DNS management"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"npmci": {
"@ship.zone/szci": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
},
"dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"

View File

@@ -1,32 +1,112 @@
{
"name": "@serve.zone/platformservice",
"version": "1.0.2",
"description": "contains the platformservice container with mail, sms, letter, ai services.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"name": "@serve.zone/dcrouter",
"private": false,
"version": "11.0.4",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js"
},
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/)",
"start": "(node --max_old_space_size=100 ./cli.js)",
"test": "(tstest test/ --logfile --timeout 60)",
"start": "(node --max_old_space_size=250 ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild --web --allowimplicitany)"
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"bundle": "(tsbundle)",
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.17",
"@git.zone/tsrun": "^1.2.8",
"@git.zone/tstest": "^1.0.28",
"@git.zone/tswatch": "^2.0.1",
"@push.rocks/tapbundle": "^5.0.3"
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.9.0",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.2.0",
"@git.zone/tswatch": "^3.2.5",
"@types/node": "^25.3.3"
},
"dependencies": {
"@api.global/typedrequest": "^3.0.4",
"@api.global/typedserver": "^3.0.20",
"@push.rocks/projectinfo": "^5.0.1",
"@push.rocks/qenv": "^6.0.5",
"@push.rocks/smartdata": "^5.0.7",
"@push.rocks/smartpath": "^5.0.5",
"@push.rocks/smartstate": "^2.0.0"
}
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.2",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/lik": "^6.2.2",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.1.3",
"@push.rocks/smartdata": "^7.1.0",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.2",
"@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.9.1",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.2.0",
"@push.rocks/smartunique": "^3.0.9",
"@serve.zone/catalog": "^2.5.0",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.4.0",
"@tsclass/tsclass": "^9.3.0",
"lru-cache": "^11.2.6",
"uuid": "^13.0.0"
},
"keywords": [
"mail service",
"SMS",
"letter delivery",
"AI services",
"SMTP server",
"mail parsing",
"DKIM",
"mail router",
"letterXpress",
"OpenAI",
"Anthropic AI",
"DKIM signing",
"mail forwarding",
"SMTP TLS",
"domain management",
"email templating",
"rule management",
"SMTP STARTTLS",
"DNS management",
"RADIUS",
"AAA",
"network authentication",
"VLAN assignment",
"MAC authentication"
],
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
},
"packageManager": "pnpm@10.11.0",
"files": [
"ts/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
"readme.md"
]
}

14504
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

773
readme.hints.md Normal file
View File

@@ -0,0 +1,773 @@
# Implementation Hints and Learnings
## smartmta Migration (2026-02-11)
### Overview
dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer.
### Architecture
- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly
- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens
- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer
### Key API Differences (smartmta vs old custom MTA)
- `updateEmailRoutes()` instead of `updateRoutes()`
- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`)
- `bounceManager` is private, but exposed via public methods:
- `emailServer.getSuppressionList()`
- `emailServer.getHardBouncedAddresses()`
- `emailServer.getBounceHistory(email)`
- `emailServer.removeFromSuppressionList(email)`
- `Email` class imported from `@push.rocks/smartmta`
- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;`
### Deleted Directories
- `ts/mail/` (60 files) — replaced by smartmta
- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta
- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors
- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence
### Remaining Cache Documents
- `CachedEmail` — kept (dcrouter-level queue persistence)
- `CachedIPReputation` — kept (dcrouter-level IP reputation caching)
### Dependencies Removed
mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge
### Pre-existing Test Failures (not caused by migration)
- `test/test.jwt-auth.ts``response.text is not a function` (webrequest compatibility issue)
- `test/test.opsserver-api.ts` — same webrequest issue, timeouts
### smartmta Location
Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`
## Dependency Upgrade (2026-02-11)
### SmartProxy v23.1.2 Route Validation
- SmartProxy 23.1.2 enforces stricter route validation
- Forward actions MUST use `targets` (array) instead of `target` (singular)
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid starting a real MongoDB process in tests
```typescript
// WRONG - will fail validation
action: { type: 'forward', target: { host: 'localhost', port: 10025 } }
// CORRECT
action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] }
```
**Files Fixed:**
- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method
- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }`
## Dependency Upgrade (2026-02-10)
### SmartProxy v23.1.0 Upgrade
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0
**Key Changes:**
- Rust-based proxy components for improved performance
- Rust binary runs as separate process via IPC
- `getStatistics()` now returns `Promise<any>` (was synchronous)
- nftables-proxy removed (not used by dcrouter)
**Code Changes Required:**
```typescript
// Old (synchronous)
const proxyStats = this.dcRouter.smartProxy.getStatistics();
// New (async)
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
```
**Files Modified:**
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call
## Dependency Upgrade (2026-02-01)
### Major Upgrades Completed
- `@api.global/typedserver`: 3.0.80 → 8.3.0
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
- `uuid`: 11.1.0 → 13.0.0
### Breaking Changes Fixed
1. **SmartProxy v22**: `target``targets` (array)
```typescript
// Old
action: { type: 'forward', target: { host: 'x', port: 25 } }
// New
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
```
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
```typescript
// Old
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
const json = resp.body;
// New
const resp = await plugins.smartrequest.SmartRequest.create()...post();
const json = await resp.json();
```
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
```typescript
// Old (deprecated but supported)
<dees-icon iconFA="check"></dees-icon>
// New
<dees-icon icon="fa:check"></dees-icon>
<dees-icon icon="lucide:menu"></dees-icon>
```
### TC39 Decorators
- ts_web components updated to use `accessor` keyword for `@state()` decorators
- Required for TC39 standard decorator support
### tswatch Configuration
The project now uses tswatch for development:
```bash
pnpm run watch
```
Configuration in `npmextra.json`:
```json
{
"@git.zone/tswatch": {
"watchers": [{
"name": "dcrouter-dev",
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
"command": "pnpm run build && tsrun test_watch/devserver.ts",
"restart": true,
"debounce": 500,
"runOnStart": true
}]
}
}
```
## RADIUS Server Integration (2026-02-01)
### Overview
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
### Key Features
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
- **RADIUS Accounting** - Track sessions, data usage, and billing
### Configuration Example
```typescript
const dcRouter = new DcRouter({
radiusConfig: {
authPort: 1812, // Authentication port (default)
acctPort: 1813, // Accounting port (default)
clients: [
{
name: 'switch-1',
ipRange: '192.168.1.0/24',
secret: 'shared-secret',
enabled: true
}
],
vlanAssignment: {
defaultVlan: 100, // VLAN for unknown MACs
allowUnknownMacs: true,
mappings: [
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
]
},
accounting: {
enabled: true,
retentionDays: 30
}
}
});
```
### Components
- `RadiusServer` - Main server wrapping smartradius
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
- `AccountingManager` - Session tracking and billing data
### OpsServer API Endpoints
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
- `testVlanAssignment` - Test what VLAN a MAC would get
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
### Files
- `ts/radius/` - RADIUS module
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
## Test Fix: test.dcrouter.email.ts (2026-02-01)
### Issue
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".
### Root Cause
The test was using outdated email config properties:
- Used `domainRules: []` (non-existent property)
- Used `defaultMode` (non-existent property)
- Missing required `domains: []` property
- Missing required `routes: []` property
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`
### Fix
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
```typescript
const emailConfig: IEmailConfig = {
ports: [2525],
hostname: 'mail.example.com',
domains: [], // Required: domain configurations
routes: [] // Required: email routing rules
};
```
And fixed the property name:
```typescript
expect(router.emailServer).toBeTruthy(); // Not unifiedEmailServer
```
### Key Learning
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
- `routes: IEmailRoute[]` is required (email routing rules)
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`
## Network Metrics Implementation (2025-06-23)
### SmartProxy Metrics API Integration
- Updated to use new SmartProxy metrics API (v19.6.7)
- Use `getMetrics()` for detailed metrics with grouped methods:
```typescript
const metrics = smartProxy.getMetrics();
metrics.connections.active() // Current active connections
metrics.throughput.instant() // Real-time throughput {in, out}
metrics.connections.topIPs(10) // Top 10 IPs by connection count
```
- Use `getStatistics()` for basic stats
### Network Traffic Display
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
- Network graph shows separate lines for inbound (green) and outbound (purple)
- Throughput tiles and graph use same data source for consistency
### Requests/sec vs Connections
- Requests/sec shows HTTP request counts (derived from connections)
- Single connection can handle multiple requests
- Current implementation tracks connections, not individual requests
- Trend line shows historical request counts, not throughput
## DKIM Implementation Status (2025-05-30)
**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.
## SmartProxy Usage
### New Route-Based Architecture (v18+)
- SmartProxy now uses a route-based configuration system
- Routes define match criteria and actions instead of simple port-to-port forwarding
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes
```typescript
// NEW: Route-based SmartProxy configuration
const smartProxy = new plugins.smartproxy.SmartProxy({
routes: [
{
name: 'https-traffic',
match: {
ports: 443,
domains: ['example.com', '*.example.com']
},
action: {
type: 'forward',
target: {
host: 'backend.server.com',
port: 8080
}
},
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
],
defaults: {
target: {
host: 'fallback.server.com',
port: 8080
}
},
acme: {
accountEmail: 'admin@example.com',
enabled: true,
useProduction: true
}
});
```
### Migration from Old to New
```typescript
// OLD configuration style (deprecated)
{
fromPort: 443,
toPort: 8080,
targetIP: 'backend.server.com',
domainConfigs: [...]
}
// NEW route-based style
{
routes: [{
name: 'main-route',
match: { ports: 443 },
action: {
type: 'forward',
target: { host: 'backend.server.com', port: 8080 }
}
}]
}
```
### Direct Component Usage
- Use SmartProxy components directly instead of creating your own wrappers
- SmartProxy already includes Port80Handler and NetworkProxy functionality
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
### Certificate Management
- SmartProxy has built-in ACME certificate management
- Configure it in the `acme` property of SmartProxy options
- Use `accountEmail` (not `email`) for the ACME contact email
- SmartProxy handles both HTTP-01 challenges and certificate application automatically
## qenv Usage
### Direct Usage
- Use qenv directly instead of creating environment variable wrappers
- Instantiate qenv with appropriate basePath and nogitPath:
```typescript
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
```
## TypeScript Interfaces
### SmartProxy Interfaces
- Always check the interfaces from the node_modules to ensure correct property names
- Important interfaces for the new architecture:
- `ISmartProxyOptions`: Main configuration with `routes` array
- `IRouteConfig`: Individual route configuration
- `IRouteMatch`: Match criteria for routes
- `IRouteTarget`: Target configuration for forwarding
- `IAcmeOptions`: ACME certificate configuration
- `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')
### New Route Configuration
```typescript
interface IRouteConfig {
name: string;
match: {
ports: number | number[];
domains?: string | string[];
path?: string;
headers?: Record<string, string | RegExp>;
};
action: {
type: 'forward' | 'redirect' | 'block' | 'static';
target?: {
host: string | string[] | ((context) => string);
port: number | 'preserve' | ((context) => number);
};
};
tls?: {
mode: TTlsMode;
certificate?: 'auto' | { key: string; cert: string; };
};
security?: {
authentication?: IRouteAuthentication;
rateLimit?: IRouteRateLimit;
ipAllowList?: string[];
ipBlockList?: string[];
};
}
```
### Required Properties
- For `ISmartProxyOptions`, `routes` array is the main configuration
- For `IAcmeOptions`, use `accountEmail` for the contact email
- Routes must have `name`, `match`, and `action` properties
## Testing
### Test Structure
- Follow the project's test structure, using `@push.rocks/tapbundle`
- Use `expect(value).toEqual(expected)` for equality checks
- Use `expect(value).toBeTruthy()` for boolean assertions
```typescript
tap.test('test description', async () => {
const result = someFunction();
expect(result.property).toEqual('expected value');
expect(result.valid).toBeTruthy();
});
```
### Cleanup
- Include a cleanup test to ensure proper test resource handling
- Add a `stop` test to forcefully end the test when needed:
```typescript
tap.test('stop', async () => {
await tap.stopForcefully();
});
```
## Architecture Principles
### Simplicity
- Prefer direct usage of libraries instead of creating wrappers
- Don't reinvent functionality that already exists in dependencies
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers
### Component Integration
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
- Use parallel operations for performance (like in the `stop()` method)
- Separate concerns clearly (HTTP handling vs. SMTP handling)
## Email Integration with SmartProxy
### Architecture (Post-Migration)
- Email traffic is routed through SmartProxy using automatic route generation
- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy
- SmartProxy handles external ports (25, 587, 465) and forwards to internal ports
- smartmta's Rust SMTP bridge handles SMTP protocol processing
### Port Mapping
- External port 25 → Internal port 10025 (SMTP)
- External port 587 → Internal port 10587 (Submission)
- External port 465 → Internal port 10465 (SMTPS)
### TLS Handling
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)
## SmartMetrics Integration (2025-06-12) - COMPLETED
### Overview
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.
### Key Findings
1. **CPU Metrics:**
- SmartMetrics provides `cpuUsageText` as a string percentage
- MetricsManager parses it as `cpuUsage.user` (system is always 0)
- UI was incorrectly dividing by 2, showing half the actual CPU usage
2. **Memory Metrics:**
- SmartMetrics calculates `maxMemoryMB` as minimum of:
- V8 heap size limit
- System total memory
- Docker memory limit (if available)
- Provides `memoryUsageBytes` (total process memory including children)
- Provides `memoryPercentage` (pre-calculated percentage)
- UI was only showing heap usage, missing actual memory constraints
### Changes Made
1. **MetricsManager Enhanced:**
- Added `maxMemoryMB` from SmartMetrics instance
- Added `actualUsageBytes` from SmartMetrics data
- Added `actualUsagePercentage` from SmartMetrics data
- Kept existing memory fields for compatibility
2. **Interface Updated:**
- Added optional fields to `IServerStats.memoryUsage`
- Fields are optional to maintain backward compatibility
3. **UI Fixed:**
- Removed incorrect CPU division by 2
- Uses `actualUsagePercentage` when available (falls back to heap percentage)
- Shows actual memory usage vs max memory limit (not just heap)
### Result
- CPU now shows accurate usage percentage
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
- Better monitoring for containerized environments
## Network UI Implementation (2025-06-20) - COMPLETED
### Overview
Revamped the Network UI to display real network data from SmartProxy instead of mock data.
### Architecture
1. **MetricsManager Integration:**
- Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
- Extended with `getNetworkStats()` method to expose unused metrics:
- `getConnectionsByIP()` - Connection counts by IP address
- `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
- `getTopIPs()` - Top connecting IPs sorted by connection count
- Note: SmartProxy base interface doesn't include all methods, manual implementation required
2. **Existing Infrastructure Leveraged:**
- `getActiveConnections` endpoint already exists in security.handler.ts
- Enhanced to include real SmartProxy data via MetricsManager
- IConnectionInfo interface already supports network data structures
3. **State Management:**
- Added `INetworkState` interface following existing patterns
- Created `networkStatePart` with connections, throughput, and IP data
- Integrated with existing auto-refresh mechanism
4. **UI Changes (Minimal):**
- Removed `generateMockData()` method and all mock generation
- Connected to real `networkStatePart` state
- Added `renderTopIPs()` section to display top connected IPs
- Updated traffic chart to show real request data
- Kept all existing UI components (DeesTable, DeesChartArea)
### Implementation Details
1. **Data Transformation:**
- Converts IConnectionInfo[] to INetworkRequest[] for table display
- Calculates traffic buckets based on selected time range
- Maps connection data to chart-compatible format
2. **Real Metrics Displayed:**
- Active connections count (from server stats)
- Requests per second (calculated from recent connections)
- Throughput rates (currently showing 0 until SmartProxy exposes rates)
- Top IPs with connection counts and percentages
3. **TypeScript Fixes:**
- SmartProxy methods like `getThroughputRate()` not in base interface
- Implemented manual fallbacks for missing methods
- Fixed `publicIpv4` → `publicIp` property name
### Result
- Network view now shows real connection activity
- Auto-refreshes with other stats every second
- Displays actual IPs and connection counts
- No more mock/demo data
- Minimal code changes (streamlined approach)
### Throughput Data Fix (2025-06-20)
The throughput was showing 0 because:
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
3. `getThroughputRate()` only exists in the extended interface
**Solution implemented:**
1. Updated MetricsManager to check if methods exist at runtime and call them
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
3. Created new `getNetworkStats` endpoint in security.handler.ts
4. Updated frontend to call the new endpoint for complete network metrics
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
## Email Operations Dashboard (2026-02-01)
### Overview
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
### New Files Created
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
### Key Interfaces
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
- `IReq_GetSentEmails` - Fetch delivered emails
- `IReq_GetFailedEmails` - Fetch failed emails
- `IReq_ResendEmail` - Re-queue a failed email for retry
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
### UI Changes (ops-view-emails.ts)
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
- **Queued**: Emails pending delivery
- **Sent**: Successfully delivered emails
- **Failed**: Failed emails with resend capability
- **Security**: Security incidents from SecurityLogger
- Removed `generateMockEmails()` method
- Added state management via `emailOpsStatePart` in appstate.ts
- Added resend button for failed emails
- Added security incident detail view
### Data Flow
```
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
```
### Backend Data Access
The handler accesses data from:
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
- `emailServer.bounceManager` - Bounce records and suppression list
## OpsServer UI Fixes (2026-02-02)
### Configuration Page Fix
The configuration page had field name mismatches between frontend and backend:
- Frontend expected `server` and `storage` sections
- Backend returns `proxy` section (not `server`)
- Backend has no `storage` section
**Fix**: Updated `ops-view-config.ts` to use correct section names:
- `proxy` instead of `server`
- Removed non-existent `storage` section
- Added optional chaining (`?.`) for safety
### Auth Persistence Fix
Login state was using `'soft'` mode in Smartstate which is memory-only:
- User login was lost on page refresh
- State reset to logged out after browser restart
**Changes**:
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
- Now uses IndexedDB to persist across browser sessions
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
- Validates stored JWT hasn't expired before auto-logging in
- Clears expired sessions and shows login form
## Config UI Read-Only Conversion (2026-02-03)
### Overview
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
### Changes Made
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
- Removed `updateConfiguration` handler
- Removed `updateConfiguration()` private method
- Kept `getConfiguration` handler (read-only)
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
- Removed `IReq_UpdateConfiguration` interface
- Kept `IReq_GetConfiguration` interface
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
- Removed `editingSection` and `editedConfig` state properties
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
- Removed Edit/Save/Cancel buttons
- Removed warning banner about immediate changes
- Enhanced read-only display with:
- Status badges for boolean values (enabled/disabled)
- Array display as pills/tags with counts
- Section icons (mail, globe, network, shield)
- Better formatting for numbers and byte sizes
- Empty state handling ("Not configured", "None configured")
- Info note explaining configuration is read-only
4. **State Management (`ts_web/appstate.ts`)**:
- Removed `updateConfigurationAction`
- Kept `fetchConfigurationAction` (read-only)
5. **Tests (`test/test.protected-endpoint.ts`)**:
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
- Added test for read-only config access
- Kept auth flow testing with different protected endpoint
6. **Documentation**:
- `readme.md`: Updated API endpoints to show config as read-only
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
### Visual Display Features
- Boolean values shown as colored badges (green=enabled, red=disabled)
- Arrays displayed as pills with count summaries
- Section headers with relevant Lucide icons
- Numbers formatted with locale separators
- Byte sizes auto-formatted (B, KB, MB, GB)
- Time values shown with "seconds" suffix
- Nested objects with visual indentation
## Smartdata Cache System (2026-02-03)
### Overview
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.
### Technology Stack
| Layer | Package | Purpose |
|-------|---------|---------|
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
### TC39 Decorators
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
- Removed `experimentalDecorators: true`
- Removed `emitDecoratorMetadata: true`
This is required for smartdata v7+ compatibility.
### Cache Document Classes
Located in `ts/cache/documents/`:
| Class | Purpose | Default TTL |
|-------|---------|-------------|
| `CachedEmail` | Email queue items | 30 days |
| `CachedIPReputation` | IP reputation lookups | 24 hours |
Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).
### Usage Pattern
```typescript
// Document classes use smartdata decorators
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id: string;
// ...
}
// Query examples
const email = await CachedEmail.getInstance({ id: 'abc123' });
const pending = await CachedEmail.getInstances({ status: 'pending' });
await email.save();
await email.delete();
```
### Configuration
```typescript
const dcRouter = new DcRouter({
cacheConfig: {
enabled: true,
storagePath: '~/.serve.zone/dcrouter/tsmdb',
dbName: 'dcrouter',
cleanupIntervalHours: 1,
ttlConfig: {
emails: 30, // days
ipReputation: 1, // days
bounces: 30, // days
dkimKeys: 90, // days
suppression: 30 // days
}
}
});
```
### Cache Cleaner
- Runs hourly by default (configurable via `cleanupIntervalHours`)
- Finds and deletes documents where `expiresAt < now()`
- Uses smartdata's `getInstances()` + `delete()` pattern
### Key Files
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
- `ts/cache/documents/*.ts` - Document class definitions

1225
readme.md

File diff suppressed because it is too large Load Diff

443
test/readme.md Normal file
View File

@@ -0,0 +1,443 @@
# DCRouter SMTP Test Suite
```
test/
├── readme.md # This file
├── helpers/
│ ├── server.loader.ts # SMTP server lifecycle management
│ ├── utils.ts # Common test utilities
│ └── smtp.client.ts # Test SMTP client utilities
└── suite/
├── smtpserver_commands/ # SMTP command tests (CMD)
├── smtpserver_connection/ # Connection management tests (CM)
├── smtpserver_edge-cases/ # Edge case tests (EDGE)
├── smtpserver_email-processing/ # Email processing tests (EP)
├── smtpserver_error-handling/ # Error handling tests (ERR)
├── smtpserver_performance/ # Performance tests (PERF)
├── smtpserver_reliability/ # Reliability tests (REL)
├── smtpserver_rfc-compliance/ # RFC compliance tests (RFC)
└── smtpserver_security/ # Security tests (SEC)
```
## Test ID Convention
All test files follow a strict naming convention: `test.<category-id>.<description>.ts`
Examples:
- `test.cmd-01.ehlo-command.ts` - EHLO command test
- `test.cm-01.tls-connection.ts` - TLS connection test
- `test.sec-01.authentication.ts` - Authentication test
## Test Categories
### 1. Connection Management (CM)
Tests for validating SMTP connection handling, TLS support, and connection lifecycle management.
| ID | Test Description | Priority | Implementation |
|-------|-------------------------------------------|----------|----------------|
| CM-01 | TLS Connection Test | High | `suite/smtpserver_connection/test.cm-01.tls-connection.ts` |
| CM-02 | Multiple Simultaneous Connections | High | `suite/smtpserver_connection/test.cm-02.multiple-connections.ts` |
| CM-03 | Connection Timeout | High | `suite/smtpserver_connection/test.cm-03.connection-timeout.ts` |
| CM-04 | Connection Limits | Medium | `suite/smtpserver_connection/test.cm-04.connection-limits.ts` |
| CM-05 | Connection Rejection | Medium | `suite/smtpserver_connection/test.cm-05.connection-rejection.ts` |
| CM-06 | STARTTLS Connection Upgrade | High | `suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts` |
| CM-07 | Abrupt Client Disconnection | Medium | `suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts` |
| CM-08 | TLS Version Compatibility | Medium | `suite/smtpserver_connection/test.cm-08.tls-versions.ts` |
| CM-09 | TLS Cipher Configuration | Medium | `suite/smtpserver_connection/test.cm-09.tls-ciphers.ts` |
| CM-10 | Plain Connection Test | Low | `suite/smtpserver_connection/test.cm-10.plain-connection.ts` |
| CM-11 | TCP Keep-Alive Test | Low | `suite/smtpserver_connection/test.cm-11.keepalive.ts` |
### 2. SMTP Commands (CMD)
Tests for validating proper SMTP protocol command implementation.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| CMD-01 | EHLO Command | High | `suite/smtpserver_commands/test.cmd-01.ehlo-command.ts` |
| CMD-02 | MAIL FROM Command | High | `suite/smtpserver_commands/test.cmd-02.mail-from.ts` |
| CMD-03 | RCPT TO Command | High | `suite/smtpserver_commands/test.cmd-03.rcpt-to.ts` |
| CMD-04 | DATA Command | High | `suite/smtpserver_commands/test.cmd-04.data-command.ts` |
| CMD-05 | NOOP Command | Medium | `suite/smtpserver_commands/test.cmd-05.noop-command.ts` |
| CMD-06 | RSET Command | Medium | `suite/smtpserver_commands/test.cmd-06.rset-command.ts` |
| CMD-07 | VRFY Command | Low | `suite/smtpserver_commands/test.cmd-07.vrfy-command.ts` |
| CMD-08 | EXPN Command | Low | `suite/smtpserver_commands/test.cmd-08.expn-command.ts` |
| CMD-09 | SIZE Extension | Medium | `suite/smtpserver_commands/test.cmd-09.size-extension.ts` |
| CMD-10 | HELP Command | Low | `suite/smtpserver_commands/test.cmd-10.help-command.ts` |
| CMD-11 | Command Pipelining | Medium | `suite/smtpserver_commands/test.cmd-11.command-pipelining.ts` |
| CMD-12 | HELO Command | Low | `suite/smtpserver_commands/test.cmd-12.helo-command.ts` |
| CMD-13 | QUIT Command | High | `suite/smtpserver_commands/test.cmd-13.quit-command.ts` |
### 3. Email Processing (EP)
Tests for validating email content handling, parsing, and delivery.
| ID | Test Description | Priority | Implementation |
|-------|-------------------------------------------|----------|----------------|
| EP-01 | Basic Email Sending | High | `suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts` |
| EP-02 | Invalid Email Address Handling | High | `suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts` |
| EP-03 | Multiple Recipients | Medium | `suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts` |
| EP-04 | Large Email Handling | High | `suite/smtpserver_email-processing/test.ep-04.large-email.ts` |
| EP-05 | MIME Handling | High | `suite/smtpserver_email-processing/test.ep-05.mime-handling.ts` |
| EP-06 | Attachment Handling | Medium | `suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts` |
| EP-07 | Special Character Handling | Medium | `suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts` |
| EP-08 | Email Routing | High | `suite/smtpserver_email-processing/test.ep-08.email-routing.ts` |
| EP-09 | Delivery Status Notifications | Medium | `suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts` |
### 4. Security (SEC)
Tests for validating security features and protections.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| SEC-01 | Authentication | High | `suite/smtpserver_security/test.sec-01.authentication.ts` |
| SEC-02 | Authorization | High | `suite/smtpserver_security/test.sec-02.authorization.ts` |
| SEC-03 | DKIM Processing | High | `suite/smtpserver_security/test.sec-03.dkim-processing.ts` |
| SEC-04 | SPF Checking | High | `suite/smtpserver_security/test.sec-04.spf-checking.ts` |
| SEC-05 | DMARC Policy Enforcement | Medium | `suite/smtpserver_security/test.sec-05.dmarc-policy.ts` |
| SEC-06 | IP Reputation Checking | High | `suite/smtpserver_security/test.sec-06.ip-reputation.ts` |
| SEC-07 | Content Scanning | Medium | `suite/smtpserver_security/test.sec-07.content-scanning.ts` |
| SEC-08 | Rate Limiting | High | `suite/smtpserver_security/test.sec-08.rate-limiting.ts` |
| SEC-09 | TLS Certificate Validation | High | `suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts` |
| SEC-10 | Header Injection Prevention | High | `suite/smtpserver_security/test.sec-10.header-injection-prevention.ts` |
| SEC-11 | Bounce Management | Medium | `suite/smtpserver_security/test.sec-11.bounce-management.ts` |
### 5. Error Handling (ERR)
Tests for validating proper error handling and recovery.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| ERR-01 | Syntax Error Handling | High | `suite/smtpserver_error-handling/test.err-01.syntax-errors.ts` |
| ERR-02 | Invalid Sequence Handling | High | `suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts` |
| ERR-03 | Temporary Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-03.temporary-failures.ts` |
| ERR-04 | Permanent Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-04.permanent-failures.ts` |
| ERR-05 | Resource Exhaustion Handling | High | `suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts` |
| ERR-06 | Malformed MIME Handling | Medium | `suite/smtpserver_error-handling/test.err-06.malformed-mime.ts` |
| ERR-07 | Exception Handling | High | `suite/smtpserver_error-handling/test.err-07.exception-handling.ts` |
| ERR-08 | Error Logging | Medium | `suite/smtpserver_error-handling/test.err-08.error-logging.ts` |
### 6. Performance (PERF)
Tests for validating performance characteristics and benchmarks.
| ID | Test Description | Priority | Implementation |
|---------|------------------------------------------|----------|----------------|
| PERF-01 | Throughput Testing | Medium | `suite/smtpserver_performance/test.perf-01.throughput.ts` |
| PERF-02 | Concurrency Testing | High | `suite/smtpserver_performance/test.perf-02.concurrency.ts` |
| PERF-03 | CPU Utilization | Medium | `suite/smtpserver_performance/test.perf-03.cpu-utilization.ts` |
| PERF-04 | Memory Usage | Medium | `suite/smtpserver_performance/test.perf-04.memory-usage.ts` |
| PERF-05 | Connection Processing Time | Medium | `suite/smtpserver_performance/test.perf-05.connection-processing-time.ts` |
| PERF-06 | Message Processing Time | Medium | `suite/smtpserver_performance/test.perf-06.message-processing-time.ts` |
| PERF-07 | Resource Cleanup | High | `suite/smtpserver_performance/test.perf-07.resource-cleanup.ts` |
### 7. Reliability (REL)
Tests for validating system reliability and stability.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| REL-01 | Long-Running Operation | High | `suite/smtpserver_reliability/test.rel-01.long-running-operation.ts` |
| REL-02 | Restart Recovery | High | `suite/smtpserver_reliability/test.rel-02.restart-recovery.ts` |
| REL-03 | Resource Leak Detection | High | `suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts` |
| REL-04 | Error Recovery | High | `suite/smtpserver_reliability/test.rel-04.error-recovery.ts` |
| REL-05 | DNS Resolution Failure Handling | Medium | `suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts` |
| REL-06 | Network Interruption Handling | Medium | `suite/smtpserver_reliability/test.rel-06.network-interruption.ts` |
### 8. Edge Cases (EDGE)
Tests for validating handling of unusual or extreme scenarios.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| EDGE-01 | Very Large Email | Low | `suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts` |
| EDGE-02 | Very Small Email | Low | `suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts` |
| EDGE-03 | Invalid Character Handling | Medium | `suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts` |
| EDGE-04 | Empty Commands | Low | `suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts` |
| EDGE-05 | Extremely Long Lines | Medium | `suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts` |
| EDGE-06 | Extremely Long Headers | Medium | `suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts` |
| EDGE-07 | Unusual MIME Types | Low | `suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts` |
| EDGE-08 | Nested MIME Structures | Low | `suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts` |
### 9. RFC Compliance (RFC)
Tests for validating compliance with SMTP-related RFCs.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| RFC-01 | RFC 5321 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts` |
| RFC-02 | RFC 5322 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts` |
| RFC-03 | RFC 7208 SPF Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts` |
| RFC-04 | RFC 6376 DKIM Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts` |
| RFC-05 | RFC 7489 DMARC Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts` |
| RFC-06 | RFC 8314 TLS Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts` |
| RFC-07 | RFC 3461 DSN Compliance | Low | `suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts` |
## SMTP Client Test Suite
The following test categories ensure our SMTP client is production-ready, RFC-compliant, and handles all real-world scenarios properly.
### Client Test Organization
```
test/
└── suite/
├── smtpclient_connection/ # Client connection management tests (CCM)
├── smtpclient_commands/ # Client command execution tests (CCMD)
├── smtpclient_email-composition/ # Email composition tests (CEP)
├── smtpclient_security/ # Client security tests (CSEC)
├── smtpclient_error-handling/ # Client error handling tests (CERR)
├── smtpclient_performance/ # Client performance tests (CPERF)
├── smtpclient_reliability/ # Client reliability tests (CREL)
├── smtpclient_edge-cases/ # Client edge case tests (CEDGE)
└── smtpclient_rfc-compliance/ # Client RFC compliance tests (CRFC)
```
### 10. Client Connection Management (CCM)
Tests for validating how the SMTP client establishes and manages connections to servers.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| CCM-01 | Basic TCP Connection | High | `suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts` |
| CCM-02 | TLS Connection Establishment | High | `suite/smtpclient_connection/test.ccm-02.tls-connection.ts` |
| CCM-03 | STARTTLS Upgrade | High | `suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts` |
| CCM-04 | Connection Pooling | High | `suite/smtpclient_connection/test.ccm-04.connection-pooling.ts` |
| CCM-05 | Connection Reuse | Medium | `suite/smtpclient_connection/test.ccm-05.connection-reuse.ts` |
| CCM-06 | Connection Timeout Handling | High | `suite/smtpclient_connection/test.ccm-06.connection-timeout.ts` |
| CCM-07 | Automatic Reconnection | High | `suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts` |
| CCM-08 | DNS Resolution & MX Records | High | `suite/smtpclient_connection/test.ccm-08.dns-mx-resolution.ts` |
| CCM-09 | IPv4/IPv6 Dual Stack Support | Medium | `suite/smtpclient_connection/test.ccm-09.dual-stack-support.ts` |
| CCM-10 | Proxy Support (SOCKS/HTTP) | Low | `suite/smtpclient_connection/test.ccm-10.proxy-support.ts` |
| CCM-11 | Keep-Alive Management | Medium | `suite/smtpclient_connection/test.ccm-11.keepalive-management.ts` |
### 11. Client Command Execution (CCMD)
Tests for validating how the client sends SMTP commands and processes responses.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CCMD-01 | EHLO/HELO Command Sending | High | `suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts` |
| CCMD-02 | MAIL FROM Command with Parameters | High | `suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts` |
| CCMD-03 | RCPT TO Command with Multiple Recipients | High | `suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts` |
| CCMD-04 | DATA Command and Content Transmission | High | `suite/smtpclient_commands/test.ccmd-04.data-transmission.ts` |
| CCMD-05 | AUTH Command (LOGIN, PLAIN, CRAM-MD5) | High | `suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts` |
| CCMD-06 | Command Pipelining | Medium | `suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts` |
| CCMD-07 | Response Code Parsing | High | `suite/smtpclient_commands/test.ccmd-07.response-parsing.ts` |
| CCMD-08 | Extended Response Handling | Medium | `suite/smtpclient_commands/test.ccmd-08.extended-responses.ts` |
| CCMD-09 | QUIT Command and Graceful Disconnect | High | `suite/smtpclient_commands/test.ccmd-09.quit-disconnect.ts` |
| CCMD-10 | RSET Command Usage | Medium | `suite/smtpclient_commands/test.ccmd-10.rset-usage.ts` |
| CCMD-11 | NOOP Keep-Alive | Low | `suite/smtpclient_commands/test.ccmd-11.noop-keepalive.ts` |
### 12. Client Email Composition (CEP)
Tests for validating email composition, formatting, and encoding.
| ID | Test Description | Priority | Implementation |
|--------|-------------------------------------------|----------|----------------|
| CEP-01 | Basic Email Headers | High | `suite/smtpclient_email-composition/test.cep-01.basic-headers.ts` |
| CEP-02 | MIME Multipart Messages | High | `suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts` |
| CEP-03 | Attachment Encoding | High | `suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts` |
| CEP-04 | UTF-8 and International Characters | High | `suite/smtpclient_email-composition/test.cep-04.utf8-international.ts` |
| CEP-05 | Base64 and Quoted-Printable Encoding | Medium | `suite/smtpclient_email-composition/test.cep-05.content-encoding.ts` |
| CEP-06 | HTML Email with Inline Images | Medium | `suite/smtpclient_email-composition/test.cep-06.html-inline-images.ts` |
| CEP-07 | Custom Headers | Low | `suite/smtpclient_email-composition/test.cep-07.custom-headers.ts` |
| CEP-08 | Message-ID Generation | Medium | `suite/smtpclient_email-composition/test.cep-08.message-id.ts` |
| CEP-09 | Date Header Formatting | Medium | `suite/smtpclient_email-composition/test.cep-09.date-formatting.ts` |
| CEP-10 | Line Length Limits (RFC 5322) | High | `suite/smtpclient_email-composition/test.cep-10.line-length-limits.ts` |
### 13. Client Security (CSEC)
Tests for client-side security features and protections.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CSEC-01 | TLS Certificate Verification | High | `suite/smtpclient_security/test.csec-01.tls-verification.ts` |
| CSEC-02 | Authentication Mechanisms | High | `suite/smtpclient_security/test.csec-02.auth-mechanisms.ts` |
| CSEC-03 | OAuth2 Support | Medium | `suite/smtpclient_security/test.csec-03.oauth2-support.ts` |
| CSEC-04 | Password Security (No Plaintext) | High | `suite/smtpclient_security/test.csec-04.password-security.ts` |
| CSEC-05 | DKIM Signing | High | `suite/smtpclient_security/test.csec-05.dkim-signing.ts` |
| CSEC-06 | SPF Record Compliance | Medium | `suite/smtpclient_security/test.csec-06.spf-compliance.ts` |
| CSEC-07 | Secure Credential Storage | High | `suite/smtpclient_security/test.csec-07.credential-storage.ts` |
| CSEC-08 | TLS Version Enforcement | High | `suite/smtpclient_security/test.csec-08.tls-version-enforcement.ts` |
| CSEC-09 | Certificate Pinning | Low | `suite/smtpclient_security/test.csec-09.certificate-pinning.ts` |
| CSEC-10 | Injection Attack Prevention | High | `suite/smtpclient_security/test.csec-10.injection-prevention.ts` |
### 14. Client Error Handling (CERR)
Tests for how the client handles various error conditions.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CERR-01 | 4xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts` |
| CERR-02 | 5xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts` |
| CERR-03 | Network Failure Recovery | High | `suite/smtpclient_error-handling/test.cerr-03.network-failures.ts` |
| CERR-04 | Timeout Recovery | High | `suite/smtpclient_error-handling/test.cerr-04.timeout-recovery.ts` |
| CERR-05 | Retry Logic with Backoff | High | `suite/smtpclient_error-handling/test.cerr-05.retry-backoff.ts` |
| CERR-06 | Greylisting Handling | Medium | `suite/smtpclient_error-handling/test.cerr-06.greylisting.ts` |
| CERR-07 | Rate Limit Response Handling | High | `suite/smtpclient_error-handling/test.cerr-07.rate-limits.ts` |
| CERR-08 | Malformed Server Response | Medium | `suite/smtpclient_error-handling/test.cerr-08.malformed-responses.ts` |
| CERR-09 | Connection Drop During Transfer | High | `suite/smtpclient_error-handling/test.cerr-09.connection-drops.ts` |
| CERR-10 | Authentication Failure Handling | High | `suite/smtpclient_error-handling/test.cerr-10.auth-failures.ts` |
### 15. Client Performance (CPERF)
Tests for client performance characteristics and optimization.
| ID | Test Description | Priority | Implementation |
|----------|-------------------------------------------|----------|----------------|
| CPERF-01 | Bulk Email Sending | High | `suite/smtpclient_performance/test.cperf-01.bulk-sending.ts` |
| CPERF-02 | Connection Pool Efficiency | High | `suite/smtpclient_performance/test.cperf-02.pool-efficiency.ts` |
| CPERF-03 | Memory Usage Under Load | High | `suite/smtpclient_performance/test.cperf-03.memory-usage.ts` |
| CPERF-04 | CPU Usage Optimization | Medium | `suite/smtpclient_performance/test.cperf-04.cpu-optimization.ts` |
| CPERF-05 | Parallel Sending Performance | High | `suite/smtpclient_performance/test.cperf-05.parallel-sending.ts` |
| CPERF-06 | Large Attachment Handling | Medium | `suite/smtpclient_performance/test.cperf-06.large-attachments.ts` |
| CPERF-07 | Queue Management | High | `suite/smtpclient_performance/test.cperf-07.queue-management.ts` |
| CPERF-08 | DNS Caching Efficiency | Medium | `suite/smtpclient_performance/test.cperf-08.dns-caching.ts` |
### 16. Client Reliability (CREL)
Tests for client reliability and resilience.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CREL-01 | Long Running Stability | High | `suite/smtpclient_reliability/test.crel-01.long-running.ts` |
| CREL-02 | Failover to Backup MX | High | `suite/smtpclient_reliability/test.crel-02.mx-failover.ts` |
| CREL-03 | Queue Persistence | High | `suite/smtpclient_reliability/test.crel-03.queue-persistence.ts` |
| CREL-04 | Crash Recovery | High | `suite/smtpclient_reliability/test.crel-04.crash-recovery.ts` |
| CREL-05 | Memory Leak Prevention | High | `suite/smtpclient_reliability/test.crel-05.memory-leaks.ts` |
| CREL-06 | Concurrent Operation Safety | High | `suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts` |
| CREL-07 | Resource Cleanup | Medium | `suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts` |
### 17. Client Edge Cases (CEDGE)
Tests for unusual scenarios and edge cases.
| ID | Test Description | Priority | Implementation |
|----------|-------------------------------------------|----------|----------------|
| CEDGE-01 | Extremely Slow Server Response | Medium | `suite/smtpclient_edge-cases/test.cedge-01.slow-server.ts` |
| CEDGE-02 | Server Sending Invalid UTF-8 | Low | `suite/smtpclient_edge-cases/test.cedge-02.invalid-utf8.ts` |
| CEDGE-03 | Extremely Large Recipients List | Medium | `suite/smtpclient_edge-cases/test.cedge-03.large-recipient-list.ts` |
| CEDGE-04 | Zero-Byte Attachments | Low | `suite/smtpclient_edge-cases/test.cedge-04.zero-byte-attachments.ts` |
| CEDGE-05 | Server Disconnect Mid-Command | High | `suite/smtpclient_edge-cases/test.cedge-05.mid-command-disconnect.ts` |
| CEDGE-06 | Unusual Server Banners | Low | `suite/smtpclient_edge-cases/test.cedge-06.unusual-banners.ts` |
| CEDGE-07 | Non-Standard Port Connections | Medium | `suite/smtpclient_edge-cases/test.cedge-07.non-standard-ports.ts` |
### 18. Client RFC Compliance (CRFC)
Tests for RFC compliance from the client perspective.
| ID | Test Description | Priority | Implementation |
|---------|-------------------------------------------|----------|----------------|
| CRFC-01 | RFC 5321 Client Requirements | High | `suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts` |
| CRFC-02 | RFC 5322 Message Format | High | `suite/smtpclient_rfc-compliance/test.crfc-02.rfc5322-format.ts` |
| CRFC-03 | RFC 2045-2049 MIME Compliance | High | `suite/smtpclient_rfc-compliance/test.crfc-03.mime-compliance.ts` |
| CRFC-04 | RFC 4954 AUTH Extension | High | `suite/smtpclient_rfc-compliance/test.crfc-04.auth-extension.ts` |
| CRFC-05 | RFC 3207 STARTTLS | High | `suite/smtpclient_rfc-compliance/test.crfc-05.starttls.ts` |
| CRFC-06 | RFC 1870 SIZE Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-06.size-extension.ts` |
| CRFC-07 | RFC 6152 8BITMIME Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-07.8bitmime.ts` |
| CRFC-08 | RFC 2920 Command Pipelining | Medium | `suite/smtpclient_rfc-compliance/test.crfc-08.pipelining.ts` |
## Running SMTP Client Tests
### Run All Client Tests
```bash
cd dcrouter
pnpm test test/suite/smtpclient_*
```
### Run Specific Client Test Category
```bash
# Run all client connection tests
pnpm test test/suite/smtpclient_connection
# Run all client security tests
pnpm test test/suite/smtpclient_security
```
### Run Single Client Test File
```bash
# Run basic TCP connection test
tsx test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts
# Run AUTH mechanisms test
tsx test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts
```
## Client Performance Benchmarks
Expected performance metrics for production-ready SMTP client:
- **Sending Rate**: >100 emails per second (with connection pooling)
- **Connection Pool Size**: 10-50 concurrent connections efficiently managed
- **Memory Usage**: <500MB for 1000 concurrent email operations
- **DNS Cache Hit Rate**: >90% for repeated domains
- **Retry Success Rate**: >95% for temporary failures
- **Large Attachment Support**: Files up to 25MB without performance degradation
- **Queue Processing**: >1000 emails/minute with persistent queue
## Client Security Requirements
All client security tests must pass for production deployment:
- **TLS Support**: TLS 1.2+ required, TLS 1.3 preferred
- **Authentication**: Support for LOGIN, PLAIN, CRAM-MD5, OAuth2
- **Certificate Validation**: Proper certificate chain validation
- **DKIM Signing**: Automatic DKIM signature generation
- **Credential Security**: No plaintext password storage
- **Injection Prevention**: Protection against header/command injection
## Client Production Readiness Criteria
### Production Gate 1: Core Functionality (>95% tests passing)
- Basic connection establishment
- Command execution and response parsing
- Email composition and sending
- Error handling and recovery
### Production Gate 2: Advanced Features (>90% tests passing)
- Connection pooling and reuse
- Authentication mechanisms
- TLS/STARTTLS support
- Retry logic and resilience
### Production Gate 3: Enterprise Ready (>85% tests passing)
- High-volume sending capabilities
- Advanced security features
- Full RFC compliance
- Performance under load
## Key Differences: Server vs Client Tests
| Aspect | Server Tests | Client Tests |
|--------|--------------|--------------|
| **Focus** | Accepting connections, processing commands | Making connections, sending commands |
| **Security** | Validating incoming data, enforcing policies | Protecting credentials, validating servers |
| **Performance** | Handling many clients concurrently | Efficient bulk sending, connection reuse |
| **Reliability** | Staying up under attack/load | Retrying failures, handling timeouts |
| **RFC Compliance** | Server MUST requirements | Client MUST requirements |
## Test Implementation Priority
1. **Critical** (implement first):
- Basic connection and command sending
- Authentication mechanisms
- Error handling and retry logic
- TLS/Security features
2. **High Priority** (implement second):
- Connection pooling
- Email composition and MIME
- Performance optimization
- RFC compliance
3. **Medium Priority** (implement third):
- Advanced features (OAuth2, etc.)
- Edge case handling
- Extended performance tests
- Additional RFC extensions
4. **Low Priority** (implement last):
- Proxy support
- Certificate pinning
- Unusual scenarios
- Optional RFC features

175
test/test.config.md Normal file
View File

@@ -0,0 +1,175 @@
# DCRouter Test Configuration
## Running Tests
### Run All Tests
```bash
cd dcrouter
pnpm test
```
### Run Specific Category
```bash
# Run all connection tests
tsx test/run-category.ts connection
# Run all security tests
tsx test/run-category.ts security
# Run all performance tests
tsx test/run-category.ts performance
```
### Run Individual Test File
```bash
# Run TLS connection test
tsx test/suite/connection/test.tls-connection.ts
# Run authentication test
tsx test/suite/security/test.authentication.ts
```
### Run Tests with Verbose Output
```bash
# All tests with verbose logging
pnpm test -- --verbose
# Individual test with verbose
tsx test/suite/connection/test.tls-connection.ts --verbose
```
## Test Server Configuration
Each test file starts its own SMTP server with specific configuration. Common configurations:
### Basic Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost'
});
```
### TLS-Enabled Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost',
tlsEnabled: true
});
```
### Authenticated Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost',
authRequired: true
});
```
### High-Performance Server
```typescript
const testServer = await startTestServer({
port: 2525,
hostname: 'localhost',
maxConnections: 1000,
size: 50 * 1024 * 1024 // 50MB
});
```
## Port Allocation
Tests use different ports to avoid conflicts:
- Connection tests: 2525-2530
- Command tests: 2531-2540
- Email processing: 2541-2550
- Security tests: 2551-2560
- Performance tests: 2561-2570
- Edge cases: 2571-2580
- RFC compliance: 2581-2590
## Test Utilities
### Server Lifecycle
All tests follow this pattern:
```typescript
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
let testServer;
tap.test('setup', async () => {
testServer = await startTestServer({ port: 2525 });
});
// Your tests here...
tap.test('cleanup', async () => {
await stopTestServer(testServer);
});
tap.start();
```
### SMTP Client Testing
```typescript
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
const client = createTestSmtpClient({
host: 'localhost',
port: 2525
});
```
### Low-Level SMTP Testing
```typescript
import { connectToSmtp, sendSmtpCommand } from '../../helpers/test.utils.js';
const socket = await connectToSmtp('localhost', 2525);
const response = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
```
## Performance Benchmarks
Expected minimums for production:
- Throughput: >10 emails/second
- Concurrent connections: >100
- Memory increase: <2% under load
- Connection time: <5000ms
- Error rate: <5%
## Debugging Failed Tests
### Enable Verbose Logging
```bash
DEBUG=* tsx test/suite/connection/test.tls-connection.ts
```
### Check Server Logs
Tests output server logs to console. Look for:
- 🚀 Server start messages
- 📧 Email processing logs
- ❌ Error messages
- ✅ Success confirmations
### Common Issues
1. **Port Already in Use**
- Tests use unique ports
- Check for orphaned processes: `lsof -i :2525`
- Kill process: `kill -9 <PID>`
2. **TLS Certificate Errors**
- Tests use self-signed certificates
- Production should use real certificates
3. **Timeout Errors**
- Increase timeout in test configuration
- Check network connectivity
- Verify server started successfully
4. **Authentication Failures**
- Test servers may not validate credentials
- Check authRequired configuration
- Verify AUTH mechanisms supported

265
test/test.contentscanner.ts Normal file
View File

@@ -0,0 +1,265 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
import { Email } from '@push.rocks/smartmta';
// Test instantiation
tap.test('ContentScanner - should be instantiable', async () => {
const scanner = ContentScanner.getInstance({
scanBody: true,
scanSubject: true,
scanAttachments: true
});
expect(scanner).toBeTruthy();
});
// Test singleton pattern
tap.test('ContentScanner - should use singleton pattern', async () => {
const scanner1 = ContentScanner.getInstance();
const scanner2 = ContentScanner.getInstance();
// Both instances should be the same object
expect(scanner1 === scanner2).toEqual(true);
});
// Test clean email can be correctly distinguished from high-risk email
tap.test('ContentScanner - should distinguish between clean and suspicious emails', async () => {
// Create an instance with a higher minimum threat score
const scanner = new ContentScanner({
minThreatScore: 50 // Higher threshold to consider clean
});
// Create a truly clean email with no potentially sensitive data patterns
const cleanEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Project Update',
text: 'The project is on track. Let me know if you have questions.',
html: '<p>The project is on track. Let me know if you have questions.</p>'
});
// Create a highly suspicious email
const suspiciousEmail = new Email({
from: 'admin@bank-fake.com',
to: 'victim@example.com',
subject: 'URGENT: Your account needs verification now!',
text: 'Click here to verify your account or it will be suspended: https://bit.ly/12345',
html: '<p>Click here to verify your account or it will be suspended: <a href="https://bit.ly/12345">click here</a></p>'
});
// Test both emails
const cleanResult = await scanner.scanEmail(cleanEmail);
const suspiciousResult = await scanner.scanEmail(suspiciousEmail);
console.log('Clean vs Suspicious results:', {
cleanScore: cleanResult.threatScore,
suspiciousScore: suspiciousResult.threatScore
});
// Verify the scanner can distinguish between them
// Suspicious email should have a significantly higher score
expect(suspiciousResult.threatScore > cleanResult.threatScore + 40).toEqual(true);
// Verify clean email scans all expected elements
expect(cleanResult.scannedElements.length > 0).toEqual(true);
});
// Test phishing detection in subject
tap.test('ContentScanner - should detect phishing in subject', async () => {
// Create a dedicated scanner for this test
const scanner = new ContentScanner({
scanSubject: true,
scanBody: true,
scanAttachments: false,
customRules: []
});
const email = new Email({
from: 'security@bank-account-verify.com',
to: 'victim@example.com',
subject: 'URGENT: Verify your bank account details immediately',
text: 'Your account will be suspended. Please verify your details.',
html: '<p>Your account will be suspended. Please verify your details.</p>'
});
const result = await scanner.scanEmail(email);
console.log('Phishing email scan result:', result);
// We only care that it detected something suspicious
expect(result.threatScore >= 20).toEqual(true);
// Check if any threat was detected (specific type may vary)
expect(result.threatType).toBeTruthy();
});
// Test malware indicators in body
tap.test('ContentScanner - should detect malware indicators in body', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'invoice@company.com',
to: 'recipient@example.com',
subject: 'Your invoice',
text: 'Please see the attached invoice. You need to enable macros to view this document properly.',
html: '<p>Please see the attached invoice. You need to enable macros to view this document properly.</p>'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType === ThreatCategory.MALWARE || result.threatType).toBeTruthy();
expect(result.threatScore >= 30).toEqual(true);
});
// Test suspicious link detection
tap.test('ContentScanner - should detect suspicious links', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'newsletter@example.com',
to: 'recipient@example.com',
subject: 'Weekly Newsletter',
text: 'Check our latest offer at https://bit.ly/2x3F5 and https://t.co/abc123',
html: '<p>Check our latest offer at <a href="https://bit.ly/2x3F5">here</a> and <a href="https://t.co/abc123">here</a></p>'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.SUSPICIOUS_LINK);
expect(result.threatScore >= 30).toEqual(true);
});
// Test script injection detection
tap.test('ContentScanner - should detect script injection', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'newsletter@example.com',
to: 'recipient@example.com',
subject: 'Newsletter',
text: 'Check our website',
html: '<p>Check our website</p><script>document.cookie="session="+localStorage.getItem("token");</script>'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.XSS);
expect(result.threatScore >= 40).toEqual(true);
});
// Test executable attachment detection
tap.test('ContentScanner - should detect executable attachments', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Software Update',
text: 'Please install the attached software update.',
attachments: [{
filename: 'update.exe',
content: Buffer.from('MZ...fake executable content...'),
contentType: 'application/octet-stream'
}]
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.EXECUTABLE);
expect(result.threatScore >= 70).toEqual(true);
});
// Test macro document detection
tap.test('ContentScanner - should detect macro documents', async () => {
// Create a mock Office document with macro indicators
const fakeDocContent = Buffer.from('Document content...vbaProject.bin...Auto_Open...DocumentOpen...Microsoft VBA...');
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Financial Report',
text: 'Please review the attached financial report.',
attachments: [{
filename: 'report.docm',
content: fakeDocContent,
contentType: 'application/vnd.ms-word.document.macroEnabled.12'
}]
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.MALICIOUS_MACRO);
expect(result.threatScore >= 60).toEqual(true);
});
// Test compound threat detection (multiple indicators)
tap.test('ContentScanner - should detect compound threats', async () => {
const scanner = ContentScanner.getInstance();
const email = new Email({
from: 'security@bank-verify.com',
to: 'victim@example.com',
subject: 'URGENT: Verify your account details immediately',
text: 'Your account will be suspended unless you verify your details at https://bit.ly/2x3F5',
html: '<p>Your account will be suspended unless you verify your details <a href="https://bit.ly/2x3F5">here</a>.</p>',
attachments: [{
filename: 'verification.exe',
content: Buffer.from('MZ...fake executable content...'),
contentType: 'application/octet-stream'
}]
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatScore > 70).toEqual(true); // Should have a high score due to multiple threats
});
// Test custom rules
tap.test('ContentScanner - should apply custom rules', async () => {
// Create a scanner with custom rules
const scanner = new ContentScanner({
customRules: [
{
pattern: /CUSTOM_PATTERN_FOR_TESTING/,
type: ThreatCategory.CUSTOM_RULE,
score: 50,
description: 'Custom pattern detected'
}
]
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Test Custom Rule',
text: 'This message contains CUSTOM_PATTERN_FOR_TESTING that should be detected.'
});
const result = await scanner.scanEmail(email);
expect(result.isClean).toEqual(false);
expect(result.threatType).toEqual(ThreatCategory.CUSTOM_RULE);
expect(result.threatScore >= 50).toEqual(true);
});
// Test threat level classification
tap.test('ContentScanner - should classify threat levels correctly', async () => {
expect(ContentScanner.getThreatLevel(10)).toEqual('none');
expect(ContentScanner.getThreatLevel(25)).toEqual('low');
expect(ContentScanner.getThreatLevel(50)).toEqual('medium');
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

159
test/test.dcrouter.email.ts Normal file
View File

@@ -0,0 +1,159 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as path from 'path';
import * as fs from 'fs';
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
tap.test('DcRouter class - Custom email port configuration', async () => {
// Define custom port mapping
const customPortMapping: Record<number, number> = {
25: 11025, // Custom SMTP port mapping
587: 11587, // Custom submission port mapping
465: 11465, // Custom SMTPS port mapping
2525: 12525 // Additional custom port
};
// Create a custom email configuration using smartmta interfaces
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465, 2525],
hostname: 'mail.example.com',
maxMessageSize: 50 * 1024 * 1024, // 50MB
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns',
},
{
domain: 'example.org',
dnsMode: 'external-dns',
}
],
routes: [
{
name: 'forward-example-com',
match: {
recipients: '*@example.com',
},
action: {
type: 'forward',
forward: {
host: 'mail1.example.com',
port: 25,
}
}
},
{
name: 'deliver-example-org',
match: {
recipients: '*@example.org',
},
action: {
type: 'deliver',
process: {
dkim: true,
}
}
}
]
};
// Create DcRouter options with custom email port configuration
const options: IDcRouterOptions = {
emailConfig,
emailPortConfig: {
portMapping: customPortMapping,
portSettings: {
2525: {
terminateTls: false,
routeName: 'custom-smtp-route'
}
},
},
tls: {
contactEmail: 'test@example.com'
}
};
// Create DcRouter instance
const router = new DcRouter(options);
// Verify the options are correctly set
expect(router.options.emailPortConfig).toBeTruthy();
expect(router.options.emailPortConfig!.portMapping).toEqual(customPortMapping);
// Test the generateEmailRoutes method
if (typeof (router as any)['generateEmailRoutes'] === 'function') {
const routes = (router as any)['generateEmailRoutes'](emailConfig);
// Verify that all ports are configured
expect(routes.length).toBeGreaterThan(0);
// Check the custom port configuration
const customPortRoute = routes.find((r: any) => {
const ports = r.match.ports;
return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525));
});
expect(customPortRoute).toBeTruthy();
expect(customPortRoute?.name).toEqual('custom-smtp-route');
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
// Check standard port mappings
const smtpRoute = routes.find((r: any) => {
const ports = r.match.ports;
return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25));
});
expect(smtpRoute?.action.targets[0].port).toEqual(11025);
const submissionRoute = routes.find((r: any) => {
const ports = r.match.ports;
return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587));
});
expect(submissionRoute?.action.targets[0].port).toEqual(11587);
}
});
tap.test('DcRouter class - Email config with domains and routes', async () => {
// Create a basic email configuration
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
hostname: 'mail.example.com',
domains: [],
routes: []
};
// Create DcRouter options
const options: IDcRouterOptions = {
emailConfig,
tls: {
contactEmail: 'test@example.com'
},
cacheConfig: {
enabled: false,
}
};
// Create DcRouter instance
const router = new DcRouter(options);
// Start the router to initialize email services
await router.start();
// Verify unified email server was initialized
expect(router.emailServer).toBeTruthy();
// Stop the router
await router.stop();
});
// Final clean-up test
tap.test('clean up after tests', async () => {
// No-op
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env tsx
/**
* Test DNS server configuration and record registration
*/
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
// Test DNS configuration
const testDnsConfig = {
udpPort: 5353, // Use non-privileged port for testing
httpsPort: 8443,
httpsKey: './test/fixtures/test-key.pem',
httpsCert: './test/fixtures/test-cert.pem',
dnssecZone: 'test.example.com',
records: [
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
]
};
tap.test('DNS server configuration - should extract records correctly', async () => {
const { records, ...dnsServerOptions } = testDnsConfig;
expect(dnsServerOptions.udpPort).toEqual(5353);
expect(dnsServerOptions.httpsPort).toEqual(8443);
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
expect(records).toBeArray();
expect(records.length).toEqual(6);
});
tap.test('DNS server configuration - should handle record parsing', async () => {
const parseDnsRecordData = (type: string, value: string): any => {
switch (type) {
case 'A':
return value;
case 'MX':
const [priority, exchange] = value.split(' ');
return { priority: parseInt(priority), exchange };
case 'TXT':
return value;
case 'NS':
return value;
default:
return value;
}
};
// Test A record parsing
const aRecord = parseDnsRecordData('A', '192.168.1.1');
expect(aRecord).toEqual('192.168.1.1');
// Test MX record parsing
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
expect(mxRecord).toHaveProperty('priority', 10);
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
// Test TXT record parsing
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
});
tap.test('DNS server configuration - should group records by domain', async () => {
const records = testDnsConfig.records;
const recordsByDomain = new Map<string, typeof records>();
for (const record of records) {
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
if (!recordsByDomain.has(pattern)) {
recordsByDomain.set(pattern, []);
}
recordsByDomain.get(pattern)!.push(record);
}
// Check grouping
expect(recordsByDomain.size).toBeGreaterThan(0);
// Verify each group has records
for (const [pattern, domainRecords] of recordsByDomain) {
expect(domainRecords.length).toBeGreaterThan(0);
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
}
});
tap.test('DNS server configuration - should extract unique record types', async () => {
const records = testDnsConfig.records;
const recordTypes = [...new Set(records.map(r => r.type))];
expect(recordTypes).toContain('A');
expect(recordTypes).toContain('MX');
expect(recordTypes).toContain('TXT');
expect(recordTypes).toContain('NS');
console.log('Unique record types:', recordTypes.join(', '));
});
tap.test('DNS server - mock handler registration', async () => {
// Mock DNS server for testing
const mockDnsServer = {
handlers: new Map<string, any>(),
registerHandler: function(pattern: string, types: string[], handler: Function) {
this.handlers.set(pattern, { types, handler });
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
}
};
// Simulate record registration
const records = testDnsConfig.records;
const recordsByDomain = new Map<string, typeof records>();
for (const record of records) {
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
if (!recordsByDomain.has(pattern)) {
recordsByDomain.set(pattern, []);
}
recordsByDomain.get(pattern)!.push(record);
}
// Register handlers
for (const [domainPattern, domainRecords] of recordsByDomain) {
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
const matchingRecord = domainRecords.find(
r => r.name === question.name && r.type === question.type
);
return matchingRecord || null;
});
}
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
});
tap.start({
throwOnError: true
});

View File

@@ -0,0 +1,148 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import * as plugins from '../ts/plugins.js';
let dcRouter: DcRouter;
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
dcRouter = new DcRouter({
smartProxyConfig: {
routes: []
},
cacheConfig: { enabled: false }
});
await dcRouter.start();
// Check that DNS server is not created
expect((dcRouter as any).dnsServer).toBeUndefined();
await dcRouter.stop();
});
tap.test('should generate DNS routes when dnsNsDomains is set', async () => {
// This test checks the route generation logic WITHOUT starting the full DcRouter
// Starting DcRouter would require DNS port 53 and cause conflicts
dcRouter = new DcRouter({
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
dnsScopes: ['test.local'],
smartProxyConfig: {
routes: []
}
});
// Check routes are generated correctly (without starting)
const generatedRoutes = (dcRouter as any).generateDnsRoutes();
expect(generatedRoutes.length).toEqual(2); // /dns-query and /resolve
// Check that routes have socket-handler action
generatedRoutes.forEach((route: any) => {
expect(route.action.type).toEqual('socket-handler');
expect(route.action.socketHandler).toBeDefined();
});
// Verify routes target the primary nameserver
const dnsQueryRoute = generatedRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
expect(dnsQueryRoute).toBeDefined();
expect(dnsQueryRoute.match.domains).toContain('ns1.test.local');
});
tap.test('should create DNS routes with correct configuration', async () => {
dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: {
routes: []
}
});
// Access the private method to generate routes
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
expect(dnsRoutes.length).toEqual(2);
// Check first route (dns-query) - uses primary nameserver (first in array)
const dnsQueryRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
expect(dnsQueryRoute).toBeDefined();
expect(dnsQueryRoute.match.ports).toContain(443);
expect(dnsQueryRoute.match.domains).toContain('ns1.example.com');
expect(dnsQueryRoute.match.path).toEqual('/dns-query');
// Check second route (resolve)
const resolveRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-resolve');
expect(resolveRoute).toBeDefined();
expect(resolveRoute.match.ports).toContain(443);
expect(resolveRoute.match.domains).toContain('ns1.example.com');
expect(resolveRoute.match.path).toEqual('/resolve');
});
tap.test('DNS socket handler should be created correctly', async () => {
// This test verifies the socket handler creation WITHOUT starting the full router
dcRouter = new DcRouter({
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
dnsScopes: ['test.local'],
smartProxyConfig: {
routes: []
}
});
// Get the socket handler (this doesn't require DNS server to be started)
const socketHandler = (dcRouter as any).createDnsSocketHandler();
expect(socketHandler).toBeDefined();
expect(typeof socketHandler).toEqual('function');
// Create a mock socket to test the handler behavior without DNS server
const mockSocket = new plugins.net.Socket();
let socketEnded = false;
mockSocket.end = () => {
socketEnded = true;
return mockSocket;
};
// When DNS server is not initialized, the handler should end the socket
try {
await socketHandler(mockSocket);
} catch (error) {
// Expected - DNS server not initialized
}
// Socket should be ended because DNS server wasn't started
expect(socketEnded).toEqual(true);
});
tap.test('DNS routes should only be generated when dnsNsDomains is configured', async () => {
// Test without DNS configuration - should return empty routes
dcRouter = new DcRouter({
smartProxyConfig: {
routes: []
}
});
const routesWithoutDns = (dcRouter as any).generateDnsRoutes();
expect(routesWithoutDns.length).toEqual(0);
// Test with DNS configuration - should return routes
const dcRouterWithDns = new DcRouter({
dnsNsDomains: ['ns1.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: {
routes: []
}
});
const routesWithDns = (dcRouterWithDns as any).generateDnsRoutes();
expect(routesWithDns.length).toEqual(2);
// Verify socket handler can be created
const socketHandler = (dcRouterWithDns as any).createDnsSocketHandler();
expect(socketHandler).toBeDefined();
expect(typeof socketHandler).toEqual('function');
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

274
test/test.errors.ts Normal file
View File

@@ -0,0 +1,274 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as errors from '../ts/errors/index.js';
import {
PlatformError,
ValidationError,
NetworkError,
ResourceError,
OperationError
} from '../ts/errors/base.errors.js';
import {
ErrorSeverity,
ErrorCategory,
ErrorRecoverability
} from '../ts/errors/error.codes.js';
import {
ErrorHandler
} from '../ts/errors/error-handler.js';
// Test base error classes
tap.test('Base error classes should set properties correctly', async () => {
const message = 'Test error message';
const code = 'TEST_ERROR_CODE';
const context = {
component: 'TestComponent',
operation: 'testOperation',
data: { foo: 'bar' }
};
// Test PlatformError
const platformError = new PlatformError(
message,
code,
ErrorSeverity.MEDIUM,
ErrorCategory.OPERATION,
ErrorRecoverability.MAYBE_RECOVERABLE,
context
);
expect(platformError.message).toEqual(message);
expect(platformError.code).toEqual(code);
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
expect(platformError.context?.component).toEqual(context.component);
expect(platformError.context?.operation).toEqual(context.operation);
expect(platformError.context?.data?.foo).toEqual('bar');
expect(platformError.name).toEqual('PlatformError');
// Test ValidationError
const validationError = new ValidationError(message, code, context);
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
// Test NetworkError
const networkError = new NetworkError(message, code, context);
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
// Test ResourceError
const resourceError = new ResourceError(message, code, context);
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
});
// Test error handler utility
tap.test('ErrorHandler should properly handle and format errors', async () => {
// Configure error handler
ErrorHandler.configure({
logErrors: false, // Disable for testing
includeStacksInProd: false,
retry: {
maxAttempts: 5,
baseDelay: 100,
maxDelay: 1000,
backoffFactor: 2
}
});
// Test converting regular Error to PlatformError
const regularError = new Error('Something went wrong');
const platformError = ErrorHandler.toPlatformError(
regularError,
'PLATFORM_OPERATION_ERROR',
{ component: 'TestHandler' }
);
expect(platformError).toBeInstanceOf(PlatformError);
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
expect(platformError.context?.component).toEqual('TestHandler');
// Test formatting error for API response
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
expect(formattedError.message).toEqual('An unexpected error occurred.');
expect(formattedError.details?.rawMessage).toEqual('Something went wrong');
// Test executing a function with error handling
let executed = false;
try {
await ErrorHandler.execute(async () => {
executed = true;
throw new Error('Execution failed');
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
} catch (error) {
expect(error).toBeInstanceOf(PlatformError);
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
expect(error.context.operation).toEqual('testExecution');
}
expect(executed).toEqual(true);
// Test executeWithRetry successful after retries
let attempts = 0;
const result = await ErrorHandler.executeWithRetry(
async () => {
attempts++;
if (attempts < 3) {
throw new Error('Temporary failure');
}
return 'success';
},
'TEST_RETRY_ERROR',
{
maxAttempts: 5,
baseDelay: 10, // Use small delay for tests
retryableErrorPatterns: [/Temporary failure/], // Add pattern to make error retryable
onRetry: (error, attempt, delay) => {
expect(error).toBeInstanceOf(PlatformError);
expect(attempt).toBeGreaterThan(0);
expect(delay).toBeGreaterThan(0);
}
}
);
expect(result).toEqual('success');
expect(attempts).toEqual(3);
// Test executeWithRetry that fails after max attempts
attempts = 0;
try {
await ErrorHandler.executeWithRetry(
async () => {
attempts++;
throw new Error('Persistent failure');
},
'TEST_RETRY_ERROR',
{
maxAttempts: 3,
baseDelay: 10,
retryableErrorPatterns: [/Persistent failure/] // Make error retryable so it tries all attempts
}
);
} catch (error) {
expect(error).toBeInstanceOf(PlatformError);
expect(attempts).toEqual(3);
}
});
// Test retry utilities
tap.test('Error retry utilities should work correctly', async () => {
let attempts = 0;
try {
await errors.retry(
async () => {
attempts++;
if (attempts < 3) {
throw new Error('Temporary error');
}
return 'success';
},
{
maxRetries: 5,
initialDelay: 20,
backoffFactor: 1.5,
retryableErrors: [/Temporary/]
}
);
} catch (e) {
// Should not reach here
expect(false).toEqual(true);
}
expect(attempts).toEqual(3);
// Test retry with non-retryable error
attempts = 0;
try {
await errors.retry(
async () => {
attempts++;
throw new Error('Critical error');
},
{
maxRetries: 3,
initialDelay: 10,
retryableErrors: [/Temporary/] // Won't match "Critical"
}
);
} catch (error) {
expect(error.message).toEqual('Critical error');
expect(attempts).toEqual(1); // Should only attempt once
}
});
// Helper function that will reject first n times, then resolve
interface FlakyFunction {
(failTimes: number, result?: any): Promise<any>;
counter: number;
reset: () => void;
}
const flaky: FlakyFunction = Object.assign(
async function (failTimes: number, result: any = 'success'): Promise<any> {
if (flaky.counter < failTimes) {
flaky.counter++;
throw new Error(`Flaky failure ${flaky.counter}`);
}
return result;
},
{
counter: 0,
reset: () => { flaky.counter = 0; }
}
);
// Test error wrapping and retry combination
tap.test('Error handling can be combined with retry for robust operations', async () => {
// Reset counter for the test
flaky.reset();
// Create a wrapped version of the flaky function
const wrapped = errors.withErrorHandling(
() => flaky(2, 'wrapped success'),
'TEST_WRAPPED_ERROR',
{ component: 'TestComponent' }
);
// Execute with retry
const result = await errors.retry(
wrapped,
{
maxRetries: 3,
initialDelay: 10,
retryableErrors: [/Flaky failure/]
}
);
expect(result).toEqual('wrapped success');
expect(flaky.counter).toEqual(2);
// Reset and test failure case
flaky.reset();
try {
await errors.retry(
() => flaky(5, 'never reached'),
{
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
initialDelay: 10,
retryableErrors: [/Flaky failure/] // Add pattern to make it retry
}
);
// Should not reach here
expect(false).toEqual(true);
} catch (error) {
expect(error.message).toContain('Flaky failure');
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
}
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,179 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
import * as plugins from '../ts/plugins.js';
// Mock for dns lookup
const originalDnsResolve = plugins.dns.promises.resolve;
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
// Setup mock DNS resolver with proper typing
(plugins.dns.promises as any).resolve = async (hostname: string) => {
return mockDnsResolveImpl(hostname);
};
// Test instantiation
tap.test('IPReputationChecker - should be instantiable', async () => {
const checker = IPReputationChecker.getInstance({
enableDNSBL: false,
enableIPInfo: false,
enableLocalCache: false
});
expect(checker).toBeTruthy();
});
// Test singleton pattern
tap.test('IPReputationChecker - should use singleton pattern', async () => {
const checker1 = IPReputationChecker.getInstance();
const checker2 = IPReputationChecker.getInstance();
// Both instances should be the same object
expect(checker1 === checker2).toEqual(true);
});
// Test IP validation
tap.test('IPReputationChecker - should validate IP address format', async () => {
const checker = IPReputationChecker.getInstance({
enableDNSBL: false,
enableIPInfo: false,
enableLocalCache: false
});
// Valid IP should work
const result = await checker.checkReputation('192.168.1.1');
expect(result.score).toBeGreaterThan(0);
expect(result.error).toBeUndefined();
// Invalid IP should fail with error
const invalidResult = await checker.checkReputation('invalid.ip');
expect(invalidResult.error).toBeTruthy();
});
// Test DNSBL lookups
tap.test('IPReputationChecker - should check IP against DNSBL', async () => {
try {
// Setup mock implementation for DNSBL
mockDnsResolveImpl = async (hostname: string) => {
// Listed in DNSBL if IP contains 2
if (hostname.includes('2.1.168.192') && hostname.includes('zen.spamhaus.org')) {
return ['127.0.0.2'];
}
throw { code: 'ENOTFOUND' };
};
// Create a new instance with specific settings for this test
const testInstance = new IPReputationChecker({
dnsblServers: ['zen.spamhaus.org'],
enableIPInfo: false,
enableLocalCache: false,
maxCacheSize: 1 // Small cache for testing
});
// Clean IP should have good score
const cleanResult = await testInstance.checkReputation('192.168.1.1');
expect(cleanResult.isSpam).toEqual(false);
expect(cleanResult.score).toEqual(100);
// Blacklisted IP should have reduced score
const blacklistedResult = await testInstance.checkReputation('192.168.1.2');
expect(blacklistedResult.isSpam).toEqual(true);
expect(blacklistedResult.score < 100).toEqual(true); // Less than 100
expect(blacklistedResult.blacklists).toBeTruthy();
expect((blacklistedResult.blacklists || []).length > 0).toEqual(true);
} catch (err) {
console.error('Test error:', err);
throw err;
}
});
// Test caching behavior
tap.test('IPReputationChecker - should cache reputation results', async () => {
// Create a fresh instance for this test
const testInstance = new IPReputationChecker({
enableIPInfo: false,
enableLocalCache: false,
maxCacheSize: 10 // Small cache for testing
});
// Check that first look performs a lookup and second uses cache
const ip = '192.168.1.10';
// First check should add to cache
const result1 = await testInstance.checkReputation(ip);
expect(result1).toBeTruthy();
// Manually verify it's in cache - access private member for testing
const hasInCache = (testInstance as any).reputationCache.has(ip);
expect(hasInCache).toEqual(true);
// Call again, should use cache
const result2 = await testInstance.checkReputation(ip);
expect(result2).toBeTruthy();
// Results should be identical
expect(result1.score).toEqual(result2.score);
});
// Test risk level classification
tap.test('IPReputationChecker - should classify risk levels correctly', async () => {
expect(IPReputationChecker.getRiskLevel(10)).toEqual('high');
expect(IPReputationChecker.getRiskLevel(30)).toEqual('medium');
expect(IPReputationChecker.getRiskLevel(60)).toEqual('low');
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
});
// Test IP type detection
tap.test('IPReputationChecker - should detect special IP types', async () => {
const testInstance = new IPReputationChecker({
enableDNSBL: false,
enableIPInfo: true,
enableLocalCache: false,
maxCacheSize: 5 // Small cache for testing
});
// Test Tor exit node detection
const torResult = await testInstance.checkReputation('171.25.1.1');
expect(torResult.isTor).toEqual(true);
expect(torResult.score < 90).toEqual(true);
// Test VPN detection
const vpnResult = await testInstance.checkReputation('185.156.1.1');
expect(vpnResult.isVPN).toEqual(true);
expect(vpnResult.score < 90).toEqual(true);
// Test proxy detection
const proxyResult = await testInstance.checkReputation('34.92.1.1');
expect(proxyResult.isProxy).toEqual(true);
expect(proxyResult.score < 90).toEqual(true);
});
// Test error handling
tap.test('IPReputationChecker - should handle DNS lookup errors gracefully', async () => {
// Setup mock implementation to simulate error
mockDnsResolveImpl = async () => {
throw new Error('DNS server error');
};
const checker = IPReputationChecker.getInstance({
dnsblServers: ['zen.spamhaus.org'],
enableIPInfo: false,
enableLocalCache: false,
maxCacheSize: 300 // Force new instance
});
// Should return a result despite errors
const result = await checker.checkReputation('192.168.1.1');
expect(result.score).toEqual(100); // No blacklist hits found due to error
expect(result.isSpam).toEqual(false);
});
// Restore original implementation at the end
tap.test('Cleanup - restore mocks', async () => {
plugins.dns.promises.resolve = originalDnsResolve;
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

131
test/test.jwt-auth.ts Normal file
View File

@@ -0,0 +1,131 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
// Minimal config for testing
cacheConfig: { enabled: false },
});
await testDcRouter.start();
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login with admin credentials and receive JWT', 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');
expect(response.identity).toHaveProperty('jwt');
expect(response.identity).toHaveProperty('userId');
expect(response.identity).toHaveProperty('name');
expect(response.identity).toHaveProperty('expiresAt');
expect(response.identity).toHaveProperty('role');
expect(response.identity.role).toEqual('admin');
identity = response.identity;
console.log('JWT:', identity.jwt);
});
tap.test('should verify valid JWT identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
const response = await verifyRequest.fire({
identity
});
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response).toHaveProperty('identity');
expect(response.identity.userId).toEqual(identity.userId);
});
tap.test('should reject invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
const response = await verifyRequest.fire({
identity: {
...identity,
jwt: 'invalid.jwt.token'
}
});
expect(response).toHaveProperty('valid');
expect(response.valid).toBeFalse();
});
tap.test('should verify JWT matches identity data', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
// The response should contain the same identity data as the JWT
const response = await verifyRequest.fire({
identity
});
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
expect(response.identity.userId).toEqual(identity.userId);
});
tap.test('should handle logout', async () => {
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
'http://localhost:3000/typedrequest',
'adminLogout'
);
const response = await logoutRequest.fire({
identity
});
expect(response).toHaveProperty('success');
expect(response.success).toBeTrue();
});
tap.test('should reject wrong credentials', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'adminLoginWithUsernameAndPassword'
);
let errorOccurred = false;
try {
await loginRequest.fire({
username: 'admin',
password: 'wrongpassword'
});
} catch (error) {
errorOccurred = true;
// TypedResponseError is thrown
expect(error).toBeTruthy();
}
expect(errorOccurred).toBeTrue();
});
tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();

123
test/test.opsserver-api.ts Normal file
View File

@@ -0,0 +1,123 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
// Minimal config for testing
cacheConfig: { enabled: false },
});
await testDcRouter.start();
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 () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3000/typedrequest',
'getHealthStatus'
);
const response = await healthRequest.fire({
identity: adminIdentity,
detailed: false,
});
expect(response).toHaveProperty('health');
expect(response.health.healthy).toBeTrue();
expect(response.health.services).toHaveProperty('OpsServer');
});
tap.test('should respond to server statistics request', async () => {
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
'http://localhost:3000/typedrequest',
'getServerStatistics'
);
const response = await statsRequest.fire({
identity: adminIdentity,
includeHistory: false,
});
expect(response).toHaveProperty('stats');
expect(response.stats).toHaveProperty('uptime');
expect(response.stats).toHaveProperty('cpuUsage');
expect(response.stats).toHaveProperty('memoryUsage');
});
tap.test('should respond to configuration request', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3000/typedrequest',
'getConfiguration'
);
const response = await configRequest.fire({
identity: adminIdentity,
});
expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy');
expect(response.config).toHaveProperty('email');
expect(response.config).toHaveProperty('dns');
expect(response.config).toHaveProperty('tls');
expect(response.config).toHaveProperty('cache');
expect(response.config).toHaveProperty('radius');
expect(response.config).toHaveProperty('remoteIngress');
});
tap.test('should handle log retrieval request', async () => {
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
'http://localhost:3000/typedrequest',
'getRecentLogs'
);
const response = await logsRequest.fire({
identity: adminIdentity,
limit: 10,
});
expect(response).toHaveProperty('logs');
expect(response).toHaveProperty('total');
expect(response).toHaveProperty('hasMore');
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 () => {
await testDcRouter.stop();
});
export default tap.start();

View File

@@ -0,0 +1,127 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
// Minimal config for testing
cacheConfig: { enabled: false },
});
await testDcRouter.start();
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;
console.log('Admin logged in with JWT');
});
tap.test('should allow admin to verify identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
const response = await verifyRequest.fire({
identity: adminIdentity,
});
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
console.log('Admin identity verified successfully');
});
tap.test('should reject verify identity without identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
try {
await verifyRequest.fire({} as any);
expect(true).toBeFalse(); // Should not reach here
} catch (error) {
expect(error).toBeTruthy();
console.log('Successfully rejected request without identity');
}
});
tap.test('should reject verify identity with invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
try {
await verifyRequest.fire({
identity: {
...adminIdentity,
jwt: 'invalid.jwt.token'
},
});
expect(true).toBeFalse(); // Should not reach here
} catch (error) {
expect(error).toBeTruthy();
console.log('Successfully rejected request with invalid JWT');
}
});
tap.test('should reject protected endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3000/typedrequest',
'getHealthStatus'
);
try {
// No identity provided — should be rejected
await healthRequest.fire({} as any);
expect(true).toBeFalse(); // Should not reach here
} catch (error) {
expect(error).toBeTruthy();
console.log('Protected endpoint correctly rejects unauthenticated request');
}
});
tap.test('should allow authenticated access to protected endpoints', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3000/typedrequest',
'getConfiguration'
);
const response = await configRequest.fire({
identity: adminIdentity,
});
expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy');
expect(response.config).toHaveProperty('email');
expect(response.config).toHaveProperty('dns');
expect(response.config).toHaveProperty('tls');
expect(response.config).toHaveProperty('cache');
expect(response.config).toHaveProperty('radius');
expect(response.config).toHaveProperty('remoteIngress');
console.log('Authenticated access to config successful');
});
tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();

289
test/test.storagemanager.ts Normal file
View File

@@ -0,0 +1,289 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
import { promises as fs } from 'fs';
import * as path from 'path';
// Test data
const testData = {
string: 'Hello, World!',
json: { name: 'test', value: 42, nested: { data: true } },
largeString: 'x'.repeat(10000)
};
tap.test('Storage Manager - Memory Backend', async () => {
// Create StorageManager without config (defaults to memory)
const storage = new StorageManager();
// Test basic get/set
await storage.set('/test/key', testData.string);
const value = await storage.get('/test/key');
expect(value).toEqual(testData.string);
// Test JSON helpers
await storage.setJSON('/test/json', testData.json);
const jsonValue = await storage.getJSON('/test/json');
expect(jsonValue).toEqual(testData.json);
// Test exists
expect(await storage.exists('/test/key')).toEqual(true);
expect(await storage.exists('/nonexistent')).toEqual(false);
// Test delete
await storage.delete('/test/key');
expect(await storage.exists('/test/key')).toEqual(false);
// Test list
await storage.set('/items/1', 'one');
await storage.set('/items/2', 'two');
await storage.set('/other/3', 'three');
const items = await storage.list('/items');
expect(items.length).toEqual(2);
expect(items).toContain('/items/1');
expect(items).toContain('/items/2');
// Verify memory backend
expect(storage.getBackend()).toEqual('memory');
});
tap.test('Storage Manager - Filesystem Backend', async () => {
const testDir = path.join(paths.dataDir, '.test-storage');
// Clean up test directory if it exists
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {}
// Create StorageManager with filesystem path
const storage = new StorageManager({ fsPath: testDir });
// Test basic operations
await storage.set('/test/file', testData.string);
const value = await storage.get('/test/file');
expect(value).toEqual(testData.string);
// Verify file exists on disk
const filePath = path.join(testDir, 'test', 'file');
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
expect(fileExists).toEqual(true);
// Test atomic writes (temp file should not exist)
const tempPath = filePath + '.tmp';
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
expect(tempExists).toEqual(false);
// Test nested paths
await storage.set('/deeply/nested/path/to/file', testData.largeString);
const nestedValue = await storage.get('/deeply/nested/path/to/file');
expect(nestedValue).toEqual(testData.largeString);
// Test list with filesystem
await storage.set('/fs/items/a', 'alpha');
await storage.set('/fs/items/b', 'beta');
await storage.set('/fs/other/c', 'gamma');
// Filesystem backend now properly supports list
const fsItems = await storage.list('/fs/items');
expect(fsItems.length).toEqual(2); // Should find both items
// Clean up
await fs.rm(testDir, { recursive: true, force: true });
});
tap.test('Storage Manager - Custom Function Backend', async () => {
// Create in-memory storage for custom functions
const customStore = new Map<string, string>();
const storage = new StorageManager({
readFunction: async (key: string) => {
return customStore.get(key) || null;
},
writeFunction: async (key: string, value: string) => {
customStore.set(key, value);
}
});
// Test basic operations
await storage.set('/custom/key', testData.string);
expect(customStore.has('/custom/key')).toEqual(true);
const value = await storage.get('/custom/key');
expect(value).toEqual(testData.string);
// Test that delete sets empty value (as per implementation)
await storage.delete('/custom/key');
expect(customStore.get('/custom/key')).toEqual('');
// Verify custom backend (filesystem is implemented as custom backend internally)
expect(storage.getBackend()).toEqual('custom');
});
tap.test('Storage Manager - Key Validation', async () => {
const storage = new StorageManager();
// Test key normalization
await storage.set('test/key', 'value1'); // Missing leading slash
const value1 = await storage.get('/test/key');
expect(value1).toEqual('value1');
// Test dangerous path elements are removed
await storage.set('/test/../danger/key', 'value2');
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
expect(value2).toEqual('value2');
// Test multiple slashes are normalized
await storage.set('/test///multiple////slashes', 'value3');
const value3 = await storage.get('/test/multiple/slashes');
expect(value3).toEqual('value3');
// Test invalid keys throw errors
let emptyKeyError: Error | null = null;
try {
await storage.set('', 'value');
} catch (error) {
emptyKeyError = error as Error;
}
expect(emptyKeyError).toBeTruthy();
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
let nullKeyError: Error | null = null;
try {
await storage.set(null as any, 'value');
} catch (error) {
nullKeyError = error as Error;
}
expect(nullKeyError).toBeTruthy();
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
});
tap.test('Storage Manager - Concurrent Access', async () => {
const storage = new StorageManager();
const promises: Promise<void>[] = [];
// Simulate concurrent writes
for (let i = 0; i < 100; i++) {
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
}
await Promise.all(promises);
// Verify all writes succeeded
for (let i = 0; i < 100; i++) {
const value = await storage.get(`/concurrent/key${i}`);
expect(value).toEqual(`value${i}`);
}
// Test concurrent reads
const readPromises: Promise<string | null>[] = [];
for (let i = 0; i < 100; i++) {
readPromises.push(storage.get(`/concurrent/key${i}`));
}
const results = await Promise.all(readPromises);
for (let i = 0; i < 100; i++) {
expect(results[i]).toEqual(`value${i}`);
}
});
tap.test('Storage Manager - Backend Priority', async () => {
const testDir = path.join(paths.dataDir, '.test-storage-priority');
// Test that custom functions take priority over fsPath
let warningLogged = false;
const originalWarn = console.warn;
console.warn = (message: string) => {
if (message.includes('Using custom read/write functions')) {
warningLogged = true;
}
};
const storage = new StorageManager({
fsPath: testDir,
readFunction: async () => 'custom-value',
writeFunction: async () => {}
});
console.warn = originalWarn;
expect(warningLogged).toEqual(true);
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
// Clean up
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {}
});
tap.test('Storage Manager - Error Handling', async () => {
// Test filesystem errors
const storage = new StorageManager({
readFunction: async () => {
throw new Error('Read error');
},
writeFunction: async () => {
throw new Error('Write error');
}
});
// Read errors should return null
const value = await storage.get('/error/key');
expect(value).toEqual(null);
// Write errors should propagate
let writeError: Error | null = null;
try {
await storage.set('/error/key', 'value');
} catch (error) {
writeError = error as Error;
}
expect(writeError).toBeTruthy();
expect(writeError?.message).toEqual('Write error');
// Test JSON parse errors
const jsonStorage = new StorageManager({
readFunction: async () => 'invalid json',
writeFunction: async () => {}
});
// Test JSON parse errors
let jsonError: Error | null = null;
try {
await jsonStorage.getJSON('/invalid/json');
} catch (error) {
jsonError = error as Error;
}
expect(jsonError).toBeTruthy();
expect(jsonError?.message).toContain('JSON');
});
tap.test('Storage Manager - List Operations', async () => {
const storage = new StorageManager();
// Populate storage with hierarchical data
await storage.set('/app/config/database', 'db-config');
await storage.set('/app/config/cache', 'cache-config');
await storage.set('/app/data/users/1', 'user1');
await storage.set('/app/data/users/2', 'user2');
await storage.set('/app/logs/error.log', 'errors');
// List root
const rootItems = await storage.list('/');
expect(rootItems.length).toBeGreaterThanOrEqual(5);
// List specific paths
const configItems = await storage.list('/app/config');
expect(configItems.length).toEqual(2);
expect(configItems).toContain('/app/config/database');
expect(configItems).toContain('/app/config/cache');
const userItems = await storage.list('/app/data/users');
expect(userItems.length).toEqual(2);
// List non-existent path
const emptyList = await storage.list('/nonexistent/path');
expect(emptyList.length).toEqual(0);
});
export default tap.start();

46
test_watch/devserver.ts Normal file
View File

@@ -0,0 +1,46 @@
import { DcRouter } from '../ts/index.js';
const devRouter = new DcRouter({
// SmartProxy routes for development/demo
smartProxyConfig: {
routes: [
{
name: 'web-traffic',
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
},
{
name: 'api-gateway',
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
},
{
name: 'tls-passthrough',
match: { ports: [18443], domains: ['secure.example.com'] },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 4443 }],
tls: { mode: 'passthrough' },
},
},
],
},
// Disable cache/mongo for dev
cacheConfig: { enabled: false },
});
console.log('Starting DcRouter in development mode...');
await devRouter.start();
// Graceful shutdown handlers
const shutdown = async () => {
console.log('\nShutting down...');
await devRouter.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
console.log('DcRouter dev server running. Press Ctrl+C to stop.');

View File

@@ -1,8 +1,8 @@
/**
* autocreated commitinfo by @pushrocks/commitinfo
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/platformservice',
version: '1.0.2',
description: 'contains the platformservice container with mail, sms, letter, ai services.'
name: '@serve.zone/dcrouter',
version: '11.0.4',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

166
ts/cache/classes.cache.cleaner.ts vendored Normal file
View File

@@ -0,0 +1,166 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { CacheDb } from './classes.cachedb.js';
// Import document classes for cleanup
import { CachedEmail } from './documents/classes.cached.email.js';
import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js';
/**
* Configuration for the cache cleaner
*/
export interface ICacheCleanerOptions {
/** Cleanup interval in milliseconds (default: 1 hour) */
intervalMs?: number;
/** Enable verbose logging */
verbose?: boolean;
}
/**
* CacheCleaner - Periodically removes expired documents from the cache
*
* Runs on a configurable interval (default: hourly) and queries each
* collection for documents where expiresAt < now(), then deletes them.
*/
export class CacheCleaner {
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
private isRunning: boolean = false;
private options: Required<ICacheCleanerOptions>;
private cacheDb: CacheDb;
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
this.cacheDb = cacheDb;
this.options = {
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
verbose: options.verbose || false,
};
}
/**
* Start the periodic cleanup process
*/
public start(): void {
if (this.isRunning) {
logger.log('warn', 'CacheCleaner already running');
return;
}
this.isRunning = true;
// Run cleanup immediately on start
this.runCleanup().catch((error) => {
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
});
// Schedule periodic cleanup
this.cleanupInterval = setInterval(() => {
this.runCleanup().catch((error) => {
logger.log('error', `Cache cleanup failed: ${error.message}`);
});
}, this.options.intervalMs);
logger.log(
'info',
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
);
}
/**
* Stop the periodic cleanup process
*/
public stop(): void {
if (!this.isRunning) {
return;
}
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.isRunning = false;
logger.log('info', 'CacheCleaner stopped');
}
/**
* Run a single cleanup cycle
*/
public async runCleanup(): Promise<void> {
if (!this.cacheDb.isReady()) {
logger.log('warn', 'CacheDb not ready, skipping cleanup');
return;
}
const now = new Date();
const results: { collection: string; deleted: number }[] = [];
try {
const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now);
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now);
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
// Log results
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
if (totalDeleted > 0 || this.options.verbose) {
const summary = results
.filter((r) => r.deleted > 0)
.map((r) => `${r.collection}: ${r.deleted}`)
.join(', ');
logger.log(
'info',
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
);
}
} catch (error) {
logger.log('error', `Cache cleanup error: ${error.message}`);
throw error;
}
}
/**
* Clean expired documents from a specific collection using smartdata API
*/
private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
documentClass: { getInstances: (filter: any) => Promise<T[]> },
now: Date
): Promise<number> {
try {
// Find all expired documents
const expiredDocs = await documentClass.getInstances({
expiresAt: { $lt: now },
});
// Delete each expired document
let deletedCount = 0;
for (const doc of expiredDocs) {
try {
await doc.delete();
deletedCount++;
} catch (deleteError) {
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
}
}
return deletedCount;
} catch (error) {
logger.log('error', `Error cleaning collection: ${error.message}`);
return 0;
}
}
/**
* Check if the cleaner is running
*/
public isActive(): boolean {
return this.isRunning;
}
/**
* Get the cleanup interval in milliseconds
*/
public getIntervalMs(): number {
return this.options.intervalMs;
}
}

111
ts/cache/classes.cached.document.ts vendored Normal file
View File

@@ -0,0 +1,111 @@
import * as plugins from '../plugins.js';
/**
* Base class for all cached documents with TTL support
*
* Extends smartdata's SmartDataDbDoc to add:
* - Automatic timestamps (createdAt, lastAccessedAt)
* - TTL/expiration support (expiresAt)
* - Helper methods for TTL management
*
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
* since decorators on abstract classes don't propagate correctly.
*/
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/**
* Timestamp when the document was created
* NOTE: Subclasses must add @svDb() decorator
*/
public createdAt: Date = new Date();
/**
* Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator
*/
public expiresAt: Date;
/**
* Timestamp of last access (for LRU-style eviction if needed)
* NOTE: Subclasses must add @svDb() decorator
*/
public lastAccessedAt: Date = new Date();
/**
* Set the TTL (time to live) for this document
* @param ttlMs Time to live in milliseconds
*/
public setTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set TTL using days
* @param days Number of days until expiration
*/
public setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/**
* Set TTL using hours
* @param hours Number of hours until expiration
*/
public setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/**
* Check if this document has expired
*/
public isExpired(): boolean {
if (!this.expiresAt) {
return false; // No expiration set
}
return new Date() > this.expiresAt;
}
/**
* Update the lastAccessedAt timestamp
*/
public touch(): void {
this.lastAccessedAt = new Date();
}
/**
* Get remaining TTL in milliseconds
* Returns 0 if expired, -1 if no expiration set
*/
public getRemainingTTL(): number {
if (!this.expiresAt) {
return -1;
}
const remaining = this.expiresAt.getTime() - Date.now();
return remaining > 0 ? remaining : 0;
}
/**
* Extend the TTL by the specified milliseconds from now
* @param ttlMs Additional time to live in milliseconds
*/
public extendTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set the document to never expire (100 years in the future)
*/
public setNeverExpires(): void {
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
}
}
/**
* TTL constants in milliseconds
*/
export const TTL = {
HOURS_1: 1 * 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;

155
ts/cache/classes.cachedb.ts vendored Normal file
View File

@@ -0,0 +1,155 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { defaultTsmDbPath } from '../paths.js';
/**
* Configuration options for CacheDb
*/
export interface ICacheDbOptions {
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
storagePath?: string;
/** Database name (default: dcrouter) */
dbName?: string;
/** Enable debug logging */
debug?: boolean;
}
/**
* CacheDb - Wrapper around LocalTsmDb and smartdata
*
* Provides persistent caching using smartdata as the ORM layer
* and LocalTsmDb as the embedded database engine.
*/
export class CacheDb {
private static instance: CacheDb | null = null;
private localTsmDb: plugins.smartmongo.LocalTsmDb;
private smartdataDb: plugins.smartdata.SmartdataDb;
private options: Required<ICacheDbOptions>;
private isStarted: boolean = false;
constructor(options: ICacheDbOptions = {}) {
this.options = {
storagePath: options.storagePath || defaultTsmDbPath,
dbName: options.dbName || 'dcrouter',
debug: options.debug || false,
};
}
/**
* Get or create the singleton instance
*/
public static getInstance(options?: ICacheDbOptions): CacheDb {
if (!CacheDb.instance) {
CacheDb.instance = new CacheDb(options);
}
return CacheDb.instance;
}
/**
* Reset the singleton instance (useful for testing)
*/
public static resetInstance(): void {
CacheDb.instance = null;
}
/**
* Start the cache database
* - Initializes LocalTsmDb with file persistence
* - Connects smartdata to the LocalTsmDb via Unix socket
*/
public async start(): Promise<void> {
if (this.isStarted) {
logger.log('warn', 'CacheDb already started');
return;
}
try {
// Ensure storage directory exists
await plugins.fsUtils.ensureDir(this.options.storagePath);
// Create LocalTsmDb instance
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
folderPath: this.options.storagePath,
});
// Start LocalTsmDb and get connection info
const connectionInfo = await this.localTsmDb.start();
if (this.options.debug) {
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
}
// Initialize smartdata with the connection URI
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionInfo.connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
this.isStarted = true;
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
} catch (error) {
logger.log('error', `Failed to start CacheDb: ${error.message}`);
throw error;
}
}
/**
* Stop the cache database
*/
public async stop(): Promise<void> {
if (!this.isStarted) {
return;
}
try {
// Close smartdata connection
if (this.smartdataDb) {
await this.smartdataDb.close();
}
// Stop LocalTsmDb
if (this.localTsmDb) {
await this.localTsmDb.stop();
}
this.isStarted = false;
logger.log('info', 'CacheDb stopped');
} catch (error) {
logger.log('error', `Error stopping CacheDb: ${error.message}`);
throw error;
}
}
/**
* Get the smartdata database instance
*/
public getDb(): plugins.smartdata.SmartdataDb {
if (!this.isStarted) {
throw new Error('CacheDb not started. Call start() first.');
}
return this.smartdataDb;
}
/**
* Check if the database is ready
*/
public isReady(): boolean {
return this.isStarted;
}
/**
* Get the storage path
*/
public getStoragePath(): string {
return this.options.storagePath;
}
/**
* Get the database name
*/
public getDbName(): string {
return this.options.dbName;
}
}

View File

@@ -0,0 +1,240 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { CacheDb } from '../classes.cachedb.js';
/**
* Email status in the cache
*/
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
/**
* Helper to get the smartdata database instance
*/
const getDb = () => CacheDb.getInstance().getDb();
/**
* CachedEmail - Stores email queue items in the cache
*
* Used for persistent email queue storage, tracking delivery status,
* and maintaining email history for the configured TTL period.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**
* Unique identifier for this email
*/
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id: string;
/**
* Email message ID (RFC 822 Message-ID header)
*/
@plugins.smartdata.svDb()
public messageId: string;
/**
* Sender email address (envelope from)
*/
@plugins.smartdata.svDb()
public from: string;
/**
* Recipient email addresses
*/
@plugins.smartdata.svDb()
public to: string[];
/**
* CC recipients
*/
@plugins.smartdata.svDb()
public cc: string[];
/**
* BCC recipients
*/
@plugins.smartdata.svDb()
public bcc: string[];
/**
* Email subject
*/
@plugins.smartdata.svDb()
public subject: string;
/**
* Raw RFC822 email content
*/
@plugins.smartdata.svDb()
public rawContent: string;
/**
* Current status of the email
*/
@plugins.smartdata.svDb()
public status: TCachedEmailStatus;
/**
* Number of delivery attempts
*/
@plugins.smartdata.svDb()
public attempts: number = 0;
/**
* Maximum number of delivery attempts
*/
@plugins.smartdata.svDb()
public maxAttempts: number = 3;
/**
* Timestamp for next delivery attempt
*/
@plugins.smartdata.svDb()
public nextAttempt: Date;
/**
* Last error message if delivery failed
*/
@plugins.smartdata.svDb()
public lastError: string;
/**
* Timestamp when the email was successfully delivered
*/
@plugins.smartdata.svDb()
public deliveredAt: Date;
/**
* Sender domain (for querying/filtering)
*/
@plugins.smartdata.svDb()
public senderDomain: string;
/**
* Priority level (higher = more important)
*/
@plugins.smartdata.svDb()
public priority: number = 0;
/**
* JSON-serialized route data
*/
@plugins.smartdata.svDb()
public routeData: string;
/**
* DKIM signature status
*/
@plugins.smartdata.svDb()
public dkimSigned: boolean = false;
constructor() {
super();
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
this.status = 'pending';
this.to = [];
this.cc = [];
this.bcc = [];
}
/**
* Create a new CachedEmail with a unique ID
*/
public static createNew(): CachedEmail {
const email = new CachedEmail();
email.id = plugins.uuid.v4();
return email;
}
/**
* Find an email by ID
*/
public static async findById(id: string): Promise<CachedEmail | null> {
return await CachedEmail.getInstance({
id,
});
}
/**
* Find all emails with a specific status
*/
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
return await CachedEmail.getInstances({
status,
});
}
/**
* Find all emails pending delivery (status = pending and nextAttempt <= now)
*/
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
const now = new Date();
return await CachedEmail.getInstances({
status: 'pending',
nextAttempt: { $lte: now },
});
}
/**
* Find emails by sender domain
*/
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
return await CachedEmail.getInstances({
senderDomain: domain,
});
}
/**
* Mark as delivered
*/
public markDelivered(): void {
this.status = 'delivered';
this.deliveredAt = new Date();
}
/**
* Mark as failed with error
*/
public markFailed(error: string): void {
this.status = 'failed';
this.lastError = error;
}
/**
* Increment attempt counter and schedule next attempt
*/
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
this.attempts++;
this.status = 'deferred';
this.nextAttempt = new Date(Date.now() + delayMs);
// If max attempts reached, mark as failed
if (this.attempts >= this.maxAttempts) {
this.status = 'failed';
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
}
}
/**
* Extract sender domain from email address
*/
public updateSenderDomain(): void {
if (this.from) {
const match = this.from.match(/@([^>]+)>?$/);
if (match) {
this.senderDomain = match[1].toLowerCase();
}
}
}
}

View File

@@ -0,0 +1,247 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { CacheDb } from '../classes.cachedb.js';
/**
* Helper to get the smartdata database instance
*/
const getDb = () => CacheDb.getInstance().getDb();
/**
* IP reputation result data
*/
export interface IIPReputationData {
score: number;
isSpam: boolean;
isProxy: boolean;
isTor: boolean;
isVPN: boolean;
country?: string;
asn?: string;
org?: string;
blacklists?: string[];
}
/**
* CachedIPReputation - Stores IP reputation lookup results
*
* Caches the results of IP reputation checks to avoid repeated
* external API calls. Default TTL is 24 hours.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**
* IP address (unique identifier)
*/
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public ipAddress: string;
/**
* Reputation score (0-100, higher = better)
*/
@plugins.smartdata.svDb()
public score: number;
/**
* Whether the IP is flagged as spam source
*/
@plugins.smartdata.svDb()
public isSpam: boolean;
/**
* Whether the IP is a known proxy
*/
@plugins.smartdata.svDb()
public isProxy: boolean;
/**
* Whether the IP is a Tor exit node
*/
@plugins.smartdata.svDb()
public isTor: boolean;
/**
* Whether the IP is a VPN endpoint
*/
@plugins.smartdata.svDb()
public isVPN: boolean;
/**
* Country code (ISO 3166-1 alpha-2)
*/
@plugins.smartdata.svDb()
public country: string;
/**
* Autonomous System Number
*/
@plugins.smartdata.svDb()
public asn: string;
/**
* Organization name
*/
@plugins.smartdata.svDb()
public org: string;
/**
* List of blacklists the IP appears on
*/
@plugins.smartdata.svDb()
public blacklists: string[];
/**
* Number of times this IP has been checked
*/
@plugins.smartdata.svDb()
public checkCount: number = 0;
/**
* Number of connections from this IP
*/
@plugins.smartdata.svDb()
public connectionCount: number = 0;
/**
* Number of emails received from this IP
*/
@plugins.smartdata.svDb()
public emailCount: number = 0;
/**
* Number of spam emails from this IP
*/
@plugins.smartdata.svDb()
public spamCount: number = 0;
constructor() {
super();
this.setTTL(TTL.HOURS_24); // Default 24-hour TTL
this.blacklists = [];
this.score = 50; // Default neutral score
this.isSpam = false;
this.isProxy = false;
this.isTor = false;
this.isVPN = false;
}
/**
* Create from reputation data
*/
public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation {
const cached = new CachedIPReputation();
cached.ipAddress = ipAddress;
cached.score = data.score;
cached.isSpam = data.isSpam;
cached.isProxy = data.isProxy;
cached.isTor = data.isTor;
cached.isVPN = data.isVPN;
cached.country = data.country || '';
cached.asn = data.asn || '';
cached.org = data.org || '';
cached.blacklists = data.blacklists || [];
cached.checkCount = 1;
return cached;
}
/**
* Convert to reputation data object
*/
public toReputationData(): IIPReputationData {
this.touch();
return {
score: this.score,
isSpam: this.isSpam,
isProxy: this.isProxy,
isTor: this.isTor,
isVPN: this.isVPN,
country: this.country,
asn: this.asn,
org: this.org,
blacklists: this.blacklists,
};
}
/**
* Find by IP address
*/
public static async findByIP(ipAddress: string): Promise<CachedIPReputation | null> {
return await CachedIPReputation.getInstance({
ipAddress,
});
}
/**
* Find all IPs flagged as spam
*/
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
return await CachedIPReputation.getInstances({
isSpam: true,
});
}
/**
* Find IPs with score below threshold
*/
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
return await CachedIPReputation.getInstances({
score: { $lt: threshold },
});
}
/**
* Record a connection from this IP
*/
public recordConnection(): void {
this.connectionCount++;
this.touch();
}
/**
* Record an email from this IP
*/
public recordEmail(isSpam: boolean = false): void {
this.emailCount++;
if (isSpam) {
this.spamCount++;
}
this.touch();
}
/**
* Update the reputation data
*/
public updateReputation(data: IIPReputationData): void {
this.score = data.score;
this.isSpam = data.isSpam;
this.isProxy = data.isProxy;
this.isTor = data.isTor;
this.isVPN = data.isVPN;
this.country = data.country || this.country;
this.asn = data.asn || this.asn;
this.org = data.org || this.org;
this.blacklists = data.blacklists || this.blacklists;
this.checkCount++;
this.touch();
// Refresh TTL on update
this.setTTL(TTL.HOURS_24);
}
/**
* Check if this IP should be blocked
*/
public shouldBlock(): boolean {
return this.isSpam || this.score < 20 || this.blacklists.length > 2;
}
}

2
ts/cache/documents/index.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js';

7
ts/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// Core cache infrastructure
export * from './classes.cachedb.js';
export * from './classes.cached.document.js';
export * from './classes.cache.cleaner.js';
// Document classes
export * from './documents/index.js';

View File

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

1824
ts/classes.dcrouter.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
import * as plugins from './platformservice.plugins.js';
import * as paths from './platformservice.paths.js';
import { PlatformServiceDb } from './classes.platformservicedb.js'
export class SzPlatformService {
public projectinfo: plugins.projectinfo.ProjectInfo;
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
public platformserviceDb: PlatformServiceDb;
public typedserver: plugins.typedserver.TypedServer;
public typedrouter = new plugins.typedrequest.TypedRouter();
public async start() {
this.platformserviceDb = new PlatformServiceDb(this);
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
this.typedserver = new plugins.typedserver.TypedServer({
cors: true,
});
await this.typedserver.start();
}
}

View File

@@ -1,27 +0,0 @@
import * as plugins from './platformservice.plugins.js';
import { SzPlatformService } from './classes.platformservice.js';
export class PlatformServiceDb {
public smartdataDb: plugins.smartdata.SmartdataDb;
public platformserviceRef: SzPlatformService;
constructor(platformserviceRefArg: SzPlatformService) {
this.platformserviceRef = platformserviceRefArg;
}
public async start() {
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUser: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
mongoDbName: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
mongoDbPass: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
mongoDbUrl: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
});
await this.smartdataDb.init();
}
public async stop() {
await this.smartdataDb.close();
}
}

View File

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

View File

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

View File

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

4
ts/config/index.ts Normal file
View File

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

266
ts/config/validator.ts Normal file
View File

@@ -0,0 +1,266 @@
import * as plugins from '../plugins.js';
import { ValidationError } from '../errors/base.errors.js';
/**
* Validation result
*/
export interface IValidationResult {
/**
* Whether the validation passed
*/
valid: boolean;
/**
* Validation errors if any
*/
errors?: string[];
/**
* Validated configuration (may include defaults)
*/
config?: any;
}
/**
* Validation schema types
*/
export type ValidationSchema = Record<string, {
/**
* Type of the value
*/
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
/**
* Whether the field is required
*/
required?: boolean;
/**
* Default value if not specified
*/
default?: any;
/**
* Minimum value (for numbers)
*/
min?: number;
/**
* Maximum value (for numbers)
*/
max?: number;
/**
* Minimum length (for strings or arrays)
*/
minLength?: number;
/**
* Maximum length (for strings or arrays)
*/
maxLength?: number;
/**
* Pattern to match (for strings)
*/
pattern?: RegExp;
/**
* Allowed values (for strings, numbers)
*/
enum?: any[];
/**
* Nested schema (for objects)
*/
schema?: ValidationSchema;
/**
* Item schema (for arrays)
*/
items?: {
type: 'string' | 'number' | 'boolean' | 'object';
schema?: ValidationSchema;
};
/**
* Custom validation function
*/
validate?: (value: any) => boolean | string;
}>;
/**
* Configuration validator
* Validates configuration objects against schemas and provides default values
*/
export class ConfigValidator {
/**
* Validate a configuration object against a schema
*
* @param config Configuration object to validate
* @param schema Validation schema
* @returns Validation result
*/
public static validate<T>(config: T, schema: ValidationSchema): IValidationResult {
const errors: string[] = [];
const validatedConfig = { ...config };
// Validate each field against the schema
for (const [key, rules] of Object.entries(schema)) {
const value = config[key];
// Check if required
if (rules.required && (value === undefined || value === null)) {
errors.push(`${key} is required`);
continue;
}
// If not present and not required, apply default if available
if ((value === undefined || value === null)) {
if (rules.default !== undefined) {
validatedConfig[key] = rules.default;
}
continue;
}
// Type validation
if (value !== undefined && value !== null) {
const valueType = Array.isArray(value) ? 'array' : typeof value;
if (valueType !== rules.type) {
errors.push(`${key} must be of type ${rules.type}, got ${valueType}`);
continue;
}
// Type-specific validations
switch (rules.type) {
case 'number':
if (rules.min !== undefined && value < rules.min) {
errors.push(`${key} must be at least ${rules.min}`);
}
if (rules.max !== undefined && value > rules.max) {
errors.push(`${key} must be at most ${rules.max}`);
}
break;
case 'string':
if (rules.minLength !== undefined && value.length < rules.minLength) {
errors.push(`${key} must be at least ${rules.minLength} characters`);
}
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
errors.push(`${key} must be at most ${rules.maxLength} characters`);
}
if (rules.pattern && !rules.pattern.test(value)) {
errors.push(`${key} must match pattern ${rules.pattern}`);
}
break;
case 'array':
if (rules.minLength !== undefined && value.length < rules.minLength) {
errors.push(`${key} must have at least ${rules.minLength} items`);
}
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
errors.push(`${key} must have at most ${rules.maxLength} items`);
}
if (rules.items && value.length > 0) {
for (let i = 0; i < value.length; i++) {
const itemType = Array.isArray(value[i]) ? 'array' : typeof value[i];
if (itemType !== rules.items.type) {
errors.push(`${key}[${i}] must be of type ${rules.items.type}, got ${itemType}`);
} else if (rules.items.schema && itemType === 'object') {
const itemResult = this.validate(value[i], rules.items.schema);
if (!itemResult.valid) {
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
}
}
}
}
break;
case 'object':
if (rules.schema) {
const nestedResult = this.validate(value, rules.schema);
if (!nestedResult.valid) {
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
}
validatedConfig[key] = nestedResult.config;
}
break;
}
// Enum validation
if (rules.enum && !rules.enum.includes(value)) {
errors.push(`${key} must be one of [${rules.enum.join(', ')}]`);
}
// Custom validation
if (rules.validate) {
const result = rules.validate(value);
if (result !== true) {
errors.push(typeof result === 'string' ? result : `${key} failed custom validation`);
}
}
}
}
return {
valid: errors.length === 0,
errors: errors.length > 0 ? errors : undefined,
config: validatedConfig
};
}
/**
* Apply defaults to a configuration object based on a schema
*
* @param config Configuration object to apply defaults to
* @param schema Validation schema with defaults
* @returns Configuration with defaults applied
*/
public static applyDefaults<T>(config: T, schema: ValidationSchema): T {
const result = { ...config };
for (const [key, rules] of Object.entries(schema)) {
if (result[key] === undefined && rules.default !== undefined) {
result[key] = rules.default;
}
// Apply defaults to nested objects
if (result[key] && rules.type === 'object' && rules.schema) {
result[key] = this.applyDefaults(result[key], rules.schema);
}
// Apply defaults to array items
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
result[key] = result[key].map(item =>
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
);
}
}
return result;
}
/**
* Throw a validation error if the configuration is invalid
*
* @param config Configuration to validate
* @param schema Validation schema
* @returns Validated configuration with defaults
* @throws ValidationError if validation fails
*/
public static validateOrThrow<T>(config: T, schema: ValidationSchema): T {
const result = this.validate(config, schema);
if (!result.valid) {
throw new ValidationError(
`Configuration validation failed: ${result.errors.join(', ')}`,
'CONFIG_VALIDATION_ERROR',
{ data: { errors: result.errors } }
);
}
return result.config;
}
}

View File

@@ -1,52 +0,0 @@
import * as plugins from './email.plugins.js';
import { Email } from './email.classes.email.js';
import { request } from 'http';
import { logger } from './email.logging.js';
export class ApiManager {
public emailRef: Email;
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(emailRefArg: Email) {
this.emailRef = emailRefArg;
this.emailRef.mainTypedRouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler<plugins.lointEmail.IRequestSendEmail>(
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
const mailToSend = new plugins.smartmail.Smartmail({
body: requestData.body,
from: requestData.from,
subject: requestData.title,
});
if (requestData.attachments) {
for (const attachment of requestData.attachments) {
mailToSend.addAttachment(
await plugins.smartfile.Smartfile.fromString(
attachment.name,
attachment.binaryAttachmentString,
'binary'
)
);
}
}
await this.emailRef.mailgunConnector.sendEmail(mailToSend, requestData.to, {});
logger.log(
'info',
`send an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
{
eventType: 'sentEmail',
email: {
to: requestData.to,
subject: mailToSend.getSubject(),
},
}
);
return {
responseId: 'abc', // TODO: generate proper response id
};
})
);
}
}

View File

@@ -1,30 +0,0 @@
import * as plugins from './email.plugins.js';
import { Email } from './email.classes.email.js';
export class MailgunConnector {
public emailRef: Email;
public mailgunAccount: plugins.mailgun.MailgunAccount;
constructor(emailRefArg: Email) {
this.emailRef = emailRefArg;
this.mailgunAccount = new plugins.mailgun.MailgunAccount({
apiToken: this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_API_TOKEN'),
region: 'eu',
});
this.mailgunAccount.addSmtpCredentials(
this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_SMTP_CREDENTIALS')
);
}
public async sendEmail(
smartMailArg: plugins.smartmail.Smartmail<any>,
toArg: string,
dataArg: any = {}
) {
this.mailgunAccount.sendSmartMail(smartMailArg, toArg, dataArg);
}
public async receiveEmail(messageUrl: string) {
return await this.mailgunAccount.retrieveSmartMailFromMessageUrl(messageUrl);
}
}

View File

@@ -1,47 +0,0 @@
import * as plugins from './email.plugins.js';
import * as paths from './email.paths.js';
import { MailgunConnector } from './email.classes.connector.mailgun.js';
import { RuleManager } from './email.classes.rulemanager.js';
import { ApiManager } from './email.classes.apimanager.js';
import { logger } from './email.logging.js';
import type { SzPlatformService } from '../classes.platformservice.js';
export class Email {
public platformServiceRef: SzPlatformService;
// typedrouter
public mainTypedRouter = new plugins.typedrequest.TypedRouter();
// connectors
public mailgunConnector: MailgunConnector;
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
// server
public apiManager = new ApiManager(this);
public ruleManager: RuleManager;
constructor(platformServiceRefArg: SzPlatformService) {
this.platformServiceRef = platformServiceRefArg;
this.mailgunConnector = new MailgunConnector(this);
this.ruleManager = new RuleManager(this);
this.platformServiceRef.typedserver.server.addRoute(
'/mailgun-notify',
new plugins.loleServiceserver.Handler('POST', async (req, res) => {
console.log('Got a mailgun email notification');
res.status(200);
res.end();
this.ruleManager.handleNotification(req.body);
})
);
}
public async start() {
await this.ruleManager.init();
logger.log('success', `Started email service`);
}
public async stop() {
}
}

View File

@@ -1,137 +0,0 @@
import * as plugins from './email.plugins.js';
import { Email } from './email.classes.email.js';
import { logger } from './email.logging.js';
export class RuleManager {
public emailRef: Email;
public smartruleInstance = new plugins.smartrule.SmartRule<
plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>
>();
constructor(emailRefArg: Email) {
this.emailRef = emailRefArg;
}
public async handleNotification(notification: plugins.mailgun.IMailgunNotification) {
console.log(notification['message-url']);
// basic checks here
// none for now
const fetchedSmartmail = await this.emailRef.mailgunConnector.receiveEmail(
notification['message-url']
);
console.log('=======================');
console.log('Received a mail:');
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
logger.log(
'info',
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
{
eventType: 'receivedEmail',
email: {
from: fetchedSmartmail.options.creationObjectRef.From,
to: fetchedSmartmail.options.creationObjectRef.To,
subject: fetchedSmartmail.options.creationObjectRef.Subject,
},
}
);
this.smartruleInstance.makeDecision(fetchedSmartmail);
}
public async init() {
// lets forward stuff
await this.createForwards();
}
/**
* creates the default forwards
*/
public async createForwards() {
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [
{
originalToAddress: ['bot@mail.nevermind.group'],
forwardedToAddress: ['phil@metadata.company', 'dominik@metadata.company'],
},
{
originalToAddress: ['legal@mail.lossless.com'],
forwardedToAddress: ['phil@lossless.com'],
},
{
originalToAddress: ['christine.nyamwaro@mail.lossless.com', 'christine@nyamwaro.com'],
forwardedToAddress: ['phil@lossless.com'],
},
];
console.log(`${forwards.length} forward rules configured:`);
for (const forward of forwards) {
console.log(forward);
}
for (const forward of forwards) {
this.smartruleInstance.createRule(
10,
async (smartmailArg) => {
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
}, false);
if (matched) {
console.log('Forward rule matched');
console.log(forward);
return 'apply-continue';
} else {
return 'continue';
}
},
async (smartmailArg: plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>) => {
forward.forwardedToAddress.map(async (toArg) => {
const forwardedSmartMail = new plugins.smartmail.Smartmail({
body:
`
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
<div><b>Original Sender:</b></div>
<div>${smartmailArg.options.creationObjectRef.From}</div>
<div><b>Original Recipient:</b></div>
<div>${smartmailArg.options.creationObjectRef.To}</div>
<div><b>Forwarded to:</b></div>
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
return `${pVal ? pVal + ', ' : ''}${cVal}`;
}, null)}</div>
<div><b>Subject:</b></div>
<div>${smartmailArg.getSubject()}</div>
<div><b>The original body can be found below.</b></div>
</div>
` + smartmailArg.getBody(),
from: 'forwarder@mail.lossless.one',
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
});
for (const attachment of smartmailArg.attachments) {
forwardedSmartMail.addAttachment(attachment);
}
await this.emailRef.mailgunConnector.sendEmail(forwardedSmartMail, toArg);
console.log(`forwarded mail to ${toArg}`);
logger.log(
'info',
`email from ${
smartmailArg.options.creationObjectRef.From
} to phil@lossless.com with subject '${smartmailArg.getSubject()}'`,
{
eventType: 'forwardedEmail',
email: {
from: smartmailArg.options.creationObjectRef.From,
to: smartmailArg.options.creationObjectRef.To,
forwardedTo: toArg,
subject: smartmailArg.options.creationObjectRef.Subject,
},
}
);
});
}
);
}
}
}

View File

@@ -1,13 +0,0 @@
import * as plugins from './email.plugins.js';
export class TemplateManager {
public smartmailDefault = new plugins.smartmail.Smartmail({
body: `
`,
from: `noreply@mail.lossless.com`,
subject: `{{subject}}`,
});
public createSmartmailFromData(tempalteTypeArg: plugins.lointEmail.TTemplates) {}
}

View File

@@ -1,13 +0,0 @@
import * as plugins from './email.plugins.js';
import * as paths from './email.paths.js';
const projectInfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
export const logger = plugins.loleLog.createLoleLogger({
companyUnit: 'lossless.cloud',
containerName: 'email',
containerVersion: projectInfoNpm.version,
sentryAppName: 'email',
sentryDsn: 'https://7037e86f36134ced85ae56a57daa1e5e@o169278.ingest.sentry.io/5280282',
zone: 'servezone',
});

View File

@@ -1,6 +0,0 @@
import * as plugins from './email.plugins.js';
export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
);

View File

@@ -1,43 +0,0 @@
// native scope
import * as path from 'path';
export { path };
// @losslessone_private scope
import * as loleLog from '@losslessone_private/lole-log';
import * as loleServiceserver from '@losslessone_private/lole-serviceserver';
import * as lointEmail from '@losslessone_private/loint-email';
export { loleLog, loleServiceserver, lointEmail };
// @apiglobal scope
import * as typedrequest from '@apiglobal/typedrequest';
export { typedrequest };
// @mojoio scope
import * as mailgun from '@mojoio/mailgun';
export { mailgun };
// @pushrocks scope
import * as projectinfo from '@pushrocks/projectinfo';
import * as qenv from '@pushrocks/qenv';
import * as smartfile from '@pushrocks/smartfile';
import * as smartmail from '@pushrocks/smartmail';
import * as smartpath from '@pushrocks/smartpath';
import * as smartrequest from '@pushrocks/smartrequest';
import * as smartrule from '@pushrocks/smartrule';
import * as smartvalidator from '@pushrocks/smartvalidator';
export {
projectinfo,
qenv,
smartfile,
smartmail,
smartpath,
smartrequest,
smartrule,
smartvalidator,
};

View File

@@ -1,3 +0,0 @@
import { Email } from './email.classes.email.js';
export { Email };

525
ts/errors/base.errors.ts Normal file
View File

@@ -0,0 +1,525 @@
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
import { logger } from '../logger.js';
// Import TLogLevel from plugins
import type { TLogLevel } from '../plugins.js';
/**
* Context information added to structured errors
*/
export interface IErrorContext {
/** Component or service where the error occurred */
component?: string;
/** Operation that was being performed */
operation?: string;
/** Unique request ID if available */
requestId?: string;
/** Error occurred at timestamp */
timestamp?: number;
/** User-visible message (safe to display to end-users) */
userMessage?: string;
/** Additional structured data for debugging */
data?: Record<string, any>;
/** Related entity IDs if applicable */
entity?: {
type: string;
id: string | number;
};
/** Stack trace (if enabled in configuration) */
stack?: string;
/** Retry information if applicable */
retry?: {
/** Maximum number of retries allowed */
maxRetries?: number;
/** Current retry count */
currentRetry?: number;
/** Next retry timestamp */
nextRetryAt?: number;
/** Delay between retries (in ms) */
retryDelay?: number;
};
}
/**
* Base class for all errors in the Platform Service
* Adds structured error information, logging, and error tracking
*/
export class PlatformError extends Error {
/** Error code identifying the specific error type */
public readonly code: string;
/** Error severity level */
public readonly severity: ErrorSeverity;
/** Error category for grouping related errors */
public readonly category: ErrorCategory;
/** Whether the error can be recovered from automatically */
public readonly recoverability: ErrorRecoverability;
/** Additional context information */
public readonly context: IErrorContext;
/**
* Creates a new PlatformError
*
* @param message Error message
* @param code Error code from error.codes.ts
* @param severity Error severity level
* @param category Error category
* @param recoverability Error recoverability indication
* @param context Additional context information
*/
constructor(
message: string,
code: string,
severity: ErrorSeverity = ErrorSeverity.MEDIUM,
category: ErrorCategory = ErrorCategory.OTHER,
recoverability: ErrorRecoverability = ErrorRecoverability.NON_RECOVERABLE,
context: IErrorContext = {}
) {
super(message);
// Set error metadata
this.name = this.constructor.name;
this.code = code;
this.severity = severity;
this.category = category;
this.recoverability = recoverability;
// Add timestamp if not provided
this.context = {
...context,
timestamp: context.timestamp || Date.now(),
};
// Capture stack trace
Error.captureStackTrace(this, this.constructor);
// Log the error automatically unless explicitly disabled
if (!context.data?.skipLogging) {
this.logError();
}
}
/**
* Logs the error using the platform logger
*/
private logError(): void {
const logLevel = this.getLogLevelFromSeverity() as TLogLevel;
// Construct structured log entry
const logData = {
error_code: this.code,
error_name: this.name,
severity: this.severity,
category: this.category,
recoverability: this.recoverability,
...this.context
};
// Log with appropriate level
logger.log(logLevel, this.message, logData);
}
/**
* Maps severity levels to log levels
*/
private getLogLevelFromSeverity(): string {
switch (this.severity) {
case ErrorSeverity.CRITICAL:
case ErrorSeverity.HIGH:
return 'error';
case ErrorSeverity.MEDIUM:
return 'warn';
case ErrorSeverity.LOW:
return 'info';
case ErrorSeverity.INFO:
return 'debug';
default:
return 'error';
}
}
/**
* Returns a JSON representation of the error
*/
public toJSON(): Record<string, any> {
return {
name: this.name,
message: this.message,
code: this.code,
severity: this.severity,
category: this.category,
recoverability: this.recoverability,
context: this.context,
stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined
};
}
/**
* Creates an instance with retry information
*
* @param maxRetries Maximum number of retries
* @param currentRetry Current retry count
* @param retryDelay Delay between retries in ms
*/
public withRetry(
maxRetries: number,
currentRetry: number = 0,
retryDelay: number = 1000
): PlatformError {
const nextRetryAt = Date.now() + retryDelay;
// Clone the error with updated context
const newContext = {
...this.context,
retry: {
maxRetries,
currentRetry,
nextRetryAt,
retryDelay
}
};
// Create a new instance using the protected method that subclasses can override
const newError = this.createWithContext(newContext);
// Update recoverability if we can retry
if (currentRetry < maxRetries && newError.recoverability === ErrorRecoverability.NON_RECOVERABLE) {
(newError as any).recoverability = ErrorRecoverability.MAYBE_RECOVERABLE;
}
return newError;
}
/**
* Protected method to create a new instance with updated context
* Subclasses can override this to handle their own constructor signatures
*/
protected createWithContext(context: IErrorContext): PlatformError {
// Default implementation for PlatformError
return new (this.constructor as typeof PlatformError)(
this.message,
this.code,
this.severity,
this.category,
this.recoverability,
context
);
}
/**
* Checks if the error should be retried based on retry information
*/
public shouldRetry(): boolean {
const { retry } = this.context;
if (!retry) return false;
return retry.currentRetry < retry.maxRetries;
}
/**
* Returns a user-friendly message that is safe to display to end users
*/
public getUserMessage(): string {
return this.context.userMessage || 'An unexpected error occurred.';
}
}
/**
* Error class for validation errors
*/
export class ValidationError extends PlatformError {
/**
* Creates a new validation error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(
message,
code,
ErrorSeverity.LOW,
ErrorCategory.VALIDATION,
ErrorRecoverability.NON_RECOVERABLE,
context
);
}
/**
* Creates a new instance with updated context
* Overrides the base implementation to handle ValidationError's constructor signature
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof ValidationError)(
this.message,
this.code,
context
);
}
}
/**
* Error class for configuration errors
*/
export class ConfigurationError extends PlatformError {
/**
* Creates a new configuration error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(
message,
code,
ErrorSeverity.MEDIUM,
ErrorCategory.CONFIGURATION,
ErrorRecoverability.NON_RECOVERABLE,
context
);
}
/**
* Creates a new instance with updated context
* Overrides the base implementation to handle ConfigurationError's constructor signature
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof ConfigurationError)(
this.message,
this.code,
context
);
}
}
/**
* Error class for network-related errors
*/
export class NetworkError extends PlatformError {
/**
* Creates a new network error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(
message,
code,
ErrorSeverity.MEDIUM,
ErrorCategory.CONNECTIVITY,
ErrorRecoverability.MAYBE_RECOVERABLE,
context
);
}
/**
* Creates a new instance with updated context
* Overrides the base implementation to handle NetworkError's constructor signature
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof NetworkError)(
this.message,
this.code,
context
);
}
}
/**
* Error class for resource availability errors (rate limits, quotas)
*/
export class ResourceError extends PlatformError {
/**
* Creates a new resource error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(
message,
code,
ErrorSeverity.MEDIUM,
ErrorCategory.RESOURCE,
ErrorRecoverability.MAYBE_RECOVERABLE,
context
);
}
/**
* Creates a new instance with updated context
* Overrides the base implementation to handle ResourceError's constructor signature
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof ResourceError)(
this.message,
this.code,
context
);
}
}
/**
* Error class for authentication/authorization errors
*/
export class AuthenticationError extends PlatformError {
/**
* Creates a new authentication error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(
message,
code,
ErrorSeverity.HIGH,
ErrorCategory.AUTHENTICATION,
ErrorRecoverability.NON_RECOVERABLE,
context
);
}
/**
* Creates a new instance with updated context
* Overrides the base implementation to handle AuthenticationError's constructor signature
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof AuthenticationError)(
this.message,
this.code,
context
);
}
}
/**
* Error class for operation errors (API calls, processing)
*/
export class OperationError extends PlatformError {
/**
* Creates a new operation error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(
message,
code,
ErrorSeverity.MEDIUM,
ErrorCategory.OPERATION,
ErrorRecoverability.MAYBE_RECOVERABLE,
context
);
}
/**
* Creates a new instance with updated context
* Overrides the base implementation to handle OperationError's constructor signature
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof OperationError)(
this.message,
this.code,
context
);
}
}
/**
* Error class for critical system errors
*/
export class SystemError extends PlatformError {
/**
* Creates a new system error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(
message,
code,
ErrorSeverity.CRITICAL,
ErrorCategory.OTHER,
ErrorRecoverability.NON_RECOVERABLE,
context
);
}
}
/**
* Helper to get the appropriate error class based on error category
*
* @param category Error category
* @returns The appropriate error class
*/
export function getErrorClassForCategory(category: ErrorCategory): any {
switch (category) {
case ErrorCategory.VALIDATION:
return ValidationError;
case ErrorCategory.CONFIGURATION:
return ConfigurationError;
case ErrorCategory.CONNECTIVITY:
return NetworkError;
case ErrorCategory.RESOURCE:
return ResourceError;
case ErrorCategory.AUTHENTICATION:
return AuthenticationError;
case ErrorCategory.OPERATION:
return OperationError;
default:
return PlatformError;
}
}

412
ts/errors/error-handler.ts Normal file
View File

@@ -0,0 +1,412 @@
import { PlatformError } from './base.errors.js';
import type { IErrorContext } from './base.errors.js';
import { ErrorCategory, ErrorRecoverability, ErrorSeverity } from './error.codes.js';
import { logger } from '../logger.js';
/**
* Error handler configuration
*/
export interface IErrorHandlerConfig {
/** Whether to log errors automatically */
logErrors: boolean;
/** Whether to include stack traces in prod environment */
includeStacksInProd: boolean;
/** Default retry options */
retry: {
/** Maximum retry attempts */
maxAttempts: number;
/** Base delay between retries in ms */
baseDelay: number;
/** Maximum delay between retries in ms */
maxDelay: number;
/** Backoff factor for exponential backoff */
backoffFactor: number;
};
}
/**
* Global error handler configuration
*/
const config: IErrorHandlerConfig = {
logErrors: true,
includeStacksInProd: false,
retry: {
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 30000,
backoffFactor: 2
}
};
/**
* Error handler utility
* Provides methods for consistent error handling across the platform
*/
export class ErrorHandler {
/**
* Current configuration
*/
public static config = config;
/**
* Update error handler configuration
*
* @param newConfig New configuration (partial)
*/
public static configure(newConfig: Partial<IErrorHandlerConfig>): void {
ErrorHandler.config = {
...ErrorHandler.config,
...newConfig,
retry: {
...ErrorHandler.config.retry,
...(newConfig.retry || {})
}
};
}
/**
* Convert any error to a PlatformError
*
* @param error Error to convert
* @param defaultCode Default error code if not a PlatformError
* @param context Additional context
* @returns PlatformError instance
*/
public static toPlatformError(
error: any,
defaultCode: string,
context: IErrorContext = {}
): PlatformError {
// If already a PlatformError, just add context
if (error instanceof PlatformError) {
// Add context if provided
if (Object.keys(context).length > 0) {
return new (error.constructor as typeof PlatformError)(
error.message,
error.code,
error.severity,
error.category,
error.recoverability,
{
...error.context,
...context,
data: {
...(error.context.data || {}),
...(context.data || {})
}
}
);
}
return error;
}
// Convert standard Error to PlatformError
if (error instanceof Error) {
return new PlatformError(
error.message,
defaultCode,
ErrorSeverity.MEDIUM,
ErrorCategory.OPERATION,
ErrorRecoverability.NON_RECOVERABLE,
{
...context,
data: {
...(context.data || {}),
originalError: {
name: error.name,
message: error.message,
stack: error.stack
}
}
}
);
}
// Not an Error instance
return new PlatformError(
typeof error === 'string' ? error : 'Unknown error',
defaultCode,
ErrorSeverity.MEDIUM,
ErrorCategory.OPERATION,
ErrorRecoverability.NON_RECOVERABLE,
context
);
}
/**
* Format an error for API responses
* Sanitizes errors for safe external exposure
*
* @param error Error to format
* @param includeDetails Whether to include detailed information
* @returns Formatted error object
*/
public static formatErrorForResponse(
error: any,
includeDetails: boolean = false
): Record<string, any> {
const platformError = ErrorHandler.toPlatformError(
error,
'PLATFORM_OPERATION_ERROR'
);
// Basic error information
const responseError: Record<string, any> = {
code: platformError.code,
message: platformError.getUserMessage(),
requestId: platformError.context.requestId
};
// Include more details if requested
if (includeDetails) {
responseError.details = {
severity: platformError.severity,
category: platformError.category,
rawMessage: platformError.message,
data: platformError.context.data
};
// Only include stack trace in non-production or if explicitly enabled
if (process.env.NODE_ENV !== 'production' || ErrorHandler.config.includeStacksInProd) {
responseError.details.stack = platformError.stack;
}
}
return responseError;
}
/**
* Handle an error with consistent logging and formatting
*
* @param error Error to handle
* @param defaultCode Default error code if not a PlatformError
* @param context Additional context
* @returns Formatted error for response
*/
public static handleError(
error: any,
defaultCode: string,
context: IErrorContext = {}
): Record<string, any> {
const platformError = ErrorHandler.toPlatformError(
error,
defaultCode,
context
);
// Log the error if enabled
if (ErrorHandler.config.logErrors) {
logger.error(platformError.message, {
error_code: platformError.code,
error_name: platformError.name,
error_severity: platformError.severity,
error_category: platformError.category,
error_recoverability: platformError.recoverability,
...platformError.context,
stack: platformError.stack
});
}
// Return formatted error for response
const isDetailedMode = process.env.NODE_ENV !== 'production';
return ErrorHandler.formatErrorForResponse(platformError, isDetailedMode);
}
/**
* Execute a function with error handling
*
* @param fn Function to execute
* @param defaultCode Default error code if the function throws
* @param context Additional context
* @returns Function result or error
*/
public static async execute<T>(
fn: () => Promise<T>,
defaultCode: string,
context: IErrorContext = {}
): Promise<T> {
try {
return await fn();
} catch (error) {
throw ErrorHandler.toPlatformError(error, defaultCode, context);
}
}
/**
* Execute a function with retries and exponential backoff
*
* @param fn Function to execute
* @param defaultCode Default error code if the function throws
* @param options Retry options
* @param context Additional context
* @returns Function result or error after max retries
*/
public static async executeWithRetry<T>(
fn: () => Promise<T>,
defaultCode: string,
options: {
maxAttempts?: number;
baseDelay?: number;
maxDelay?: number;
backoffFactor?: number;
retryableErrorCodes?: string[];
retryableErrorPatterns?: RegExp[];
onRetry?: (error: PlatformError, attempt: number, delay: number) => void;
} = {},
context: IErrorContext = {}
): Promise<T> {
const {
maxAttempts = ErrorHandler.config.retry.maxAttempts,
baseDelay = ErrorHandler.config.retry.baseDelay,
maxDelay = ErrorHandler.config.retry.maxDelay,
backoffFactor = ErrorHandler.config.retry.backoffFactor,
retryableErrorCodes = [],
retryableErrorPatterns = [],
onRetry = () => {}
} = options;
let lastError: PlatformError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
// Convert to PlatformError
const platformError = ErrorHandler.toPlatformError(
error,
defaultCode,
{
...context,
retry: {
currentRetry: attempt,
maxRetries: maxAttempts,
nextRetryAt: 0 // Will be set below if retrying
}
}
);
lastError = platformError;
// Check if we should retry
const isLastAttempt = attempt >= maxAttempts - 1;
if (isLastAttempt) {
// No more retries
throw platformError;
}
// Check if error is retryable
const isRetryable =
// Built-in recoverability
platformError.recoverability === ErrorRecoverability.RECOVERABLE ||
platformError.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
platformError.recoverability === ErrorRecoverability.TRANSIENT ||
// Specifically included error codes
retryableErrorCodes.includes(platformError.code) ||
// Matches error message patterns
retryableErrorPatterns.some(pattern => pattern.test(platformError.message));
if (!isRetryable) {
throw platformError;
}
// Calculate delay with exponential backoff
const delay = Math.min(baseDelay * Math.pow(backoffFactor, attempt), maxDelay);
// Add jitter to prevent thundering herd problem (±20%)
const jitter = 0.8 + Math.random() * 0.4;
const actualDelay = Math.floor(delay * jitter);
// Update nextRetryAt in error context
const nextRetryAt = Date.now() + actualDelay;
platformError.context.retry!.nextRetryAt = nextRetryAt;
// Log retry attempt
logger.warn(`Retrying operation after error (attempt ${attempt + 1}/${maxAttempts}): ${platformError.message}`, {
error_code: platformError.code,
retry_attempt: attempt + 1,
retry_max_attempts: maxAttempts,
retry_delay_ms: actualDelay,
retry_next_at: new Date(nextRetryAt).toISOString()
});
// Call onRetry callback
onRetry(platformError, attempt + 1, actualDelay);
// Wait before next retry
await new Promise(resolve => setTimeout(resolve, actualDelay));
}
}
// This should never happen, but TypeScript needs it
throw lastError!;
}
}
/**
* Create a middleware for handling errors in HTTP requests
*
* @returns Middleware function
*/
export function createErrorHandlerMiddleware() {
return (error: any, req: any, res: any, next: any) => {
// Add request context
const context: IErrorContext = {
requestId: req.headers['x-request-id'] || req.headers['x-correlation-id'],
component: 'HttpServer',
operation: `${req.method} ${req.url}`,
data: {
method: req.method,
url: req.url,
query: req.query,
params: req.params,
ip: req.ip || req.connection.remoteAddress,
userAgent: req.headers['user-agent']
}
};
// Handle the error
const formattedError = ErrorHandler.handleError(
error,
'PLATFORM_OPERATION_ERROR',
context
);
// Set status code based on error type
let statusCode = 500;
if (error instanceof PlatformError) {
// Map error categories to HTTP status codes
switch (error.category) {
case ErrorCategory.VALIDATION:
statusCode = 400;
break;
case ErrorCategory.AUTHENTICATION:
statusCode = 401;
break;
case ErrorCategory.RESOURCE:
statusCode = 429;
break;
case ErrorCategory.OPERATION:
statusCode = 400;
break;
default:
statusCode = 500;
}
} else if (error.statusCode) {
// Use provided status code if available
statusCode = error.statusCode;
}
// Send error response
res.status(statusCode).json({
success: false,
error: formattedError
});
};
}

165
ts/errors/error.codes.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Platform Service Error Codes
*
* This file contains all error codes used across the platform service.
*
* Format: PREFIX_ERROR_TYPE
* - PREFIX: Component/domain prefix (e.g., EMAIL, MTA, SMS)
* - ERROR_TYPE: Specific error type within the domain
*/
// General platform errors (PLATFORM_*)
export const PLATFORM_INITIALIZATION_ERROR = 'PLATFORM_INITIALIZATION_ERROR';
export const PLATFORM_CONFIGURATION_ERROR = 'PLATFORM_CONFIGURATION_ERROR';
export const PLATFORM_OPERATION_ERROR = 'PLATFORM_OPERATION_ERROR';
export const PLATFORM_NOT_IMPLEMENTED = 'PLATFORM_NOT_IMPLEMENTED';
export const PLATFORM_NOT_SUPPORTED = 'PLATFORM_NOT_SUPPORTED';
export const PLATFORM_SERVICE_UNAVAILABLE = 'PLATFORM_SERVICE_UNAVAILABLE';
// Email service errors (EMAIL_*)
export const EMAIL_SERVICE_ERROR = 'EMAIL_SERVICE_ERROR';
export const EMAIL_TEMPLATE_ERROR = 'EMAIL_TEMPLATE_ERROR';
export const EMAIL_VALIDATION_ERROR = 'EMAIL_VALIDATION_ERROR';
export const EMAIL_SEND_ERROR = 'EMAIL_SEND_ERROR';
export const EMAIL_RECEIVE_ERROR = 'EMAIL_RECEIVE_ERROR';
export const EMAIL_ATTACHMENT_ERROR = 'EMAIL_ATTACHMENT_ERROR';
export const EMAIL_PARSE_ERROR = 'EMAIL_PARSE_ERROR';
export const EMAIL_RATE_LIMIT_EXCEEDED = 'EMAIL_RATE_LIMIT_EXCEEDED';
// MTA-specific errors (MTA_*)
export const MTA_CONNECTION_ERROR = 'MTA_CONNECTION_ERROR';
export const MTA_AUTHENTICATION_ERROR = 'MTA_AUTHENTICATION_ERROR';
export const MTA_DELIVERY_ERROR = 'MTA_DELIVERY_ERROR';
export const MTA_CONFIGURATION_ERROR = 'MTA_CONFIGURATION_ERROR';
export const MTA_DNS_ERROR = 'MTA_DNS_ERROR';
export const MTA_TIMEOUT_ERROR = 'MTA_TIMEOUT_ERROR';
export const MTA_PROTOCOL_ERROR = 'MTA_PROTOCOL_ERROR';
// Bounce management errors (BOUNCE_*)
export const BOUNCE_PROCESSING_ERROR = 'BOUNCE_PROCESSING_ERROR';
export const BOUNCE_STORAGE_ERROR = 'BOUNCE_STORAGE_ERROR';
export const BOUNCE_CLASSIFICATION_ERROR = 'BOUNCE_CLASSIFICATION_ERROR';
// Email authentication errors (AUTH_*)
export const AUTH_SPF_ERROR = 'AUTH_SPF_ERROR';
export const AUTH_DKIM_ERROR = 'AUTH_DKIM_ERROR';
export const AUTH_DMARC_ERROR = 'AUTH_DMARC_ERROR';
export const AUTH_KEY_ERROR = 'AUTH_KEY_ERROR';
// Content scanning errors (SCAN_*)
export const SCAN_ANALYSIS_ERROR = 'SCAN_ANALYSIS_ERROR';
export const SCAN_MALWARE_DETECTED = 'SCAN_MALWARE_DETECTED';
export const SCAN_PHISHING_DETECTED = 'SCAN_PHISHING_DETECTED';
export const SCAN_CONTENT_REJECTED = 'SCAN_CONTENT_REJECTED';
// IP and reputation errors (REPUTATION_*)
export const REPUTATION_CHECK_ERROR = 'REPUTATION_CHECK_ERROR';
export const REPUTATION_DATA_ERROR = 'REPUTATION_DATA_ERROR';
export const REPUTATION_BLOCKLIST_ERROR = 'REPUTATION_BLOCKLIST_ERROR';
export const REPUTATION_UPDATE_ERROR = 'REPUTATION_UPDATE_ERROR';
// IP warmup errors (WARMUP_*)
export const WARMUP_ALLOCATION_ERROR = 'WARMUP_ALLOCATION_ERROR';
export const WARMUP_LIMIT_EXCEEDED = 'WARMUP_LIMIT_EXCEEDED';
export const WARMUP_SCHEDULE_ERROR = 'WARMUP_SCHEDULE_ERROR';
// Network and connectivity errors (NETWORK_*)
export const NETWORK_CONNECTION_ERROR = 'NETWORK_CONNECTION_ERROR';
export const NETWORK_TIMEOUT = 'NETWORK_TIMEOUT';
export const NETWORK_DNS_ERROR = 'NETWORK_DNS_ERROR';
export const NETWORK_TLS_ERROR = 'NETWORK_TLS_ERROR';
// Queue and processing errors (QUEUE_*)
export const QUEUE_FULL_ERROR = 'QUEUE_FULL_ERROR';
export const QUEUE_PROCESSING_ERROR = 'QUEUE_PROCESSING_ERROR';
export const QUEUE_PERSISTENCE_ERROR = 'QUEUE_PERSISTENCE_ERROR';
export const QUEUE_ITEM_NOT_FOUND = 'QUEUE_ITEM_NOT_FOUND';
// DcRouter errors (DCR_*)
export const DCR_ROUTING_ERROR = 'DCR_ROUTING_ERROR';
export const DCR_CONFIGURATION_ERROR = 'DCR_CONFIGURATION_ERROR';
export const DCR_PROXY_ERROR = 'DCR_PROXY_ERROR';
export const DCR_DOMAIN_ERROR = 'DCR_DOMAIN_ERROR';
// SMS service errors (SMS_*)
export const SMS_SERVICE_ERROR = 'SMS_SERVICE_ERROR';
export const SMS_SEND_ERROR = 'SMS_SEND_ERROR';
export const SMS_VALIDATION_ERROR = 'SMS_VALIDATION_ERROR';
export const SMS_RATE_LIMIT_EXCEEDED = 'SMS_RATE_LIMIT_EXCEEDED';
// Storage errors (STORAGE_*)
export const STORAGE_WRITE_ERROR = 'STORAGE_WRITE_ERROR';
export const STORAGE_READ_ERROR = 'STORAGE_READ_ERROR';
export const STORAGE_DELETE_ERROR = 'STORAGE_DELETE_ERROR';
export const STORAGE_QUOTA_EXCEEDED = 'STORAGE_QUOTA_EXCEEDED';
// Rule management errors (RULE_*)
export const RULE_VALIDATION_ERROR = 'RULE_VALIDATION_ERROR';
export const RULE_EXECUTION_ERROR = 'RULE_EXECUTION_ERROR';
export const RULE_NOT_FOUND = 'RULE_NOT_FOUND';
// Type definitions for error severity
export enum ErrorSeverity {
/** Critical errors that require immediate attention */
CRITICAL = 'CRITICAL',
/** High-impact errors that may affect service functioning */
HIGH = 'HIGH',
/** Medium-impact errors that cause partial degradation */
MEDIUM = 'MEDIUM',
/** Low-impact errors that have minimal or local impact */
LOW = 'LOW',
/** Informational errors that are not problematic */
INFO = 'INFO'
}
// Type definitions for error categories
export enum ErrorCategory {
/** Errors related to configuration */
CONFIGURATION = 'CONFIGURATION',
/** Errors related to network connectivity */
CONNECTIVITY = 'CONNECTIVITY',
/** Errors related to authentication/authorization */
AUTHENTICATION = 'AUTHENTICATION',
/** Errors related to data validation */
VALIDATION = 'VALIDATION',
/** Errors related to resource availability */
RESOURCE = 'RESOURCE',
/** Errors related to service operations */
OPERATION = 'OPERATION',
/** Errors related to third-party integrations */
INTEGRATION = 'INTEGRATION',
/** Errors related to security */
SECURITY = 'SECURITY',
/** Errors related to data storage */
STORAGE = 'STORAGE',
/** Errors that don't fit into other categories */
OTHER = 'OTHER'
}
// Type definitions for error recoverability
export enum ErrorRecoverability {
/** Error cannot be automatically recovered from */
NON_RECOVERABLE = 'NON_RECOVERABLE',
/** Error might be recoverable with retry */
MAYBE_RECOVERABLE = 'MAYBE_RECOVERABLE',
/** Error is definitely recoverable with retries */
RECOVERABLE = 'RECOVERABLE',
/** Error is transient and should resolve without action */
TRANSIENT = 'TRANSIENT'
}

193
ts/errors/index.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Platform Service Error System
*
* This module provides a comprehensive error handling system for the Platform Service,
* with structured error types, error codes, and consistent patterns for logging and recovery.
*/
// Export error codes and types
export * from './error.codes.js';
// Export base error classes
export * from './base.errors.js';
// Export domain-specific error classes
export * from './reputation.errors.js';
// Export error handler
export * from './error-handler.js';
// Export utility function to create specific error types based on the error category
import { getErrorClassForCategory } from './base.errors.js';
export { getErrorClassForCategory };
// Import needed classes for utility functions
import { PlatformError } from './base.errors.js';
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
/**
* Create a typed error from a standard Error
* Useful for converting errors from external libraries or APIs
*
* @param error Standard error to convert
* @param code Error code to assign
* @param contextData Additional context data
* @returns Typed PlatformError
*/
export function fromError(
error: Error,
code: string,
contextData: Record<string, any> = {}
): PlatformError {
return new PlatformError(
error.message,
code,
ErrorSeverity.MEDIUM,
ErrorCategory.OPERATION,
ErrorRecoverability.NON_RECOVERABLE,
{
data: {
...contextData,
originalError: {
name: error.name,
message: error.message,
stack: error.stack
}
}
}
);
}
/**
* Determine if an error is retryable
*
* @param error Error to check
* @returns Boolean indicating if the error should be retried
*/
export function isRetryable(error: any): boolean {
// If it's our platform error, use its recoverability property
if (error && typeof error === 'object' && 'recoverability' in error) {
return error.recoverability === ErrorRecoverability.RECOVERABLE ||
error.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
error.recoverability === ErrorRecoverability.TRANSIENT;
}
// Check if it's a network error (these are often transient)
if (error && typeof error === 'object' && error.code) {
const networkErrors = [
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH',
'ENETUNREACH', 'ENOTFOUND', 'EPROTO', 'ECONNABORTED'
];
return networkErrors.includes(error.code);
}
// By default, we can't determine if the error is retryable
return false;
}
/**
* Create a wrapped version of a function that catches errors
* and converts them to typed PlatformErrors
*
* @param fn Function to wrap
* @param errorCode Default error code to use
* @param contextData Additional context data
* @returns Wrapped function
*/
export function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
fn: T,
errorCode: string,
contextData: Record<string, any> = {}
): T {
return (async function(...args: Parameters<T>): Promise<ReturnType<T>> {
try {
return await fn(...args);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error) {
// Already a typed error, rethrow
throw error;
}
throw fromError(
error instanceof Error ? error : new Error(String(error)),
errorCode,
{
...contextData,
fnName: fn.name,
args: args.map(arg =>
typeof arg === 'object'
? '[Object]'
: String(arg).substring(0, 100)
)
}
);
}
}) as T;
}
/**
* Retry a function with exponential backoff
*
* @param fn Function to retry
* @param options Retry options
* @returns Function result or throws after max retries
*/
export async function retry<T>(
fn: () => Promise<T>,
options: {
maxRetries?: number;
initialDelay?: number;
maxDelay?: number;
backoffFactor?: number;
retryableErrors?: Array<string | RegExp>;
} = {}
): Promise<T> {
const {
maxRetries = 3,
initialDelay = 1000,
maxDelay = 30000,
backoffFactor = 2,
retryableErrors = []
} = options;
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error
? error
: new Error(String(error));
// Check if we should retry
const shouldRetry = attempt < maxRetries && (
isRetryable(error) ||
retryableErrors.some(pattern => {
if (typeof pattern === 'string') {
return lastError.message.includes(pattern);
}
return pattern.test(lastError.message);
})
);
if (!shouldRetry) {
throw lastError;
}
// Calculate delay with exponential backoff
const delay = Math.min(initialDelay * Math.pow(backoffFactor, attempt), maxDelay);
// Add jitter to prevent thundering herd problem (±20%)
const jitter = 0.8 + Math.random() * 0.4;
const actualDelay = Math.floor(delay * jitter);
// Wait before next retry
await new Promise(resolve => setTimeout(resolve, actualDelay));
}
}
// This should never happen, but TypeScript needs it
throw lastError!;
}

View File

@@ -0,0 +1,422 @@
import {
PlatformError,
OperationError,
ResourceError
} from './base.errors.js';
import type { IErrorContext } from './base.errors.js';
import {
REPUTATION_CHECK_ERROR,
REPUTATION_DATA_ERROR,
REPUTATION_BLOCKLIST_ERROR,
REPUTATION_UPDATE_ERROR,
WARMUP_ALLOCATION_ERROR,
WARMUP_LIMIT_EXCEEDED,
WARMUP_SCHEDULE_ERROR
} from './error.codes.js';
/**
* Base class for reputation-related errors
*/
export class ReputationError extends OperationError {
/**
* Creates a new reputation error
*
* @param message Error message
* @param code Error code
* @param context Additional context
*/
constructor(
message: string,
code: string,
context: IErrorContext = {}
) {
super(message, code, context);
}
}
/**
* Error class for reputation check errors
*/
export class ReputationCheckError extends ReputationError {
/**
* Creates a new reputation check error
*
* @param message Error message
* @param context Additional context
*/
constructor(
message: string,
context: IErrorContext = {}
) {
super(message, REPUTATION_CHECK_ERROR, context);
}
/**
* Creates a new instance with updated context
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof ReputationCheckError)(
this.message,
context
);
}
/**
* Creates an instance for an IP reputation check error
*
* @param ip IP address
* @param provider Reputation provider
* @param originalError Original error
* @param context Additional context
*/
public static ipCheckFailed(
ip: string,
provider: string,
originalError?: Error,
context: IErrorContext = {}
): ReputationCheckError {
const errorMsg = originalError ? `: ${originalError.message}` : '';
return new ReputationCheckError(
`Failed to check reputation for IP ${ip} with provider ${provider}${errorMsg}`,
{
...context,
data: {
...context.data,
ip,
provider,
originalError: originalError ? {
message: originalError.message,
stack: originalError.stack
} : undefined
}
}
);
}
/**
* Creates an instance for a domain reputation check error
*
* @param domain Domain
* @param provider Reputation provider
* @param originalError Original error
* @param context Additional context
*/
public static domainCheckFailed(
domain: string,
provider: string,
originalError?: Error,
context: IErrorContext = {}
): ReputationCheckError {
const errorMsg = originalError ? `: ${originalError.message}` : '';
return new ReputationCheckError(
`Failed to check reputation for domain ${domain} with provider ${provider}${errorMsg}`,
{
...context,
data: {
...context.data,
domain,
provider,
originalError: originalError ? {
message: originalError.message,
stack: originalError.stack
} : undefined
}
}
);
}
}
/**
* Error class for reputation data errors
*/
export class ReputationDataError extends ReputationError {
/**
* Creates a new reputation data error
*
* @param message Error message
* @param context Additional context
*/
constructor(
message: string,
context: IErrorContext = {}
) {
super(message, REPUTATION_DATA_ERROR, context);
}
/**
* Creates a new instance with updated context
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof ReputationDataError)(
this.message,
context
);
}
/**
* Creates an instance for a data access error
*
* @param entity Entity type (domain, ip)
* @param entityId Entity identifier
* @param operation Operation that failed (read, write, update)
* @param originalError Original error
* @param context Additional context
*/
public static dataAccessFailed(
entity: string,
entityId: string,
operation: string,
originalError?: Error,
context: IErrorContext = {}
): ReputationDataError {
const errorMsg = originalError ? `: ${originalError.message}` : '';
return new ReputationDataError(
`Failed to ${operation} reputation data for ${entity} ${entityId}${errorMsg}`,
{
...context,
data: {
...context.data,
entity,
entityId,
operation,
originalError: originalError ? {
message: originalError.message,
stack: originalError.stack
} : undefined
}
}
);
}
}
/**
* Error class for blocklist-related errors
*/
export class BlocklistError extends ReputationError {
/**
* Creates a new blocklist error
*
* @param message Error message
* @param context Additional context
*/
constructor(
message: string,
context: IErrorContext = {}
) {
super(message, REPUTATION_BLOCKLIST_ERROR, context);
}
/**
* Creates a new instance with updated context
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof BlocklistError)(
this.message,
context
);
}
/**
* Creates an instance for an entity found on a blocklist
*
* @param entity Entity type (domain, ip)
* @param entityId Entity identifier
* @param blocklist Blocklist name
* @param reason Reason for listing (if available)
* @param context Additional context
*/
public static entityBlocked(
entity: string,
entityId: string,
blocklist: string,
reason?: string,
context: IErrorContext = {}
): BlocklistError {
const reasonText = reason ? ` (${reason})` : '';
return new BlocklistError(
`${entity.charAt(0).toUpperCase() + entity.slice(1)} ${entityId} is listed on blocklist ${blocklist}${reasonText}`,
{
...context,
data: {
...context.data,
entity,
entityId,
blocklist,
reason
},
userMessage: `The ${entity} ${entityId} is on a blocklist. This may affect email deliverability.`
}
);
}
}
/**
* Error class for reputation update errors
*/
export class ReputationUpdateError extends ReputationError {
/**
* Creates a new reputation update error
*
* @param message Error message
* @param context Additional context
*/
constructor(
message: string,
context: IErrorContext = {}
) {
super(message, REPUTATION_UPDATE_ERROR, context);
}
/**
* Creates a new instance with updated context
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof ReputationUpdateError)(
this.message,
context
);
}
}
/**
* Error class for IP warmup allocation errors
*/
export class WarmupAllocationError extends ReputationError {
/**
* Creates a new warmup allocation error
*
* @param message Error message
* @param context Additional context
*/
constructor(
message: string,
context: IErrorContext = {}
) {
super(message, WARMUP_ALLOCATION_ERROR, context);
}
/**
* Creates a new instance with updated context
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof WarmupAllocationError)(
this.message,
context
);
}
/**
* Creates an instance for no available IPs
*
* @param domain Domain requesting an IP
* @param policy Allocation policy that was used
* @param context Additional context
*/
public static noAvailableIps(
domain: string,
policy: string,
context: IErrorContext = {}
): WarmupAllocationError {
return new WarmupAllocationError(
`No available IPs for domain ${domain} using ${policy} allocation policy`,
{
...context,
data: {
...context.data,
domain,
policy
},
userMessage: `No available sending IPs for ${domain}.`
}
);
}
}
/**
* Error class for IP warmup limit exceeded errors
*/
export class WarmupLimitError extends ResourceError {
/**
* Creates a new warmup limit error
*
* @param message Error message
* @param context Additional context
*/
constructor(
message: string,
context: IErrorContext = {}
) {
super(message, WARMUP_LIMIT_EXCEEDED, context);
}
/**
* Creates a new instance with updated context
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof WarmupLimitError)(
this.message,
context
);
}
/**
* Creates an instance for daily sending limit exceeded
*
* @param ip IP address
* @param domain Domain
* @param limit Daily limit
* @param sent Number of emails sent
* @param context Additional context
*/
public static dailyLimitExceeded(
ip: string,
domain: string,
limit: number,
sent: number,
context: IErrorContext = {}
): WarmupLimitError {
return new WarmupLimitError(
`Daily sending limit exceeded for IP ${ip} and domain ${domain}: ${sent}/${limit}`,
{
...context,
data: {
...context.data,
ip,
domain,
limit,
sent
},
userMessage: `Daily sending limit reached for ${domain}.`
}
);
}
}
/**
* Error class for IP warmup schedule errors
*/
export class WarmupScheduleError extends ReputationError {
/**
* Creates a new warmup schedule error
*
* @param message Error message
* @param context Additional context
*/
constructor(
message: string,
context: IErrorContext = {}
) {
super(message, WARMUP_SCHEDULE_ERROR, context);
}
/**
* Creates a new instance with updated context
*/
protected createWithContext(context: IErrorContext): PlatformError {
return new (this.constructor as typeof WarmupScheduleError)(
this.message,
context
);
}
}

View File

@@ -1,4 +1,16 @@
export * from './00_commitinfo_data.js';
import { SzPlatformService } from './classes.platformservice.js'
export const runCli = async () => {}
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
export { UnifiedEmailServer } from '@push.rocks/smartmta';
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
// DcRouter
export * from './classes.dcrouter.js';
// RADIUS module
export * from './radius/index.js';
// Remote Ingress module
export * from './remoteingress/index.js';
export const runCli = async () => {};

98
ts/logger.ts Normal file
View File

@@ -0,0 +1,98 @@
import * as plugins from './plugins.js';
import { randomUUID } from 'node:crypto';
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
// Map NODE_ENV to valid TEnvironment
const nodeEnv = process.env.NODE_ENV || 'production';
const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
'development': 'local',
'test': 'test',
'staging': 'staging',
'production': 'production'
};
// In-memory log buffer for the OpsServer UI
export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
// Default Smartlog instance (exported so OpsServer can add push destinations)
export const baseLogger = new plugins.smartlog.Smartlog({
logContext: {
environment: envMap[nodeEnv] || 'production',
runtime: 'node',
zone: 'serve.zone',
}
});
// Wire the buffer destination so all logs are captured
baseLogger.addLogDestination(logBuffer);
// Extended logger compatible with the original enhanced logger API
class StandardLogger {
private defaultContext: Record<string, any> = {};
private correlationId: string | null = null;
constructor() {}
// Log methods
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
const combinedContext = {
...this.defaultContext,
...context
};
if (this.correlationId) {
combinedContext.correlation_id = this.correlationId;
}
baseLogger.log(level, message, combinedContext);
}
public error(message: string, context: Record<string, any> = {}) {
this.log('error', message, context);
}
public warn(message: string, context: Record<string, any> = {}) {
this.log('warn', message, context);
}
public info(message: string, context: Record<string, any> = {}) {
this.log('info', message, context);
}
public success(message: string, context: Record<string, any> = {}) {
this.log('success', message, context);
}
public debug(message: string, context: Record<string, any> = {}) {
this.log('debug', message, context);
}
// Context management
public setContext(context: Record<string, any>, overwrite: boolean = false) {
if (overwrite) {
this.defaultContext = context;
} else {
this.defaultContext = {
...this.defaultContext,
...context
};
}
}
// Correlation ID management
public setCorrelationId(id: string | null = null): string {
this.correlationId = id || randomUUID();
return this.correlationId;
}
public getCorrelationId(): string | null {
return this.correlationId;
}
public clearCorrelationId(): void {
this.correlationId = null;
}
}
// Export a singleton instance
export const logger = new StandardLogger();

View File

@@ -0,0 +1,75 @@
export interface ICacheEntry<T> {
data: T;
timestamp: number;
}
export class MetricsCache {
private cache = new Map<string, ICacheEntry<any>>();
private readonly defaultTTL: number;
constructor(defaultTTL: number = 500) {
this.defaultTTL = defaultTTL;
}
/**
* Get cached data or compute and cache it
*/
public get<T>(key: string, computeFn: () => T | Promise<T>, ttl?: number): T | Promise<T> {
const cached = this.cache.get(key);
const now = Date.now();
const actualTTL = ttl ?? this.defaultTTL;
if (cached && (now - cached.timestamp) < actualTTL) {
return cached.data;
}
const result = computeFn();
// Handle both sync and async compute functions
if (result instanceof Promise) {
return result.then(data => {
this.cache.set(key, { data, timestamp: now });
return data;
});
} else {
this.cache.set(key, { data: result, timestamp: now });
return result;
}
}
/**
* Invalidate a specific cache entry
*/
public invalidate(key: string): void {
this.cache.delete(key);
}
/**
* Clear all cache entries
*/
public clear(): void {
this.cache.clear();
}
/**
* Get cache statistics
*/
public getStats(): { size: number; keys: string[] } {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
};
}
/**
* Clean up expired entries
*/
public cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > this.defaultTTL) {
this.cache.delete(key);
}
}
}
}

View File

@@ -0,0 +1,748 @@
import * as plugins from '../plugins.js';
import { DcRouter } from '../classes.dcrouter.js';
import { MetricsCache } from './classes.metricscache.js';
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
import { logger } from '../logger.js';
export class MetricsManager {
private metricsLogger: plugins.smartlog.Smartlog;
private smartMetrics: plugins.smartmetrics.SmartMetrics;
private dcRouter: DcRouter;
private resetInterval?: NodeJS.Timeout;
private metricsCache: MetricsCache;
// Constants
private readonly MAX_TOP_DOMAINS = 1000; // Limit topDomains Map size
// Track email-specific metrics
private emailMetrics = {
sentToday: 0,
receivedToday: 0,
failedToday: 0,
bouncedToday: 0,
queueSize: 0,
lastResetDate: new Date().toDateString(),
deliveryTimes: [] as number[], // Track delivery times in ms
recipients: new Map<string, number>(), // Track email count by recipient
recentActivity: [] as Array<{ timestamp: number; type: string; details: string }>,
};
// Track DNS-specific metrics
private dnsMetrics = {
totalQueries: 0,
cacheHits: 0,
cacheMisses: 0,
queryTypes: {} as Record<string, number>,
topDomains: new Map<string, number>(),
lastResetDate: new Date().toDateString(),
// 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
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
};
// Per-minute time-series buckets for charts
private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
private dnsMinuteBuckets = new Map<number, { queries: number }>();
// Track security-specific metrics
private securityMetrics = {
blockedIPs: 0,
authFailures: 0,
spamDetected: 0,
malwareDetected: 0,
phishingDetected: 0,
lastResetDate: new Date().toDateString(),
incidents: [] as Array<{ timestamp: number; type: string; severity: string; details: string }>,
};
constructor(dcRouter: DcRouter) {
this.dcRouter = dcRouter;
// Create a Smartlog instance for SmartMetrics (requires its own instance)
this.metricsLogger = new plugins.smartlog.Smartlog({
logContext: {
environment: 'production',
runtime: 'node',
zone: 'dcrouter-metrics',
}
});
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
// Initialize metrics cache with 500ms TTL
this.metricsCache = new MetricsCache(500);
}
public async start(): Promise<void> {
// Start SmartMetrics collection
this.smartMetrics.start();
// Reset daily counters at midnight
this.resetInterval = setInterval(() => {
const currentDate = new Date().toDateString();
if (currentDate !== this.emailMetrics.lastResetDate) {
this.emailMetrics.sentToday = 0;
this.emailMetrics.receivedToday = 0;
this.emailMetrics.failedToday = 0;
this.emailMetrics.bouncedToday = 0;
this.emailMetrics.deliveryTimes = [];
this.emailMetrics.recipients.clear();
this.emailMetrics.recentActivity = [];
this.emailMetrics.lastResetDate = currentDate;
}
if (currentDate !== this.dnsMetrics.lastResetDate) {
this.dnsMetrics.totalQueries = 0;
this.dnsMetrics.cacheHits = 0;
this.dnsMetrics.cacheMisses = 0;
this.dnsMetrics.queryTypes = {};
this.dnsMetrics.topDomains.clear();
this.dnsMetrics.queryRing.fill(0);
this.dnsMetrics.queryRingLastSecond = 0;
this.dnsMetrics.responseTimes = [];
this.dnsMetrics.recentQueries = [];
this.dnsMetrics.lastResetDate = currentDate;
}
if (currentDate !== this.securityMetrics.lastResetDate) {
this.securityMetrics.blockedIPs = 0;
this.securityMetrics.authFailures = 0;
this.securityMetrics.spamDetected = 0;
this.securityMetrics.malwareDetected = 0;
this.securityMetrics.phishingDetected = 0;
this.securityMetrics.incidents = [];
this.securityMetrics.lastResetDate = currentDate;
}
// Prune old time-series buckets every minute (don't wait for lazy query)
this.pruneOldBuckets();
}, 60000); // Check every minute
logger.log('info', 'MetricsManager started');
}
public async stop(): Promise<void> {
// Clear the reset interval
if (this.resetInterval) {
clearInterval(this.resetInterval);
this.resetInterval = undefined;
}
this.smartMetrics.stop();
// Clear caches and time-series buckets on shutdown
this.metricsCache.clear();
this.emailMinuteBuckets.clear();
this.dnsMinuteBuckets.clear();
logger.log('info', 'MetricsManager stopped');
}
// Get server metrics from SmartMetrics and SmartProxy
public async getServerStats() {
return this.metricsCache.get('serverStats', async () => {
const smartMetricsData = await this.smartMetrics.getMetrics();
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
return {
uptime: process.uptime(),
startTime: Date.now() - (process.uptime() * 1000),
memoryUsage: {
heapUsed,
heapTotal,
external,
rss,
maxMemoryMB: this.smartMetrics.maxMemoryMB,
actualUsageBytes: smartMetricsData.memoryUsageBytes,
actualUsagePercentage: smartMetricsData.memoryPercentage,
},
cpuUsage: {
user: smartMetricsData.cpuPercentage,
system: 0,
},
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0,
throughput: proxyMetrics ? {
bytesIn: proxyMetrics.totals.bytesIn(),
bytesOut: proxyMetrics.totals.bytesOut(),
bytesInPerSecond: proxyMetrics.throughput.instant().in,
bytesOutPerSecond: proxyMetrics.throughput.instant().out,
} : { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
};
});
}
// Get email metrics
public async getEmailStats() {
return this.metricsCache.get('emailStats', () => {
// Calculate average delivery time
const avgDeliveryTime = this.emailMetrics.deliveryTimes.length > 0
? this.emailMetrics.deliveryTimes.reduce((a, b) => a + b, 0) / this.emailMetrics.deliveryTimes.length
: 0;
// Get top recipients
const topRecipients = Array.from(this.emailMetrics.recipients.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([email, count]) => ({ email, count }));
// Get recent activity (last 50 entries)
const recentActivity = this.emailMetrics.recentActivity.slice(-50);
return {
sentToday: this.emailMetrics.sentToday,
receivedToday: this.emailMetrics.receivedToday,
failedToday: this.emailMetrics.failedToday,
bounceRate: this.emailMetrics.bouncedToday > 0
? (this.emailMetrics.bouncedToday / this.emailMetrics.sentToday) * 100
: 0,
deliveryRate: this.emailMetrics.sentToday > 0
? ((this.emailMetrics.sentToday - this.emailMetrics.failedToday) / this.emailMetrics.sentToday) * 100
: 100,
queueSize: this.emailMetrics.queueSize,
averageDeliveryTime: Math.round(avgDeliveryTime),
topRecipients,
recentActivity,
};
});
}
// Get DNS metrics
public async getDnsStats() {
return this.metricsCache.get('dnsStats', () => {
const cacheHitRate = this.dnsMetrics.totalQueries > 0
? (this.dnsMetrics.cacheHits / this.dnsMetrics.totalQueries) * 100
: 0;
const topDomains = Array.from(this.dnsMetrics.topDomains.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([domain, count]) => ({ domain, count }));
// Calculate queries per second from ring buffer (sum last 60 seconds)
const queriesPerSecond = this.getQueryRingSum(60) / 60;
// Calculate average response time
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
? this.dnsMetrics.responseTimes.reduce((a, b) => a + b, 0) / this.dnsMetrics.responseTimes.length
: 0;
return {
queriesPerSecond: Math.round(queriesPerSecond * 10) / 10,
totalQueries: this.dnsMetrics.totalQueries,
cacheHits: this.dnsMetrics.cacheHits,
cacheMisses: this.dnsMetrics.cacheMisses,
cacheHitRate: cacheHitRate,
topDomains: topDomains,
queryTypes: this.dnsMetrics.queryTypes,
averageResponseTime: Math.round(avgResponseTime),
activeDomains: this.dnsMetrics.topDomains.size,
recentQueries: this.dnsMetrics.recentQueries.slice(),
};
});
}
/**
* Sync security metrics from the SecurityLogger singleton (last 24h).
* Called before returning security stats so counters reflect real events.
*/
private syncFromSecurityLogger(): void {
try {
const securityLogger = SecurityLogger.getInstance();
const summary = securityLogger.getEventsSummary(86400000); // last 24h
this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
this.securityMetrics.authFailures =
summary.byType[SecurityEventType.AUTHENTICATION] || 0;
this.securityMetrics.blockedIPs =
(summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
(summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
} catch {
// SecurityLogger may not be initialized yet — ignore
}
}
// Get security metrics
public async getSecurityStats() {
return this.metricsCache.get('securityStats', () => {
// Sync counters from the real SecurityLogger events
this.syncFromSecurityLogger();
// Get recent incidents (last 20)
const recentIncidents = this.securityMetrics.incidents.slice(-20);
return {
blockedIPs: this.securityMetrics.blockedIPs,
authFailures: this.securityMetrics.authFailures,
spamDetected: this.securityMetrics.spamDetected,
malwareDetected: this.securityMetrics.malwareDetected,
phishingDetected: this.securityMetrics.phishingDetected,
totalThreatsBlocked: this.securityMetrics.spamDetected +
this.securityMetrics.malwareDetected +
this.securityMetrics.phishingDetected,
recentIncidents,
};
});
}
// Get connection info from SmartProxy
public async getConnectionInfo() {
return this.metricsCache.get('connectionInfo', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return [];
}
const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo = [];
for (const [routeName, count] of connectionsByRoute) {
connectionInfo.push({
type: 'https',
count,
source: routeName,
lastActivity: new Date(),
});
}
return connectionInfo;
});
}
// Email event tracking methods
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
this.emailMetrics.sentToday++;
this.incrementEmailBucket('sent');
if (recipient) {
const count = this.emailMetrics.recipients.get(recipient) || 0;
this.emailMetrics.recipients.set(recipient, count + 1);
// Cap recipients map to prevent unbounded growth within a day
if (this.emailMetrics.recipients.size > this.MAX_TOP_DOMAINS) {
const sorted = Array.from(this.emailMetrics.recipients.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8));
this.emailMetrics.recipients = new Map(sorted);
}
}
if (deliveryTimeMs) {
this.emailMetrics.deliveryTimes.push(deliveryTimeMs);
// Keep only last 1000 delivery times
if (this.emailMetrics.deliveryTimes.length > 1000) {
this.emailMetrics.deliveryTimes.shift();
}
}
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'sent',
details: recipient || 'unknown',
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public trackEmailReceived(sender?: string): void {
this.emailMetrics.receivedToday++;
this.incrementEmailBucket('received');
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'received',
details: sender || 'unknown',
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public trackEmailFailed(recipient?: string, reason?: string): void {
this.emailMetrics.failedToday++;
this.incrementEmailBucket('failed');
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'failed',
details: `${recipient || 'unknown'}: ${reason || 'unknown error'}`,
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public trackEmailBounced(recipient?: string): void {
this.emailMetrics.bouncedToday++;
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'bounced',
details: recipient || 'unknown',
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public updateQueueSize(size: number): void {
this.emailMetrics.queueSize = size;
}
// DNS event tracking methods
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void {
this.dnsMetrics.totalQueries++;
this.incrementDnsBucket();
// Store recent query entry
this.dnsMetrics.recentQueries.push({
timestamp: Date.now(),
domain,
type: queryType,
answered: answered ?? true,
responseTimeMs: responseTimeMs ?? 0,
});
if (this.dnsMetrics.recentQueries.length > 100) {
this.dnsMetrics.recentQueries.shift();
}
if (cacheHit) {
this.dnsMetrics.cacheHits++;
} else {
this.dnsMetrics.cacheMisses++;
}
// Increment per-second query counter in ring buffer
this.incrementQueryRing();
// Track response time if provided
if (responseTimeMs) {
this.dnsMetrics.responseTimes.push(responseTimeMs);
// Keep only last 1000 response times
if (this.dnsMetrics.responseTimes.length > 1000) {
this.dnsMetrics.responseTimes.shift();
}
}
// Track query types
this.dnsMetrics.queryTypes[queryType] = (this.dnsMetrics.queryTypes[queryType] || 0) + 1;
// Track top domains with size limit
const currentCount = this.dnsMetrics.topDomains.get(domain) || 0;
this.dnsMetrics.topDomains.set(domain, currentCount + 1);
// If we've exceeded the limit, remove the least accessed domains
if (this.dnsMetrics.topDomains.size > this.MAX_TOP_DOMAINS) {
// Convert to array, sort by count, and keep only top domains
const sortedDomains = Array.from(this.dnsMetrics.topDomains.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8)); // Keep 80% to avoid frequent cleanup
// Clear and repopulate with top domains
this.dnsMetrics.topDomains.clear();
sortedDomains.forEach(([domain, count]) => {
this.dnsMetrics.topDomains.set(domain, count);
});
}
}
// Security event tracking methods
public trackBlockedIP(ip?: string, reason?: string): void {
this.securityMetrics.blockedIPs++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'ip_blocked',
severity: 'medium',
details: `IP ${ip || 'unknown'} blocked: ${reason || 'security policy'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackAuthFailure(username?: string, ip?: string): void {
this.securityMetrics.authFailures++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'auth_failure',
severity: 'low',
details: `Authentication failed for ${username || 'unknown'} from ${ip || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackSpamDetected(sender?: string): void {
this.securityMetrics.spamDetected++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'spam_detected',
severity: 'low',
details: `Spam detected from ${sender || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackMalwareDetected(source?: string): void {
this.securityMetrics.malwareDetected++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'malware_detected',
severity: 'high',
details: `Malware detected from ${source || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackPhishingDetected(source?: string): void {
this.securityMetrics.phishingDetected++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'phishing_detected',
severity: 'high',
details: `Phishing attempt from ${source || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
// Get network metrics from SmartProxy
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return {
connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>,
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0,
requestsTotal: 0,
};
}
// Get metrics using the new API
const connectionsByIP = proxyMetrics.connections.byIP();
const instantThroughput = proxyMetrics.throughput.instant();
// Get throughput rate
const throughputRate = {
bytesInPerSecond: instantThroughput.in,
bytesOutPerSecond: instantThroughput.out
};
// Get top IPs
const topIPs = proxyMetrics.connections.topIPs(10);
// Get total data transferred
const totalDataTransferred = {
bytesIn: proxyMetrics.totals.bytesIn(),
bytesOut: proxyMetrics.totals.bytesOut()
};
// Get throughput history from Rust engine (up to 300 seconds)
const throughputHistory = proxyMetrics.throughput.history(300);
// Get per-IP throughput
const throughputByIP = proxyMetrics.throughput.byIP();
// Get HTTP request rates
const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total();
return {
connectionsByIP,
throughputRate,
topIPs,
totalDataTransferred,
throughputHistory,
throughputByIP,
requestsPerSecond,
requestsTotal,
};
}, 1000); // 1s cache — matches typical dashboard poll interval
}
// --- Time-series helpers ---
private static minuteKey(ts: number = Date.now()): number {
return Math.floor(ts / 60000) * 60000;
}
private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
const key = MetricsManager.minuteKey();
let bucket = this.emailMinuteBuckets.get(key);
if (!bucket) {
bucket = { sent: 0, received: 0, failed: 0 };
this.emailMinuteBuckets.set(key, bucket);
}
bucket[field]++;
}
private incrementDnsBucket(): void {
const key = MetricsManager.minuteKey();
let bucket = this.dnsMinuteBuckets.get(key);
if (!bucket) {
bucket = { queries: 0 };
this.dnsMinuteBuckets.set(key, bucket);
}
bucket.queries++;
}
/**
* 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 {
const cutoff = Date.now() - 86400000; // 24h
for (const key of this.emailMinuteBuckets.keys()) {
if (key < cutoff) this.emailMinuteBuckets.delete(key);
}
for (const key of this.dnsMinuteBuckets.keys()) {
if (key < cutoff) this.dnsMinuteBuckets.delete(key);
}
}
/**
* Get email time-series data for the last N hours, aggregated per minute.
*/
public getEmailTimeSeries(hours: number = 24): {
sent: Array<{ timestamp: number; value: number }>;
received: Array<{ timestamp: number; value: number }>;
failed: Array<{ timestamp: number; value: number }>;
} {
this.pruneOldBuckets();
const cutoff = Date.now() - hours * 3600000;
const sent: Array<{ timestamp: number; value: number }> = [];
const received: Array<{ timestamp: number; value: number }> = [];
const failed: Array<{ timestamp: number; value: number }> = [];
const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
.filter((k) => k >= cutoff)
.sort((a, b) => a - b);
for (const key of sortedKeys) {
const bucket = this.emailMinuteBuckets.get(key)!;
sent.push({ timestamp: key, value: bucket.sent });
received.push({ timestamp: key, value: bucket.received });
failed.push({ timestamp: key, value: bucket.failed });
}
return { sent, received, failed };
}
/**
* Get DNS time-series data for the last N hours, aggregated per minute.
*/
public getDnsTimeSeries(hours: number = 24): {
queries: Array<{ timestamp: number; value: number }>;
} {
this.pruneOldBuckets();
const cutoff = Date.now() - hours * 3600000;
const queries: Array<{ timestamp: number; value: number }> = [];
const sortedKeys = Array.from(this.dnsMinuteBuckets.keys())
.filter((k) => k >= cutoff)
.sort((a, b) => a - b);
for (const key of sortedKeys) {
const bucket = this.dnsMinuteBuckets.get(key)!;
queries.push({ timestamp: key, value: bucket.queries });
}
return { queries };
}
}

1
ts/monitoring/index.ts Normal file
View File

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

View File

@@ -0,0 +1,102 @@
import type DcRouter from '../classes.dcrouter.js';
import * as plugins from '../plugins.js';
import * as paths from '../paths.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 {
public dcRouterRef: DcRouter;
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
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
public adminHandler: handlers.AdminHandler;
private configHandler: handlers.ConfigHandler;
private logsHandler: handlers.LogsHandler;
private securityHandler: handlers.SecurityHandler;
private statsHandler: handlers.StatsHandler;
private radiusHandler: handlers.RadiusHandler;
private emailOpsHandler: handlers.EmailOpsHandler;
private certificateHandler: handlers.CertificateHandler;
private remoteIngressHandler: handlers.RemoteIngressHandler;
private routeManagementHandler: handlers.RouteManagementHandler;
private apiTokenHandler: handlers.ApiTokenHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
// Add our typedrouter to the dcRouter's main typedrouter
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
}
public async start() {
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost',
feedMetadata: null,
serveDir: paths.distServe,
});
// The server has a built-in typedrouter at /typedrequest
// Add the main dcRouter typedrouter to the server's typedrouter
this.server.typedrouter.addTypedRouter(this.dcRouterRef.typedrouter);
// Set up handlers
await this.setupHandlers();
await this.server.start(3000);
}
/**
* Set up all TypedRequest handlers
*/
private async setupHandlers(): Promise<void> {
// AdminHandler must be initialized first (JWT setup needed for guards)
this.adminHandler = new handlers.AdminHandler(this);
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.logsHandler = new handlers.LogsHandler(this);
this.securityHandler = new handlers.SecurityHandler(this);
this.statsHandler = new handlers.StatsHandler(this);
this.radiusHandler = new handlers.RadiusHandler(this);
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
this.certificateHandler = new handlers.CertificateHandler(this);
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}
public async stop() {
// Clean up log handler streams and push destination before stopping the server
if (this.logsHandler) {
this.logsHandler.cleanup();
}
if (this.server) {
await this.server.stop();
}
}
}

View File

@@ -0,0 +1,240 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export interface IJwtData {
userId: string;
status: 'loggedIn' | 'loggedOut';
expiresAt: number;
}
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
// JWT instance
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
// Simple in-memory user storage (in production, use proper database)
private users = new Map<string, {
id: string;
username: string;
password: string;
role: string;
}>();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
}
public async initialize(): Promise<void> {
await this.initializeJwt();
this.initializeDefaultUsers();
this.registerHandlers();
}
private async initializeJwt(): Promise<void> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
await this.smartjwtInstance.init();
// For development, create new keypair each time
// In production, load from storage like cloudly does
await this.smartjwtInstance.createNewKeyPair();
}
private initializeDefaultUsers(): void {
// Add default admin user
const adminId = plugins.uuid.v4();
this.users.set(adminId, {
id: adminId,
username: 'admin',
password: 'admin',
role: 'admin',
});
}
private registerHandlers(): void {
// Admin Login Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
try {
// Find user by username and password
let user: { id: string; username: string; password: string; role: string } | null = null;
for (const [_, userData] of this.users) {
if (userData.username === dataArg.username && userData.password === dataArg.password) {
user = userData;
break;
}
}
if (!user) {
throw new plugins.typedrequest.TypedResponseError('login failed');
}
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
const jwt = await this.smartjwtInstance.createJWT({
userId: user.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
identity: {
jwt,
userId: user.id,
name: user.username,
expiresAt: expiresAtTimestamp,
role: user.role,
type: 'user',
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) {
throw error;
}
throw new plugins.typedrequest.TypedResponseError('login failed');
}
}
)
);
// Admin Logout Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (dataArg) => {
// In a real implementation, you might want to blacklist the JWT
// For now, just return success
return {
success: true,
};
}
)
);
// Verify Identity Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return {
valid: false,
};
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check if expired
if (jwtData.expiresAt < Date.now()) {
return {
valid: false,
};
}
// Check if logged in
if (jwtData.status !== 'loggedIn') {
return {
valid: false,
};
}
// Find user
const user = this.users.get(jwtData.userId);
if (!user) {
return {
valid: false,
};
}
return {
valid: true,
identity: {
jwt: dataArg.identity.jwt,
userId: user.id,
name: user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
},
};
} catch (error) {
return {
valid: false,
};
}
}
)
);
}
/**
* Create a guard for valid identity (matching cloudly pattern)
*/
public validIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return false;
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check expiration
if (jwtData.expiresAt < Date.now()) {
return false;
}
// Check status
if (jwtData.status !== 'loggedIn') {
return false;
}
// Verify data hasn't been tampered with
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
return false;
}
if (dataArg.identity.userId !== jwtData.userId) {
return false;
}
return true;
} catch (error) {
return false;
}
},
{
failedHint: 'identity is not valid',
name: 'validIdentityGuard',
}
);
/**
* Create a guard for admin identity (matching cloudly pattern)
*/
public adminIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
// First check if identity is valid
const isValid = await this.validIdentityGuard.exec(dataArg);
if (!isValid) {
return false;
}
// Check if user has admin role
return dataArg.identity.role === 'admin';
},
{
failedHint: 'user is not admin',
name: 'adminIdentityGuard',
}
);
}

View File

@@ -0,0 +1,97 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class ApiTokenHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
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
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
'createApiToken',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await manager.createToken(
dataArg.name,
dataArg.scopes,
dataArg.expiresInDays ?? null,
dataArg.identity.userId,
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
),
);
// List API tokens
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { tokens: [] };
}
return { tokens: manager.listTokens() };
},
),
);
// Revoke API token
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const ok = await manager.revokeToken(dataArg.id);
return { success: ok, message: ok ? undefined : 'Token not found' };
},
),
);
// 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
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const ok = await manager.toggleToken(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Token not found' };
},
),
);
}
}

View File

@@ -0,0 +1,511 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class CertificateHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get Certificate Overview
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
'getCertificateOverview',
async (dataArg) => {
const certificates = await this.buildCertificateOverview();
const summary = this.buildSummary(certificates);
return { certificates, summary };
}
)
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Legacy route-based reprovision (backward compat)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
return this.reprovisionCertificateDomain(dataArg.domain);
}
)
);
// Delete certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
return this.deleteCertificate(dataArg.domain);
}
)
);
// Export certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
return this.exportCertificate(dataArg.domain);
}
)
);
// Import certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
return this.importCertificate(dataArg.cert);
}
)
);
}
/**
* Build domain-centric certificate overview.
* Instead of one row per route, we produce one row per unique domain.
*/
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) return [];
const routes = smartProxy.routeManager.getRoutes();
// Phase 1: Collect unique domains with their associated route info
const domainMap = new Map<string, {
routeNames: string[];
source: interfaces.requests.TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
canReprovision: boolean;
}>();
for (const route of routes) {
if (!route.name) continue;
const tls = route.action?.tls;
if (!tls) continue;
// Skip passthrough routes - they don't manage certificates
if (tls.mode === 'passthrough') continue;
const routeDomains = route.match.domains
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
: [];
// Determine source
let source: interfaces.requests.TCertificateSource = 'none';
if (tls.certificate === 'auto') {
if ((smartProxy.settings as any).certProvisionFunction) {
source = 'provision-function';
} else {
source = 'acme';
}
} else if (tls.certificate && typeof tls.certificate === 'object') {
source = 'static';
}
const canReprovision = source === 'acme' || source === 'provision-function';
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
for (const domain of routeDomains) {
const existing = domainMap.get(domain);
if (existing) {
// Add this route name to the existing domain entry
if (!existing.routeNames.includes(route.name)) {
existing.routeNames.push(route.name);
}
// Upgrade source if more specific
if (existing.source === 'none' && source !== 'none') {
existing.source = source;
existing.canReprovision = canReprovision;
}
} else {
domainMap.set(domain, {
routeNames: [route.name],
source,
tlsMode,
canReprovision,
});
}
}
}
// Phase 2: Resolve status for each unique domain
const certificates: interfaces.requests.ICertificateInfo[] = [];
for (const [domain, info] of domainMap) {
let status: interfaces.requests.TCertificateStatus = 'unknown';
let expiryDate: string | undefined;
let issuedAt: string | undefined;
let issuer: string | undefined;
let error: string | undefined;
// Check event-based status from certificateStatusMap (now keyed by domain)
const eventStatus = dcRouter.certificateStatusMap.get(domain);
if (eventStatus) {
status = eventStatus.status;
expiryDate = eventStatus.expiryDate;
issuedAt = eventStatus.issuedAt;
error = eventStatus.error;
if (eventStatus.source) {
issuer = eventStatus.source;
}
}
// Try SmartProxy certificate status if no event data
if (status === 'unknown' && info.routeNames.length > 0) {
try {
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
if (rustStatus) {
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
if (rustStatus.issuer) issuer = rustStatus.issuer;
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
status = rustStatus.status;
}
}
} catch {
// Rust bridge may not support this command yet — ignore
}
}
// Check persisted cert data from StorageManager
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (!certData) {
// Also check certStore path (proxy-certs)
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
}
if (certData?.validUntil) {
expiryDate = new Date(certData.validUntil).toISOString();
if (certData.created) {
issuedAt = new Date(certData.created).toISOString();
}
issuer = 'smartacme-dns-01';
} else if (certData?.publicKey) {
// certStore has the cert — parse PEM for expiry
try {
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
expiryDate = new Date(x509.validTo).toISOString();
issuedAt = new Date(x509.validFrom).toISOString();
} catch { /* PEM parsing failed */ }
status = 'valid';
issuer = 'cert-store';
} else if (certData) {
status = 'valid';
issuer = 'cert-store';
}
}
// Compute status from expiry date
if (expiryDate && (status === 'valid' || status === 'unknown')) {
const expiry = new Date(expiryDate);
const now = new Date();
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
if (daysUntilExpiry < 0) {
status = 'expired';
} else if (daysUntilExpiry < 30) {
status = 'expiring';
} else {
status = 'valid';
}
}
// Static certs with no other info default to 'valid'
if (info.source === 'static' && status === 'unknown') {
status = 'valid';
}
// ACME/provision-function routes with no cert data are still provisioning
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
status = 'provisioning';
}
// Phase 3: Attach backoff info
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
if (dcRouter.certProvisionScheduler) {
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
if (bi) {
backoffInfo = bi;
}
}
certificates.push({
domain,
routeNames: info.routeNames,
status,
source: info.source,
tlsMode: info.tlsMode,
expiryDate,
issuer,
issuedAt,
error,
canReprovision: info.canReprovision,
backoffInfo,
});
}
return certificates;
}
private buildSummary(certificates: interfaces.requests.ICertificateInfo[]): {
total: number;
valid: number;
expiring: number;
expired: number;
failed: number;
unknown: number;
} {
const summary = { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 };
summary.total = certificates.length;
for (const cert of certificates) {
switch (cert.status) {
case 'valid': summary.valid++; break;
case 'expiring': summary.expiring++; break;
case 'expired': summary.expired++; break;
case 'failed': summary.failed++; break;
case 'provisioning': // count as unknown
case 'unknown': summary.unknown++; break;
}
}
return summary;
}
/**
* Legacy route-based reprovisioning
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) {
return { success: false, message: 'SmartProxy is not running' };
}
try {
await smartProxy.provisionCertificate(routeName);
// Clear event-based status for domains in this route
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err) {
return { success: false, message: err.message || 'Failed to reprovision certificate' };
}
}
/**
* Domain-based reprovisioning — clears backoff first, then triggers provision
*/
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear backoff for this domain (user override)
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
// Clear status map entry so it gets refreshed
dcRouter.certificateStatusMap.delete(domain);
// Try to provision via SmartAcme directly
if (dcRouter.smartAcme) {
try {
await dcRouter.smartAcme.getCertificateForDomain(domain);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
}
}
// Fallback: try provisioning via the first matching route
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length > 0) {
try {
await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
} catch (err) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
}
}
return { success: false, message: `No routes found for domain '${domain}'` };
}
/**
* Delete certificate data for a domain from storage
*/
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
// Delete from all known storage paths
const paths = [
`/proxy-certs/${domain}`,
`/proxy-certs/${cleanDomain}`,
`/certs/${cleanDomain}`,
];
for (const path of paths) {
try {
await dcRouter.storageManager.delete(path);
} catch {
// Path may not exist — ignore
}
}
// Clear from in-memory status map
dcRouter.certificateStatusMap.delete(domain);
// Clear backoff info
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
return { success: true, message: `Certificate data deleted for '${domain}'` };
}
/**
* Export certificate data for a domain as ICert-shaped JSON
*/
private async exportCertificate(domain: string): Promise<{
success: boolean;
cert?: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
message?: string;
}> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
// Try SmartAcme /certs/ path first (has full ICert fields)
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certData && certData.publicKey && certData.privateKey) {
return {
success: true,
cert: {
id: certData.id || plugins.crypto.randomUUID(),
domainName: certData.domainName || domain,
created: certData.created || Date.now(),
validUntil: certData.validUntil || 0,
privateKey: certData.privateKey,
publicKey: certData.publicKey,
csr: certData.csr || '',
},
};
}
// Fallback: try /proxy-certs/ with original domain
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
if (!certData || !certData.publicKey) {
// Try with clean domain
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
}
if (certData && certData.publicKey && certData.privateKey) {
return {
success: true,
cert: {
id: plugins.crypto.randomUUID(),
domainName: domain,
created: certData.validFrom || Date.now(),
validUntil: certData.validUntil || 0,
privateKey: certData.privateKey,
publicKey: certData.publicKey,
csr: '',
},
};
}
return { success: false, message: `No certificate data found for '${domain}'` };
}
/**
* Import a certificate from ICert-shaped JSON
*/
private async importCertificate(cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
}): Promise<{ success: boolean; message?: string }> {
// Validate PEM content
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
}
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
}
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
// Save to /certs/ (SmartAcme-compatible path)
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
validUntil: cert.validUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr || '',
});
// Also save to /proxy-certs/ (proxy-cert format)
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
domain: cert.domainName,
publicKey: cert.publicKey,
privateKey: cert.privateKey,
ca: undefined,
validUntil: cert.validUntil,
validFrom: cert.created,
});
// Update in-memory status map
dcRouter.certificateStatusMap.set(cert.domainName, {
status: 'valid',
source: 'static',
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
routeNames: [],
});
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
}
}

View File

@@ -0,0 +1,214 @@
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class ConfigHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Get Configuration Handler (read-only)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration',
async (dataArg, toolsArg) => {
const config = await this.getConfiguration();
return {
config,
section: dataArg.section,
};
}
)
);
}
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
const dcRouter = this.opsServerRef.dcRouterRef;
const opts = dcRouter.options;
const resolvedPaths = dcRouter.resolvedPaths;
// --- System ---
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction
? 'custom'
: opts.storage?.fsPath
? 'filesystem'
: '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'] = {
baseDir: resolvedPaths.dcrouterHomeDir,
dataDir: resolvedPaths.dataDir,
publicIp: opts.publicIp || dcRouter.detectedPublicIp || null,
proxyIps,
uptime: Math.floor(process.uptime()),
storageBackend,
storagePath: opts.storage?.fsPath || null,
};
// --- SmartProxy ---
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
if (opts.smartProxyConfig?.acme) {
const acme = opts.smartProxyConfig.acme;
acmeInfo = {
enabled: acme.enabled !== false,
accountEmail: acme.accountEmail || '',
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays || 30,
};
}
let routeCount = 0;
if (dcRouter.routeConfigManager) {
try {
const merged = await dcRouter.routeConfigManager.getMergedRoutes();
routeCount = merged.routes.length;
} catch {
routeCount = opts.smartProxyConfig?.routes?.length || 0;
}
} else if (opts.smartProxyConfig?.routes) {
routeCount = opts.smartProxyConfig.routes.length;
}
const smartProxy: interfaces.requests.IConfigData['smartProxy'] = {
enabled: !!dcRouter.smartProxy,
routeCount,
acme: acmeInfo,
};
// --- Email ---
let emailDomains: string[] = [];
if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
} else if (opts.emailConfig?.domains) {
emailDomains = opts.emailConfig.domains.map((d: any) =>
typeof d === 'string' ? d : d.domain
);
}
let portMapping: Record<string, number> | null = null;
if (opts.emailPortConfig?.portMapping) {
portMapping = {};
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
portMapping[String(ext)] = int as number;
}
}
const email: interfaces.requests.IConfigData['email'] = {
enabled: !!dcRouter.emailServer,
ports: opts.emailConfig?.ports || [],
portMapping,
hostname: opts.emailConfig?.hostname || null,
domains: emailDomains,
emailRouteCount: opts.emailConfig?.routes?.length || 0,
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
};
// --- DNS ---
const dnsRecords = (opts.dnsRecords || []).map(r => ({
name: r.name,
type: r.type,
value: r.value,
ttl: r.ttl,
}));
const dns: interfaces.requests.IConfigData['dns'] = {
enabled: !!dcRouter.dnsServer,
port: 53,
nsDomains: opts.dnsNsDomains || [],
scopes: opts.dnsScopes || [],
recordCount: dnsRecords.length,
records: dnsRecords,
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
};
// --- TLS ---
let tlsSource: 'acme' | 'static' | 'none' = 'none';
if (opts.tls?.certPath && opts.tls?.keyPath) {
tlsSource = 'static';
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
tlsSource = 'acme';
}
const tls: interfaces.requests.IConfigData['tls'] = {
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
domain: opts.tls?.domain || null,
source: tlsSource,
certPath: opts.tls?.certPath || null,
keyPath: opts.tls?.keyPath || null,
};
// --- Cache ---
const cacheConfig = opts.cacheConfig;
const cache: interfaces.requests.IConfigData['cache'] = {
enabled: cacheConfig?.enabled !== false,
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
dbName: cacheConfig?.dbName || 'dcrouter',
defaultTTLDays: cacheConfig?.defaultTTLDays || 30,
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1,
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {},
};
// --- RADIUS ---
const radiusCfg = opts.radiusConfig;
const radius: interfaces.requests.IConfigData['radius'] = {
enabled: !!dcRouter.radiusServer,
authPort: radiusCfg?.authPort || null,
acctPort: radiusCfg?.acctPort || null,
bindAddress: radiusCfg?.bindAddress || null,
clientCount: radiusCfg?.clients?.length || 0,
vlanDefaultVlan: radiusCfg?.vlanAssignment?.defaultVlan ?? null,
vlanAllowUnknownMacs: radiusCfg?.vlanAssignment?.allowUnknownMacs ?? null,
vlanMappingCount: radiusCfg?.vlanAssignment?.mappings?.length || 0,
};
// --- Remote Ingress ---
const riCfg = opts.remoteIngressConfig;
const 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'] = {
enabled: !!dcRouter.remoteIngressManager,
tunnelPort: riCfg?.tunnelPort || null,
hubDomain: riCfg?.hubDomain || null,
tlsMode,
connectedEdgeIps,
};
return {
system,
smartProxy,
email,
dns,
tls,
cache,
radius,
remoteIngress,
};
}
}

View File

@@ -0,0 +1,273 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class EmailOpsHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get All Emails Handler
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getAllEmails',
async (dataArg) => {
const emails = this.getAllQueueEmails();
return { emails };
}
)
);
// Get Email Detail Handler
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
async (dataArg) => {
const email = this.getEmailDetail(dataArg.emailId);
return { email };
}
)
);
// ---- Write endpoints (adminRouter) ----
// Resend Failed Email Handler
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
'resendEmail',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return { success: false, error: 'Email server not available' };
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(dataArg.emailId);
if (!item) {
return { success: false, error: 'Email not found in queue' };
}
if (item.status !== 'failed') {
return { success: false, error: `Email is not in failed state (current: ${item.status})` };
}
try {
const newQueueId = await queue.enqueue(
item.processingResult,
item.processingMode,
item.route
);
await queue.removeItem(dataArg.emailId);
return { success: true, newQueueId };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to resend email'
};
}
}
)
);
}
/**
* Get all queue items mapped to catalog IEmail format
*/
private getAllQueueEmails(): interfaces.requests.IEmail[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return [];
}
const queue = emailServer.deliveryQueue;
const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) {
return [];
}
const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) {
emails.push(this.mapQueueItemToEmail(item));
}
// Sort by createdAt descending (newest first)
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
return emails;
}
/**
* Get a single email detail by ID
*/
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return null;
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(emailId);
if (!item) {
return null;
}
return this.mapQueueItemToEmailDetail(item);
}
/**
* Map a queue item to catalog IEmail format
*/
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
const processingResult = item.processingResult;
let from = '';
let to = '';
let subject = '';
let messageId = '';
let size = '0 B';
if (processingResult) {
if (processingResult.email) {
from = processingResult.email.from || '';
to = (processingResult.email.to || [])[0] || '';
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = (processingResult.to || [])[0] || '';
subject = processingResult.subject || '';
}
// Try to get messageId
if (typeof processingResult.getMessageId === 'function') {
try {
messageId = processingResult.getMessageId() || '';
} catch {
messageId = '';
}
}
// Compute approximate size
const textLen = processingResult.text?.length || 0;
const htmlLen = processingResult.html?.length || 0;
let attachSize = 0;
if (typeof processingResult.getAttachmentsSize === 'function') {
try {
attachSize = processingResult.getAttachmentsSize() || 0;
} catch {
attachSize = 0;
}
}
size = this.formatSize(textLen + htmlLen + attachSize);
}
// Map queue status to catalog TEmailStatus
const status = this.mapStatus(item.status);
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
return {
id: item.id,
direction: 'outbound' as interfaces.requests.TEmailDirection,
status,
from,
to,
subject,
timestamp: new Date(createdAt).toISOString(),
messageId,
size,
};
}
/**
* Map a queue item to catalog IEmailDetail format
*/
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
const base = this.mapQueueItemToEmail(item);
const processingResult = item.processingResult;
let toList: string[] = [];
let cc: string[] = [];
let headers: Record<string, string> = {};
let body = '';
if (processingResult) {
if (processingResult.email) {
toList = processingResult.email.to || [];
cc = processingResult.email.cc || [];
} else {
toList = processingResult.to || [];
cc = processingResult.cc || [];
}
headers = processingResult.headers || {};
body = processingResult.html || processingResult.text || '';
}
return {
...base,
toList,
cc,
smtpLog: [],
connectionInfo: {
sourceIp: '',
sourceHostname: '',
destinationIp: '',
destinationPort: 0,
tlsVersion: '',
tlsCipher: '',
authenticated: false,
authMethod: '',
authUser: '',
},
authenticationResults: {
spf: 'none',
spfDomain: '',
dkim: 'none',
dkimDomain: '',
dmarc: 'none',
dmarcPolicy: '',
},
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
headers,
body,
};
}
/**
* Map queue status to catalog TEmailStatus
*/
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
switch (queueStatus) {
case 'pending':
case 'processing':
return 'pending';
case 'delivered':
return 'delivered';
case 'failed':
return 'bounced';
case 'deferred':
return 'deferred';
default:
return 'pending';
}
}
/**
* Format byte size to human-readable string
*/
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}

View File

@@ -0,0 +1,11 @@
export * from './admin.handler.js';
export * from './config.handler.js';
export * from './logs.handler.js';
export * from './security.handler.js';
export * from './stats.handler.js';
export * from './radius.handler.js';
export * from './email-ops.handler.js';
export * from './certificate.handler.js';
export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';

View File

@@ -0,0 +1,340 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.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 {
private activeStreamStops: Set<() => void> = new Set();
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
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 {
// All log endpoints register directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Get Recent Logs Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
'getRecentLogs',
async (dataArg, toolsArg) => {
const logs = await this.getRecentLogs(
dataArg.level,
dataArg.category,
dataArg.limit || 100,
dataArg.offset || 0,
dataArg.search,
dataArg.timeRange
);
return {
logs,
total: logs.length,
hasMore: false,
};
}
)
);
// Get Log Stream Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
'getLogStream',
async (dataArg, toolsArg) => {
// Create a virtual stream for log streaming
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
// Set up log streaming
const streamLogs = this.setupLogStream(
virtualStream,
dataArg.filters?.level,
dataArg.filters?.category,
dataArg.follow
);
// Start streaming
streamLogs.start();
// Track the stop function so we can clean up on shutdown
this.activeStreamStops.add(streamLogs.stop);
return {
logStream: virtualStream as any,
};
}
)
);
}
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
switch (smartlogLevel) {
case 'silly':
case 'debug':
return 'debug';
case 'warn':
return 'warn';
case 'error':
return 'error';
default:
return 'info';
}
}
private static deriveCategory(
zone?: string,
message?: string
): 'smtp' | 'dns' | 'security' | 'system' | 'email' {
const msg = (message || '').toLowerCase();
if (msg.includes('[security:') || msg.includes('security')) return 'security';
if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email';
if (zone === 'dns' || msg.includes('dns')) return 'dns';
if (msg.includes('smtp')) return 'smtp';
return 'system';
}
private async getRecentLogs(
level?: 'error' | 'warn' | 'info' | 'debug',
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
limit: number = 100,
offset: number = 0,
search?: string,
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d'
): Promise<Array<{
timestamp: number;
level: 'debug' | 'info' | 'warn' | 'error';
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
message: string;
metadata?: any;
}>> {
// Compute a timestamp cutoff from timeRange
let since: number | undefined;
if (timeRange) {
const rangeMs: Record<string, number> = {
'1h': 3600000,
'6h': 21600000,
'24h': 86400000,
'7d': 604800000,
'30d': 2592000000,
};
since = Date.now() - (rangeMs[timeRange] || 86400000);
}
// Map the UI level to smartlog levels for filtering
const smartlogLevels: string[] | undefined = level
? level === 'debug'
? ['debug', 'silly']
: level === 'info'
? ['info', 'ok', 'success', 'note', 'lifecycle']
: [level]
: undefined;
// Fetch a larger batch from buffer, then apply category filter client-side
const rawEntries = logBuffer.getEntries({
level: smartlogLevels as any,
search,
since,
limit: limit * 3, // over-fetch to compensate for category filtering
offset: 0,
});
// Map ILogPackage → UI log format and apply category filter
const mapped: Array<{
timestamp: number;
level: 'debug' | 'info' | 'warn' | 'error';
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
message: string;
metadata?: any;
}> = [];
for (const pkg of rawEntries) {
const uiLevel = LogsHandler.mapLogLevel(pkg.level);
const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message);
if (category && uiCategory !== category) continue;
mapped.push({
timestamp: pkg.timestamp,
level: uiLevel,
category: uiCategory,
message: pkg.message,
metadata: pkg.data,
});
if (mapped.length >= limit) break;
}
return mapped;
}
/**
* Add a log destination to the base logger that pushes entries
* 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 {
// 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({
async handleLog(logPackage: any) {
const opsServer = currentOpsServerRef;
if (!opsServer) return;
const typedsocket = opsServer.server?.typedserver?.typedsocket;
if (!typedsocket) return;
let connections: any[];
try {
connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
} catch {
return;
}
if (connections.length === 0) return;
const entry: interfaces.data.ILogEntry = {
timestamp: logPackage.timestamp || Date.now(),
level: LogsHandler.mapLogLevel(logPackage.level),
category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message),
message: logPackage.message,
metadata: logPackage.data,
};
for (const conn of connections) {
try {
const push = typedsocket.createTypedRequest<interfaces.requests.IReq_PushLogEntry>(
'pushLogEntry',
conn,
);
push.fire({ entry }).catch(() => {}); // fire-and-forget
} catch {
// connection may have closed
}
}
},
});
}
private setupLogStream(
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
levelFilter?: string[],
categoryFilter?: string[],
follow: boolean = true
): {
start: () => void;
stop: () => void;
} {
let intervalId: NodeJS.Timeout | null = null;
let stopped = false;
let logIndex = 0;
const stop = () => {
stopped = true;
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
this.activeStreamStops.delete(stop);
};
const start = () => {
if (!follow) {
// Send existing logs and close
this.getRecentLogs(
levelFilter?.[0] as any,
categoryFilter?.[0] as any,
100,
0
).then(logs => {
logs.forEach(log => {
const logData = JSON.stringify(log);
const encoder = new TextEncoder();
virtualStream.sendData(encoder.encode(logData));
});
});
return;
}
// For follow mode, simulate real-time log streaming
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 levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
// Filter by requested criteria
if (levelFilter && !levelFilter.includes(mockLevel)) return;
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
const logEntry = {
timestamp: Date.now(),
level: mockLevel,
category: mockCategory,
message: `Real-time log ${logIndex++} from ${mockCategory}`,
metadata: {
requestId: plugins.uuid.v4(),
},
};
const logData = JSON.stringify(logEntry);
const encoder = new TextEncoder();
try {
// 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 {
// Stream closed, errored, or timed out — clean up
stop();
}
}, 2000);
};
return { start, stop };
}
}

View File

@@ -0,0 +1,403 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class RadiusHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ========================================================================
// RADIUS Client Management
// ========================================================================
// Get all RADIUS clients (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
'getRadiusClients',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { clients: [] };
}
const clients = radiusServer.getClients();
return {
clients: clients.map(c => ({
name: c.name,
ipRange: c.ipRange,
description: c.description,
enabled: c.enabled,
})),
};
}
)
);
// Add or update a RADIUS client (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
'setRadiusClient',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
try {
await radiusServer.addClient(dataArg.client);
return { success: true };
} catch (error) {
return { success: false, message: error.message };
}
}
)
);
// Remove a RADIUS client (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
'removeRadiusClient',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
const removed = radiusServer.removeClient(dataArg.name);
return {
success: removed,
message: removed ? undefined : 'Client not found',
};
}
)
);
// ========================================================================
// VLAN Mapping Management
// ========================================================================
// Get all VLAN mappings (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
'getVlanMappings',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
mappings: [],
config: { defaultVlan: 1, allowUnknownMacs: true },
};
}
const vlanManager = radiusServer.getVlanManager();
const mappings = vlanManager.getAllMappings();
const config = vlanManager.getConfig();
return {
mappings: mappings.map(m => ({
mac: m.mac,
vlan: m.vlan,
description: m.description,
enabled: m.enabled,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
})),
config: {
defaultVlan: config.defaultVlan,
allowUnknownMacs: config.allowUnknownMacs,
},
};
}
)
);
// Add or update a VLAN mapping (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
'setVlanMapping',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
try {
const vlanManager = radiusServer.getVlanManager();
const mapping = await vlanManager.addMapping(dataArg.mapping);
return {
success: true,
mapping: {
mac: mapping.mac,
vlan: mapping.vlan,
description: mapping.description,
enabled: mapping.enabled,
createdAt: mapping.createdAt,
updatedAt: mapping.updatedAt,
},
};
} catch (error) {
return { success: false, message: error.message };
}
}
)
);
// Remove a VLAN mapping (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
'removeVlanMapping',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
const vlanManager = radiusServer.getVlanManager();
const removed = await vlanManager.removeMapping(dataArg.mac);
return {
success: removed,
message: removed ? undefined : 'Mapping not found',
};
}
)
);
// Update VLAN configuration (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
'updateVlanConfig',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
success: false,
config: { defaultVlan: 1, allowUnknownMacs: true },
};
}
const vlanManager = radiusServer.getVlanManager();
vlanManager.updateConfig({
defaultVlan: dataArg.defaultVlan,
allowUnknownMacs: dataArg.allowUnknownMacs,
});
const config = vlanManager.getConfig();
return {
success: true,
config: {
defaultVlan: config.defaultVlan,
allowUnknownMacs: config.allowUnknownMacs,
},
};
}
)
);
// Test VLAN assignment (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
'testVlanAssignment',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { assigned: false, vlan: 0, isDefault: false };
}
const vlanManager = radiusServer.getVlanManager();
const result = vlanManager.assignVlan(dataArg.mac);
return {
assigned: result.assigned,
vlan: result.vlan,
isDefault: result.isDefault,
matchedRule: result.matchedRule
? {
mac: result.matchedRule.mac,
vlan: result.matchedRule.vlan,
description: result.matchedRule.description,
}
: undefined,
};
}
)
);
// ========================================================================
// Accounting / Session Management
// ========================================================================
// Get active sessions (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
'getRadiusSessions',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { sessions: [], totalCount: 0 };
}
const accountingManager = radiusServer.getAccountingManager();
let sessions = accountingManager.getActiveSessions();
// Apply filters
if (dataArg.filter) {
if (dataArg.filter.username) {
sessions = sessions.filter(s => s.username === dataArg.filter!.username);
}
if (dataArg.filter.nasIpAddress) {
sessions = sessions.filter(s => s.nasIpAddress === dataArg.filter!.nasIpAddress);
}
if (dataArg.filter.vlanId !== undefined) {
sessions = sessions.filter(s => s.vlanId === dataArg.filter!.vlanId);
}
}
return {
sessions: sessions.map(s => ({
sessionId: s.sessionId,
username: s.username,
macAddress: s.macAddress,
nasIpAddress: s.nasIpAddress,
nasIdentifier: s.nasIdentifier,
vlanId: s.vlanId,
framedIpAddress: s.framedIpAddress,
startTime: s.startTime,
lastUpdateTime: s.lastUpdateTime,
status: s.status,
inputOctets: s.inputOctets,
outputOctets: s.outputOctets,
sessionTime: s.sessionTime,
})),
totalCount: sessions.length,
};
}
)
);
// Disconnect a session (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
'disconnectRadiusSession',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
const accountingManager = radiusServer.getAccountingManager();
const disconnected = await accountingManager.disconnectSession(
dataArg.sessionId,
dataArg.reason || 'AdminReset'
);
return {
success: disconnected,
message: disconnected ? undefined : 'Session not found',
};
}
)
);
// Get accounting summary (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
'getRadiusAccountingSummary',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
summary: {
periodStart: dataArg.startTime,
periodEnd: dataArg.endTime,
totalSessions: 0,
activeSessions: 0,
totalInputBytes: 0,
totalOutputBytes: 0,
totalSessionTime: 0,
averageSessionDuration: 0,
uniqueUsers: 0,
sessionsByVlan: {},
topUsersByTraffic: [],
},
};
}
const accountingManager = radiusServer.getAccountingManager();
const summary = await accountingManager.getSummary(dataArg.startTime, dataArg.endTime);
return { summary };
}
)
);
// ========================================================================
// Statistics
// ========================================================================
// Get RADIUS statistics (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
'getRadiusStatistics',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
stats: {
running: false,
uptime: 0,
authRequests: 0,
authAccepts: 0,
authRejects: 0,
accountingRequests: 0,
activeSessions: 0,
vlanMappings: 0,
clients: 0,
},
vlanStats: {
totalMappings: 0,
enabledMappings: 0,
exactMatches: 0,
ouiPatterns: 0,
wildcardPatterns: 0,
},
accountingStats: {
activeSessions: 0,
totalSessionsStarted: 0,
totalSessionsStopped: 0,
totalInputBytes: 0,
totalOutputBytes: 0,
interimUpdatesReceived: 0,
},
};
}
const stats = radiusServer.getStats();
const vlanStats = radiusServer.getVlanManager().getStats();
const accountingStats = radiusServer.getAccountingManager().getStats();
return {
stats,
vlanStats,
accountingStats,
};
}
)
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,332 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
export class SecurityHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
// All security endpoints register directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Security Metrics Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
'getSecurityMetrics',
async (dataArg, toolsArg) => {
const metrics = await this.collectSecurityMetrics();
return {
metrics: {
blockedIPs: metrics.blockedIPs,
reputationScores: metrics.reputationScores,
spamDetected: metrics.spamDetection.detected,
malwareDetected: metrics.malwareDetected,
phishingDetected: metrics.phishingDetected,
authenticationFailures: metrics.authFailures,
suspiciousActivities: metrics.suspiciousActivities,
},
trends: dataArg.includeDetails ? {
spam: metrics.trends.spam,
malware: metrics.trends.malware,
phishing: metrics.trends.phishing,
} : undefined,
};
}
)
);
// Active Connections Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
'getActiveConnections',
async (dataArg, toolsArg) => {
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
id: conn.id,
remoteAddress: conn.source.ip,
localAddress: conn.destination.ip,
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any,
bytesReceived: Math.floor(conn.bytesTransferred / 2),
bytesSent: Math.floor(conn.bytesTransferred / 2),
}));
const summary = {
total: connectionInfos.length,
byProtocol: connectionInfos.reduce((acc, conn) => {
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1;
return acc;
}, {} as { [protocol: string]: number }),
byState: connectionInfos.reduce((acc, conn) => {
acc[conn.state] = (acc[conn.state] || 0) + 1;
return acc;
}, {} as { [state: string]: number }),
};
return {
connections: connectionInfos,
summary,
};
}
)
);
// Network Stats Handler - provides comprehensive network metrics
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats',
async (dataArg, toolsArg) => {
// Get network stats from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Convert per-IP throughput Map to serializable array
const throughputByIP: Array<{ ip: string; in: number; out: number }> = [];
if (networkStats.throughputByIP) {
for (const [ip, tp] of networkStats.throughputByIP) {
throughputByIP.push({ ip, in: tp.in, out: tp.out });
}
}
return {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0,
};
}
// Fallback if MetricsManager not available
return {
connectionsByIP: [],
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
requestsPerSecond: 0,
requestsTotal: 0,
};
}
)
);
// Rate Limit Status Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
'getRateLimitStatus',
async (dataArg, toolsArg) => {
const status = await this.getRateLimitStatus(dataArg.domain, dataArg.ip);
const limits: interfaces.data.IRateLimitInfo[] = status.limits.map(limit => ({
domain: limit.identifier,
currentRate: limit.current,
limit: limit.limit,
remaining: limit.limit - limit.current,
resetTime: limit.resetAt,
blocked: limit.status === 'limited',
}));
return {
limits,
globalLimit: dataArg.includeBlocked ? {
current: limits.reduce((sum, l) => sum + l.currentRate, 0),
limit: 1000, // Global limit
remaining: 1000 - limits.reduce((sum, l) => sum + l.currentRate, 0),
} : undefined,
};
}
)
);
}
private async collectSecurityMetrics(): Promise<{
blockedIPs: string[];
reputationScores: { [domain: string]: number };
spamDetection: {
detected: number;
falsePositives: number;
};
malwareDetected: number;
phishingDetected: number;
authFailures: number;
suspiciousActivities: number;
trends: {
spam: Array<{ timestamp: number; value: number }>;
malware: Array<{ timestamp: number; value: number }>;
phishing: Array<{ timestamp: number; value: number }>;
};
}> {
// Get metrics from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const securityStats = await this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats();
return {
blockedIPs: [], // TODO: Track actual blocked IPs
reputationScores: {},
spamDetection: {
detected: securityStats.spamDetected,
falsePositives: 0,
},
malwareDetected: securityStats.malwareDetected,
phishingDetected: securityStats.phishingDetected,
authFailures: securityStats.authFailures,
suspiciousActivities: 0,
trends: {
spam: [],
malware: [],
phishing: [],
},
};
}
// Fallback if MetricsManager not available
return {
blockedIPs: [],
reputationScores: {},
spamDetection: {
detected: 0,
falsePositives: 0,
},
malwareDetected: 0,
phishingDetected: 0,
authFailures: 0,
suspiciousActivities: 0,
trends: {
spam: [],
malware: [],
phishing: [],
},
};
}
private async getActiveConnections(
protocol?: 'http' | 'https' | 'smtp' | 'smtps',
state?: string
): Promise<Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}>> {
const connections: Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}> = [];
// Get connection info and network stats from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Use IP-based connection data from the new metrics API
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
for (const [ip, count] of networkStats.connectionsByIP) {
// Create a connection entry for each active IP connection
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
connections.push({
id: `conn-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: Math.floor(Math.random() * 50000) + 10000, // High port range
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
status: 'active',
});
}
}
} else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available
connectionInfo.forEach((info, index) => {
connections.push({
id: `conn-${index}`,
type: 'http',
source: {
ip: 'unknown',
port: 0,
},
destination: {
ip: this.opsServerRef.dcRouterRef.options.publicIp || 'server',
port: 443,
service: info.source,
},
startTime: info.lastActivity.getTime(),
bytesTransferred: 0,
status: 'active',
});
});
}
}
// Filter by protocol if specified
if (protocol) {
return connections.filter(conn => {
if (protocol === 'https' || protocol === 'http') {
return conn.type === 'http';
}
return conn.type === protocol.replace('s', ''); // smtp/smtps -> smtp
});
}
return connections;
}
private async getRateLimitStatus(
domain?: string,
ip?: string
): Promise<{
limits: Array<{
identifier: string;
type: 'ip' | 'domain' | 'email';
limit: number;
current: number;
resetAt: number;
status: 'ok' | 'warning' | 'limited';
}>;
}> {
// TODO: Implement actual rate limit status collection
return {
limits: [],
};
}
}

View File

@@ -0,0 +1,542 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
import { SecurityLogger } from '../../security/classes.securitylogger.js';
export class StatsHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
// All stats endpoints register directly on viewRouter (valid identity required via middleware)
const router = this.opsServerRef.viewRouter;
// Server Statistics Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
'getServerStatistics',
async (dataArg, toolsArg) => {
const stats = await this.collectServerStats();
return {
stats: {
uptime: stats.uptime,
startTime: Date.now() - (stats.uptime * 1000),
memoryUsage: stats.memoryUsage,
cpuUsage: stats.cpuUsage,
activeConnections: stats.activeConnections,
totalConnections: stats.totalConnections,
requestsPerSecond: stats.requestsPerSecond,
throughput: stats.throughput,
},
history: dataArg.includeHistory ? stats.history : undefined,
};
}
)
);
// Email Statistics Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
'getEmailStatistics',
async (dataArg, toolsArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return {
stats: {
sent: 0,
received: 0,
bounced: 0,
queued: 0,
failed: 0,
averageDeliveryTime: 0,
deliveryRate: 0,
bounceRate: 0,
},
};
}
const stats = await this.collectEmailStats();
return {
stats: {
sent: stats.sentToday,
received: stats.receivedToday,
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
queued: stats.queueSize,
failed: 0,
averageDeliveryTime: 0,
deliveryRate: stats.deliveryRate,
bounceRate: stats.bounceRate,
},
domainBreakdown: dataArg.includeDetails ? stats.domainBreakdown : undefined,
};
}
)
);
// DNS Statistics Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
'getDnsStatistics',
async (dataArg, toolsArg) => {
const dnsServer = this.opsServerRef.dcRouterRef.dnsServer;
if (!dnsServer) {
return {
stats: {
totalQueries: 0,
cacheHits: 0,
cacheMisses: 0,
cacheHitRate: 0,
activeDomains: 0,
averageResponseTime: 0,
queryTypes: {},
},
};
}
const stats = await this.collectDnsStats();
return {
stats: {
totalQueries: stats.totalQueries,
cacheHits: stats.cacheHits,
cacheMisses: stats.cacheMisses,
cacheHitRate: stats.cacheHitRate,
activeDomains: stats.topDomains.length,
averageResponseTime: 0,
queryTypes: stats.queryTypes,
},
domainBreakdown: dataArg.includeQueryTypes ? stats.domainBreakdown : undefined,
};
}
)
);
// Queue Status Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
'getQueueStatus',
async (dataArg, toolsArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
const queues: interfaces.data.IQueueStatus[] = [];
if (emailServer) {
const status = await this.getQueueStatus();
queues.push({
name: dataArg.queueName || 'default',
size: status.pending,
processing: status.active,
failed: status.failed,
retrying: status.retrying,
averageProcessingTime: 0,
});
}
return {
queues,
totalItems: queues.reduce((sum, q) => sum + q.size + q.processing + q.failed + q.retrying, 0),
};
}
)
);
// Health Status Handler
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
'getHealthStatus',
async (dataArg, toolsArg) => {
const health = await this.checkHealthStatus();
return {
health: {
healthy: health.healthy,
uptime: process.uptime(),
services: health.services.reduce((acc, service) => {
acc[service.name] = {
status: service.status,
message: service.message,
lastCheck: Date.now(),
};
return acc;
}, {} as any),
version: '2.12.0', // TODO: Get from package.json
},
};
}
)
);
// Combined Metrics Handler - More efficient for frontend polling
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
'getCombinedMetrics',
async (dataArg, toolsArg) => {
const sections = dataArg.sections || {
server: true,
email: true,
dns: true,
security: true,
network: true,
};
const metrics: any = {};
// Run all metrics collection in parallel
const promises: Promise<void>[] = [];
if (sections.server) {
promises.push(
this.collectServerStats().then(stats => {
metrics.server = {
uptime: stats.uptime,
startTime: Date.now() - (stats.uptime * 1000),
memoryUsage: stats.memoryUsage,
cpuUsage: stats.cpuUsage,
activeConnections: stats.activeConnections,
totalConnections: stats.totalConnections,
requestsPerSecond: stats.requestsPerSecond,
throughput: stats.throughput,
};
})
);
}
if (sections.email) {
promises.push(
this.collectEmailStats().then(stats => {
// Get time-series data from MetricsManager
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
? this.opsServerRef.dcRouterRef.metricsManager.getEmailTimeSeries(24)
: undefined;
metrics.email = {
sent: stats.sentToday,
received: stats.receivedToday,
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
queued: stats.queueSize,
failed: 0,
averageDeliveryTime: 0,
deliveryRate: stats.deliveryRate,
bounceRate: stats.bounceRate,
timeSeries,
};
})
);
}
if (sections.dns) {
promises.push(
this.collectDnsStats().then(stats => {
// Get time-series data from MetricsManager
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
? this.opsServerRef.dcRouterRef.metricsManager.getDnsTimeSeries(24)
: undefined;
metrics.dns = {
totalQueries: stats.totalQueries,
cacheHits: stats.cacheHits,
cacheMisses: stats.cacheMisses,
cacheHitRate: stats.cacheHitRate,
activeDomains: stats.topDomains.length,
averageResponseTime: 0,
queryTypes: stats.queryTypes,
timeSeries,
recentQueries: stats.recentQueries,
};
})
);
}
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push(
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
// Get recent events from the SecurityLogger singleton
const securityLogger = SecurityLogger.getInstance();
const recentEvents = securityLogger.getRecentEvents(50).map((evt) => ({
timestamp: evt.timestamp,
level: evt.level,
type: evt.type,
message: evt.message,
details: evt.details,
ipAddress: evt.ipAddress,
domain: evt.domain,
success: evt.success,
}));
metrics.security = {
blockedIPs: stats.blockedIPs,
reputationScores: {},
spamDetected: stats.spamDetected,
malwareDetected: stats.malwareDetected,
phishingDetected: stats.phishingDetected,
authenticationFailures: stats.authFailures,
suspiciousActivities: stats.totalThreatsBlocked,
recentEvents,
};
})
);
}
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push(
(async () => {
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
const serverStats = await this.collectServerStats();
// Build per-IP bandwidth lookup from throughputByIP
const ipBandwidth = new Map<string, { in: number; out: number }>();
if (stats.throughputByIP) {
for (const [ip, tp] of stats.throughputByIP) {
ipBandwidth.set(ip, { in: tp.in, out: tp.out });
}
}
metrics.network = {
totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond,
out: stats.throughputRate.bytesOutPerSecond,
},
totalBytes: {
in: stats.totalDataTransferred.bytesIn,
out: stats.totalDataTransferred.bytesOut,
},
activeConnections: serverStats.activeConnections,
connectionDetails: [],
topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip,
requests: ip.count,
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
})),
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0,
};
})()
);
}
await Promise.all(promises);
return {
metrics,
timestamp: Date.now(),
};
}
)
);
}
private async collectServerStats(): Promise<{
uptime: number;
cpuUsage: {
user: number;
system: number;
};
memoryUsage: interfaces.data.IServerStats['memoryUsage'];
requestsPerSecond: number;
activeConnections: number;
totalConnections: number;
throughput: interfaces.data.IServerStats['throughput'];
history: Array<{
timestamp: number;
value: number;
}>;
}> {
// Get metrics from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const serverStats = await this.opsServerRef.dcRouterRef.metricsManager.getServerStats();
return {
uptime: serverStats.uptime,
cpuUsage: serverStats.cpuUsage,
memoryUsage: serverStats.memoryUsage,
requestsPerSecond: serverStats.requestsPerSecond,
activeConnections: serverStats.activeConnections,
totalConnections: serverStats.totalConnections,
throughput: serverStats.throughput,
history: [], // TODO: Implement history tracking
};
}
// Fallback to basic stats if MetricsManager not available
const uptime = process.uptime();
const memUsage = process.memoryUsage();
const cpuUsage = plugins.os.loadavg()[0] * 100 / plugins.os.cpus().length;
return {
uptime,
cpuUsage: {
user: cpuUsage * 0.7,
system: cpuUsage * 0.3,
},
memoryUsage: {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
external: memUsage.external,
rss: memUsage.rss,
},
requestsPerSecond: 0,
activeConnections: 0,
totalConnections: 0,
throughput: { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
history: [],
};
}
private async collectEmailStats(): Promise<{
sentToday: number;
receivedToday: number;
bounceRate: number;
deliveryRate: number;
queueSize: number;
domainBreakdown?: { [domain: string]: interfaces.data.IEmailStats };
}> {
// Get metrics from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const emailStats = await this.opsServerRef.dcRouterRef.metricsManager.getEmailStats();
return {
sentToday: emailStats.sentToday,
receivedToday: emailStats.receivedToday,
bounceRate: emailStats.bounceRate,
deliveryRate: emailStats.deliveryRate,
queueSize: emailStats.queueSize,
};
}
// Fallback if MetricsManager not available
return {
sentToday: 0,
receivedToday: 0,
bounceRate: 0,
deliveryRate: 100,
queueSize: 0,
};
}
private async collectDnsStats(): Promise<{
queriesPerSecond: number;
totalQueries: number;
cacheHits: number;
cacheMisses: number;
cacheHitRate: number;
topDomains: Array<{
domain: string;
count: number;
}>;
queryTypes: { [key: string]: number };
recentQueries?: Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>;
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
}> {
// Get metrics from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const dnsStats = await this.opsServerRef.dcRouterRef.metricsManager.getDnsStats();
return {
queriesPerSecond: dnsStats.queriesPerSecond,
totalQueries: dnsStats.totalQueries,
cacheHits: dnsStats.cacheHits,
cacheMisses: dnsStats.cacheMisses,
cacheHitRate: dnsStats.cacheHitRate,
topDomains: dnsStats.topDomains,
queryTypes: dnsStats.queryTypes,
recentQueries: dnsStats.recentQueries,
};
}
// Fallback if MetricsManager not available
return {
queriesPerSecond: 0,
totalQueries: 0,
cacheHits: 0,
cacheMisses: 0,
cacheHitRate: 0,
topDomains: [],
queryTypes: {},
};
}
private async getQueueStatus(): Promise<{
pending: number;
active: number;
failed: number;
retrying: number;
items: Array<{
id: string;
recipient: string;
subject: string;
status: string;
attempts: number;
nextRetry?: number;
}>;
}> {
// TODO: Implement actual queue status collection
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
};
}
private async checkHealthStatus(): Promise<{
healthy: boolean;
services: Array<{
name: string;
status: 'healthy' | 'degraded' | 'unhealthy';
message?: string;
}>;
checks: Array<{
name: string;
passed: boolean;
message?: string;
}>;
}> {
const services: Array<{
name: string;
status: 'healthy' | 'degraded' | 'unhealthy';
message?: string;
}> = [];
// Check HTTP Proxy
if (this.opsServerRef.dcRouterRef.smartProxy) {
services.push({
name: 'HTTP/HTTPS Proxy',
status: 'healthy',
});
}
// Check Email Server
if (this.opsServerRef.dcRouterRef.emailServer) {
services.push({
name: 'Email Server',
status: 'healthy',
});
}
// Check DNS Server
if (this.opsServerRef.dcRouterRef.dnsServer) {
services.push({
name: 'DNS Server',
status: 'healthy',
});
}
// Check OpsServer
services.push({
name: 'OpsServer',
status: 'healthy',
});
const healthy = services.every(s => s.status === 'healthy');
return {
healthy,
services,
checks: [
{
name: 'Memory Usage',
passed: process.memoryUsage().heapUsed < (plugins.os.totalmem() * 0.9),
message: 'Memory usage within limits',
},
],
};
}
}

View File

@@ -0,0 +1,58 @@
import * as plugins from '../../plugins.js';
import type { AdminHandler } from '../handlers/admin.handler.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* Helper function to use identity guards in handlers
*
* @example
* // In a handler:
* await passGuards(toolsArg, this.opsServerRef.adminHandler.validIdentityGuard, dataArg);
*/
export async function passGuards<T extends { identity?: any }>(
toolsArg: any,
guard: plugins.smartguard.Guard<T>,
dataArg: T
): Promise<void> {
const result = await guard.exec(dataArg);
if (!result) {
const failedHint = await guard.getFailedHint(dataArg);
throw new plugins.typedrequest.TypedResponseError(failedHint || 'Guard check failed');
}
}
/**
* Helper to check admin identity in handlers and middleware.
* Accepts both optional and required identity for flexibility.
*/
export async function requireAdminIdentity(
adminHandler: AdminHandler,
dataArg: { identity?: interfaces.data.IIdentity }
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
}
/**
* Helper to check valid identity in handlers and middleware.
* Accepts both optional and required identity for flexibility.
*/
export async function requireValidIdentity(
adminHandler: AdminHandler,
dataArg: { identity?: interfaces.data.IIdentity }
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
}

1
ts/opsserver/index.ts Normal file
View File

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

55
ts/paths.ts Normal file
View File

@@ -0,0 +1,55 @@
import * as plugins from './plugins.js';
// Code/asset paths (not affected by baseDir)
export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
);
export const distServe = plugins.path.join(packageDir, './dist_serve');
// Default base for all dcrouter data (always user-writable)
export const dcrouterHomeDir = plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
// Configure data directory with environment variable or default to ~/.serve.zone/dcrouter/data
const DEFAULT_DATA_PATH = plugins.path.join(dcrouterHomeDir, 'data');
export const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR
: DEFAULT_DATA_PATH;
// Default TsmDB path for CacheDb
export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb');
// DNS records directory (only surviving MTA directory reference)
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
/**
* Resolve all data paths from a given baseDir.
* When no baseDir is provided, falls back to ~/.serve.zone/dcrouter.
* Specific overrides (e.g. DATA_DIR env) take precedence.
*/
export function resolvePaths(baseDir?: string) {
const root = baseDir ?? plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
const resolvedDataDir = process.env.DATA_DIR ?? plugins.path.join(root, 'data');
return {
dcrouterHomeDir: root,
dataDir: resolvedDataDir,
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
defaultStoragePath: plugins.path.join(root, 'storage'),
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
};
}
/**
* Ensure only the data directories that are actually used exist.
*/
export function ensureDataDirectories(resolvedPaths: ReturnType<typeof resolvePaths>) {
plugins.fsUtils.ensureDirSync(resolvedPaths.dataDir);
plugins.fsUtils.ensureDirSync(resolvedPaths.dnsRecordsDir);
}
/**
* Legacy wrapper — delegates to ensureDataDirectories with module-level defaults.
*/
export function ensureDirectories() {
ensureDataDirectories(resolvePaths());
}

View File

@@ -1,6 +0,0 @@
import * as plugins from './platformservice.plugins.js';
export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
);

View File

@@ -1,24 +0,0 @@
// node native
import * as path from 'path';
export {
path
}
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
import * as typedserver from '@api.global/typedserver';
export {
typedrequest,
typedserver,
}
// pushrocks scope
// pushrocks scope
import * as projectinfo from '@push.rocks/projectinfo';
import * as qenv from '@push.rocks/qenv';
import * as smartdata from '@push.rocks/smartdata';
import * as smartpath from '@push.rocks/smartpath';
export { projectinfo, qenv, smartdata, smartpath };

158
ts/plugins.ts Normal file
View File

@@ -0,0 +1,158 @@
// node native
import * as dns from 'dns';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as http from 'http';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import * as tls from 'tls';
import * as util from 'util';
export {
dns,
fs,
crypto,
http,
net,
os,
path,
tls,
util,
}
// @serve.zone scope
import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as remoteingress from '@serve.zone/remoteingress';
export {
servezoneInterfaces,
remoteingress,
}
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
import * as typedserver from '@api.global/typedserver';
import * as typedsocket from '@api.global/typedsocket';
export {
typedrequest,
typedserver,
typedsocket,
}
// @push.rocks scope
import * as projectinfo from '@push.rocks/projectinfo';
import * as qenv from '@push.rocks/qenv';
import * as smartacme from '@push.rocks/smartacme';
import * as smartdata from '@push.rocks/smartdata';
import * as smartdns from '@push.rocks/smartdns';
import * as smartfile from '@push.rocks/smartfile';
import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt';
import * as smartlog from '@push.rocks/smartlog';
import * as smartmetrics from '@push.rocks/smartmetrics';
import * as smartmta from '@push.rocks/smartmta';
import * as smartmongo from '@push.rocks/smartmongo';
import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique };
// Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
// apiclient.xyz scope
import * as cloudflare from '@apiclient.xyz/cloudflare';
export {
cloudflare,
}
// tsclass scope
import * as tsclass from '@tsclass/tsclass';
export {
tsclass,
}
// third party
import * as uuid from 'uuid';
export {
uuid,
}
// Filesystem utilities (compatibility helpers for smartfile v13+)
export const fsUtils = {
/**
* Ensure a directory exists, creating it recursively if needed (sync)
*/
ensureDirSync: (dirPath: string): void => {
fs.mkdirSync(dirPath, { recursive: true });
},
/**
* Ensure a directory exists, creating it recursively if needed (async)
*/
ensureDir: async (dirPath: string): Promise<void> => {
await fs.promises.mkdir(dirPath, { recursive: true });
},
/**
* Write JSON content to a file synchronously
*/
toFsSync: (content: any, filePath: string): void => {
const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
fs.writeFileSync(filePath, data);
},
/**
* Write JSON content to a file asynchronously
*/
toFs: async (content: any, filePath: string): Promise<void> => {
const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
await fs.promises.writeFile(filePath, data);
},
/**
* Check if a file or directory exists
*/
fileExistsSync: (filePath: string): boolean => {
return fs.existsSync(filePath);
},
/**
* Check if a file or directory exists (async)
*/
fileExists: async (filePath: string): Promise<boolean> => {
try {
await fs.promises.access(filePath);
return true;
} catch {
return false;
}
},
/**
* Read a JSON file synchronously
*/
toObjectSync: <T = any>(filePath: string): T => {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content) as T;
},
/**
* Read a JSON file asynchronously
*/
toObject: async <T = any>(filePath: string): Promise<T> => {
const content = await fs.promises.readFile(filePath, 'utf8');
return JSON.parse(content) as T;
},
};

View File

@@ -0,0 +1,607 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
/**
* RADIUS accounting session
*/
export interface IAccountingSession {
/** Unique session ID from RADIUS */
sessionId: string;
/** Username (often MAC address for MAB) */
username: string;
/** MAC address of the device */
macAddress?: string;
/** NAS IP address (switch/AP) */
nasIpAddress: string;
/** NAS port (physical or virtual) */
nasPort?: number;
/** NAS port type */
nasPortType?: string;
/** NAS identifier (name) */
nasIdentifier?: string;
/** Assigned VLAN */
vlanId?: number;
/** Assigned IP address (if any) */
framedIpAddress?: string;
/** Called station ID (usually BSSID for wireless) */
calledStationId?: string;
/** Calling station ID (usually client MAC) */
callingStationId?: string;
/** Session start time */
startTime: number;
/** Session end time (0 if active) */
endTime: number;
/** Last update time (interim accounting) */
lastUpdateTime: number;
/** Session status */
status: 'active' | 'stopped' | 'terminated';
/** Termination cause (if stopped) */
terminateCause?: string;
/** Input octets (bytes received by NAS from client) */
inputOctets: number;
/** Output octets (bytes sent by NAS to client) */
outputOctets: number;
/** Input packets */
inputPackets: number;
/** Output packets */
outputPackets: number;
/** Session duration in seconds */
sessionTime: number;
/** Service type */
serviceType?: string;
}
/**
* Accounting summary for a time period
*/
export interface IAccountingSummary {
/** Time period start */
periodStart: number;
/** Time period end */
periodEnd: number;
/** Total sessions */
totalSessions: number;
/** Active sessions */
activeSessions: number;
/** Total input bytes */
totalInputBytes: number;
/** Total output bytes */
totalOutputBytes: number;
/** Total session time (seconds) */
totalSessionTime: number;
/** Average session duration (seconds) */
averageSessionDuration: number;
/** Unique users/devices */
uniqueUsers: number;
/** Sessions by VLAN */
sessionsByVlan: Record<number, number>;
/** Top users by traffic */
topUsersByTraffic: Array<{ username: string; totalBytes: number }>;
}
/**
* Accounting manager configuration
*/
export interface IAccountingManagerConfig {
/** Storage key prefix */
storagePrefix?: string;
/** Session retention period in days (default: 30) */
retentionDays?: number;
/** Enable detailed session logging */
detailedLogging?: boolean;
/** Maximum active sessions to track in memory */
maxActiveSessions?: number;
}
/**
* Manages RADIUS accounting data including:
* - Session tracking (start/stop/interim)
* - Data usage tracking (bytes in/out)
* - Session history and retention
* - Billing reports and summaries
*/
export class AccountingManager {
private activeSessions: Map<string, IAccountingSession> = new Map();
private config: Required<IAccountingManagerConfig>;
private storageManager?: StorageManager;
// Counters for statistics
private stats = {
totalSessionsStarted: 0,
totalSessionsStopped: 0,
totalInputBytes: 0,
totalOutputBytes: 0,
interimUpdatesReceived: 0,
};
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) {
this.config = {
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
retentionDays: config?.retentionDays ?? 30,
detailedLogging: config?.detailedLogging ?? false,
maxActiveSessions: config?.maxActiveSessions ?? 10000,
};
this.storageManager = storageManager;
}
/**
* Initialize the accounting manager
*/
async initialize(): Promise<void> {
if (this.storageManager) {
await this.loadActiveSessions();
}
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
}
/**
* Handle accounting start request
*/
async handleAccountingStart(data: {
sessionId: string;
username: string;
macAddress?: string;
nasIpAddress: string;
nasPort?: number;
nasPortType?: string;
nasIdentifier?: string;
vlanId?: number;
framedIpAddress?: string;
calledStationId?: string;
callingStationId?: string;
serviceType?: string;
}): Promise<void> {
const now = Date.now();
const session: IAccountingSession = {
sessionId: data.sessionId,
username: data.username,
macAddress: data.macAddress,
nasIpAddress: data.nasIpAddress,
nasPort: data.nasPort,
nasPortType: data.nasPortType,
nasIdentifier: data.nasIdentifier,
vlanId: data.vlanId,
framedIpAddress: data.framedIpAddress,
calledStationId: data.calledStationId,
callingStationId: data.callingStationId,
serviceType: data.serviceType,
startTime: now,
endTime: 0,
lastUpdateTime: now,
status: 'active',
inputOctets: 0,
outputOctets: 0,
inputPackets: 0,
outputPackets: 0,
sessionTime: 0,
};
// Check if we're at capacity
if (this.activeSessions.size >= this.config.maxActiveSessions) {
// Remove oldest session
const oldest = this.findOldestSession();
if (oldest) {
await this.evictSession(oldest);
}
}
this.activeSessions.set(data.sessionId, session);
this.stats.totalSessionsStarted++;
if (this.config.detailedLogging) {
logger.log('info', `Accounting Start: session=${data.sessionId}, user=${data.username}, NAS=${data.nasIpAddress}`);
}
// Persist session
if (this.storageManager) {
await this.persistSession(session);
}
}
/**
* Handle accounting interim update request
*/
async handleAccountingUpdate(data: {
sessionId: string;
inputOctets?: number;
outputOctets?: number;
inputPackets?: number;
outputPackets?: number;
sessionTime?: number;
}): Promise<void> {
const session = this.activeSessions.get(data.sessionId);
if (!session) {
logger.log('warn', `Interim update for unknown session: ${data.sessionId}`);
return;
}
// Update session metrics
if (data.inputOctets !== undefined) {
session.inputOctets = data.inputOctets;
}
if (data.outputOctets !== undefined) {
session.outputOctets = data.outputOctets;
}
if (data.inputPackets !== undefined) {
session.inputPackets = data.inputPackets;
}
if (data.outputPackets !== undefined) {
session.outputPackets = data.outputPackets;
}
if (data.sessionTime !== undefined) {
session.sessionTime = data.sessionTime;
}
session.lastUpdateTime = Date.now();
this.stats.interimUpdatesReceived++;
if (this.config.detailedLogging) {
logger.log('debug', `Accounting Interim: session=${data.sessionId}, in=${data.inputOctets}, out=${data.outputOctets}`);
}
// Update persisted session
if (this.storageManager) {
await this.persistSession(session);
}
}
/**
* Handle accounting stop request
*/
async handleAccountingStop(data: {
sessionId: string;
terminateCause?: string;
inputOctets?: number;
outputOctets?: number;
inputPackets?: number;
outputPackets?: number;
sessionTime?: number;
}): Promise<void> {
const session = this.activeSessions.get(data.sessionId);
if (!session) {
logger.log('warn', `Stop for unknown session: ${data.sessionId}`);
return;
}
// Update final metrics
if (data.inputOctets !== undefined) {
session.inputOctets = data.inputOctets;
}
if (data.outputOctets !== undefined) {
session.outputOctets = data.outputOctets;
}
if (data.inputPackets !== undefined) {
session.inputPackets = data.inputPackets;
}
if (data.outputPackets !== undefined) {
session.outputPackets = data.outputPackets;
}
if (data.sessionTime !== undefined) {
session.sessionTime = data.sessionTime;
}
session.endTime = Date.now();
session.lastUpdateTime = session.endTime;
session.status = 'stopped';
session.terminateCause = data.terminateCause;
// Update global stats
this.stats.totalSessionsStopped++;
this.stats.totalInputBytes += session.inputOctets;
this.stats.totalOutputBytes += session.outputOctets;
if (this.config.detailedLogging) {
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
}
// Archive the session
if (this.storageManager) {
await this.archiveSession(session);
}
// Remove from active sessions
this.activeSessions.delete(data.sessionId);
}
/**
* Get an active session by ID
*/
getSession(sessionId: string): IAccountingSession | undefined {
return this.activeSessions.get(sessionId);
}
/**
* Get all active sessions
*/
getActiveSessions(): IAccountingSession[] {
return Array.from(this.activeSessions.values());
}
/**
* Get active sessions by username
*/
getSessionsByUsername(username: string): IAccountingSession[] {
return Array.from(this.activeSessions.values()).filter(s => s.username === username);
}
/**
* Get active sessions by NAS IP
*/
getSessionsByNas(nasIpAddress: string): IAccountingSession[] {
return Array.from(this.activeSessions.values()).filter(s => s.nasIpAddress === nasIpAddress);
}
/**
* Get active sessions by VLAN
*/
getSessionsByVlan(vlanId: number): IAccountingSession[] {
return Array.from(this.activeSessions.values()).filter(s => s.vlanId === vlanId);
}
/**
* Get accounting summary for a time period
*/
async getSummary(startTime: number, endTime: number): Promise<IAccountingSummary> {
// Get archived sessions for the time period
const archivedSessions = await this.getArchivedSessions(startTime, endTime);
// Combine with active sessions that started within the period
const activeSessions = Array.from(this.activeSessions.values()).filter(
s => s.startTime >= startTime && s.startTime <= endTime
);
const allSessions = [...archivedSessions, ...activeSessions];
// Calculate summary
let totalInputBytes = 0;
let totalOutputBytes = 0;
let totalSessionTime = 0;
const uniqueUsers = new Set<string>();
const sessionsByVlan: Record<number, number> = {};
const userTraffic: Record<string, number> = {};
for (const session of allSessions) {
totalInputBytes += session.inputOctets;
totalOutputBytes += session.outputOctets;
totalSessionTime += session.sessionTime;
uniqueUsers.add(session.username);
if (session.vlanId !== undefined) {
sessionsByVlan[session.vlanId] = (sessionsByVlan[session.vlanId] || 0) + 1;
}
const userBytes = session.inputOctets + session.outputOctets;
userTraffic[session.username] = (userTraffic[session.username] || 0) + userBytes;
}
// Top users by traffic
const topUsersByTraffic = Object.entries(userTraffic)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([username, totalBytes]) => ({ username, totalBytes }));
return {
periodStart: startTime,
periodEnd: endTime,
totalSessions: allSessions.length,
activeSessions: activeSessions.length,
totalInputBytes,
totalOutputBytes,
totalSessionTime,
averageSessionDuration: allSessions.length > 0 ? totalSessionTime / allSessions.length : 0,
uniqueUsers: uniqueUsers.size,
sessionsByVlan,
topUsersByTraffic,
};
}
/**
* Get statistics
*/
getStats(): {
activeSessions: number;
totalSessionsStarted: number;
totalSessionsStopped: number;
totalInputBytes: number;
totalOutputBytes: number;
interimUpdatesReceived: number;
} {
return {
activeSessions: this.activeSessions.size,
...this.stats,
};
}
/**
* Disconnect a session (admin action)
*/
async disconnectSession(sessionId: string, reason: string = 'AdminReset'): Promise<boolean> {
const session = this.activeSessions.get(sessionId);
if (!session) {
return false;
}
await this.handleAccountingStop({
sessionId,
terminateCause: reason,
sessionTime: Math.floor((Date.now() - session.startTime) / 1000),
});
return true;
}
/**
* Clean up old archived sessions based on retention policy
*/
async cleanupOldSessions(): Promise<number> {
if (!this.storageManager) {
return 0;
}
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
let deletedCount = 0;
try {
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
for (const key of keys) {
try {
const session = await this.storageManager.getJSON<IAccountingSession>(key);
if (session && session.endTime > 0 && session.endTime < cutoffTime) {
await this.storageManager.delete(key);
deletedCount++;
}
} catch (error) {
// Ignore individual errors
}
}
if (deletedCount > 0) {
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
}
} catch (error) {
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
}
return deletedCount;
}
/**
* Find the oldest active session
*/
private findOldestSession(): string | null {
let oldestTime = Infinity;
let oldestSessionId: string | null = null;
for (const [sessionId, session] of this.activeSessions) {
if (session.lastUpdateTime < oldestTime) {
oldestTime = session.lastUpdateTime;
oldestSessionId = sessionId;
}
}
return oldestSessionId;
}
/**
* Evict a session from memory
*/
private async evictSession(sessionId: string): Promise<void> {
const session = this.activeSessions.get(sessionId);
if (session) {
session.status = 'terminated';
session.terminateCause = 'SessionEvicted';
session.endTime = Date.now();
if (this.storageManager) {
await this.archiveSession(session);
}
this.activeSessions.delete(sessionId);
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
}
}
/**
* Load active sessions from storage
*/
private async loadActiveSessions(): Promise<void> {
if (!this.storageManager) {
return;
}
try {
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`);
for (const key of keys) {
try {
const session = await this.storageManager.getJSON<IAccountingSession>(key);
if (session && session.status === 'active') {
this.activeSessions.set(session.sessionId, session);
}
} catch (error) {
// Ignore individual errors
}
}
} catch (error) {
logger.log('warn', `Failed to load active sessions: ${error.message}`);
}
}
/**
* Persist a session to storage
*/
private async persistSession(session: IAccountingSession): Promise<void> {
if (!this.storageManager) {
return;
}
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
try {
await this.storageManager.setJSON(key, session);
} catch (error) {
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`);
}
}
/**
* Archive a completed session
*/
private async archiveSession(session: IAccountingSession): Promise<void> {
if (!this.storageManager) {
return;
}
try {
// Remove from active
const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
await this.storageManager.delete(activeKey);
// Add to archive with date-based path
const date = new Date(session.endTime);
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
await this.storageManager.setJSON(archiveKey, session);
} catch (error) {
logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`);
}
}
/**
* Get archived sessions for a time period
*/
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
if (!this.storageManager) {
return [];
}
const sessions: IAccountingSession[] = [];
try {
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
for (const key of keys) {
try {
const session = await this.storageManager.getJSON<IAccountingSession>(key);
if (
session &&
session.endTime > 0 &&
session.startTime <= endTime &&
session.endTime >= startTime
) {
sessions.push(session);
}
} catch (error) {
// Ignore individual errors
}
}
} catch (error) {
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
}
return sessions;
}
}

Some files were not shown because too many files have changed in this diff Show More