Compare commits

..

413 Commits

Author SHA1 Message Date
81f8e543e1 v12.0.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 15:31:16 +00:00
bb6c26484d BREAKING CHANGE(db): replace StorageManager and CacheDb with a unified smartdata-backed database layer 2026-03-31 15:31:16 +00:00
193a4bb180 v11.23.5
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:51:46 +00:00
0d9e6a4925 fix(config): correct VPN mandatory flag default handling in route config manager 2026-03-31 11:51:45 +00:00
ece9e46be9 v11.23.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:41:44 +00:00
918390a6a4 fix(deps): bump @push.rocks/smartvpn to 1.17.1 2026-03-31 11:41:44 +00:00
4ec0b67a71 v11.23.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:33:45 +00:00
356d6eca77 fix(ts_web): update appstate to import interfaces from source TypeScript module path 2026-03-31 11:33:45 +00:00
39c77accf8 v11.23.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:30:39 +00:00
b8fba52cb3 fix(repo): no changes to commit 2026-03-31 11:30:39 +00:00
f247c77807 v11.23.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:28:26 +00:00
e88938cf95 fix(repo): no changes to commit 2026-03-31 11:28:26 +00:00
4f705a591e v11.23.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:19:29 +00:00
29687670e8 feat(vpn): support optional non-mandatory VPN route access and align route config with enabled semantics 2026-03-31 11:19:29 +00:00
95daee1d8f v11.22.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 09:53:37 +00:00
11ca64a1cd feat(vpn): add VPN client editing and connected client visibility in ops server 2026-03-31 09:53:37 +00:00
cfb727b86d v11.21.5
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 04:15:51 +00:00
1e4b9997f4 fix(routing): apply VPN route allowlists dynamically after VPN clients load 2026-03-31 04:15:51 +00:00
bb32f23d77 v11.21.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 03:36:36 +00:00
1aa6451dba fix(deps): bump @push.rocks/smartvpn to 1.16.4 2026-03-31 03:36:36 +00:00
eb0408c036 v11.21.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-03-31 03:21:41 +00:00
098a2567fa fix(deps): bump @push.rocks/smartvpn to 1.16.3 2026-03-31 03:21:41 +00:00
c6534df362 v11.21.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 02:12:18 +00:00
2e4b375ad5 fix(deps): bump @push.rocks/smartvpn to 1.16.2 2026-03-31 02:12:18 +00:00
802bcf1c3d v11.21.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 01:10:19 +00:00
bad0bd9053 fix(vpn): resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups 2026-03-31 01:10:19 +00:00
ca990781b0 v11.21.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:45:46 +00:00
6807aefce8 feat(vpn): add tag-aware WireGuard AllowedIPs for VPN-gated routes 2026-03-31 00:45:46 +00:00
450ec4816e v11.20.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:08:54 +00:00
ab4310b775 fix(vpn-manager): persist WireGuard private keys for valid client exports and QR codes 2026-03-31 00:08:54 +00:00
6efd986406 v11.20.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 23:50:51 +00:00
7370d7f0e7 feat(vpn-ui): add QR code export for WireGuard client configurations 2026-03-30 23:50:51 +00:00
e733067c25 v11.19.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 18:14:51 +00:00
bc2ed808f9 fix(vpn): configure SmartVPN client exports with explicit server endpoint and split-tunnel allowed IPs 2026-03-30 18:14:51 +00:00
61d856f371 v11.19.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 17:24:18 +00:00
a8d52a4709 feat(vpn): document tag-based VPN access control, declarative clients, and destination policy options 2026-03-30 17:24:17 +00:00
f685ce9928 v11.18.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 17:08:57 +00:00
699aa8a8e1 feat(vpn-ui): add format selection for VPN client config exports 2026-03-30 17:08:57 +00:00
6fa7206f86 v11.17.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 16:49:58 +00:00
11cce23e21 feat(vpn): expand VPN operations view with client management and config export actions 2026-03-30 16:49:58 +00:00
d109554134 v11.16.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 13:06:14 +00:00
cc3a7cb5b6 feat(vpn): add destination-based VPN routing policy and standardize socket proxy forwarding 2026-03-30 13:06:14 +00:00
d53cff6a94 v11.15.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 12:07:58 +00:00
eb211348d2 feat(vpn): add tag-based VPN route access control and support configured initial VPN clients 2026-03-30 12:07:58 +00:00
43618abeba v11.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:59:38 +00:00
dd9769b814 feat(docs): document VPN access control and add OpsServer VPN navigation 2026-03-30 08:59:38 +00:00
99b40fea3f v11.13.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:15:09 +00:00
6f72e4fdbc feat(vpn): add VPN server management and route-based VPN access control 2026-03-30 08:15:09 +00:00
fbe845cd8e v11.12.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 22:38:29 +00:00
31413d28be fix(acme): use X509 certificate expiry when reporting ACME certificate validity 2026-03-27 22:38:29 +00:00
cd286cede6 v11.12.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:49:39 +00:00
36a3060cce fix(dcrouter): re-trigger auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:49:38 +00:00
d2b108317e v11.12.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:28:55 +00:00
dcd75f5e47 fix(dcrouter): guard auto certificate reprovisioning against unnamed routes 2026-03-27 19:28:55 +00:00
3d443fa147 v11.12.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:26:40 +00:00
2efdd2f16b fix(dcrouter): retry auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:26:39 +00:00
ec0348a83c v11.12.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 18:46:11 +00:00
6c4adf70c7 feat(web-ui): pause dashboard polling, sockets, and chart updates when the tab is hidden 2026-03-27 18:46:11 +00:00
29d6076355 v11.11.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 16:21:45 +00:00
fa96a41e68 feat(docker,cache,proxy): improve container runtime defaults and add configurable connection limits 2026-03-26 16:21:45 +00:00
1ea38ed5d2 v11.10.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 08:43:36 +00:00
7209903d02 fix(sms): update sms service to use async ProjectInfo initialization 2026-03-26 08:43:36 +00:00
20eda1ab3e v11.10.6
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:40:56 +00:00
44f2a7f0a9 fix(typescript): tighten TypeScript null safety and error handling across backend and ops UI 2026-03-26 07:40:56 +00:00
0195a21f30 v11.10.5
Some checks failed
Docker (tags) / security (push) Failing after 4s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:10:59 +00:00
4dca747386 fix(build): rename smart tooling config to .smartconfig.json and update package references 2026-03-26 07:10:59 +00:00
7663f502fa v11.10.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-24 13:40:28 +00:00
104cd417d8 fix(monitoring): handle multiple protocol cache entries per backend in metrics output 2026-03-24 13:40:28 +00:00
93254d5d3d v11.10.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 21:18:20 +00:00
9a3f121a9c fix(deps): bump tstest, smartmetrics, and taskbuffer to latest patch releases 2026-03-23 21:18:20 +00:00
bef74eb1aa v11.10.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 14:22:24 +00:00
308d8e4851 fix(deps): bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1 2026-03-23 14:22:24 +00:00
dc010dc3ae v11.10.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 10:29:16 +00:00
61d5d3b1ad fix(deps): bump @push.rocks/smartproxy to ^26.2.0 2026-03-23 10:29:16 +00:00
dd70790d40 v11.10.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 07:17:33 +00:00
2f8c04edc4 feat(monitoring): add backend protocol metrics to network stats and ops dashboard 2026-03-23 07:17:33 +00:00
474cc328dd v11.9.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-21 22:30:30 +00:00
39ff159bf7 fix(lifecycle): clean up service subscriptions, proxy retries, and stale runtime state on shutdown 2026-03-21 22:30:30 +00:00
c7fe7aeb50 v11.9.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 15:35:10 +00:00
2cf362020f feat(dcrouter): add service manager lifecycle orchestration and health-based ops status reporting 2026-03-20 15:35:10 +00:00
b62bad3616 v11.8.11
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 10:31:05 +00:00
3d372863a4 fix(deps): bump @push.rocks/smartproxy to ^25.17.10 2026-03-20 10:31:05 +00:00
1045dc04fe v11.8.10
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 08:31:46 +00:00
89ef7597df fix(deps): bump @push.rocks/smartproxy to ^25.17.9 2026-03-20 08:31:46 +00:00
0804544564 v11.8.9
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 08:08:38 +00:00
671e72452a fix(deps): bump @push.rocks/smartproxy to ^25.17.8 2026-03-20 08:08:38 +00:00
647c705b81 v11.8.8
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 07:51:50 +00:00
40c3202082 fix(deps): bump @push.rocks/smartproxy to ^25.17.7 2026-03-20 07:51:50 +00:00
3b91ed3d5a v11.8.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 03:21:29 +00:00
133b17f136 fix(deps): bump @push.rocks/smartproxy to ^25.17.4 2026-03-20 03:21:29 +00:00
efa45dfdc9 v11.8.6
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 02:55:37 +00:00
79b4ea6bd9 fix(deps): bump @push.rocks/smartproxy to ^25.17.3 2026-03-20 02:55:37 +00:00
b483412a2e v11.8.5
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 02:37:35 +00:00
d964515ff9 fix(deps): bump @push.rocks/smartproxy to ^25.17.1 2026-03-20 02:37:35 +00:00
e2c453423e v11.8.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-20 00:53:07 +00:00
c44b7d513a fix(deps): bump @serve.zone/remoteingress to ^4.14.0 2026-03-20 00:53:07 +00:00
2487f77b8a v11.8.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-03-20 00:12:09 +00:00
ea80ef005c fix(deps): bump @serve.zone/remoteingress to ^4.13.2 2026-03-20 00:12:09 +00:00
dd45b7fbe7 v11.8.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 23:29:57 +00:00
ca73da7b9b fix(deps): bump smartproxy and remoteingress dependencies 2026-03-19 23:29:57 +00:00
f6e1951aa2 v11.8.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 22:01:50 +00:00
76fd563e21 fix(dcrouter): use constructor routes for remote ingress setup and bump smartproxy dependency 2026-03-19 22:01:50 +00:00
ee831ea057 v11.8.0
Some checks failed
Docker (tags) / security (push) Failing after 21s
Docker (tags) / test (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
Docker (tags) / release (push) Has been skipped
2026-03-19 21:30:06 +00:00
a65c2ec096 feat(remoteingress): add UDP listen port derivation and edge configuration support 2026-03-19 21:30:06 +00:00
65822278d5 v11.7.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 20:43:47 +00:00
aa3955fc67 fix(deps): bump @push.rocks/smartproxy to ^25.16.0 2026-03-19 20:43:47 +00:00
d4605062bb v11.7.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 19:10:33 +00:00
cd3f08d55f feat(readme): document HTTP/3 QUIC support and configuration options 2026-03-19 19:10:33 +00:00
6d447f0086 v11.6.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 19:06:15 +00:00
c7de3873d8 feat(http3): add automatic HTTP/3 route augmentation for qualifying HTTPS routes 2026-03-19 19:06:15 +00:00
6d4e30e8a9 v11.5.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 16:54:39 +00:00
0e308b692b fix(project): no changes to commit 2026-03-19 16:54:39 +00:00
9f74b6e063 v11.5.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 16:41:16 +00:00
1d0f47f256 feat(opsserver): add configurable OpsServer port and update related tests and documentation 2026-03-19 16:41:16 +00:00
4e9301ae2a v11.4.0
Some checks failed
Docker (tags) / security (push) Failing after 2m0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 14:58:08 +00:00
7e2142ce53 feat(docs): document OCI container deployment and enable verbose docker build scripts 2026-03-19 14:58:08 +00:00
67190605a6 v11.3.0
Some checks failed
Docker (tags) / security (push) Failing after 13s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-18 16:22:02 +00:00
9479a07ddf feat(docker): add OCI container startup configuration and migrate Docker release pipeline to tsdocker 2026-03-18 16:22:02 +00:00
fbed56092f v11.2.56
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-18 00:13:58 +00:00
547b82b35b fix(deps): bump @serve.zone/remoteingress to ^4.9.0 2026-03-18 00:13:58 +00:00
3dc63fa02e v11.2.55
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-03-17 23:29:58 +00:00
e0154f5b70 fix(deps): bump @serve.zone/catalog to ^2.7.0 and @serve.zone/remoteingress to ^4.8.18 2026-03-17 23:29:58 +00:00
b268409897 v11.2.54
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-03-17 19:14:26 +00:00
f3a9fd12c5 fix(deps): bump @serve.zone/remoteingress to ^4.8.16 2026-03-17 19:14:26 +00:00
ef741d84fb v11.2.53
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-17 16:49:17 +00:00
b0ea97b922 fix(deps): bump @push.rocks/smartproxy and @serve.zone/remoteingress patch versions 2026-03-17 16:49:17 +00:00
d1560811f5 v11.2.52
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-17 15:51:31 +00:00
5e872c4e6a fix(deps): bump @serve.zone/remoteingress to ^4.8.13 2026-03-17 15:51:31 +00:00
3620e4549a v11.2.51
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-03-17 13:28:33 +00:00
b32865e790 fix(deps): bump @serve.zone/remoteingress to ^4.8.12 2026-03-17 13:28:33 +00:00
ebe71a2a94 v11.2.50
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-03-17 13:06:39 +00:00
877a2ad0ee fix(deps): bump @serve.zone/remoteingress to ^4.8.11 2026-03-17 13:06:39 +00:00
7be1aaedb3 v11.2.49
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-17 12:50:44 +00:00
05eb8e9723 fix(deps): bump @serve.zone/remoteingress to ^4.8.10 2026-03-17 12:50:44 +00:00
d95d89ea6f v11.2.48
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-17 12:36:14 +00:00
5d1b988579 fix(deps): bump @serve.zone/remoteingress to ^4.8.9 2026-03-17 12:36:14 +00:00
bae85eea9e v11.2.47
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-17 12:24:37 +00:00
2be7974991 fix(deps): bump @push.rocks/smartproxy to ^25.11.23 2026-03-17 12:24:37 +00:00
ac03b1f081 v11.2.46
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-17 12:13:49 +00:00
5ca209dd5a fix(deps): bump @push.rocks/smartproxy to ^25.11.22 2026-03-17 12:13:49 +00:00
867e93b246 v11.2.45
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-17 12:05:41 +00:00
aa9c4c1c28 fix(deps): bump @push.rocks/smartproxy and @serve.zone/remoteingress dependencies 2026-03-17 12:05:41 +00:00
207f21cb77 v11.2.44
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-17 11:18:11 +00:00
96a47ef588 fix(deps): bump @serve.zone/remoteingress to ^4.8.3 2026-03-17 11:18:11 +00:00
3bac80eb41 v11.2.43
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-17 10:35:25 +00:00
19d67a644c fix(deps): bump @serve.zone/remoteingress to ^4.8.2 2026-03-17 10:35:25 +00:00
341e4113bd v11.2.42
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-17 01:51:04 +00:00
81eb19a9ab fix(deps): bump @serve.zone/remoteingress to ^4.8.1 2026-03-17 01:51:04 +00:00
e0221c0d05 v11.2.41
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-17 01:33:47 +00:00
e6a1f23c84 fix(deps): bump @push.rocks/smartproxy to ^25.11.20 2026-03-17 01:33:47 +00:00
f77772e1c0 v11.2.40
Some checks failed
Docker (tags) / security (push) Failing after 4s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-17 01:00:41 +00:00
0a706c03c4 fix(deps): bump @serve.zone/remoteingress to ^4.8.0 2026-03-17 01:00:41 +00:00
df5547fda0 v11.2.39
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-17 00:43:03 +00:00
a677ee081c fix(repository): no changes to commit 2026-03-17 00:43:03 +00:00
7339a91513 v11.2.38
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-17 00:41:16 +00:00
cd27645489 fix(deps): bump @serve.zone/remoteingress to ^4.7.2 2026-03-17 00:41:16 +00:00
71f2d53e45 v11.2.37
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-16 23:36:55 +00:00
f74fcc68de fix(deps): bump @serve.zone/remoteingress to ^4.7.0 2026-03-16 23:36:55 +00:00
abcaf9c858 v11.2.36
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-16 20:58:17 +00:00
c9f185dd82 fix(deps): bump @push.rocks/smartproxy to ^25.11.19 2026-03-16 20:58:17 +00:00
5e3186d311 v11.2.35
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-16 20:01:59 +00:00
db35c01a09 fix(deps): bump @push.rocks/smartproxy, @serve.zone/catalog, and @serve.zone/remoteingress dependencies 2026-03-16 20:01:59 +00:00
3cf6c84a60 v11.2.34
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-16 14:32:19 +00:00
d570d5c916 fix(deps): bump @push.rocks/smartproxy and @serve.zone/catalog patch versions 2026-03-16 14:32:19 +00:00
4f6afb62f3 v11.2.33
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-16 14:12:00 +00:00
e5edfdc052 fix(deps): bump smartproxy and remoteingress dependencies 2026-03-16 14:12:00 +00:00
55cbb92a00 v11.2.32
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-16 13:49:26 +00:00
928be6a5c6 fix(deps): bump smartproxy and remoteingress dependencies 2026-03-16 13:49:26 +00:00
dff3e3c80d v11.2.31
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-16 13:03:33 +00:00
8db2118a78 fix(deps): bump @push.rocks/smartproxy to ^25.11.11 2026-03-16 13:03:33 +00:00
dc6da4ce34 v11.2.30
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-16 12:35:27 +00:00
63eb99ae5d fix(deps): bump @push.rocks/smartproxy and @serve.zone/catalog dependencies 2026-03-16 12:35:27 +00:00
6b533fba9f v11.2.29
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-16 11:31:30 +00:00
d3e102b6d2 fix(deps): bump @serve.zone/remoteingress to ^4.5.9 2026-03-16 11:31:30 +00:00
2bc9761d21 v11.2.28
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-16 10:53:17 +00:00
d60b9fb8e2 fix(deps): bump @serve.zone/remoteingress to ^4.5.8 2026-03-16 10:53:17 +00:00
b2705f3e88 v11.2.27
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-16 09:45:33 +00:00
4dea263cb0 fix(deps): bump smartproxy and remoteingress dependencies 2026-03-16 09:45:33 +00:00
54593d0a9b v11.2.26
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-16 09:03:08 +00:00
225a75a81b fix(deps): bump @serve.zone/remoteingress to ^4.5.5 2026-03-16 09:03:08 +00:00
72ae4e8a9b v11.2.25
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-16 08:59:55 +00:00
b171590179 fix(deps): bump @push.rocks/smartproxy to ^25.11.8 2026-03-16 08:59:55 +00:00
c46f79914f v11.2.24
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-03-16 03:02:18 +00:00
914223972a fix(deps): bump @push.rocks/smartproxy to ^25.11.7 2026-03-16 03:02:18 +00:00
559435d13c v11.2.23
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-03-16 02:07:39 +00:00
47300e4ced fix(deps): bump @push.rocks/smartproxy to ^25.11.6 2026-03-16 02:07:39 +00:00
a35a35f302 v11.2.22
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-16 00:04:18 +00:00
3b09587ca9 fix(deps): bump @push.rocks/smartproxy to ^25.11.5 2026-03-16 00:04:18 +00:00
1cb2de2c3e v11.2.21
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-15 23:47:30 +00:00
f972c38784 fix(deps): bump @push.rocks/smartproxy to ^25.11.4 2026-03-15 23:47:30 +00:00
90736c3668 v11.2.20
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-15 21:07:59 +00:00
f27ef5c768 fix(deps): bump @serve.zone/remoteingress to ^4.5.4 2026-03-15 21:07:59 +00:00
68ecd18646 v11.2.19
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-15 19:30:05 +00:00
e3d7c91705 fix(deps): bump @serve.zone/remoteingress to ^4.5.3 2026-03-15 19:30:05 +00:00
d3c2fa436c v11.2.18
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-15 18:17:38 +00:00
53b7da7e3f fix(deps): bump @serve.zone/remoteingress to ^4.5.2 2026-03-15 18:17:38 +00:00
8e312d80c0 v11.2.17
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-15 17:56:51 +00:00
c52d0ca759 fix(repo): no changes to commit 2026-03-15 17:56:51 +00:00
ca024345f6 v11.2.16
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-15 17:54:33 +00:00
89ea875ca8 fix(deps): bump @serve.zone/remoteingress to ^4.5.1 2026-03-15 17:54:33 +00:00
f15414e509 v11.2.15
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-15 17:45:34 +00:00
cfaafab057 fix(deps): bump @serve.zone/remoteingress to ^4.5.0 2026-03-15 17:45:34 +00:00
0aed608578 v11.2.14
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-15 17:04:45 +00:00
00a24051c9 fix(deps): bump smartproxy and remoteingress patch dependencies 2026-03-15 17:04:45 +00:00
310b43d1a6 v11.2.13
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-15 16:34:26 +00:00
e7f56fb870 fix(deps): bump runtime dependencies to latest compatible patch and minor versions 2026-03-15 16:34:26 +00:00
1f6eee20d3 v11.2.12
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-12 22:44:22 +00:00
7db7f92abd fix(deps): bump @push.rocks/smartproxy to ^25.10.7 2026-03-12 22:44:22 +00:00
fdf995cf61 v11.2.11
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-12 22:07:58 +00:00
fa5adbbf61 fix(deps): bump @push.rocks/smartproxy to ^25.10.6 2026-03-12 22:07:58 +00:00
1bdda9f501 v11.2.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-03-12 21:56:38 +00:00
39a5d6aaa6 fix(deps): bump @push.rocks/smartproxy to ^25.10.5 2026-03-12 21:56:38 +00:00
1d46ec709b v11.2.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-12 21:46:01 +00:00
2b4bf42812 fix(deps): bump @push.rocks/smartproxy to ^25.10.4 2026-03-12 21:46:01 +00:00
6faccc643b v11.2.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-12 21:11:32 +00:00
9f63908f7d fix(deps): bump @design.estate/dees-element and @push.rocks/smartproxy patch versions 2026-03-12 21:11:32 +00:00
5889eb5210 v11.2.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-12 20:25:57 +00:00
1de40c0d4e fix(deps): bump @design.estate/dees-catalog and @push.rocks/smartproxy patch versions 2026-03-12 20:25:57 +00:00
e201efe0b4 v11.2.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-12 20:05:38 +00:00
f8c582ee9b fix(deps): bump @design.estate/dees-catalog and @push.rocks/smartproxy patch versions 2026-03-12 20:05:38 +00:00
bf9e85f518 v11.2.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-12 19:59:15 +00:00
0366ec8160 fix(repo): no changes to commit 2026-03-12 19:59:15 +00:00
58bd4a0d33 v11.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-03-12 19:42:46 +00:00
bbff76814e fix(repo): no changes to commit 2026-03-12 19:42:46 +00:00
292486c33b v11.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-03-12 19:40:44 +00:00
2df8937d86 fix(deps): bump package dependencies to latest compatible patch and minor releases 2026-03-12 19:40:44 +00:00
14aa1fa1d4 v11.2.2
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-03-11 17:54:32 +00:00
7a4214a7b8 fix(deps): update dependencies and devDependencies to newer patch/minor versions 2026-03-11 17:54:32 +00:00
bd6130013c v11.2.1
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-03-08 18:58:29 +00:00
0f814bbcdd fix(deps): bump devDependency @git.zone/tstest to ^3.3.0 and dependency @push.rocks/smartproxy to ^25.9.2 2026-03-08 18:58:29 +00:00
8ec94b7dae v11.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-03-06 08:01:05 +00:00
d5dfe439c7 feat(apiclient): add typed, object-oriented API client documentation and interfaces; document builders, resource managers, and new programmatic endpoints 2026-03-06 08:01:05 +00:00
aaf3c9cb1c v11.1.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-03-06 07:52:10 +00:00
abde872ab2 feat(apiclient): add TypeScript API client (ts_apiclient) with resource managers and package exports 2026-03-06 07:52:10 +00:00
ca2d2b09ad v11.0.51
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-03-05 19:06:53 +00:00
fb7d4d988b fix(build): include HTML files in tsbundle output and bump tsbuild/tsbundle devDependencies 2026-03-05 19:06:53 +00:00
26e6eea5d5 v11.0.50
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-03-05 16:00:58 +00:00
2458dd08d8 fix(devDependencies): bump @git.zone/tsbuild to ^4.2.4 2026-03-05 16:00:58 +00:00
dee648b3bc v11.0.49
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-03-05 15:58:14 +00:00
f4ed32cee4 fix(dcrouter): no changes detected 2026-03-05 15:58:14 +00:00
e9c72952ab v11.0.48
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-03-05 15:57:20 +00:00
1bd485c43e fix(deps): bump @git.zone/tsbuild to ^4.2.3 2026-03-05 15:57:20 +00:00
421a0390ba v11.0.47
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-03-05 15:50:09 +00:00
c7f87a7c22 fix(dcrouter): no code changes; nothing to release 2026-03-05 15:50:09 +00:00
390d5c648f v11.0.46
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-03-05 15:38:01 +00:00
ec651c1cdb fix(none): no changes detected 2026-03-05 15:38:01 +00:00
6f82c393e7 v11.0.45
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-05 15:37:09 +00:00
afdb48367b fix(deps): bump @git.zone/tsbuild to ^4.2.2 2026-03-05 15:37:09 +00:00
53526ca3ba v11.0.44
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-03-05 15:29:31 +00:00
07e8f4489b fix(dev-deps): bump @git.zone/tsbuild devDependency to ^4.2.1 2026-03-05 15:29:31 +00:00
14101a09d3 v11.0.43
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-03-05 15:26:05 +00:00
5344d53806 fix(dcrouter): no changes detected; nothing to release 2026-03-05 15:26:05 +00:00
971535926c v11.0.42
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-03-05 15:24:56 +00:00
c13a4ae4be fix(dcrouter): empty commit — no changes 2026-03-05 15:24:55 +00:00
e7a03c48ae v11.0.41
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-03-05 15:23:36 +00:00
a682329a3f fix(deps): bump devDependency @git.zone/tsbuild to ^4.2.0 2026-03-05 15:23:36 +00:00
c4580f9874 v11.0.40
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-03-05 15:19:39 +00:00
b331065b8c fix(deps): bump @git.zone/tsbuild devDependency to ^4.1.26 2026-03-05 15:19:39 +00:00
4675ca3e89 v11.0.39
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-03-05 15:15:25 +00:00
70e2c8e17d fix(devDependencies): bump @git.zone/tsbuild devDependency to ^4.1.25 2026-03-05 15:15:25 +00:00
db53d87cc5 v11.0.38
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-03-05 15:11:21 +00:00
ff6244d3d1 fix(deps): bump @git.zone/tsbuild to ^4.1.24 2026-03-05 15:11:20 +00:00
f0aafe9027 v11.0.37
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-03-05 15:07:45 +00:00
487f2acac8 fix(dcrouter): bump patch version (no changes detected) 2026-03-05 15:07:45 +00:00
0a5e35c58e v11.0.36
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-03-05 15:05:33 +00:00
34c0cab5dc fix(repo): no changes detected; no release necessary 2026-03-05 15:05:33 +00:00
3a666e9300 v11.0.35
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-03-05 15:03:46 +00:00
cbe1b5d37d fix(dev-deps): bump @git.zone/tsbuild devDependency to ^4.1.23 2026-03-05 15:03:45 +00:00
30f2044d9f v11.0.34
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-03-05 14:59:17 +00:00
593b000ca3 fix(dcrouter): empty diff — no changes detected; no version bump suggested 2026-03-05 14:59:17 +00:00
60c298c396 v11.0.33
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-03-05 14:56:27 +00:00
d7f1c16454 fix(build): bump @git.zone/tsbuild to ^4.1.22 2026-03-05 14:56:27 +00:00
4290d4be86 v11.0.32
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-03-05 14:52:57 +00:00
bc34cb5eab fix(dev-deps): bump @git.zone/tsbuild devDependency to ^4.1.21 2026-03-05 14:52:57 +00:00
eda12f3ce3 v11.0.31
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-03-05 14:49:25 +00:00
65f19aac72 fix(deps): bump @git.zone/tsbuild devDependency to ^4.1.20 2026-03-05 14:49:25 +00:00
29a992a695 v11.0.30
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-03-05 14:46:13 +00:00
dbb2166a8f fix(devDependencies): bump @git.zone/tsbuild devDependency to ^4.1.19 2026-03-05 14:46:13 +00:00
22691329a5 v11.0.29
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-03-05 14:42:13 +00:00
e098e1a2ad fix(build): bump @git.zone/tsbuild devDependency to ^4.1.18 2026-03-05 14:42:13 +00:00
16d64ec988 v11.0.28
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-05 14:36:54 +00:00
cb1332ff76 fix(devDependencies): bump @git.zone/tsbuild devDependency to ^4.1.17 2026-03-05 14:36:54 +00:00
3e52060788 v11.0.27
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-03-05 14:32:07 +00:00
f041891a3f fix(deps): bump @git.zone/tsbuild to ^4.1.16 2026-03-05 14:32:07 +00:00
f902c2c1db v11.0.26
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-03-05 14:28:36 +00:00
e1a9e1f997 fix(devDependencies): bump @git.zone/tsbuild devDependency to ^4.1.15 2026-03-05 14:28:36 +00:00
d7b39a3017 v11.0.25
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-03-05 14:13:58 +00:00
0f41b0d8c7 fix(logger): remove build verification comment from logger export 2026-03-05 14:13:58 +00:00
2d33c037ba v11.0.24
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-03-05 14:08:29 +00:00
dca7b37eb8 fix(dcrouter): no changes detected — no release necessary 2026-03-05 14:08:29 +00:00
b56598ba00 v11.0.23
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-03-05 14:02:07 +00:00
bbf550b183 fix(deps): bump @git.zone/tsbuild devDependency to ^4.1.14 2026-03-05 14:02:07 +00:00
f4fc5eb1fd v11.0.22
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-03-05 13:47:53 +00:00
d9e88cf5f9 fix(deps): bump @git.zone/tsbuild devDependency to ^4.1.13 2026-03-05 13:47:53 +00:00
eccb9706f2 v11.0.21
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-03-05 13:31:36 +00:00
285e681413 fix(): no changes detected 2026-03-05 13:31:36 +00:00
4f3958d94d v11.0.20
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-03-05 13:29:06 +00:00
d19f22255d fix(logger): annotate singleton logger export comment for build verification 2026-03-05 13:29:06 +00:00
87ec55619a v11.0.19
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-03-05 13:23:20 +00:00
b91dab0f85 fix(dcrouter): no changes 2026-03-05 13:23:20 +00:00
df573d498e v11.0.18
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-03-05 13:19:15 +00:00
da2b838019 fix(dcrouter): no changes detected; no version bump required 2026-03-05 13:19:15 +00:00
107adeee1d v11.0.17
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-03-05 13:15:44 +00:00
45f933b473 fix(dcrouter): no changes detected in diff; no code or documentation updates 2026-03-05 13:15:44 +00:00
ad16bc44f1 v11.0.16
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-03-05 13:12:40 +00:00
96d5b7e01a fix(dcrouter): noop commit: no changes detected 2026-03-05 13:12:40 +00:00
93ffcf86b3 v11.0.15
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-03-05 13:09:29 +00:00
de98b070db fix(): no changes detected; no version bump necessary 2026-03-05 13:09:29 +00:00
d3d2bde440 v11.0.14
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-03-05 13:07:28 +00:00
0840b2b571 fix(dcrouter): no changes detected 2026-03-05 13:07:28 +00:00
fa2e784eaa v11.0.13
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-03-05 13:05:13 +00:00
64f2854023 fix(): no code changes 2026-03-05 13:05:13 +00:00
03e3261755 v11.0.12
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-03-05 12:56:50 +00:00
c724e68b8c fix(dcrouter): no changes detected — nothing to commit 2026-03-05 12:56:50 +00:00
f8f66d1392 v11.0.11 2026-03-05 12:50:17 +00:00
c66bdc9f88 fix(deps): bump @git.zone/tsbuild devDependency to ^4.1.9 2026-03-05 12:50:17 +00:00
8d57547ace v11.0.10
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-03-05 11:35:09 +00:00
54eaf23298 fix(playwright-mcp): remove committed Playwright artifacts and add .playwright-mcp/ to .gitignore 2026-03-05 11:35:09 +00:00
7148306381 v11.0.9
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-03-05 10:32:41 +00:00
d3aefef78d fix(devDependencies): bump @git.zone/tsbuild devDependency to ^4.1.4 2026-03-05 10:32:41 +00:00
ecd0cc0066 v11.0.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-05 09:10:14 +00:00
eac490297a fix(): no changes detected 2026-03-05 09:10:14 +00:00
de65641f6f v11.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-03-05 08:57:55 +00:00
ffddc1a5f5 fix(deps): bump @git.zone/tsbuild to ^4.1.3 and @push.rocks/lik to ^6.3.1 2026-03-05 08:57:55 +00:00
26152e0520 11.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-03-04 07:40:52 +00:00
f79ad07a57 v11.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) Failing after 12m9s
2026-03-04 07:37:12 +00:00
76d5b9bf7c fix(none): no changes detected; nothing to release 2026-03-04 07:37:12 +00:00
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
139 changed files with 16476 additions and 7543 deletions

View File

@@ -6,7 +6,7 @@ on:
- '**'
env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}

View File

@@ -6,7 +6,7 @@ on:
- '*'
env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
@@ -74,7 +74,7 @@ jobs:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
image: code.foss.global/host.today/ht-docker-node:dbase_dind
steps:
- uses: actions/checkout@v3
@@ -82,15 +82,13 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
pnpm install -g @git.zone/tsdocker
- name: Release
run: |
npmci docker login
npmci docker build
npmci docker test
# npmci docker push gitea.lossless.digital
npmci docker push dockerregistry.lossless.digital
tsdocker login
tsdocker build
tsdocker push
metadata:
needs: test

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ dist_*/
**/.claude/settings.local.json
.nogit/data/
readme.plan.md
.playwright-mcp/

View File

@@ -22,7 +22,8 @@
"to": "./dist_serve/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild",
"production": true
"production": true,
"includeFiles": ["./html/**/*.html"]
}
]
},
@@ -71,9 +72,14 @@
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
},
"dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
},
"npmRegistryUrl": "verdaccio.lossless.digital"
},
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter",
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
}

View File

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

View File

@@ -1,46 +1,34 @@
# gitzone dockerfile_service
## STAGE 1 // BUILD
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
FROM code.foss.global/host.today/ht-docker-node:lts AS build
COPY ./ /app
WORKDIR /app
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
RUN pnpm run build
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
## STAGE 2 // PRODUCTION
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
# gcompat + libstdc++ for glibc-linked Rust binaries (smartproxy, smartmta, remoteingress)
RUN apk add --no-cache gcompat libstdc++
# gitzone dockerfile_service
## STAGE 2 // install production
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
WORKDIR /app
COPY --from=node1 /app /app
RUN rm -rf .pnpm-store
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules/ && pnpm install --prod
COPY --from=build /app /app
ENV DCROUTER_MODE=OCI_CONTAINER
ENV DCROUTER_HEAP_SIZE=512
ENV UV_THREADPOOL_SIZE=16
## STAGE 3 // rebuild dependencies for alpine
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
WORKDIR /app
COPY --from=node2 /app /app
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN pnpm rebuild -r
## STAGE 4 // the final production image with all dependencies in place
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
WORKDIR /app
COPY --from=node3 /app /app
### Healthchecks
RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
EXPOSE 80
CMD ["npm", "start"]
LABEL org.opencontainers.image.title="dcrouter" \
org.opencontainers.image.description="Multi-service datacenter gateway" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]

File diff suppressed because it is too large Load Diff

21
license Normal file
View File

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

View File

@@ -1,64 +1,73 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "6.13.0",
"version": "12.0.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js"
"./interfaces": "./dist_ts_interfaces/index.js",
"./apiclient": "./dist_ts_apiclient/index.js"
},
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --logfile --timeout 60)",
"start": "(node --max_old_space_size=250 ./cli.js)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build:docker": "tsdocker build --verbose",
"release:docker": "tsdocker push --verbose",
"bundle": "(tsbundle)",
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.8",
"@git.zone/tswatch": "^3.1.0",
"@types/node": "^25.2.3"
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.0"
},
"dependencies": {
"@api.global/typedrequest": "^3.2.6",
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.0",
"@api.global/typedsocket": "^4.1.0",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.43.0",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/projectinfo": "^5.0.2",
"@design.estate/dees-catalog": "^3.49.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.1.3",
"@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdns": "^7.8.1",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartacme": "^9.3.1",
"@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^2.0.0",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.11",
"@push.rocks/smartmetrics": "^2.0.10",
"@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.2.2",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.7.3",
"@push.rocks/smartproxy": "^27.1.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.0.30",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.17.1",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^3.3.0",
"@tsclass/tsclass": "^9.3.0",
"lru-cache": "^11.2.6",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.2.7",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
},
"keywords": [
@@ -98,13 +107,15 @@
"files": [
"ts/**/*",
"ts_web/**/*",
"ts_apiclient/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"dist_ts_apiclient/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
]
}

5795
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

512
readme.md
View File

@@ -4,7 +4,7 @@
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
## Issue Reporting and Security
@@ -18,23 +18,28 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [Architecture](#architecture)
- [Configuration Reference](#configuration-reference)
- [HTTP/HTTPS & TCP/SNI Routing](#httphttps--tcpsni-routing)
- [HTTP/3 (QUIC) Support](#http3-quic-support)
- [Email System](#email-system)
- [DNS Server](#dns-server)
- [RADIUS Server](#radius-server)
- [Remote Ingress](#remote-ingress)
- [VPN Access Control](#vpn-access-control)
- [Certificate Management](#certificate-management)
- [Storage & Caching](#storage--caching)
- [Security Features](#security-features)
- [OpsServer Dashboard](#opsserver-dashboard)
- [API Client](#api-client)
- [API Reference](#api-reference)
- [Sub-Modules](#sub-modules)
- [Testing](#testing)
- [Docker / OCI Container Deployment](#docker--oci-container-deployment)
- [License and Legal Information](#license-and-legal-information)
## Features
### 🌐 Universal Traffic Router
- **HTTP/HTTPS routing** with domain matching, path-based forwarding, and automatic TLS
- **HTTP/3 (QUIC) enabled by default** — qualifying HTTPS routes automatically get QUIC/H3 support with zero configuration
- **TCP/SNI proxy** for any protocol with TLS termination or passthrough
- **DNS server** (Rust-powered via [SmartDNS](https://code.foss.global/push.rocks/smartdns)) with authoritative zones, dynamic record management, and DNS-over-HTTPS
- **Multi-protocol support** on the same infrastructure via [SmartProxy](https://code.foss.global/push.rocks/smartproxy)
@@ -69,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
- **Route-level VPN gating** — mark any route with `vpn: { enabled: true }` to restrict access to VPN clients only, or `vpn: { enabled: true, mandatory: false }` to add VPN clients alongside existing access rules
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
### ⚡ High Performance
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
@@ -79,16 +95,24 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 💾 Persistent Storage & Caching
- **Multiple storage backends**: filesystem, custom functions, or in-memory
- **Embedded cache database** via smartdata + LocalTsmDb (MongoDB-compatible)
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
### 🖥️ OpsServer Dashboard
- **Web-based management interface** with real-time monitoring
- **JWT authentication** with session persistence
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
- **Remote ingress management** with connection token generation and one-click copy
- **Read-only configuration display** — DcRouter is configured through code
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
### 🔧 Programmatic API Client
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
- **Builder pattern** — fluent `.setName().setMatch().save()` chains for creating routes, tokens, and edges
- **Auto-injected auth** — JWT identity and API tokens included automatically in every request
- **Dual auth modes** — login with credentials (JWT) or pass an API token for programmatic access
- **Full coverage** — wraps every OpsServer endpoint with typed request/response pairs
## Installation
@@ -236,6 +260,15 @@ const router = new DcRouter({
hubDomain: 'hub.example.com',
},
// VPN — restrict sensitive routes to VPN clients
vpnConfig: {
enabled: true,
serverEndpoint: 'vpn.example.com',
clients: [
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
],
},
// Persistent storage
storage: { fsPath: '/var/lib/dcrouter/data' },
@@ -264,6 +297,7 @@ graph TB
DNS[DNS Queries]
RAD[RADIUS Clients]
EDGE[Edge Nodes]
VPN[VPN Clients]
end
subgraph "DcRouter Core"
@@ -273,6 +307,7 @@ graph TB
DS[SmartDNS Server<br/><i>Rust-powered</i>]
RS[SmartRadius Server]
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
VS[SmartVPN Server<br/><i>Rust data plane</i>]
CM[Certificate Manager<br/><i>smartacme v9</i>]
OS[OpsServer Dashboard]
MM[Metrics Manager]
@@ -293,12 +328,14 @@ graph TB
DNS --> DS
RAD --> RS
EDGE --> RI
VPN --> VS
DC --> SP
DC --> ES
DC --> DS
DC --> RS
DC --> RI
DC --> VS
DC --> CM
DC --> OS
DC --> MM
@@ -329,14 +366,14 @@ graph TB
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
| **CacheDb** | `@push.rocks/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) for persistent caching |
| **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
### How It Works
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and SmartVPN based on which configs are provided. Services start in dependency order via `ServiceManager`.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartVPN runs a Rust data plane for WireGuard and custom transports. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
### Rust-Powered Architecture
@@ -349,6 +386,7 @@ DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-c
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
| **SmartVPN** | `smartvpn_daemon` | WireGuard (boringtun), Noise IK handshake, QUIC/WS transports, userspace NAT (smoltcp) |
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
## Configuration Reference
@@ -416,6 +454,52 @@ interface IDcRouterOptions {
};
};
// ── VPN ───────────────────────────────────────────────────────
/** VPN server for route-level access control */
vpnConfig?: {
enabled?: boolean; // default: false
subnet?: string; // default: '10.8.0.0/24'
wgListenPort?: number; // default: 51820
dns?: string[]; // DNS servers pushed to VPN clients
serverEndpoint?: string; // Hostname in generated client configs
clients?: Array<{ // Pre-defined VPN clients
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
destinationPolicy?: { // Traffic routing policy
default: 'forceTarget' | 'block' | 'allow';
target?: string; // IP for forceTarget (default: '127.0.0.1')
allowList?: string[]; // Pass through directly
blockList?: string[]; // Always block (overrides allowList)
};
};
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
http3?: {
enabled?: boolean; // default: true
quicSettings?: {
maxIdleTimeout?: number; // default: 30000ms
maxConcurrentBidiStreams?: number; // default: 100
maxConcurrentUniStreams?: number; // default: 100
initialCongestionWindow?: number;
};
altSvc?: {
port?: number; // default: listening port
maxAge?: number; // default: 86400s
};
udpSettings?: {
sessionTimeout?: number; // default: 60000ms
maxSessionsPerIP?: number; // default: 1000
maxDatagramSize?: number; // default: 65535
};
};
// ── OpsServer ────────────────────────────────────────────────
/** Port for the OpsServer web dashboard (default: 3000) */
opsServerPort?: number;
// ── TLS & Certificates ────────────────────────────────────────
tls?: {
contactEmail: string;
@@ -503,6 +587,102 @@ DcRouter uses [SmartProxy](https://code.foss.global/push.rocks/smartproxy) for a
}
```
## HTTP/3 (QUIC) Support
DcRouter ships with **HTTP/3 enabled by default** 🚀. All qualifying HTTPS routes on port 443 are automatically augmented with QUIC/H3 configuration — no extra setup needed. Under the hood, SmartProxy's native HTTP/3 support (via `IRouteQuic`) handles QUIC transport, Alt-Svc advertisement, and HTTP/3 negotiation.
### How It Works
When DcRouter assembles routes in `setupSmartProxy()`, it automatically augments qualifying routes with:
- `match.transport: 'all'` — listen on both TCP (HTTP/1.1 + HTTP/2) and UDP (QUIC/HTTP/3) on the same port
- `action.udp.quic` — QUIC configuration with `enableHttp3: true` and `altSvcMaxAge: 86400`
Browsers that support HTTP/3 will discover it via the `Alt-Svc` header on initial TCP responses, then upgrade to QUIC for subsequent requests.
### What Gets Augmented
A route qualifies for HTTP/3 augmentation when **all** of these are true:
- Port includes **443** (single number, array, or range)
- Action type is **`forward`** (not `socket-handler`)
- **TLS is enabled** (passthrough, terminate, or terminate-and-reencrypt)
- Route is **not** an email route (ports 25/587/465)
- Route doesn't already have `transport: 'all'` or existing `udp.quic` config
### Zero-Config (Default Behavior)
```typescript
// HTTP/3 is ON by default — this route automatically gets QUIC/H3:
const router = new DcRouter({
smartProxyConfig: {
routes: [{
name: 'web-app',
match: { domains: ['example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
}]
}
});
```
### Per-Route Opt-Out
Disable HTTP/3 on a specific route using `action.options.http3`:
```typescript
{
name: 'legacy-app',
match: { domains: ['legacy.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.50', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
options: { http3: false } // ← This route stays TCP-only
}
}
```
### Global Opt-Out
Disable HTTP/3 across all routes:
```typescript
const router = new DcRouter({
http3: { enabled: false },
smartProxyConfig: { routes: [/* ... */] }
});
```
### Custom QUIC Settings
Fine-tune QUIC parameters globally:
```typescript
const router = new DcRouter({
http3: {
quicSettings: {
maxIdleTimeout: 60000, // 60s idle timeout
maxConcurrentBidiStreams: 200, // More parallel streams
maxConcurrentUniStreams: 50,
},
altSvc: {
maxAge: 3600, // 1 hour Alt-Svc cache
},
udpSettings: {
sessionTimeout: 120000, // 2 min UDP session timeout
maxSessionsPerIP: 500,
}
},
smartProxyConfig: { routes: [/* ... */] }
});
```
### Programmatic Routes
Routes added at runtime via the Route Management API also get HTTP/3 augmentation automatically — the `RouteConfigManager` applies the same augmentation logic when merging programmatic routes.
## Email System
The email system is powered by [`@push.rocks/smartmta`](https://code.foss.global/push.rocks/smartmta), a TypeScript + Rust hybrid MTA. DcRouter configures and orchestrates smartmta's **UnifiedEmailServer**, which handles SMTP sessions, route matching, delivery queuing, DKIM signing, and all email processing.
@@ -842,6 +1022,129 @@ The OpsServer Remote Ingress view provides:
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
| **Delete** | Remove the edge registration |
## VPN Access Control
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
### How It Works
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.enabled` are resolved at config generation time, so clients route only the necessary traffic through the tunnel
4. Routes with `vpn: { enabled: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change). With `mandatory: true` (default), the allowlist is replaced; with `mandatory: false`, VPN IPs are appended to existing rules
5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
### Destination Policy
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
```typescript
// Default: all traffic → SmartProxy
destinationPolicy: { default: 'forceTarget', target: '127.0.0.1' }
// Allow direct access to a backend subnet
destinationPolicy: {
default: 'forceTarget',
target: '127.0.0.1',
allowList: ['192.168.190.*'], // direct access to this subnet
blockList: ['192.168.190.1'], // except the gateway
}
// Block everything except specific IPs
destinationPolicy: {
default: 'block',
allowList: ['10.0.0.*', '192.168.1.*'],
}
```
### Configuration
```typescript
const router = new DcRouter({
vpnConfig: {
enabled: true,
subnet: '10.8.0.0/24', // VPN client IP pool (default)
wgListenPort: 51820, // WireGuard UDP port (default)
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
// Pre-define VPN clients with server-defined tags
clients: [
{ clientId: 'alice-laptop', serverDefinedClientTags: ['engineering'], description: 'Dev laptop' },
{ clientId: 'bob-phone', serverDefinedClientTags: ['engineering', 'mobile'] },
{ clientId: 'carol-desktop', serverDefinedClientTags: ['finance'] },
],
// Optional: customize destination policy (default: forceTarget → localhost)
// destinationPolicy: { default: 'forceTarget', target: '127.0.0.1', allowList: ['192.168.1.*'] },
},
smartProxyConfig: {
routes: [
// 🔐 VPN-only: any VPN client can access
{
name: 'internal-app',
match: { domains: ['internal.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.50', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { enabled: true },
},
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients
{
name: 'eng-dashboard',
match: { domains: ['eng.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.51', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
// → alice + bob can access, carol cannot
},
// 🌐 Public: no VPN
{
name: 'public-site',
match: { domains: ['example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 80 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
],
},
});
```
### Client Tags
SmartVPN distinguishes between two types of client tags:
| Tag Type | Set By | Purpose |
|----------|--------|---------|
| `serverDefinedClientTags` | Admin (via config or API) | **Trusted** — used for route access control |
| `clientDefinedClientTags` | Connecting client | **Informational** — displayed in dashboard, never used for security |
Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin-assigned tags match. Clients cannot influence their own server-defined tags.
### Client Management via OpsServer
The OpsServer dashboard and API provide full VPN client lifecycle management:
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
- **QR code** — scan with the WireGuard mobile app (iOS/Android) for instant setup
- **Enable / Disable** — toggle client access without deleting
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
- **Delete** — remove a client and revoke access
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or by scanning the QR code — no custom VPN software needed.
## Certificate Management
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
@@ -934,7 +1237,7 @@ Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, I
### Cache Database
An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persistent caching with automatic TTL cleanup:
An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
```typescript
cacheConfig: {
@@ -1007,7 +1310,7 @@ action: {
## OpsServer Dashboard
The OpsServer provides a web-based management interface served on port 3000. It's built with modern web components using [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog).
The OpsServer provides a web-based management interface served on port 3000 by default (configurable via `opsServerPort`). It's built with modern web components using [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog).
### Dashboard Views
@@ -1016,8 +1319,12 @@ The OpsServer provides a web-based management interface served on port 3000. It'
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
| 🛣️ **Routes** | Merged route list (hardcoded + programmatic), create/edit/toggle/override routes |
| 🔑 **API Tokens** | Token management with scopes, create/revoke/roll/toggle |
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
| 📜 **Logs** | Real-time log viewer with level filtering and search |
| ⚙️ **Configuration** | Read-only view of current system configuration |
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
@@ -1038,12 +1345,9 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
'getCombinedMetrics' // All metrics in one call
// Email Operations
'getQueuedEmails' // Emails pending delivery
'getSentEmails' // Successfully delivered emails
'getFailedEmails' // Failed emails
'getAllEmails' // List all emails (queued/sent/failed)
'getEmailDetail' // Full detail for a specific email
'resendEmail' // Re-queue a failed email
'getBounceRecords' // Bounce records
'removeFromSuppressionList' // Unsuppress an address
// Certificates
'getCertificateOverview' // Domain-centric certificate status
@@ -1062,11 +1366,39 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
'getRemoteIngressStatus' // Runtime status of all edges
'getRemoteIngressConnectionToken' // Generate a connection token for an edge
// Route Management (JWT or API token auth)
'getMergedRoutes' // List all routes (hardcoded + programmatic)
'createRoute' // Create a new programmatic route
'updateRoute' // Update a programmatic route
'deleteRoute' // Delete a programmatic route
'toggleRoute' // Enable/disable a programmatic route
'setRouteOverride' // Override a hardcoded route
'removeRouteOverride' // Remove a hardcoded route override
// API Token Management (admin JWT only)
'createApiToken' // Create API token → returns raw value once
'listApiTokens' // List all tokens (without secrets)
'revokeApiToken' // Delete an API token
'rollApiToken' // Regenerate token secret
'toggleApiToken' // Enable/disable a token
// Configuration (read-only)
'getConfiguration' // Current system config
// Logs
'getLogs' // Retrieve system logs
'getRecentLogs' // Retrieve system logs with filtering
'getLogStream' // Stream live logs
// VPN
'getVpnClients' // List all registered VPN clients
'getVpnStatus' // VPN server status (running, subnet, port, keys)
'createVpnClient' // Create client → returns WireGuard config (shown once)
'deleteVpnClient' // Remove a VPN client
'enableVpnClient' // Enable a disabled client
'disableVpnClient' // Disable a client
'rotateVpnClientKey' // Generate new keys (invalidates old ones)
'exportVpnClientConfig' // Export WireGuard (.conf) or SmartVPN (.json) config
'getVpnClientTelemetry' // Per-client bytes sent/received, keepalives
// RADIUS
'getRadiusSessions' // Active RADIUS sessions
@@ -1080,6 +1412,77 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
'testVlanAssignment' // Test what VLAN a MAC gets
```
## API Client
DcRouter ships with a typed, object-oriented API client for programmatic management of a running instance. Install it separately or import from the main package:
```bash
pnpm add @serve.zone/dcrouter-apiclient
# or import from the main package:
# import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
```
### Quick Example
```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
await client.login('admin', 'password');
// OO resource instances with methods
const { routes } = await client.routes.list();
await routes[0].toggle(false);
// Builder pattern for creation
const newRoute = await client.routes.build()
.setName('api-gateway')
.setMatch({ ports: 443, domains: ['api.example.com'] })
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
.setTls({ mode: 'terminate', certificate: 'auto' })
.save();
// Manage certificates
const { certificates, summary } = await client.certificates.list();
await certificates[0].reprovision();
// Create API tokens with builder
const token = await client.apiTokens.build()
.setName('ci-token')
.setScopes(['routes:read', 'routes:write'])
.setExpiresInDays(90)
.save();
console.log(token.tokenValue); // only available at creation
// Remote ingress edges
const edge = await client.remoteIngress.build()
.setName('edge-nyc-01')
.setListenPorts([80, 443])
.save();
const connToken = await edge.getConnectionToken();
// Read-only managers
const health = await client.stats.getHealth();
const config = await client.config.get();
const { logs } = await client.logs.getRecent({ level: 'error', limit: 50 });
```
### Resource Managers
| Manager | Operations |
|---------|-----------|
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
| `client.config` | `get(section?)` |
| `client.logs` | `getRecent()`, `getStream()` |
| `client.emails` | `list()` → Email: `getDetail()`, `resend()` |
| `client.radius` | `.clients`, `.vlans`, `.sessions` sub-managers + `getStatistics()`, `getAccountingSummary()` |
See the [full API client documentation](./ts_apiclient/readme.md) for detailed usage of every manager, builder, and resource class.
## API Reference
### DcRouter Class
@@ -1114,6 +1517,7 @@ const router = new DcRouter(options: IDcRouterOptions);
| `radiusServer` | `RadiusServer` | RADIUS server instance |
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
| `storageManager` | `StorageManager` | Storage backend |
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
| `metricsManager` | `MetricsManager` | Metrics collector |
@@ -1123,7 +1527,7 @@ const router = new DcRouter(options: IDcRouterOptions);
### Re-exported Types
DcRouter re-exports key types from smartmta for convenience:
DcRouter re-exports key types for convenience:
```typescript
import {
@@ -1133,6 +1537,7 @@ import {
type IUnifiedEmailServerOptions,
type IEmailRoute,
type IEmailDomainConfig,
type IHttp3Config,
} from '@serve.zone/dcrouter';
```
@@ -1144,12 +1549,14 @@ DcRouter is published as a monorepo with separately-installable interface and we
|---------|-------------|---------|
| [`@serve.zone/dcrouter`](https://www.npmjs.com/package/@serve.zone/dcrouter) | Main package — the full router | `pnpm add @serve.zone/dcrouter` |
| [`@serve.zone/dcrouter-interfaces`](https://www.npmjs.com/package/@serve.zone/dcrouter-interfaces) | TypedRequest interfaces for the OpsServer API | `pnpm add @serve.zone/dcrouter-interfaces` |
| [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) | OO API client with builder pattern | `pnpm add @serve.zone/dcrouter-apiclient` |
| [`@serve.zone/dcrouter-web`](https://www.npmjs.com/package/@serve.zone/dcrouter-web) | Web dashboard components | `pnpm add @serve.zone/dcrouter-web` |
You can also import interfaces directly from the main package:
You can also import directly from the main package:
```typescript
import { data, requests } from '@serve.zone/dcrouter/interfaces';
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
```
## Testing
@@ -1171,20 +1578,87 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
| Test File | Area | Tests |
|-----------|------|-------|
| `test.apiclient.ts` | API client instantiation, builders, resource hydration, exports | 18 |
| `test.contentscanner.ts` | Content scanning (spam, phishing, malware, attachments) | 13 |
| `test.dcrouter.email.ts` | Email config, domain and route setup | 4 |
| `test.dns-server-config.ts` | DNS record parsing, grouping, extraction | 5 |
| `test.dns-socket-handler.ts` | DNS socket handler and route generation | 6 |
| `test.errors.ts` | Error classes, handler, retry utilities | 5 |
| `test.http3-augmentation.ts` | HTTP/3 route augmentation, qualification, opt-in/out, QUIC settings | 20 |
| `test.ipreputationchecker.ts` | IP reputation, DNSBL, caching, risk classification | 10 |
| `test.jwt-auth.ts` | JWT login, verification, logout, invalid credentials | 8 |
| `test.opsserver-api.ts` | Health, statistics, configuration, log APIs | 6 |
| `test.opsserver-api.ts` | Health, statistics, configuration, log APIs | 8 |
| `test.protected-endpoint.ts` | Admin auth, identity verification, public endpoints | 8 |
| `test.storagemanager.ts` | Memory, filesystem, custom backends, concurrency | 8 |
## Docker / OCI Container Deployment
DcRouter ships with a production-ready `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. The container image includes tini as PID 1 (via the base image), proper health checks, and configurable resource limits. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
### Running with Docker
```bash
docker run -d \
--ulimit nofile=65536:65536 \
-e DCROUTER_TLS_EMAIL=admin@example.com \
-e DCROUTER_PUBLIC_IP=203.0.113.1 \
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
-e DCROUTER_DNS_SCOPES=example.com \
-p 80:80 -p 443:443 -p 25:25 -p 587:587 -p 465:465 \
-p 53:53/udp -p 3000:3000 -p 8443:8443 \
code.foss.global/serve.zone/dcrouter:latest
```
> ⚡ **Production tip:** Always set `--ulimit nofile=65536:65536` for production deployments. DcRouter will log a warning at startup if the file descriptor limit is below 65536.
### Environment Variables
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `DCROUTER_MODE` | Container mode (set automatically in image) | `OCI_CONTAINER` | — |
| `DCROUTER_CONFIG_PATH` | Path to JSON config file (env vars override) | — | `/config/dcrouter.json` |
| `DCROUTER_BASE_DIR` | Base data directory | `~/.serve.zone/dcrouter` | `/data/dcrouter` |
| `DCROUTER_TLS_EMAIL` | ACME contact email | — | `admin@example.com` |
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | — | `example.com` |
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | — | `203.0.113.1` |
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | — | `198.51.100.1,198.51.100.2` |
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | — | `ns1.example.com,ns2.example.com` |
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | — | `example.com,other.com` |
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | — | `mail.example.com` |
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | — | `25,587,465` |
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | `false` |
| `DCROUTER_HEAP_SIZE` | Node.js V8 heap size in MB | `512` | `1024` |
| `DCROUTER_MAX_CONNECTIONS` | Global max concurrent connections | `50000` | `100000` |
| `DCROUTER_MAX_CONNECTIONS_PER_IP` | Max connections per source IP | `100` | `200` |
| `DCROUTER_CONNECTION_RATE_LIMIT` | Max new connections/min per IP | `600` | `1200` |
### Exposed Ports
The container exposes all service ports:
| Port(s) | Protocol | Service |
|---------|----------|---------|
| 80, 443 | TCP | HTTP/HTTPS (SmartProxy) |
| 25, 587, 465 | TCP | SMTP, Submission, SMTPS |
| 53 | TCP/UDP | DNS |
| 1812, 1813 | UDP | RADIUS auth/acct |
| 3000 | TCP | OpsServer dashboard |
| 8443 | TCP | Remote ingress tunnels |
| 51820 | UDP | WireGuard VPN |
| 2900030000 | TCP | Dynamic port range |
### Building the Image
```bash
pnpm run build:docker # Build the container image
pnpm run release:docker # Push to registry
```
The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsdocker](https://code.foss.global/git.zone/tsdocker).
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
@@ -1196,7 +1670,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.

84
readme.storage.md Normal file
View File

@@ -0,0 +1,84 @@
# DCRouter Storage Overview
DCRouter uses a **unified database layer** backed by `@push.rocks/smartdata` for all persistent data. All data is stored as typed document classes in a single database.
## Database Modes
### Embedded Mode (default)
When no external MongoDB URL is provided, DCRouter starts an embedded `LocalSmartDb` (Rust-based MongoDB-compatible engine) via `@push.rocks/smartdb`.
```
~/.serve.zone/dcrouter/tsmdb/
```
### External Mode
Connect to any MongoDB-compatible database by providing a connection URL.
```typescript
dbConfig: {
mongoDbUrl: 'mongodb://host:27017',
dbName: 'dcrouter',
}
```
## Configuration
```typescript
dbConfig: {
enabled: true, // default: true
mongoDbUrl: undefined, // default: embedded LocalSmartDb
storagePath: '~/.serve.zone/dcrouter/tsmdb', // default (embedded mode only)
dbName: 'dcrouter', // default
cleanupIntervalHours: 1, // TTL cleanup interval
}
```
## Document Classes
All data is stored as smartdata document classes in `ts/db/documents/`.
| Document Class | Collection | Unique Key | Purpose |
|---|---|---|---|
| `StoredRouteDoc` | storedRoutes | `id` | Programmatic routes (created via API) |
| `RouteOverrideDoc` | routeOverrides | `routeName` | Hardcoded route enable/disable overrides |
| `ApiTokenDoc` | apiTokens | `id` | API tokens (hashed secrets, scopes, expiry) |
| `VpnServerKeysDoc` | vpnServerKeys | `configId` (singleton) | VPN server Noise + WireGuard keypairs |
| `VpnClientDoc` | vpnClients | `clientId` | VPN client registrations |
| `AcmeCertDoc` | acmeCerts | `domainName` | ACME certificates and keys |
| `ProxyCertDoc` | proxyCerts | `domain` | SmartProxy TLS certificates |
| `CertBackoffDoc` | certBackoff | `domain` | Per-domain cert provision backoff state |
| `RemoteIngressEdgeDoc` | remoteIngressEdges | `id` | Edge node registrations |
| `VlanMappingsDoc` | vlanMappings | `configId` (singleton) | MAC-to-VLAN mapping table |
| `AccountingSessionDoc` | accountingSessions | `sessionId` | RADIUS accounting sessions |
| `CachedEmail` | cachedEmails | `id` | Email metadata (TTL: 30 days) |
| `CachedIPReputation` | cachedIPReputation | `ipAddress` | IP reputation results (TTL: 24 hours) |
## Architecture
```
DcRouterDb (singleton)
├── LocalSmartDb (embedded, Rust) ─── or ─── External MongoDB
└── SmartdataDb (ORM)
└── @Collection(() => getDb())
├── StoredRouteDoc
├── RouteOverrideDoc
├── ApiTokenDoc
├── VpnServerKeysDoc / VpnClientDoc
├── AcmeCertDoc / ProxyCertDoc / CertBackoffDoc
├── RemoteIngressEdgeDoc
├── VlanMappingsDoc / AccountingSessionDoc
├── CachedEmail (TTL)
└── CachedIPReputation (TTL)
```
### TTL Cleanup
`CacheCleaner` runs on a configurable interval (default: 1 hour) and removes expired documents where `expiresAt < now()`.
## Disabling
For tests or lightweight deployments without persistence:
```typescript
dbConfig: { enabled: false }
```

376
test/test.apiclient.ts Normal file
View File

@@ -0,0 +1,376 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
DcRouterApiClient,
Route,
RouteBuilder,
RouteManager,
Certificate,
CertificateManager,
ApiToken,
ApiTokenBuilder,
ApiTokenManager,
RemoteIngress,
RemoteIngressBuilder,
RemoteIngressManager,
Email,
EmailManager,
StatsManager,
ConfigManager,
LogManager,
RadiusManager,
RadiusClientManager,
RadiusVlanManager,
RadiusSessionManager,
} from '../ts_apiclient/index.js';
// =============================================================================
// Instantiation & Structure
// =============================================================================
tap.test('DcRouterApiClient - should instantiate with baseUrl', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
expect(client).toBeTruthy();
expect(client.baseUrl).toEqual('https://localhost:3000');
expect(client.identity).toBeUndefined();
});
tap.test('DcRouterApiClient - should strip trailing slashes from baseUrl', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000///' });
expect(client.baseUrl).toEqual('https://localhost:3000');
});
tap.test('DcRouterApiClient - should accept optional apiToken', async () => {
const client = new DcRouterApiClient({
baseUrl: 'https://localhost:3000',
apiToken: 'dcr_test_token',
});
expect(client.apiToken).toEqual('dcr_test_token');
});
tap.test('DcRouterApiClient - should have all resource managers', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
expect(client.routes).toBeInstanceOf(RouteManager);
expect(client.certificates).toBeInstanceOf(CertificateManager);
expect(client.apiTokens).toBeInstanceOf(ApiTokenManager);
expect(client.remoteIngress).toBeInstanceOf(RemoteIngressManager);
expect(client.stats).toBeInstanceOf(StatsManager);
expect(client.config).toBeInstanceOf(ConfigManager);
expect(client.logs).toBeInstanceOf(LogManager);
expect(client.emails).toBeInstanceOf(EmailManager);
expect(client.radius).toBeInstanceOf(RadiusManager);
});
// =============================================================================
// buildRequestPayload
// =============================================================================
tap.test('DcRouterApiClient - buildRequestPayload includes identity when set', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const identity = {
jwt: 'test-jwt',
userId: 'user1',
name: 'Admin',
expiresAt: Date.now() + 3600000,
};
client.identity = identity;
const payload = client.buildRequestPayload({ extra: 'data' });
expect(payload.identity).toEqual(identity);
expect(payload.extra).toEqual('data');
});
tap.test('DcRouterApiClient - buildRequestPayload includes apiToken when set', async () => {
const client = new DcRouterApiClient({
baseUrl: 'https://localhost:3000',
apiToken: 'dcr_abc123',
});
const payload = client.buildRequestPayload();
expect(payload.apiToken).toEqual('dcr_abc123');
});
tap.test('DcRouterApiClient - buildRequestPayload with both identity and apiToken', async () => {
const client = new DcRouterApiClient({
baseUrl: 'https://localhost:3000',
apiToken: 'dcr_abc123',
});
client.identity = {
jwt: 'test-jwt',
userId: 'user1',
name: 'Admin',
expiresAt: Date.now() + 3600000,
};
const payload = client.buildRequestPayload({ foo: 'bar' });
expect(payload.identity).toBeTruthy();
expect(payload.apiToken).toEqual('dcr_abc123');
expect(payload.foo).toEqual('bar');
});
// =============================================================================
// Route Builder
// =============================================================================
tap.test('RouteBuilder - should support fluent builder pattern', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const builder = client.routes.build();
expect(builder).toBeInstanceOf(RouteBuilder);
// Fluent methods return `this` (same reference)
const result = builder
.setName('test-route')
.setMatch({ ports: 443, domains: 'example.com' })
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
.setEnabled(true);
expect(result === builder).toBeTrue();
});
// =============================================================================
// ApiToken Builder
// =============================================================================
tap.test('ApiTokenBuilder - should support fluent builder pattern', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const builder = client.apiTokens.build();
expect(builder).toBeInstanceOf(ApiTokenBuilder);
const result = builder
.setName('ci-token')
.setScopes(['routes:read', 'routes:write'])
.addScope('config:read')
.setExpiresInDays(30);
expect(result === builder).toBeTrue();
});
// =============================================================================
// RemoteIngress Builder
// =============================================================================
tap.test('RemoteIngressBuilder - should support fluent builder pattern', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const builder = client.remoteIngress.build();
expect(builder).toBeInstanceOf(RemoteIngressBuilder);
const result = builder
.setName('edge-1')
.setListenPorts([80, 443])
.setAutoDerivePorts(true)
.setTags(['production']);
expect(result === builder).toBeTrue();
});
// =============================================================================
// Route resource class
// =============================================================================
tap.test('Route - should hydrate from IMergedRoute data', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const route = new Route(client, {
route: {
name: 'test-route',
match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
},
source: 'programmatic',
enabled: true,
overridden: false,
storedRouteId: 'route-123',
createdAt: 1000,
updatedAt: 2000,
});
expect(route.name).toEqual('test-route');
expect(route.source).toEqual('programmatic');
expect(route.enabled).toEqual(true);
expect(route.overridden).toEqual(false);
expect(route.storedRouteId).toEqual('route-123');
expect(route.routeConfig.match.ports).toEqual(443);
});
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const route = new Route(client, {
route: {
name: 'hardcoded-route',
match: { ports: 80 },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
},
source: 'hardcoded',
enabled: true,
overridden: false,
// No storedRouteId for hardcoded routes
});
let updateError: Error | undefined;
try {
await route.update({ name: 'new-name' });
} catch (e) {
updateError = e as Error;
}
expect(updateError).toBeTruthy();
expect(updateError!.message).toInclude('hardcoded');
let deleteError: Error | undefined;
try {
await route.delete();
} catch (e) {
deleteError = e as Error;
}
expect(deleteError).toBeTruthy();
let toggleError: Error | undefined;
try {
await route.toggle(false);
} catch (e) {
toggleError = e as Error;
}
expect(toggleError).toBeTruthy();
});
// =============================================================================
// Certificate resource class
// =============================================================================
tap.test('Certificate - should hydrate from ICertificateInfo data', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const cert = new Certificate(client, {
domain: 'example.com',
routeNames: ['main-route'],
status: 'valid',
source: 'acme',
tlsMode: 'terminate',
expiryDate: '2027-01-01T00:00:00Z',
issuer: "Let's Encrypt",
canReprovision: true,
});
expect(cert.domain).toEqual('example.com');
expect(cert.status).toEqual('valid');
expect(cert.source).toEqual('acme');
expect(cert.canReprovision).toEqual(true);
expect(cert.routeNames.length).toEqual(1);
});
// =============================================================================
// ApiToken resource class
// =============================================================================
tap.test('ApiToken - should hydrate from IApiTokenInfo data', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const token = new ApiToken(
client,
{
id: 'token-1',
name: 'ci-token',
scopes: ['routes:read', 'routes:write'],
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
},
'dcr_secret_value',
);
expect(token.id).toEqual('token-1');
expect(token.name).toEqual('ci-token');
expect(token.scopes.length).toEqual(2);
expect(token.enabled).toEqual(true);
expect(token.tokenValue).toEqual('dcr_secret_value');
});
// =============================================================================
// RemoteIngress resource class
// =============================================================================
tap.test('RemoteIngress - should hydrate from IRemoteIngress data', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const edge = new RemoteIngress(client, {
id: 'edge-1',
name: 'test-edge',
secret: 'secret123',
listenPorts: [80, 443],
enabled: true,
autoDerivePorts: true,
tags: ['prod'],
createdAt: 1000,
updatedAt: 2000,
effectiveListenPorts: [80, 443, 8080],
manualPorts: [80, 443],
derivedPorts: [8080],
});
expect(edge.id).toEqual('edge-1');
expect(edge.name).toEqual('test-edge');
expect(edge.listenPorts.length).toEqual(2);
expect(edge.effectiveListenPorts!.length).toEqual(3);
expect(edge.autoDerivePorts).toEqual(true);
});
// =============================================================================
// Email resource class
// =============================================================================
tap.test('Email - should hydrate from IEmail data', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const email = new Email(client, {
id: 'email-1',
direction: 'inbound',
status: 'delivered',
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Test email',
timestamp: '2026-03-06T00:00:00Z',
messageId: '<msg-1@example.com>',
size: '1234',
});
expect(email.id).toEqual('email-1');
expect(email.direction).toEqual('inbound');
expect(email.status).toEqual('delivered');
expect(email.from).toEqual('sender@example.com');
expect(email.subject).toEqual('Test email');
});
// =============================================================================
// RadiusManager structure
// =============================================================================
tap.test('RadiusManager - should have sub-managers', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
expect(client.radius.clients).toBeInstanceOf(RadiusClientManager);
expect(client.radius.vlans).toBeInstanceOf(RadiusVlanManager);
expect(client.radius.sessions).toBeInstanceOf(RadiusSessionManager);
});
// =============================================================================
// Exports verification
// =============================================================================
tap.test('Exports - all expected classes should be importable', async () => {
expect(DcRouterApiClient).toBeTruthy();
expect(Route).toBeTruthy();
expect(RouteBuilder).toBeTruthy();
expect(RouteManager).toBeTruthy();
expect(Certificate).toBeTruthy();
expect(CertificateManager).toBeTruthy();
expect(ApiToken).toBeTruthy();
expect(ApiTokenBuilder).toBeTruthy();
expect(ApiTokenManager).toBeTruthy();
expect(RemoteIngress).toBeTruthy();
expect(RemoteIngressBuilder).toBeTruthy();
expect(RemoteIngressManager).toBeTruthy();
expect(Email).toBeTruthy();
expect(EmailManager).toBeTruthy();
expect(StatsManager).toBeTruthy();
expect(ConfigManager).toBeTruthy();
expect(LogManager).toBeTruthy();
expect(RadiusManager).toBeTruthy();
expect(RadiusClientManager).toBeTruthy();
expect(RadiusVlanManager).toBeTruthy();
expect(RadiusSessionManager).toBeTruthy();
});
export default tap.start();

View File

@@ -129,7 +129,8 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
tls: {
contactEmail: 'test@example.com'
},
cacheConfig: {
opsServerPort: 3104,
dbConfig: {
enabled: false,
}
};

View File

@@ -9,7 +9,8 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
smartProxyConfig: {
routes: []
},
cacheConfig: { enabled: false }
opsServerPort: 3100,
dbConfig: { enabled: false }
});
await dcRouter.start();

View File

@@ -0,0 +1,304 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
routeQualifiesForHttp3,
augmentRouteWithHttp3,
augmentRoutesWithHttp3,
type IHttp3Config,
} from '../ts/http3/index.js';
import type * as plugins from '../ts/plugins.js';
// Helper to create a basic HTTPS forward route on port 443
function makeRoute(
overrides: Partial<plugins.smartproxy.IRouteConfig> = {},
): plugins.smartproxy.IRouteConfig {
return {
match: { ports: 443, ...overrides.match },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
...overrides.action,
},
name: overrides.name ?? 'test-https-route',
...Object.fromEntries(
Object.entries(overrides).filter(([k]) => !['match', 'action', 'name'].includes(k)),
),
} as plugins.smartproxy.IRouteConfig;
}
const defaultConfig: IHttp3Config = { enabled: true };
// ──────────────────────────────────────────────────────────────────────────────
// Qualification tests
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should augment qualifying HTTPS route on port 443', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp).toBeTruthy();
expect(result.action.udp!.quic).toBeTruthy();
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
});
tap.test('should NOT augment route on non-443 port', async () => {
const route = makeRoute({ match: { ports: 8080 } });
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
expect(result.action.udp).toBeUndefined();
});
tap.test('should NOT augment socket-handler type route', async () => {
const route = makeRoute({
action: {
type: 'socket-handler' as any,
socketHandler: (() => {}) as any,
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
});
tap.test('should NOT augment route without TLS', async () => {
const route: plugins.smartproxy.IRouteConfig = {
match: { ports: 443 },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
},
name: 'no-tls-route',
};
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
});
tap.test('should NOT augment email routes', async () => {
const emailNames = ['smtp-route', 'submission-route', 'smtps-route', 'email-port-2525-route'];
for (const name of emailNames) {
const route = makeRoute({ name });
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
}
});
tap.test('should respect per-route opt-out (options.http3 = false)', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
options: { http3: false },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
expect(result.action.udp).toBeUndefined();
});
tap.test('should respect per-route opt-in when global is disabled', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
options: { http3: true },
},
});
const result = augmentRouteWithHttp3(route, { enabled: false });
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should NOT double-augment routes with transport: all', async () => {
const route = makeRoute({
match: { ports: 443, transport: 'all' as any },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
// Should be the exact same object (no augmentation)
expect(result).toEqual(route);
});
tap.test('should NOT double-augment routes with existing udp.quic', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
udp: { quic: { enableHttp3: true } },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result).toEqual(route);
});
tap.test('should augment route with port range including 443', async () => {
const route = makeRoute({
match: { ports: [{ from: 400, to: 500 }] },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should augment route with port array including 443', async () => {
const route = makeRoute({
match: { ports: [80, 443] },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should NOT augment route with port range NOT including 443', async () => {
const route = makeRoute({
match: { ports: [{ from: 8000, to: 9000 }] },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
});
tap.test('should augment TLS passthrough routes', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'passthrough' },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should augment terminate-and-reencrypt routes', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
// ──────────────────────────────────────────────────────────────────────────────
// Configuration tests
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should apply default QUIC settings when none provided', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
// Undefined means SmartProxy will use its own defaults
expect(result.action.udp!.quic!.maxIdleTimeout).toBeUndefined();
expect(result.action.udp!.quic!.altSvcPort).toBeUndefined();
});
tap.test('should apply custom QUIC settings', async () => {
const route = makeRoute();
const config: IHttp3Config = {
enabled: true,
quicSettings: {
maxIdleTimeout: 60000,
maxConcurrentBidiStreams: 200,
maxConcurrentUniStreams: 50,
initialCongestionWindow: 65536,
},
altSvc: {
port: 8443,
maxAge: 3600,
},
udpSettings: {
sessionTimeout: 120000,
maxSessionsPerIP: 500,
maxDatagramSize: 32768,
},
};
const result = augmentRouteWithHttp3(route, config);
expect(result.action.udp!.quic!.maxIdleTimeout).toEqual(60000);
expect(result.action.udp!.quic!.maxConcurrentBidiStreams).toEqual(200);
expect(result.action.udp!.quic!.maxConcurrentUniStreams).toEqual(50);
expect(result.action.udp!.quic!.initialCongestionWindow).toEqual(65536);
expect(result.action.udp!.quic!.altSvcPort).toEqual(8443);
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(3600);
expect(result.action.udp!.sessionTimeout).toEqual(120000);
expect(result.action.udp!.maxSessionsPerIP).toEqual(500);
expect(result.action.udp!.maxDatagramSize).toEqual(32768);
});
tap.test('should not mutate the original route', async () => {
const route = makeRoute();
const originalTransport = route.match.transport;
const originalUdp = route.action.udp;
augmentRouteWithHttp3(route, defaultConfig);
expect(route.match.transport).toEqual(originalTransport);
expect(route.action.udp).toEqual(originalUdp);
});
// ──────────────────────────────────────────────────────────────────────────────
// Batch augmentation
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should augment multiple routes in a batch', async () => {
const routes = [
makeRoute({ name: 'web-app' }),
makeRoute({ name: 'smtp-route', match: { ports: 25 } }),
makeRoute({ name: 'api-gateway' }),
makeRoute({
name: 'dns-query',
action: { type: 'socket-handler' as any, socketHandler: (() => {}) as any },
}),
];
const results = augmentRoutesWithHttp3(routes, defaultConfig);
// web-app and api-gateway should be augmented
expect(results[0].match.transport).toEqual('all');
expect(results[2].match.transport).toEqual('all');
// smtp and dns should NOT be augmented
expect(results[1].match.transport).toBeUndefined();
expect(results[3].match.transport).toBeUndefined();
});
// ──────────────────────────────────────────────────────────────────────────────
// Default enabled behavior
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should treat undefined enabled as true (default on)', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, {}); // no enabled field at all
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should disable when enabled is explicitly false', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, { enabled: false });
expect(result.match.transport).toBeUndefined();
expect(result.action.udp).toBeUndefined();
});
export default tap.start();

View File

@@ -9,7 +9,8 @@ let identity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
// Minimal config for testing
cacheConfig: { enabled: false },
opsServerPort: 3102,
dbConfig: { enabled: false },
});
await testDcRouter.start();
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login with admin credentials and receive JWT', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'http://localhost:3102/typedrequest',
'adminLoginWithUsernameAndPassword'
);
@@ -41,7 +42,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
tap.test('should verify valid JWT identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'http://localhost:3102/typedrequest',
'verifyIdentity'
);
@@ -57,7 +58,7 @@ tap.test('should verify valid JWT identity', async () => {
tap.test('should reject invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'http://localhost:3102/typedrequest',
'verifyIdentity'
);
@@ -74,7 +75,7 @@ tap.test('should reject invalid JWT', async () => {
tap.test('should verify JWT matches identity data', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'http://localhost:3102/typedrequest',
'verifyIdentity'
);
@@ -91,7 +92,7 @@ tap.test('should verify JWT matches identity data', async () => {
tap.test('should handle logout', async () => {
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
'http://localhost:3000/typedrequest',
'http://localhost:3102/typedrequest',
'adminLogout'
);
@@ -105,7 +106,7 @@ tap.test('should handle logout', async () => {
tap.test('should reject wrong credentials', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'http://localhost:3102/typedrequest',
'adminLoginWithUsernameAndPassword'
);

View File

@@ -4,27 +4,45 @@ 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 },
opsServerPort: 3101,
dbConfig: { 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:3101/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',
'http://localhost:3101/typedrequest',
'getHealthStatus'
);
const response = await healthRequest.fire({
detailed: false
identity: adminIdentity,
detailed: false,
});
expect(response).toHaveProperty('health');
expect(response.health.healthy).toBeTrue();
expect(response.health.services).toHaveProperty('OpsServer');
@@ -32,14 +50,15 @@ tap.test('should respond to health status request', async () => {
tap.test('should respond to server statistics request', async () => {
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
'http://localhost:3000/typedrequest',
'http://localhost:3101/typedrequest',
'getServerStatistics'
);
const response = await statsRequest.fire({
includeHistory: false
identity: adminIdentity,
includeHistory: false,
});
expect(response).toHaveProperty('stats');
expect(response.stats).toHaveProperty('uptime');
expect(response.stats).toHaveProperty('cpuUsage');
@@ -48,37 +67,58 @@ tap.test('should respond to server statistics request', async () => {
tap.test('should respond to configuration request', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3000/typedrequest',
'http://localhost:3101/typedrequest',
'getConfiguration'
);
const response = await configRequest.fire({});
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('proxy');
expect(response.config).toHaveProperty('security');
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',
'http://localhost:3101/typedrequest',
'getRecentLogs'
);
const response = await logsRequest.fire({
limit: 10
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:3101/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();
export default tap.start();

View File

@@ -9,7 +9,8 @@ let adminIdentity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
// Minimal config for testing
cacheConfig: { enabled: false },
opsServerPort: 3103,
dbConfig: { enabled: false },
});
await testDcRouter.start();
@@ -18,7 +19,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'http://localhost:3103/typedrequest',
'adminLoginWithUsernameAndPassword'
);
@@ -34,7 +35,7 @@ tap.test('should login as admin', async () => {
tap.test('should allow admin to verify identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'http://localhost:3103/typedrequest',
'verifyIdentity'
);
@@ -49,7 +50,7 @@ tap.test('should allow admin to verify identity', async () => {
tap.test('should reject verify identity without identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'http://localhost:3103/typedrequest',
'verifyIdentity'
);
@@ -64,7 +65,7 @@ tap.test('should reject verify identity without identity', async () => {
tap.test('should reject verify identity with invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'http://localhost:3103/typedrequest',
'verifyIdentity'
);
@@ -82,35 +83,42 @@ tap.test('should reject verify identity with invalid JWT', async () => {
}
});
tap.test('should allow access to public endpoints without auth', async () => {
tap.test('should reject protected endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3000/typedrequest',
'http://localhost:3103/typedrequest',
'getHealthStatus'
);
// No identity provided
const response = await healthRequest.fire({});
expect(response).toHaveProperty('health');
expect(response.health.healthy).toBeTrue();
console.log('Public endpoint accessible without auth');
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 read-only config access', async () => {
tap.test('should allow authenticated access to protected endpoints', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3000/typedrequest',
'http://localhost:3103/typedrequest',
'getConfiguration'
);
// Config is read-only and doesn't require auth
const response = await configRequest.fire({});
const response = await configRequest.fire({
identity: adminIdentity,
});
expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy');
expect(response.config).toHaveProperty('email');
expect(response.config).toHaveProperty('dns');
expect(response.config).toHaveProperty('proxy');
expect(response.config).toHaveProperty('security');
console.log('Configuration read successfully');
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 () => {

View File

@@ -1,289 +0,0 @@
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();

View File

@@ -1,21 +1,56 @@
import { DcRouter } from '../ts/index.js';
const devRouter = new DcRouter({
// Configure services as needed for development
// OpsServer always starts on port 3000
// Example: Add SmartProxy routes
// smartProxyConfig: {
// routes: [...]
// },
// Example: Add email configuration
// emailConfig: {
// ports: [2525],
// hostname: 'localhost',
// domains: [],
// routes: []
// },
// Server public IP (used for VPN AllowedIPs)
publicIp: '203.0.113.1',
// 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' },
},
},
{
name: 'vpn-internal-app',
match: { ports: [18080], domains: ['internal.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
vpn: { enabled: true },
},
{
name: 'vpn-eng-dashboard',
match: { ports: [18080], domains: ['eng.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
},
] as any[],
},
// VPN with pre-defined clients
vpnConfig: {
enabled: true,
serverEndpoint: 'vpn.dev.local',
clients: [
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
],
},
// Disable db/mongo for dev
dbConfig: { enabled: false },
});
console.log('Starting DcRouter in development mode...');

View File

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

View File

@@ -1,155 +0,0 @@
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

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

View File

@@ -1,5 +1,5 @@
import { logger } from './logger.js';
import type { StorageManager } from './storage/index.js';
import { CertBackoffDoc } from './db/index.js';
interface IBackoffEntry {
failures: number;
@@ -10,65 +10,86 @@ interface IBackoffEntry {
/**
* Manages certificate provisioning scheduling with:
* - Per-domain exponential backoff persisted in StorageManager
* - Per-domain exponential backoff persisted via CertBackoffDoc
*
* 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
* Sanitized domain key for storage lookups
*/
private backoffKey(domain: string): string {
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
return `/cert-backoff/${clean}`;
private sanitizeDomain(domain: string): string {
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* Load backoff entry from storage (with in-memory cache)
* Load backoff entry from database (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) {
const sanitized = this.sanitizeDomain(domain);
const doc = await CertBackoffDoc.findByDomain(sanitized);
if (doc) {
const entry: IBackoffEntry = {
failures: doc.failures,
lastFailure: doc.lastFailure,
retryAfter: doc.retryAfter,
lastError: doc.lastError,
};
this.backoffCache.set(domain, entry);
return entry;
}
return entry;
return null;
}
/**
* Save backoff entry to both cache and storage
* Save backoff entry to both cache and database
*/
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
this.backoffCache.set(domain, entry);
await this.storageManager.setJSON(this.backoffKey(domain), entry);
const sanitized = this.sanitizeDomain(domain);
let doc = await CertBackoffDoc.findByDomain(sanitized);
if (!doc) {
doc = new CertBackoffDoc();
doc.domain = sanitized;
}
doc.failures = entry.failures;
doc.lastFailure = entry.lastFailure;
doc.retryAfter = entry.retryAfter;
doc.lastError = entry.lastError || '';
await doc.save();
}
/**
* Check if a domain is currently in backoff
* Check if a domain is currently in backoff.
* Expired entries are pruned from the cache to prevent unbounded growth.
*/
async isInBackoff(domain: string): Promise<boolean> {
const entry = await this.loadBackoff(domain);
if (!entry) return false;
const retryAfter = new Date(entry.retryAfter);
return retryAfter.getTime() > Date.now();
if (retryAfter.getTime() > Date.now()) {
return true;
}
// Backoff has expired — prune the stale entry
this.backoffCache.delete(domain);
return false;
}
/**
@@ -100,12 +121,23 @@ export class CertProvisionScheduler {
async clearBackoff(domain: string): Promise<void> {
this.backoffCache.delete(domain);
try {
await this.storageManager.delete(this.backoffKey(domain));
const sanitized = this.sanitizeDomain(domain);
const doc = await CertBackoffDoc.findByDomain(sanitized);
if (doc) {
await doc.delete();
}
} catch {
// Ignore delete errors (key may not exist)
// Ignore delete errors (doc may not exist)
}
}
/**
* Clear all in-memory backoff cache entries
*/
public clear(): void {
this.backoffCache.clear();
}
/**
* Get backoff info for UI display
*/
@@ -117,9 +149,12 @@ export class CertProvisionScheduler {
const entry = await this.loadBackoff(domain);
if (!entry) return null;
// Only return if still in backoff
// Only return if still in backoff — prune expired entries
const retryAfter = new Date(entry.retryAfter);
if (retryAfter.getTime() <= Date.now()) return null;
if (retryAfter.getTime() <= Date.now()) {
this.backoffCache.delete(domain);
return null;
}
return {
failures: entry.failures,

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,58 @@
import * as plugins from './plugins.js';
import { StorageManager } from './storage/index.js';
import { AcmeCertDoc } from './db/index.js';
/**
* ICertManager implementation backed by StorageManager.
* Persists SmartAcme certificates under a /certs/ key prefix so they
* ICertManager implementation backed by smartdata document classes.
* Persists SmartAcme certificates via AcmeCertDoc so they
* survive process restarts without re-hitting ACME.
*/
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
private keyPrefix = '/certs/';
constructor(private storageManager: StorageManager) {}
constructor() {}
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,
const doc = await AcmeCertDoc.findByDomain(domainName);
if (!doc) return null;
return new plugins.smartacme.Cert({
id: doc.id,
domainName: doc.domainName,
created: doc.created,
privateKey: doc.privateKey,
publicKey: doc.publicKey,
csr: doc.csr,
validUntil: doc.validUntil,
});
}
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
if (!doc) {
doc = new AcmeCertDoc();
doc.domainName = cert.domainName;
}
doc.id = cert.id;
doc.created = cert.created;
doc.privateKey = cert.privateKey;
doc.publicKey = cert.publicKey;
doc.csr = cert.csr;
doc.validUntil = cert.validUntil;
await doc.save();
}
async deleteCertificate(domainName: string): Promise<void> {
await this.storageManager.delete(this.keyPrefix + domainName);
const doc = await AcmeCertDoc.findByDomain(domainName);
if (doc) {
await doc.delete();
}
}
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);
const docs = await AcmeCertDoc.findAll();
for (const doc of docs) {
await doc.delete();
}
}
}

View File

@@ -0,0 +1,204 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { ApiTokenDoc } from '../db/index.js';
import type {
IStoredApiToken,
IApiTokenInfo,
TApiTokenScope,
} from '../../ts_interfaces/data/route-management.js';
const TOKEN_PREFIX_STR = 'dcr_';
export class ApiTokenManager {
private tokens = new Map<string, IStoredApiToken>();
constructor() {}
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);
const doc = await ApiTokenDoc.findById(id);
if (doc) await doc.delete();
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 docs = await ApiTokenDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.tokens.set(doc.id, {
id: doc.id,
name: doc.name,
tokenHash: doc.tokenHash,
scopes: doc.scopes,
createdAt: doc.createdAt,
expiresAt: doc.expiresAt,
lastUsedAt: doc.lastUsedAt,
createdBy: doc.createdBy,
enabled: doc.enabled,
});
}
}
}
private async persistToken(stored: IStoredApiToken): Promise<void> {
const existing = await ApiTokenDoc.findById(stored.id);
if (existing) {
existing.name = stored.name;
existing.tokenHash = stored.tokenHash;
existing.scopes = stored.scopes;
existing.createdAt = stored.createdAt;
existing.expiresAt = stored.expiresAt;
existing.lastUsedAt = stored.lastUsedAt;
existing.createdBy = stored.createdBy;
existing.enabled = stored.enabled;
await existing.save();
} else {
const doc = new ApiTokenDoc();
doc.id = stored.id;
doc.name = stored.name;
doc.tokenHash = stored.tokenHash;
doc.scopes = stored.scopes;
doc.createdAt = stored.createdAt;
doc.expiresAt = stored.expiresAt;
doc.lastUsedAt = stored.lastUsedAt;
doc.createdBy = stored.createdBy;
doc.enabled = stored.enabled;
await doc.save();
}
}
}

View File

@@ -0,0 +1,335 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
import type {
IStoredRoute,
IRouteOverride,
IMergedRoute,
IRouteWarning,
} from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>();
private overrides = new Map<string, IRouteOverride>();
private warnings: IRouteWarning[] = [];
constructor(
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
) {}
/**
* 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);
const doc = await StoredRouteDoc.findById(id);
if (doc) await doc.delete();
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);
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
if (existingDoc) {
existingDoc.enabled = override.enabled;
existingDoc.updatedAt = override.updatedAt;
existingDoc.updatedBy = override.updatedBy;
await existingDoc.save();
} else {
const doc = new RouteOverrideDoc();
doc.routeName = override.routeName;
doc.enabled = override.enabled;
doc.updatedAt = override.updatedAt;
doc.updatedBy = override.updatedBy;
await doc.save();
}
this.computeWarnings();
await this.applyRoutes();
}
public async removeOverride(routeName: string): Promise<boolean> {
if (!this.overrides.has(routeName)) return false;
this.overrides.delete(routeName);
const doc = await RouteOverrideDoc.findByRouteName(routeName);
if (doc) await doc.delete();
this.computeWarnings();
await this.applyRoutes();
return true;
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadStoredRoutes(): Promise<void> {
const docs = await StoredRouteDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.storedRoutes.set(doc.id, {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.storedRoutes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
}
}
private async loadOverrides(): Promise<void> {
const docs = await RouteOverrideDoc.findAll();
for (const doc of docs) {
if (doc.routeName) {
this.overrides.set(doc.routeName, {
routeName: doc.routeName,
enabled: doc.enabled,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
});
}
}
if (this.overrides.size > 0) {
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
}
}
private async persistRoute(stored: IStoredRoute): Promise<void> {
const existingDoc = await StoredRouteDoc.findById(stored.id);
if (existingDoc) {
existingDoc.route = stored.route;
existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy;
await existingDoc.save();
} else {
const doc = new StoredRouteDoc();
doc.id = stored.id;
doc.route = stored.route;
doc.enabled = stored.enabled;
doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy;
await doc.save();
}
}
// =========================================================================
// 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
// =========================================================================
public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList;
// Helper: inject VPN security into a route if vpn.enabled is set
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
if (!vpnAllowList) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpn?.enabled) return route;
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
const mandatory = dcRoute.vpn.mandatory === true; // defaults to false
return {
...route,
security: {
...route.security,
ipAllowList: mandatory
? allowList
: [...(route.security?.ipAllowList || []), ...allowList],
},
};
};
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
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(injectVpn(route));
}
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
let route = stored.route;
if (http3Config && http3Config.enabled !== false) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(route));
}
}
await smartProxy.updateRoutes(enabledRoutes);
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
}
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { CacheDb } from './classes.cachedb.js';
import { DcRouterDb } from './classes.dcrouter-db.js';
// Import document classes for cleanup
import { CachedEmail } from './documents/classes.cached.email.js';
@@ -26,10 +26,10 @@ export class CacheCleaner {
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
private isRunning: boolean = false;
private options: Required<ICacheCleanerOptions>;
private cacheDb: CacheDb;
private dcRouterDb: DcRouterDb;
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
this.cacheDb = cacheDb;
constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
this.dcRouterDb = dcRouterDb;
this.options = {
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
verbose: options.verbose || false,
@@ -48,14 +48,14 @@ export class CacheCleaner {
this.isRunning = true;
// Run cleanup immediately on start
this.runCleanup().catch((error) => {
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
this.runCleanup().catch((error: unknown) => {
logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
});
// Schedule periodic cleanup
this.cleanupInterval = setInterval(() => {
this.runCleanup().catch((error) => {
logger.log('error', `Cache cleanup failed: ${error.message}`);
this.runCleanup().catch((error: unknown) => {
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
});
}, this.options.intervalMs);
@@ -86,8 +86,8 @@ export class CacheCleaner {
* Run a single cleanup cycle
*/
public async runCleanup(): Promise<void> {
if (!this.cacheDb.isReady()) {
logger.log('warn', 'CacheDb not ready, skipping cleanup');
if (!this.dcRouterDb.isReady()) {
logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
return;
}
@@ -113,8 +113,8 @@ export class CacheCleaner {
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
);
}
} catch (error) {
logger.log('error', `Cache cleanup error: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
throw error;
}
}
@@ -138,14 +138,14 @@ export class CacheCleaner {
try {
await doc.delete();
deletedCount++;
} catch (deleteError) {
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
} catch (deleteError: unknown) {
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
}
}
return deletedCount;
} catch (error) {
logger.log('error', `Error cleaning collection: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
return 0;
}
}

View File

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

View File

@@ -0,0 +1,179 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { defaultTsmDbPath } from '../paths.js';
/**
* Configuration options for the unified DCRouter database
*/
export interface IDcRouterDbConfig {
/** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
mongoDbUrl?: string;
/** Storage path for embedded LocalSmartDb data (default: ~/.serve.zone/dcrouter/tsmdb) */
storagePath?: string;
/** Database name (default: dcrouter) */
dbName?: string;
/** Enable debug logging */
debug?: boolean;
}
/**
* DcRouterDb - Unified database layer for DCRouter
*
* Replaces both StorageManager (flat-file key-value) and CacheDb (embedded MongoDB).
* All data is stored as smartdata document classes in a single database.
*
* Two modes:
* - **Embedded** (default): Spawns a LocalSmartDb (Rust-based MongoDB-compatible engine)
* - **External**: Connects to a provided MongoDB URL
*/
export class DcRouterDb {
private static instance: DcRouterDb | null = null;
private localSmartDb: plugins.smartdb.LocalSmartDb | null = null;
private smartdataDb!: plugins.smartdata.SmartdataDb;
private options: Required<IDcRouterDbConfig>;
private isStarted: boolean = false;
constructor(options: IDcRouterDbConfig = {}) {
this.options = {
mongoDbUrl: options.mongoDbUrl || '',
storagePath: options.storagePath || defaultTsmDbPath,
dbName: options.dbName || 'dcrouter',
debug: options.debug || false,
};
}
/**
* Get or create the singleton instance
*/
public static getInstance(options?: IDcRouterDbConfig): DcRouterDb {
if (!DcRouterDb.instance) {
DcRouterDb.instance = new DcRouterDb(options);
}
return DcRouterDb.instance;
}
/**
* Reset the singleton instance (useful for testing)
*/
public static resetInstance(): void {
DcRouterDb.instance = null;
}
/**
* Start the database
* - If mongoDbUrl is provided, connects directly to external MongoDB
* - Otherwise, starts an embedded LocalSmartDb instance
*/
public async start(): Promise<void> {
if (this.isStarted) {
logger.log('warn', 'DcRouterDb already started');
return;
}
try {
let connectionUri: string;
if (this.options.mongoDbUrl) {
// External MongoDB mode
connectionUri = this.options.mongoDbUrl;
logger.log('info', `DcRouterDb connecting to external MongoDB`);
} else {
// Embedded LocalSmartDb mode
await plugins.fsUtils.ensureDir(this.options.storagePath);
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
folderPath: this.options.storagePath,
});
const connectionInfo = await this.localSmartDb.start();
connectionUri = connectionInfo.connectionUri;
if (this.options.debug) {
logger.log('debug', `LocalSmartDb started with URI: ${connectionUri}`);
}
logger.log('info', `DcRouterDb started embedded instance at ${this.options.storagePath}`);
}
// Initialize smartdata ORM
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
this.isStarted = true;
logger.log('info', `DcRouterDb ready (db: ${this.options.dbName})`);
} catch (error: unknown) {
logger.log('error', `Failed to start DcRouterDb: ${(error as Error).message}`);
throw error;
}
}
/**
* Stop the database
*/
public async stop(): Promise<void> {
if (!this.isStarted) {
return;
}
try {
// Close smartdata connection
if (this.smartdataDb) {
await this.smartdataDb.close();
}
// Stop embedded LocalSmartDb if running
if (this.localSmartDb) {
await this.localSmartDb.stop();
this.localSmartDb = null;
}
this.isStarted = false;
logger.log('info', 'DcRouterDb stopped');
} catch (error: unknown) {
logger.log('error', `Error stopping DcRouterDb: ${(error as Error).message}`);
throw error;
}
}
/**
* Get the smartdata database instance for @Collection decorators
*/
public getDb(): plugins.smartdata.SmartdataDb {
if (!this.isStarted) {
throw new Error('DcRouterDb not started. Call start() first.');
}
return this.smartdataDb;
}
/**
* Check if the database is ready
*/
public isReady(): boolean {
return this.isStarted;
}
/**
* Whether running in embedded mode (LocalSmartDb) vs external MongoDB
*/
public isEmbedded(): boolean {
return !this.options.mongoDbUrl;
}
/**
* Get the storage path (only relevant for embedded mode)
*/
public getStoragePath(): string {
return this.options.storagePath;
}
/**
* Get the database name
*/
public getDbName(): string {
return this.options.dbName;
}
}

View File

@@ -0,0 +1,106 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class AccountingSessionDoc extends plugins.smartdata.SmartDataDbDoc<AccountingSessionDoc, AccountingSessionDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public sessionId!: string;
@plugins.smartdata.svDb()
public username!: string;
@plugins.smartdata.svDb()
public macAddress!: string;
@plugins.smartdata.svDb()
public nasIpAddress!: string;
@plugins.smartdata.svDb()
public nasPort!: number;
@plugins.smartdata.svDb()
public nasPortType!: string;
@plugins.smartdata.svDb()
public nasIdentifier!: string;
@plugins.smartdata.svDb()
public vlanId!: number;
@plugins.smartdata.svDb()
public framedIpAddress!: string;
@plugins.smartdata.svDb()
public calledStationId!: string;
@plugins.smartdata.svDb()
public callingStationId!: string;
@plugins.smartdata.svDb()
public startTime!: number;
@plugins.smartdata.svDb()
public endTime!: number;
@plugins.smartdata.svDb()
public lastUpdateTime!: number;
@plugins.smartdata.index()
@plugins.smartdata.svDb()
public status!: 'active' | 'stopped' | 'terminated';
@plugins.smartdata.svDb()
public terminateCause!: string;
@plugins.smartdata.svDb()
public inputOctets!: number;
@plugins.smartdata.svDb()
public outputOctets!: number;
@plugins.smartdata.svDb()
public inputPackets!: number;
@plugins.smartdata.svDb()
public outputPackets!: number;
@plugins.smartdata.svDb()
public sessionTime!: number;
@plugins.smartdata.svDb()
public serviceType!: string;
constructor() {
super();
}
public static async findBySessionId(sessionId: string): Promise<AccountingSessionDoc | null> {
return await AccountingSessionDoc.getInstance({ sessionId });
}
public static async findActive(): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ status: 'active' });
}
public static async findByUsername(username: string): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ username });
}
public static async findByNas(nasIpAddress: string): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ nasIpAddress });
}
public static async findByVlan(vlanId: number): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ vlanId });
}
public static async findStoppedBefore(cutoffTime: number): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({
status: { $in: ['stopped', 'terminated'] } as any,
endTime: { $lt: cutoffTime, $gt: 0 } as any,
});
}
}

View File

@@ -0,0 +1,41 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class AcmeCertDoc extends plugins.smartdata.SmartDataDbDoc<AcmeCertDoc, AcmeCertDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public domainName!: string;
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public created!: number;
@plugins.smartdata.svDb()
public privateKey!: string;
@plugins.smartdata.svDb()
public publicKey!: string;
@plugins.smartdata.svDb()
public csr!: string;
@plugins.smartdata.svDb()
public validUntil!: number;
constructor() {
super();
}
public static async findByDomain(domainName: string): Promise<AcmeCertDoc | null> {
return await AcmeCertDoc.getInstance({ domainName });
}
public static async findAll(): Promise<AcmeCertDoc[]> {
return await AcmeCertDoc.getInstances({});
}
}

View File

@@ -0,0 +1,56 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, ApiTokenDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public tokenHash!: string;
@plugins.smartdata.svDb()
public scopes!: TApiTokenScope[];
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public expiresAt!: number | null;
@plugins.smartdata.svDb()
public lastUsedAt!: number | null;
@plugins.smartdata.svDb()
public createdBy!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
constructor() {
super();
}
public static async findById(id: string): Promise<ApiTokenDoc | null> {
return await ApiTokenDoc.getInstance({ id });
}
public static async findByTokenHash(tokenHash: string): Promise<ApiTokenDoc | null> {
return await ApiTokenDoc.getInstance({ tokenHash });
}
public static async findAll(): Promise<ApiTokenDoc[]> {
return await ApiTokenDoc.getInstances({});
}
public static async findEnabled(): Promise<ApiTokenDoc[]> {
return await ApiTokenDoc.getInstances({ enabled: true });
}
}

View File

@@ -1,6 +1,6 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { CacheDb } from '../classes.cachedb.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
/**
* Email status in the cache
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
/**
* Helper to get the smartdata database instance
*/
const getDb = () => CacheDb.getInstance().getDb();
const getDb = () => DcRouterDb.getInstance().getDb();
/**
* CachedEmail - Stores email queue items in the cache
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
*/
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id: string;
public id!: string;
/**
* Email message ID (RFC 822 Message-ID header)
*/
@plugins.smartdata.svDb()
public messageId: string;
public messageId!: string;
/**
* Sender email address (envelope from)
*/
@plugins.smartdata.svDb()
public from: string;
public from!: string;
/**
* Recipient email addresses
*/
@plugins.smartdata.svDb()
public to: string[];
public to!: string[];
/**
* CC recipients
*/
@plugins.smartdata.svDb()
public cc: string[];
public cc!: string[];
/**
* BCC recipients
*/
@plugins.smartdata.svDb()
public bcc: string[];
public bcc!: string[];
/**
* Email subject
*/
@plugins.smartdata.svDb()
public subject: string;
public subject!: string;
/**
* Raw RFC822 email content
*/
@plugins.smartdata.svDb()
public rawContent: string;
public rawContent!: string;
/**
* Current status of the email
*/
@plugins.smartdata.svDb()
public status: TCachedEmailStatus;
public status!: TCachedEmailStatus;
/**
* Number of delivery attempts
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
* Timestamp for next delivery attempt
*/
@plugins.smartdata.svDb()
public nextAttempt: Date;
public nextAttempt!: Date;
/**
* Last error message if delivery failed
*/
@plugins.smartdata.svDb()
public lastError: string;
public lastError!: string;
/**
* Timestamp when the email was successfully delivered
*/
@plugins.smartdata.svDb()
public deliveredAt: Date;
public deliveredAt!: Date;
/**
* Sender domain (for querying/filtering)
*/
@plugins.smartdata.svDb()
public senderDomain: string;
public senderDomain!: string;
/**
* Priority level (higher = more important)
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
* JSON-serialized route data
*/
@plugins.smartdata.svDb()
public routeData: string;
public routeData!: string;
/**
* DKIM signature status

View File

@@ -1,11 +1,11 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { CacheDb } from '../classes.cachedb.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
/**
* Helper to get the smartdata database instance
*/
const getDb = () => CacheDb.getInstance().getDb();
const getDb = () => DcRouterDb.getInstance().getDb();
/**
* IP reputation result data
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
*/
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public ipAddress: string;
public ipAddress!: string;
/**
* Reputation score (0-100, higher = better)
*/
@plugins.smartdata.svDb()
public score: number;
public score!: number;
/**
* Whether the IP is flagged as spam source
*/
@plugins.smartdata.svDb()
public isSpam: boolean;
public isSpam!: boolean;
/**
* Whether the IP is a known proxy
*/
@plugins.smartdata.svDb()
public isProxy: boolean;
public isProxy!: boolean;
/**
* Whether the IP is a Tor exit node
*/
@plugins.smartdata.svDb()
public isTor: boolean;
public isTor!: boolean;
/**
* Whether the IP is a VPN endpoint
*/
@plugins.smartdata.svDb()
public isVPN: boolean;
public isVPN!: boolean;
/**
* Country code (ISO 3166-1 alpha-2)
*/
@plugins.smartdata.svDb()
public country: string;
public country!: string;
/**
* Autonomous System Number
*/
@plugins.smartdata.svDb()
public asn: string;
public asn!: string;
/**
* Organization name
*/
@plugins.smartdata.svDb()
public org: string;
public org!: string;
/**
* List of blacklists the IP appears on
*/
@plugins.smartdata.svDb()
public blacklists: string[];
public blacklists!: string[];
/**
* Number of times this IP has been checked

View File

@@ -0,0 +1,35 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class CertBackoffDoc extends plugins.smartdata.SmartDataDbDoc<CertBackoffDoc, CertBackoffDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public domain!: string;
@plugins.smartdata.svDb()
public failures!: number;
@plugins.smartdata.svDb()
public lastFailure!: string;
@plugins.smartdata.svDb()
public retryAfter!: string;
@plugins.smartdata.svDb()
public lastError!: string;
constructor() {
super();
}
public static async findByDomain(domain: string): Promise<CertBackoffDoc | null> {
return await CertBackoffDoc.getInstance({ domain });
}
public static async findAll(): Promise<CertBackoffDoc[]> {
return await CertBackoffDoc.getInstances({});
}
}

View File

@@ -0,0 +1,38 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class ProxyCertDoc extends plugins.smartdata.SmartDataDbDoc<ProxyCertDoc, ProxyCertDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public domain!: string;
@plugins.smartdata.svDb()
public publicKey!: string;
@plugins.smartdata.svDb()
public privateKey!: string;
@plugins.smartdata.svDb()
public ca!: string;
@plugins.smartdata.svDb()
public validUntil!: number;
@plugins.smartdata.svDb()
public validFrom!: number;
constructor() {
super();
}
public static async findByDomain(domain: string): Promise<ProxyCertDoc | null> {
return await ProxyCertDoc.getInstance({ domain });
}
public static async findAll(): Promise<ProxyCertDoc[]> {
return await ProxyCertDoc.getInstances({});
}
}

View File

@@ -0,0 +1,54 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressEdgeDoc, RemoteIngressEdgeDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public secret!: string;
@plugins.smartdata.svDb()
public listenPorts!: number[];
@plugins.smartdata.svDb()
public listenPortsUdp!: number[];
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public autoDerivePorts!: boolean;
@plugins.smartdata.svDb()
public tags!: string[];
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
constructor() {
super();
}
public static async findById(id: string): Promise<RemoteIngressEdgeDoc | null> {
return await RemoteIngressEdgeDoc.getInstance({ id });
}
public static async findAll(): Promise<RemoteIngressEdgeDoc[]> {
return await RemoteIngressEdgeDoc.getInstances({});
}
public static async findEnabled(): Promise<RemoteIngressEdgeDoc[]> {
return await RemoteIngressEdgeDoc.getInstances({ enabled: true });
}
}

View File

@@ -0,0 +1,32 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public routeName!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public updatedBy!: string;
constructor() {
super();
}
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
return await RouteOverrideDoc.getInstance({ routeName });
}
public static async findAll(): Promise<RouteOverrideDoc[]> {
return await RouteOverrideDoc.getInstances({});
}
}

View File

@@ -0,0 +1,38 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public route!: plugins.smartproxy.IRouteConfig;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<StoredRouteDoc | null> {
return await StoredRouteDoc.getInstance({ id });
}
public static async findAll(): Promise<StoredRouteDoc[]> {
return await StoredRouteDoc.getInstances({});
}
}

View File

@@ -0,0 +1,32 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
export interface IMacVlanMapping {
mac: string;
vlan: number;
description?: string;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
@plugins.smartdata.Collection(() => getDb())
export class VlanMappingsDoc extends plugins.smartdata.SmartDataDbDoc<VlanMappingsDoc, VlanMappingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public configId: string = 'vlan-mappings';
@plugins.smartdata.svDb()
public mappings!: IMacVlanMapping[];
constructor() {
super();
this.mappings = [];
}
public static async load(): Promise<VlanMappingsDoc | null> {
return await VlanMappingsDoc.getInstance({ configId: 'vlan-mappings' });
}
}

View File

@@ -0,0 +1,57 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc, VpnClientDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public clientId!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public serverDefinedClientTags?: string[];
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public assignedIp?: string;
@plugins.smartdata.svDb()
public noisePublicKey!: string;
@plugins.smartdata.svDb()
public wgPublicKey!: string;
@plugins.smartdata.svDb()
public wgPrivateKey?: string;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public expiresAt?: string;
constructor() {
super();
}
public static async findByClientId(clientId: string): Promise<VpnClientDoc | null> {
return await VpnClientDoc.getInstance({ clientId });
}
public static async findAll(): Promise<VpnClientDoc[]> {
return await VpnClientDoc.getInstances({});
}
public static async findEnabled(): Promise<VpnClientDoc[]> {
return await VpnClientDoc.getInstances({ enabled: true });
}
}

View File

@@ -0,0 +1,31 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class VpnServerKeysDoc extends plugins.smartdata.SmartDataDbDoc<VpnServerKeysDoc, VpnServerKeysDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public configId: string = 'vpn-server-keys';
@plugins.smartdata.svDb()
public noisePrivateKey!: string;
@plugins.smartdata.svDb()
public noisePublicKey!: string;
@plugins.smartdata.svDb()
public wgPrivateKey!: string;
@plugins.smartdata.svDb()
public wgPublicKey!: string;
constructor() {
super();
}
public static async load(): Promise<VpnServerKeysDoc | null> {
return await VpnServerKeysDoc.getInstance({ configId: 'vpn-server-keys' });
}
}

24
ts/db/documents/index.ts Normal file
View File

@@ -0,0 +1,24 @@
// Cached/TTL document classes
export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js';
// Config document classes
export * from './classes.stored-route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.api-token.doc.js';
// VPN document classes
export * from './classes.vpn-server-keys.doc.js';
export * from './classes.vpn-client.doc.js';
// Certificate document classes
export * from './classes.acme-cert.doc.js';
export * from './classes.proxy-cert.doc.js';
export * from './classes.cert-backoff.doc.js';
// Remote ingress document classes
export * from './classes.remote-ingress-edge.doc.js';
// RADIUS document classes
export * from './classes.vlan-mappings.doc.js';
export * from './classes.accounting-session.doc.js';

View File

@@ -1,6 +1,10 @@
// Core cache infrastructure
export * from './classes.cachedb.js';
// Unified database manager
export * from './classes.dcrouter-db.js';
// TTL base class and constants
export * from './classes.cached.document.js';
// Cache cleaner
export * from './classes.cache.cleaner.js';
// Document classes

View File

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

View File

@@ -0,0 +1,153 @@
import type * as plugins from '../plugins.js';
/**
* Configuration for HTTP/3 (QUIC) route augmentation.
* HTTP/3 is enabled by default on all qualifying HTTPS routes.
*/
export interface IHttp3Config {
/** Enable HTTP/3 augmentation on qualifying routes (default: true) */
enabled?: boolean;
/** QUIC-specific settings applied to all augmented routes */
quicSettings?: {
/** QUIC connection idle timeout in ms (default: 30000) */
maxIdleTimeout?: number;
/** Max concurrent bidirectional streams per connection (default: 100) */
maxConcurrentBidiStreams?: number;
/** Max concurrent unidirectional streams per connection (default: 100) */
maxConcurrentUniStreams?: number;
/** Initial congestion window size in bytes */
initialCongestionWindow?: number;
};
/** Alt-Svc header settings */
altSvc?: {
/** Port advertised in Alt-Svc header (default: same as listening port) */
port?: number;
/** Max age for Alt-Svc advertisement in seconds (default: 86400) */
maxAge?: number;
};
/** UDP session settings */
udpSettings?: {
/** Idle timeout for UDP sessions in ms (default: 60000) */
sessionTimeout?: number;
/** Max concurrent UDP sessions per source IP (default: 1000) */
maxSessionsPerIP?: number;
/** Max accepted datagram size in bytes (default: 65535) */
maxDatagramSize?: number;
};
}
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
/**
* Check whether a TPortRange includes port 443.
*/
function portRangeIncludes443(ports: TPortRange): boolean {
if (typeof ports === 'number') return ports === 443;
if (Array.isArray(ports)) {
return ports.some((p) => {
if (typeof p === 'number') return p === 443;
return p.from <= 443 && p.to >= 443;
});
}
return false;
}
/**
* Check if a route name indicates an email route that should not get HTTP/3.
*/
function isEmailRoute(route: plugins.smartproxy.IRouteConfig): boolean {
const name = route.name?.toLowerCase() || '';
return (
name.startsWith('smtp-') ||
name.startsWith('submission-') ||
name.startsWith('smtps-') ||
name.startsWith('email-')
);
}
/**
* Determine if a route qualifies for HTTP/3 augmentation.
*/
export function routeQualifiesForHttp3(
route: plugins.smartproxy.IRouteConfig,
globalConfig: IHttp3Config,
): boolean {
// Check global enable + per-route override
const globalEnabled = globalConfig.enabled !== false; // default true
const perRouteOverride = route.action.options?.http3;
// If per-route explicitly set, use that; otherwise use global
const shouldAugment =
perRouteOverride !== undefined ? perRouteOverride : globalEnabled;
if (!shouldAugment) return false;
// Must be forward type
if (route.action.type !== 'forward') return false;
// Must include port 443
if (!portRangeIncludes443(route.match.ports)) return false;
// Must have TLS
if (!route.action.tls) return false;
// Skip email routes
if (isEmailRoute(route)) return false;
// Skip if already configured with transport 'all' or 'udp'
if (route.match.transport === 'all' || route.match.transport === 'udp') return false;
// Skip if already has QUIC config
if (route.action.udp?.quic) return false;
return true;
}
/**
* Augment a single route with HTTP/3 fields.
* Returns a new route object (does not mutate the original).
*/
export function augmentRouteWithHttp3(
route: plugins.smartproxy.IRouteConfig,
config: IHttp3Config,
): plugins.smartproxy.IRouteConfig {
if (!routeQualifiesForHttp3(route, config)) {
return route;
}
return {
...route,
match: {
...route.match,
transport: 'all' as const,
},
action: {
...route.action,
udp: {
...(route.action.udp || {}),
sessionTimeout: config.udpSettings?.sessionTimeout,
maxSessionsPerIP: config.udpSettings?.maxSessionsPerIP,
maxDatagramSize: config.udpSettings?.maxDatagramSize,
quic: {
enableHttp3: true,
maxIdleTimeout: config.quicSettings?.maxIdleTimeout,
maxConcurrentBidiStreams: config.quicSettings?.maxConcurrentBidiStreams,
maxConcurrentUniStreams: config.quicSettings?.maxConcurrentUniStreams,
altSvcPort: config.altSvc?.port,
altSvcMaxAge: config.altSvc?.maxAge ?? 86400,
initialCongestionWindow: config.quicSettings?.initialCongestionWindow,
},
},
},
};
}
/**
* Augment all qualifying routes in an array.
* Returns a new array (does not mutate originals).
*/
export function augmentRoutesWithHttp3(
routes: plugins.smartproxy.IRouteConfig[],
config: IHttp3Config,
): plugins.smartproxy.IRouteConfig[] {
return routes.map((route) => augmentRouteWithHttp3(route, config));
}

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

@@ -0,0 +1 @@
export * from './http3-route-augmentation.js';

View File

@@ -5,6 +5,7 @@ export { UnifiedEmailServer } from '@push.rocks/smartmta';
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
// DcRouter
import { DcRouter } from './classes.dcrouter.js';
export * from './classes.dcrouter.js';
// RADIUS module
@@ -13,4 +14,27 @@ export * from './radius/index.js';
// Remote Ingress module
export * from './remoteingress/index.js';
export const runCli = async () => {};
// HTTP/3 module
export type { IHttp3Config } from './http3/index.js';
export const runCli = async () => {
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
const { getOciContainerConfig } = await import('../ts_oci_container/index.js');
options = getOciContainerConfig();
console.log('[DCRouter] Starting in OCI Container mode...');
}
const dcRouter = new DcRouter(options);
await dcRouter.start();
console.log('[DCRouter] Running. Send SIGTERM or SIGINT to stop.');
const shutdown = async () => {
console.log('[DCRouter] Shutting down...');
await dcRouter.stop();
process.exit(0);
};
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
};

View File

@@ -1,5 +1,6 @@
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';
@@ -10,8 +11,11 @@ const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
'production': 'production'
};
// Default Smartlog instance
const baseLogger = new plugins.smartlog.Smartlog({
// 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',
@@ -19,6 +23,9 @@ const baseLogger = new plugins.smartlog.Smartlog({
}
});
// Wire the buffer destination so all logs are captured
baseLogger.addLogDestination(logBuffer);
// Extended logger compatible with the original enhanced logger API
class StandardLogger {
private defaultContext: Record<string, any> = {};

View File

@@ -1,9 +1,11 @@
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 logger: plugins.smartlog.Smartlog;
private metricsLogger: plugins.smartlog.Smartlog;
private smartMetrics: plugins.smartmetrics.SmartMetrics;
private dcRouter: DcRouter;
private resetInterval?: NodeJS.Timeout;
@@ -33,10 +35,17 @@ export class MetricsManager {
queryTypes: {} as Record<string, number>,
topDomains: new Map<string, number>(),
lastResetDate: new Date().toDateString(),
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
// Per-second query count ring buffer (300 entries = 5 minutes)
queryRing: new Int32Array(300),
queryRingLastSecond: 0, // last epoch second that was written
responseTimes: [] as number[], // Track response times in ms
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,
@@ -50,15 +59,15 @@ export class MetricsManager {
constructor(dcRouter: DcRouter) {
this.dcRouter = dcRouter;
// Create a new Smartlog instance for metrics
this.logger = new plugins.smartlog.Smartlog({
// 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.logger, 'dcrouter');
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
// Initialize metrics cache with 500ms TTL
this.metricsCache = new MetricsCache(500);
}
@@ -88,11 +97,13 @@ export class MetricsManager {
this.dnsMetrics.cacheMisses = 0;
this.dnsMetrics.queryTypes = {};
this.dnsMetrics.topDomains.clear();
this.dnsMetrics.queryTimestamps = [];
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;
@@ -102,20 +113,29 @@ export class MetricsManager {
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
this.logger.log('info', 'MetricsManager started');
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();
this.logger.log('info', 'MetricsManager stopped');
// Clear caches and time-series buckets on shutdown
this.metricsCache.clear();
this.emailMinuteBuckets.clear();
this.dnsMinuteBuckets.clear();
logger.log('info', 'MetricsManager stopped');
}
// Get server metrics from SmartMetrics and SmartProxy
@@ -124,23 +144,23 @@ export class MetricsManager {
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: process.memoryUsage().heapUsed,
heapTotal: process.memoryUsage().heapTotal,
external: process.memoryUsage().external,
rss: process.memoryUsage().rss,
// Add SmartMetrics memory data
heapUsed,
heapTotal,
external,
rss,
maxMemoryMB: this.smartMetrics.maxMemoryMB,
actualUsageBytes: smartMetricsData.memoryUsageBytes,
actualUsagePercentage: smartMetricsData.memoryPercentage,
},
cpuUsage: {
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
system: 0, // SmartMetrics doesn't separate user/system
user: smartMetricsData.cpuPercentage,
system: 0,
},
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
@@ -202,11 +222,8 @@ export class MetricsManager {
.slice(0, 10)
.map(([domain, count]) => ({ domain, count }));
// Calculate queries per second from recent timestamps
const now = Date.now();
const oneMinuteAgo = now - 60000;
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
const queriesPerSecond = recentQueries.length / 60;
// 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
@@ -223,24 +240,50 @@ export class MetricsManager {
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 +
totalThreatsBlocked: this.securityMetrics.spamDetected +
this.securityMetrics.malwareDetected +
this.securityMetrics.phishingDetected,
recentIncidents,
};
@@ -253,11 +296,11 @@ export class MetricsManager {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return [];
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
}
const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo = [];
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
for (const [routeName, count] of connectionsByRoute) {
connectionInfo.push({
@@ -275,10 +318,19 @@ export class MetricsManager {
// 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) {
@@ -303,6 +355,7 @@ export class MetricsManager {
public trackEmailReceived(sender?: string): void {
this.emailMetrics.receivedToday++;
this.incrementEmailBucket('received');
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
@@ -318,6 +371,7 @@ export class MetricsManager {
public trackEmailFailed(recipient?: string, reason?: string): void {
this.emailMetrics.failedToday++;
this.incrementEmailBucket('failed');
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
@@ -351,8 +405,21 @@ export class MetricsManager {
}
// DNS event tracking methods
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void {
this.dnsMetrics.totalQueries++;
this.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++;
@@ -360,12 +427,8 @@ export class MetricsManager {
this.dnsMetrics.cacheMisses++;
}
// Track query timestamp
this.dnsMetrics.queryTimestamps.push(Date.now());
// Keep only timestamps from last 5 minutes
const fiveMinutesAgo = Date.now() - 300000;
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
// Increment per-second query counter in ring buffer
this.incrementQueryRing();
// Track response time if provided
if (responseTimeMs) {
@@ -495,6 +558,7 @@ export class MetricsManager {
throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0,
requestsTotal: 0,
backends: [] as Array<any>,
};
}
@@ -527,6 +591,110 @@ export class MetricsManager {
const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total();
// Collect backend protocol data
const backendMetrics = proxyMetrics.backends.byBackend();
const protocolCache = proxyMetrics.backends.detectedProtocols();
// Group protocol cache entries by host:port so we can match them to backend metrics.
// The protocol cache is keyed by (host, port, domain) in Rust, so the same host:port
// can have multiple entries for different domains.
const cacheByBackend = new Map<string, (typeof protocolCache)[number][]>();
for (const entry of protocolCache) {
const backendKey = `${entry.host}:${entry.port}`;
let entries = cacheByBackend.get(backendKey);
if (!entries) {
entries = [];
cacheByBackend.set(backendKey, entries);
}
entries.push(entry);
}
const backends: Array<any> = [];
const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) {
const cacheEntries = cacheByBackend.get(key);
if (!cacheEntries || cacheEntries.length === 0) {
// No protocol cache entry — emit one row with backend metrics only
backends.push({
backend: key,
domain: null,
protocol: bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
cacheAgeSecs: null,
});
} else {
// One row per domain, each enriched with the shared backend metrics
for (const cache of cacheEntries) {
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
seenCacheKeys.add(compositeKey);
backends.push({
backend: key,
domain: cache.domain ?? null,
protocol: cache.protocol ?? bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
h2Suppressed: cache.h2Suppressed,
h3Suppressed: cache.h3Suppressed,
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
h3CooldownRemainingSecs: cache.h3CooldownRemainingSecs,
h2ConsecutiveFailures: cache.h2ConsecutiveFailures,
h3ConsecutiveFailures: cache.h3ConsecutiveFailures,
h3Port: cache.h3Port,
cacheAgeSecs: cache.ageSecs,
});
}
}
}
// Include protocol cache entries with no matching backend metric
for (const entry of protocolCache) {
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
if (!seenCacheKeys.has(compositeKey)) {
backends.push({
backend: `${entry.host}:${entry.port}`,
domain: entry.domain,
protocol: entry.protocol,
activeConnections: 0,
totalConnections: 0,
connectErrors: 0,
handshakeErrors: 0,
requestErrors: 0,
avgConnectTimeMs: 0,
poolHitRate: 0,
h2Failures: 0,
h2Suppressed: entry.h2Suppressed,
h3Suppressed: entry.h3Suppressed,
h2CooldownRemainingSecs: entry.h2CooldownRemainingSecs,
h3CooldownRemainingSecs: entry.h3CooldownRemainingSecs,
h2ConsecutiveFailures: entry.h2ConsecutiveFailures,
h3ConsecutiveFailures: entry.h3ConsecutiveFailures,
h3Port: entry.h3Port,
cacheAgeSecs: entry.ageSecs,
});
}
}
return {
connectionsByIP,
throughputRate,
@@ -536,7 +704,151 @@ export class MetricsManager {
throughputByIP,
requestsPerSecond,
requestsTotal,
backends,
};
}, 200); // Use 200ms cache for more frequent updates
}, 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 };
}
}

View File

@@ -2,28 +2,37 @@ 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;
// TypedRouter for OpsServer-specific handlers
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;
public adminHandler!: handlers.AdminHandler;
private configHandler!: handlers.ConfigHandler;
private logsHandler!: handlers.LogsHandler;
private securityHandler!: handlers.SecurityHandler;
private statsHandler!: handlers.StatsHandler;
private radiusHandler!: handlers.RadiusHandler;
private emailOpsHandler!: handlers.EmailOpsHandler;
private certificateHandler!: handlers.CertificateHandler;
private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
// Add our typedrouter to the dcRouter's main typedrouter
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
}
@@ -31,7 +40,7 @@ export class OpsServer {
public async start() {
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost',
feedMetadata: null,
feedMetadata: undefined,
serveDir: paths.distServe,
});
@@ -42,17 +51,32 @@ export class OpsServer {
// Set up handlers
await this.setupHandlers();
await this.server.start(3000);
await this.server.start(this.dcRouterRef.options.opsServerPort ?? 3000);
}
/**
* Set up all TypedRequest handlers
*/
private async setupHandlers(): Promise<void> {
// Instantiate all handlers - they self-register with the typedrouter
// AdminHandler must be initialized first (JWT setup needed for guards)
this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize(); // JWT needs async initialization
await this.adminHandler.initialize();
// viewRouter middleware: requires valid identity (any logged-in user)
this.viewRouter.addMiddleware(async (typedRequest) => {
await requireValidIdentity(this.adminHandler, typedRequest.request);
});
// adminRouter middleware: requires admin identity
this.adminRouter.addMiddleware(async (typedRequest) => {
await requireAdminIdentity(this.adminHandler, typedRequest.request);
});
// Connect auth routers to the main typedrouter
this.typedrouter.addTypedRouter(this.viewRouter);
this.typedrouter.addTypedRouter(this.adminRouter);
// Instantiate all handlers — they self-register with the appropriate router
this.configHandler = new handlers.ConfigHandler(this);
this.logsHandler = new handlers.LogsHandler(this);
this.securityHandler = new handlers.SecurityHandler(this);
@@ -61,11 +85,18 @@ export class OpsServer {
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);
this.vpnHandler = new handlers.VpnHandler(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

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

View File

@@ -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

@@ -1,18 +1,21 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
export class CertificateHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
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
this.typedrouter.addTypedHandler(
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
'getCertificateOverview',
async (dataArg) => {
@@ -23,8 +26,10 @@ export class CertificateHandler {
)
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Legacy route-based reprovision (backward compat)
this.typedrouter.addTypedHandler(
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
@@ -34,7 +39,7 @@ export class CertificateHandler {
);
// Domain-based reprovision (preferred)
this.typedrouter.addTypedHandler(
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
@@ -44,7 +49,7 @@ export class CertificateHandler {
);
// Delete certificate
this.typedrouter.addTypedHandler(
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
@@ -54,7 +59,7 @@ export class CertificateHandler {
);
// Export certificate
this.typedrouter.addTypedHandler(
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
@@ -64,7 +69,7 @@ export class CertificateHandler {
);
// Import certificate
this.typedrouter.addTypedHandler(
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
@@ -183,30 +188,28 @@ export class CertificateHandler {
}
}
// Check persisted cert data from StorageManager
// Check persisted cert data from smartdata document classes
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();
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
if (acmeDoc?.validUntil) {
expiryDate = new Date(acmeDoc.validUntil).toISOString();
if (acmeDoc.created) {
issuedAt = new Date(acmeDoc.created).toISOString();
}
issuer = 'smartacme-dns-01';
} else if (certData?.publicKey) {
} else if (proxyDoc?.publicKey) {
// certStore has the cert — parse PEM for expiry
try {
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
const x509 = new plugins.crypto.X509Certificate(proxyDoc.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) {
} else if (acmeDoc || proxyDoc) {
status = 'valid';
issuer = 'cert-store';
}
@@ -307,8 +310,8 @@ export class CertificateHandler {
}
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err) {
return { success: false, message: err.message || 'Failed to reprovision certificate' };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
}
}
@@ -336,8 +339,8 @@ export class CertificateHandler {
try {
await dcRouter.smartAcme.getCertificateForDomain(domain);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
@@ -347,8 +350,8 @@ export class CertificateHandler {
try {
await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
} catch (err) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
@@ -362,18 +365,17 @@ export class CertificateHandler {
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}`,
];
// Delete from smartdata document classes
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (acmeDoc) {
await acmeDoc.delete();
}
for (const path of paths) {
try {
await dcRouter.storageManager.delete(path);
} catch {
// Path may not exist — ignore
// Try both original domain and clean domain for proxy certs
for (const d of [domain, cleanDomain]) {
const proxyDoc = await ProxyCertDoc.findByDomain(d);
if (proxyDoc) {
await proxyDoc.delete();
}
}
@@ -404,43 +406,41 @@ export class CertificateHandler {
};
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) {
// Try AcmeCertDoc first (has full ICert fields)
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (acmeDoc && acmeDoc.publicKey && acmeDoc.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 || '',
id: acmeDoc.id || plugins.crypto.randomUUID(),
domainName: acmeDoc.domainName || domain,
created: acmeDoc.created || Date.now(),
validUntil: acmeDoc.validUntil || 0,
privateKey: acmeDoc.privateKey,
publicKey: acmeDoc.publicKey,
csr: acmeDoc.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}`);
// Fallback: try ProxyCertDoc with original domain, then clean domain
let proxyDoc = await ProxyCertDoc.findByDomain(domain);
if (!proxyDoc || !proxyDoc.publicKey) {
proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
}
if (certData && certData.publicKey && certData.privateKey) {
if (proxyDoc && proxyDoc.publicKey && proxyDoc.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,
created: proxyDoc.validFrom || Date.now(),
validUntil: proxyDoc.validUntil || 0,
privateKey: proxyDoc.privateKey,
publicKey: proxyDoc.publicKey,
csr: '',
},
};
@@ -472,26 +472,32 @@ export class CertificateHandler {
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 || '',
});
// Save to AcmeCertDoc (SmartAcme-compatible)
let acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (!acmeDoc) {
acmeDoc = new AcmeCertDoc();
acmeDoc.domainName = cleanDomain;
}
acmeDoc.id = cert.id;
acmeDoc.created = cert.created;
acmeDoc.validUntil = cert.validUntil;
acmeDoc.privateKey = cert.privateKey;
acmeDoc.publicKey = cert.publicKey;
acmeDoc.csr = cert.csr || '';
await acmeDoc.save();
// 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,
});
// Also save to ProxyCertDoc (proxy-cert format)
let proxyDoc = await ProxyCertDoc.findByDomain(cert.domainName);
if (!proxyDoc) {
proxyDoc = new ProxyCertDoc();
proxyDoc.domain = cert.domainName;
}
proxyDoc.publicKey = cert.publicKey;
proxyDoc.privateKey = cert.privateKey;
proxyDoc.ca = '';
proxyDoc.validUntil = cert.validUntil;
proxyDoc.validFrom = cert.created;
await proxyDoc.save();
// Update in-memory status map
dcRouter.certificateStatusMap.set(cert.domainName, {

View File

@@ -1,23 +1,23 @@
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 {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
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)
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration',
async (dataArg, toolsArg) => {
const config = await this.getConfiguration(dataArg.section);
const config = await this.getConfiguration();
return {
config,
section: dataArg.section,
@@ -26,83 +26,188 @@ export class ConfigHandler {
)
);
}
private async getConfiguration(section?: string): Promise<{
email: {
enabled: boolean;
ports: number[];
maxMessageSize: number;
rateLimits: {
perMinute: number;
perHour: number;
perDay: number;
};
domains?: string[];
};
dns: {
enabled: boolean;
port: number;
nameservers: string[];
caching: boolean;
ttl: number;
};
proxy: {
enabled: boolean;
httpPort: number;
httpsPort: number;
maxConnections: number;
};
security: {
blockList: string[];
rateLimit: boolean;
spamDetection: boolean;
tlsRequired: boolean;
};
}> {
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
const dcRouter = this.opsServerRef.dcRouterRef;
// Get email domains if email server is configured
const opts = dcRouter.options;
const resolvedPaths = dcRouter.resolvedPaths;
// --- System ---
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.dbConfig?.mongoDbUrl
? 'custom'
: 'filesystem';
// 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.dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
};
// --- 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.domainRegistry) {
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
} else if (dcRouter.options.emailConfig?.domains) {
// Fallback: get domains from email config options
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
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,
};
// --- Database ---
const dbConfig = opts.dbConfig;
const cache: interfaces.requests.IConfigData['cache'] = {
enabled: dbConfig?.enabled !== false,
storagePath: dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
dbName: dbConfig?.dbName || 'dcrouter',
defaultTTLDays: 30,
cleanupIntervalHours: dbConfig?.cleanupIntervalHours || 1,
ttlConfig: {},
};
// --- 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 { ProxyCertDoc } = await import('../../db/index.js');
const stored = await ProxyCertDoc.findByDomain(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 {
email: {
enabled: !!dcRouter.emailServer,
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
maxMessageSize: 10 * 1024 * 1024, // 10MB default
rateLimits: {
perMinute: 10,
perHour: 100,
perDay: 1000,
},
domains: emailDomains,
},
dns: {
enabled: !!dcRouter.dnsServer,
port: 53,
nameservers: dcRouter.options.dnsNsDomains || [],
caching: true,
ttl: 300,
},
proxy: {
enabled: !!dcRouter.smartProxy,
httpPort: 80,
httpsPort: 443,
maxConnections: 1000,
},
security: {
blockList: [],
rateLimit: true,
spamDetection: true,
tlsRequired: false,
},
system,
smartProxy,
email,
dns,
tls,
cache,
radius,
remoteIngress,
};
}
}
}

View File

@@ -1,86 +1,44 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { SecurityLogger } from '../../security/index.js';
export class EmailOpsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get Queued Emails Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
'getQueuedEmails',
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 emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return { items: [], total: 0 };
}
const queue = emailServer.deliveryQueue;
const stats = queue.getStats();
// Get all queue items and filter by status if provided
const items = this.getQueueItems(
dataArg.status,
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: stats.queueSize,
};
const emails = this.getAllQueueEmails();
return { emails };
}
)
);
// Get Sent Emails Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
'getSentEmails',
// Get Email Detail Handler
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
async (dataArg) => {
const items = this.getQueueItems(
'delivered',
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length, // Note: total would ideally come from a counter
};
const email = this.getEmailDetail(dataArg.emailId);
return { email };
}
)
);
// Get Failed Emails Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
'getFailedEmails',
async (dataArg) => {
const items = this.getQueueItems(
'failed',
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length,
};
}
)
);
// ---- Write endpoints (adminRouter) ----
// Resend Failed Email Handler
this.typedrouter.addTypedHandler(
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
'resendEmail',
async (dataArg) => {
@@ -101,17 +59,12 @@ export class EmailOpsHandler {
}
try {
// Re-enqueue the failed email by creating a new queue entry
// with the same data but reset attempt count
const newQueueId = await queue.enqueue(
item.processingResult,
item.processingMode,
item.route
);
// Optionally remove the old failed entry
await queue.removeItem(dataArg.emailId);
return { success: true, newQueueId };
} catch (error) {
return {
@@ -122,197 +75,199 @@ export class EmailOpsHandler {
}
)
);
// Get Security Incidents Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
'getSecurityIncidents',
async (dataArg) => {
const securityLogger = SecurityLogger.getInstance();
const filter: {
level?: any;
type?: any;
} = {};
if (dataArg.level) {
filter.level = dataArg.level;
}
if (dataArg.type) {
filter.type = dataArg.type;
}
const incidents = securityLogger.getRecentEvents(
dataArg.limit || 100,
Object.keys(filter).length > 0 ? filter : undefined
);
return {
incidents: incidents.map(event => ({
timestamp: event.timestamp,
level: event.level as interfaces.requests.TSecurityLogLevel,
type: event.type as interfaces.requests.TSecurityEventType,
message: event.message,
details: event.details,
ipAddress: event.ipAddress,
userId: event.userId,
sessionId: event.sessionId,
emailId: event.emailId,
domain: event.domain,
action: event.action,
result: event.result,
success: event.success,
})),
total: incidents.length,
};
}
)
);
// Get Bounce Records Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
'getBounceRecords',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { records: [], suppressionList: [], total: 0 };
}
// Use smartmta's public API for bounce/suppression data
const suppressionList = emailServer.getSuppressionList();
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
// Create bounce records from the available data
const records: interfaces.requests.IBounceRecord[] = [];
for (const email of hardBouncedAddresses) {
const bounceInfo = emailServer.getBounceHistory(email);
if (bounceInfo) {
records.push({
id: `bounce-${email}`,
recipient: email,
sender: '',
domain: email.split('@')[1] || '',
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
timestamp: (bounceInfo as any).lastBounce,
processed: true,
});
}
}
// Apply limit and offset
const limit = dataArg.limit || 50;
const offset = dataArg.offset || 0;
const paginatedRecords = records.slice(offset, offset + limit);
return {
records: paginatedRecords,
suppressionList,
total: records.length,
};
}
)
);
// Remove from Suppression List Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
'removeFromSuppressionList',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { success: false, error: 'Email server not available' };
}
try {
emailServer.removeFromSuppressionList(dataArg.email);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
};
}
}
)
);
}
/**
* Helper method to get queue items with filtering and pagination
* Get all queue items mapped to catalog IEmail format
*/
private getQueueItems(
status?: interfaces.requests.TEmailQueueStatus,
limit: number = 50,
offset: number = 0
): interfaces.requests.IEmailQueueItem[] {
private getAllQueueEmails(): interfaces.requests.IEmail[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return [];
}
const queue = emailServer.deliveryQueue;
const items: interfaces.requests.IEmailQueueItem[] = [];
// Access the internal queue map via reflection
// This is necessary because the queue doesn't expose iteration methods
const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) {
return [];
}
// Filter and convert items
const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) {
// Apply status filter if provided
if (status && item.status !== status) {
continue;
}
// Extract email details from processingResult if available
const processingResult = item.processingResult;
let from = '';
let to: string[] = [];
let subject = '';
if (processingResult) {
// Check if it's an Email object or raw email data
if (processingResult.email) {
from = processingResult.email.from || '';
to = processingResult.email.to || [];
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = processingResult.to || [];
subject = processingResult.subject || '';
}
}
items.push({
id: item.id,
processingMode: item.processingMode,
status: item.status,
attempts: item.attempts,
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
lastError: item.lastError,
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
from,
to,
subject,
});
emails.push(this.mapQueueItemToEmail(item));
}
// Sort by createdAt descending (newest first)
items.sort((a, b) => b.createdAt - a.createdAt);
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination
return items.slice(offset, offset + limit);
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

@@ -6,4 +6,7 @@ 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 './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';
export * from './vpn.handler.js';

View File

@@ -1,19 +1,42 @@
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 {
public typedrouter = new plugins.typedrequest.TypedRouter();
private activeStreamStops: Set<() => void> = new Set();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
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
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
'getRecentLogs',
async (dataArg, toolsArg) => {
@@ -25,24 +48,24 @@ export class LogsHandler {
dataArg.search,
dataArg.timeRange
);
return {
logs,
total: logs.length, // TODO: Implement proper total count
hasMore: false, // TODO: Implement proper pagination
total: logs.length,
hasMore: false,
};
}
)
);
// Get Log Stream Handler
this.typedrouter.addTypedHandler(
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,
@@ -50,20 +73,47 @@ export class LogsHandler {
dataArg.filters?.category,
dataArg.follow
);
// Start streaming
streamLogs.start();
// VirtualStream handles cleanup automatically
// Track the stop function so we can clean up on shutdown
this.activeStreamStops.add(streamLogs.stop);
return {
logStream: virtualStream as any, // Cast to IVirtualStream interface
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',
@@ -78,44 +128,122 @@ export class LogsHandler {
message: string;
metadata?: any;
}>> {
// TODO: Implement actual log retrieval from storage or logger
// For now, return mock data
const mockLogs: Array<{
// 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;
}> = [];
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 now = Date.now();
// Generate some mock log entries
for (let i = 0; i < 50; i++) {
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
// Filter by requested criteria
if (level && mockLevel !== level) continue;
if (category && mockCategory !== category) continue;
mockLogs.push({
timestamp: now - (i * 60000), // 1 minute apart
level: mockLevel,
category: mockCategory,
message: `Sample log message ${i} from ${mockCategory}`,
metadata: {
requestId: plugins.uuid.v4(),
},
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;
}
// Apply pagination
return mockLogs.slice(offset, offset + limit);
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[],
@@ -126,8 +254,18 @@ export class LogsHandler {
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
@@ -142,23 +280,29 @@ export class LogsHandler {
const encoder = new TextEncoder();
virtualStream.sendData(encoder.encode(logData));
});
// VirtualStream doesn't have end() method - it closes automatically
});
return;
}
// For follow mode, simulate real-time log streaming
intervalId = setInterval(() => {
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,
@@ -168,28 +312,29 @@ export class LogsHandler {
requestId: plugins.uuid.v4(),
},
};
const logData = JSON.stringify(logEntry);
const encoder = new TextEncoder();
virtualStream.sendData(encoder.encode(logData));
}, 2000); // Send a log every 2 seconds
// TODO: Hook into actual logger events
// logger.on('log', (logEntry) => {
// if (matchesCriteria(logEntry, level, service)) {
// virtualStream.sendData(formatLogEntry(logEntry));
// }
// });
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);
};
const stop = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
// TODO: Unhook from logger events
};
return { start, stop };
}
}
}

View File

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

View File

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

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

@@ -4,17 +4,16 @@ import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
export class SecurityHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
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
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
'getSecurityMetrics',
async (dataArg, toolsArg) => {
@@ -40,7 +39,7 @@ export class SecurityHandler {
);
// Active Connections Handler
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
'getActiveConnections',
async (dataArg, toolsArg) => {
@@ -77,8 +76,8 @@ export class SecurityHandler {
);
// Network Stats Handler - provides comprehensive network metrics
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats',
async (dataArg, toolsArg) => {
// Get network stats from MetricsManager if available
@@ -102,6 +101,7 @@ export class SecurityHandler {
throughputByIP,
requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [],
};
}
@@ -115,13 +115,14 @@ export class SecurityHandler {
throughputByIP: [],
requestsPerSecond: 0,
requestsTotal: 0,
backends: [],
};
}
)
);
// Rate Limit Status Handler
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
'getRateLimitStatus',
async (dataArg, toolsArg) => {

View File

@@ -2,19 +2,19 @@ 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 {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
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
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
'getServerStatistics',
async (dataArg, toolsArg) => {
@@ -37,7 +37,7 @@ export class StatsHandler {
);
// Email Statistics Handler
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
'getEmailStatistics',
async (dataArg, toolsArg) => {
@@ -76,7 +76,7 @@ export class StatsHandler {
);
// DNS Statistics Handler
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
'getDnsStatistics',
async (dataArg, toolsArg) => {
@@ -113,7 +113,7 @@ export class StatsHandler {
);
// Queue Status Handler
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
'getQueueStatus',
async (dataArg, toolsArg) => {
@@ -141,7 +141,7 @@ export class StatsHandler {
);
// Health Status Handler
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
'getHealthStatus',
async (dataArg, toolsArg) => {
@@ -166,7 +166,7 @@ export class StatsHandler {
);
// Combined Metrics Handler - More efficient for frontend polling
this.typedrouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
'getCombinedMetrics',
async (dataArg, toolsArg) => {
@@ -203,6 +203,11 @@ export class StatsHandler {
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,
@@ -212,6 +217,7 @@ export class StatsHandler {
averageDeliveryTime: 0,
deliveryRate: stats.deliveryRate,
bounceRate: stats.bounceRate,
timeSeries,
};
})
);
@@ -220,6 +226,11 @@ export class StatsHandler {
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,
@@ -228,6 +239,8 @@ export class StatsHandler {
activeDomains: stats.topDomains.length,
averageResponseTime: 0,
queryTypes: stats.queryTypes,
timeSeries,
recentQueries: stats.recentQueries,
};
})
);
@@ -236,6 +249,19 @@ export class StatsHandler {
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: {},
@@ -244,6 +270,7 @@ export class StatsHandler {
phishingDetected: stats.phishingDetected,
authenticationFailures: stats.authFailures,
suspiciousActivities: stats.totalThreatsBlocked,
recentEvents,
};
})
);
@@ -252,7 +279,7 @@ export class StatsHandler {
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push(
(async () => {
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
const serverStats = await this.collectServerStats();
// Build per-IP bandwidth lookup from throughputByIP
@@ -282,6 +309,7 @@ export class StatsHandler {
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0,
backends: stats.backends || [],
};
})()
);
@@ -395,6 +423,7 @@ export class StatsHandler {
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
@@ -408,9 +437,10 @@ export class StatsHandler {
cacheHitRate: dnsStats.cacheHitRate,
topDomains: dnsStats.topDomains,
queryTypes: dnsStats.queryTypes,
recentQueries: dnsStats.recentQueries,
};
}
// Fallback if MetricsManager not available
return {
queriesPerSecond: 0,
@@ -460,44 +490,41 @@ export class StatsHandler {
message?: string;
}>;
}> {
const services: Array<{
name: string;
status: 'healthy' | 'degraded' | 'unhealthy';
message?: string;
}> = [];
// Check HTTP Proxy
if (this.opsServerRef.dcRouterRef.smartProxy) {
services.push({
name: 'HTTP/HTTPS Proxy',
status: 'healthy',
});
}
// Check Email Server
if (this.opsServerRef.dcRouterRef.emailServer) {
services.push({
name: 'Email Server',
status: 'healthy',
});
}
// Check DNS Server
if (this.opsServerRef.dcRouterRef.dnsServer) {
services.push({
name: 'DNS Server',
status: 'healthy',
});
}
// Check OpsServer
services.push({
name: 'OpsServer',
status: 'healthy',
const dcRouter = this.opsServerRef.dcRouterRef;
const health = dcRouter.serviceManager.getHealth();
const services = health.services.map((svc) => {
let status: 'healthy' | 'degraded' | 'unhealthy';
switch (svc.state) {
case 'running':
status = 'healthy';
break;
case 'starting':
case 'degraded':
status = 'degraded';
break;
case 'failed':
status = svc.criticality === 'critical' ? 'unhealthy' : 'degraded';
break;
case 'stopped':
case 'stopping':
default:
status = 'degraded';
break;
}
let message: string | undefined;
if (svc.state === 'failed' && svc.lastError) {
message = svc.lastError;
} else if (svc.retryCount > 0 && svc.state !== 'running') {
message = `Retry attempt ${svc.retryCount}`;
}
return { name: svc.name, status, message };
});
const healthy = services.every(s => s.status === 'healthy');
const healthy = health.overall === 'healthy';
return {
healthy,
services,

View File

@@ -0,0 +1,303 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class VpnHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get all registered VPN clients
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
'getVpnClients',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { clients: [] };
}
const clients = manager.listClients().map((c) => ({
clientId: c.clientId,
enabled: c.enabled,
serverDefinedClientTags: c.serverDefinedClientTags,
description: c.description,
assignedIp: c.assignedIp,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
expiresAt: c.expiresAt,
}));
return { clients };
},
),
);
// Get VPN server status
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
'getVpnStatus',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
if (!manager) {
return {
status: {
running: false,
subnet: vpnConfig?.subnet || '10.8.0.0/24',
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: null,
registeredClients: 0,
connectedClients: 0,
},
};
}
const connected = await manager.getConnectedClients();
return {
status: {
running: manager.running,
subnet: manager.getSubnet(),
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: manager.getServerPublicKeys(),
registeredClients: manager.listClients().length,
connectedClients: connected.length,
},
};
},
),
);
// Get currently connected VPN clients
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnConnectedClients>(
'getVpnConnectedClients',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { connectedClients: [] };
}
const connected = await manager.getConnectedClients();
return {
connectedClients: connected.map((c) => ({
clientId: c.registeredClientId || c.clientId,
assignedIp: c.assignedIp,
connectedSince: c.connectedSince,
bytesSent: c.bytesSent,
bytesReceived: c.bytesReceived,
transport: c.transportType,
})),
};
},
),
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Create a new VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
'createVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const bundle = await manager.createClient({
clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
description: dataArg.description,
});
return {
success: true,
client: {
clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true,
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp,
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt,
},
wireguardConfig: bundle.wireguardConfig,
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Update a VPN client's metadata
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVpnClient>(
'updateVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.updateClient(dataArg.clientId, {
description: dataArg.description,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
});
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Delete a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
'deleteVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.removeClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Enable a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
'enableVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.enableClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Disable a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
'disableVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.disableClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Rotate a VPN client's keys
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
'rotateVpnClientKey',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const bundle = await manager.rotateClientKey(dataArg.clientId);
return {
success: true,
wireguardConfig: bundle.wireguardConfig,
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Export a VPN client config
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
'exportVpnClientConfig',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format);
return { success: true, config };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Get telemetry for a specific VPN client
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
'getVpnClientTelemetry',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const telemetry = await manager.getClientTelemetry(dataArg.clientId);
if (!telemetry) {
return { success: false, message: 'Client not found or not connected' };
}
return {
success: true,
telemetry: {
clientId: telemetry.clientId,
assignedIp: telemetry.assignedIp,
bytesSent: telemetry.bytesSent,
bytesReceived: telemetry.bytesReceived,
packetsDropped: telemetry.packetsDropped,
bytesDropped: telemetry.bytesDropped,
lastKeepaliveAt: telemetry.lastKeepaliveAt,
keepalivesReceived: telemetry.keepalivesReceived,
rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec,
burstBytes: telemetry.burstBytes,
},
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}

View File

@@ -22,16 +22,17 @@ export async function passGuards<T extends { identity?: any }>(
}
/**
* Helper to check admin identity in handlers
* Helper to check admin identity in handlers and middleware.
* Accepts both optional and required identity for flexibility.
*/
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
export async function requireAdminIdentity(
adminHandler: AdminHandler,
dataArg: T
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');
@@ -39,16 +40,17 @@ export async function requireAdminIdentity<T extends { identity?: interfaces.dat
}
/**
* Helper to check valid identity in handlers
* Helper to check valid identity in handlers and middleware.
* Accepts both optional and required identity for flexibility.
*/
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
export async function requireValidIdentity(
adminHandler: AdminHandler,
dataArg: T
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');

View File

@@ -34,7 +34,6 @@ export function resolvePaths(baseDir?: string) {
dcrouterHomeDir: root,
dataDir: resolvedDataDir,
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
defaultStoragePath: plugins.path.join(root, 'storage'),
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
};
}

View File

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

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import { AccountingSessionDoc } from '../db/index.js';
/**
* RADIUS accounting session
@@ -84,14 +84,14 @@ export interface IAccountingSummary {
* 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;
/** Stale session timeout in hours — sessions with no update for this long are evicted (default: 24) */
staleSessionTimeoutHours?: number;
}
/**
@@ -104,7 +104,7 @@ export interface IAccountingManagerConfig {
export class AccountingManager {
private activeSessions: Map<string, IAccountingSession> = new Map();
private config: Required<IAccountingManagerConfig>;
private storageManager?: StorageManager;
private staleSessionSweepTimer?: ReturnType<typeof setInterval>;
// Counters for statistics
private stats = {
@@ -115,26 +115,72 @@ export class AccountingManager {
interimUpdatesReceived: 0,
};
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) {
constructor(config?: IAccountingManagerConfig) {
this.config = {
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
retentionDays: config?.retentionDays ?? 30,
detailedLogging: config?.detailedLogging ?? false,
maxActiveSessions: config?.maxActiveSessions ?? 10000,
staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24,
};
this.storageManager = storageManager;
}
/**
* Initialize the accounting manager
*/
async initialize(): Promise<void> {
if (this.storageManager) {
await this.loadActiveSessions();
await this.loadActiveSessions();
// Start periodic sweep to evict stale sessions (every 15 minutes)
this.staleSessionSweepTimer = setInterval(() => {
this.sweepStaleSessions();
}, 15 * 60 * 1000);
// Allow the process to exit even if the timer is pending
if (this.staleSessionSweepTimer.unref) {
this.staleSessionSweepTimer.unref();
}
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
}
/**
* Stop the accounting manager and clean up timers
*/
stop(): void {
if (this.staleSessionSweepTimer) {
clearInterval(this.staleSessionSweepTimer);
this.staleSessionSweepTimer = undefined;
}
}
/**
* Sweep stale active sessions that have not received any update
* within the configured timeout. These are orphaned sessions where
* the Stop packet was never received.
*/
private sweepStaleSessions(): void {
const timeoutMs = this.config.staleSessionTimeoutHours * 60 * 60 * 1000;
const cutoff = Date.now() - timeoutMs;
let swept = 0;
for (const [sessionId, session] of this.activeSessions) {
if (session.lastUpdateTime < cutoff) {
session.status = 'terminated';
session.terminateCause = 'StaleSessionTimeout';
session.endTime = Date.now();
session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000);
this.persistSession(session).catch(() => {});
this.activeSessions.delete(sessionId);
swept++;
}
}
if (swept > 0) {
logger.log('info', `Swept ${swept} stale RADIUS sessions (no update for ${this.config.staleSessionTimeoutHours}h)`);
}
}
/**
* Handle accounting start request
*/
@@ -195,9 +241,7 @@ export class AccountingManager {
}
// Persist session
if (this.storageManager) {
await this.persistSession(session);
}
await this.persistSession(session);
}
/**
@@ -243,9 +287,7 @@ export class AccountingManager {
}
// Update persisted session
if (this.storageManager) {
await this.persistSession(session);
}
await this.persistSession(session);
}
/**
@@ -298,10 +340,8 @@ export class AccountingManager {
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);
}
// Update status in the database (single collection, no active->archive move needed)
await this.persistSession(session);
// Remove from active sessions
this.activeSessions.delete(data.sessionId);
@@ -438,23 +478,16 @@ export class AccountingManager {
* 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/`);
const oldDocs = await AccountingSessionDoc.findStoppedBefore(cutoffTime);
for (const key of keys) {
for (const doc of oldDocs) {
try {
const session = await this.storageManager.getJSON<IAccountingSession>(key);
if (session && session.endTime > 0 && session.endTime < cutoffTime) {
await this.storageManager.delete(key);
deletedCount++;
}
await doc.delete();
deletedCount++;
} catch (error) {
// Ignore individual errors
}
@@ -463,8 +496,8 @@ export class AccountingManager {
if (deletedCount > 0) {
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
}
} catch (error) {
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`);
}
return deletedCount;
@@ -497,9 +530,7 @@ export class AccountingManager {
session.terminateCause = 'SessionEvicted';
session.endTime = Date.now();
if (this.storageManager) {
await this.archiveSession(session);
}
await this.persistSession(session);
this.activeSessions.delete(sessionId);
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
@@ -507,99 +538,101 @@ export class AccountingManager {
}
/**
* Load active sessions from storage
* Load active sessions from database
*/
private async loadActiveSessions(): Promise<void> {
if (!this.storageManager) {
return;
}
try {
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`);
const docs = await AccountingSessionDoc.findActive();
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
}
for (const doc of docs) {
const session: IAccountingSession = {
sessionId: doc.sessionId,
username: doc.username,
macAddress: doc.macAddress,
nasIpAddress: doc.nasIpAddress,
nasPort: doc.nasPort,
nasPortType: doc.nasPortType,
nasIdentifier: doc.nasIdentifier,
vlanId: doc.vlanId,
framedIpAddress: doc.framedIpAddress,
calledStationId: doc.calledStationId,
callingStationId: doc.callingStationId,
startTime: doc.startTime,
endTime: doc.endTime,
lastUpdateTime: doc.lastUpdateTime,
status: doc.status,
terminateCause: doc.terminateCause,
inputOctets: doc.inputOctets,
outputOctets: doc.outputOctets,
inputPackets: doc.inputPackets,
outputPackets: doc.outputPackets,
sessionTime: doc.sessionTime,
serviceType: doc.serviceType,
};
this.activeSessions.set(session.sessionId, session);
}
} catch (error) {
logger.log('warn', `Failed to load active sessions: ${error.message}`);
} catch (error: unknown) {
logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
}
}
/**
* Persist a session to storage
* Persist a session to the database (create or update)
*/
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}`);
let doc = await AccountingSessionDoc.findBySessionId(session.sessionId);
if (!doc) {
doc = new AccountingSessionDoc();
}
Object.assign(doc, session);
await doc.save();
} catch (error: unknown) {
logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as 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
* Get archived (stopped/terminated) 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/`);
const docs = await AccountingSessionDoc.getInstances({
status: { $in: ['stopped', 'terminated'] } as any,
endTime: { $gt: 0, $gte: startTime } as any,
startTime: { $lte: endTime } as any,
});
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
}
for (const doc of docs) {
sessions.push({
sessionId: doc.sessionId,
username: doc.username,
macAddress: doc.macAddress,
nasIpAddress: doc.nasIpAddress,
nasPort: doc.nasPort,
nasPortType: doc.nasPortType,
nasIdentifier: doc.nasIdentifier,
vlanId: doc.vlanId,
framedIpAddress: doc.framedIpAddress,
calledStationId: doc.calledStationId,
callingStationId: doc.callingStationId,
startTime: doc.startTime,
endTime: doc.endTime,
lastUpdateTime: doc.lastUpdateTime,
status: doc.status,
terminateCause: doc.terminateCause,
inputOctets: doc.inputOctets,
outputOctets: doc.outputOctets,
inputPackets: doc.inputPackets,
outputPackets: doc.outputPackets,
sessionTime: doc.sessionTime,
serviceType: doc.serviceType,
});
}
} catch (error) {
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
} catch (error: unknown) {
logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
}
return sessions;

View File

@@ -1,6 +1,5 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
@@ -92,7 +91,6 @@ export class RadiusServer {
private vlanManager: VlanManager;
private accountingManager: AccountingManager;
private config: IRadiusServerConfig;
private storageManager?: StorageManager;
private clientSecrets: Map<string, string> = new Map();
private running: boolean = false;
@@ -105,20 +103,19 @@ export class RadiusServer {
startTime: 0,
};
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) {
constructor(config: IRadiusServerConfig) {
this.config = {
authPort: config.authPort ?? 1812,
acctPort: config.acctPort ?? 1813,
bindAddress: config.bindAddress ?? '0.0.0.0',
...config,
};
this.storageManager = storageManager;
// Initialize VLAN manager
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager);
this.vlanManager = new VlanManager(config.vlanAssignment);
// Initialize accounting manager
this.accountingManager = new AccountingManager(config.accounting, storageManager);
this.accountingManager = new AccountingManager(config.accounting);
}
/**
@@ -183,6 +180,8 @@ export class RadiusServer {
this.radiusServer = undefined;
}
this.accountingManager.stop();
this.running = false;
logger.log('info', 'RADIUS server stopped');
}
@@ -308,8 +307,8 @@ export class RadiusServer {
default:
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
}
} catch (error) {
logger.log('error', `RADIUS accounting error: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
}
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import { VlanMappingsDoc } from '../db/index.js';
/**
* MAC address to VLAN mapping
@@ -42,8 +42,6 @@ export interface IVlanManagerConfig {
defaultVlan?: number;
/** Whether to allow unknown MACs (assign default VLAN) or reject */
allowUnknownMacs?: boolean;
/** Storage key prefix for persistence */
storagePrefix?: string;
}
/**
@@ -56,27 +54,22 @@ export interface IVlanManagerConfig {
export class VlanManager {
private mappings: Map<string, IMacVlanMapping> = new Map();
private config: Required<IVlanManagerConfig>;
private storageManager?: StorageManager;
// Cache for normalized MAC lookups
private normalizedMacCache: Map<string, string> = new Map();
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) {
constructor(config?: IVlanManagerConfig) {
this.config = {
defaultVlan: config?.defaultVlan ?? 1,
allowUnknownMacs: config?.allowUnknownMacs ?? true,
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
};
this.storageManager = storageManager;
}
/**
* Initialize the VLAN manager and load persisted mappings
*/
async initialize(): Promise<void> {
if (this.storageManager) {
await this.loadMappings();
}
await this.loadMappings();
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
}
@@ -100,6 +93,14 @@ export class VlanManager {
// Cache the result
this.normalizedMacCache.set(mac, normalized);
// Prevent unbounded cache growth
if (this.normalizedMacCache.size > 10000) {
const iterator = this.normalizedMacCache.keys();
for (let i = 0; i < 1000; i++) {
this.normalizedMacCache.delete(iterator.next().value!);
}
}
return normalized;
}
@@ -149,10 +150,8 @@ export class VlanManager {
this.mappings.set(normalizedMac, fullMapping);
// Persist to storage
if (this.storageManager) {
await this.saveMappings();
}
// Persist to database
await this.saveMappings();
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
return fullMapping;
@@ -165,7 +164,7 @@ export class VlanManager {
const normalizedMac = this.normalizeMac(mac);
const removed = this.mappings.delete(normalizedMac);
if (removed && this.storageManager) {
if (removed) {
await this.saveMappings();
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
}
@@ -325,39 +324,36 @@ export class VlanManager {
}
/**
* Load mappings from storage
* Load mappings from database
*/
private async loadMappings(): Promise<void> {
if (!this.storageManager) {
return;
}
try {
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix);
if (data && Array.isArray(data)) {
for (const mapping of data) {
const doc = await VlanMappingsDoc.load();
if (doc && Array.isArray(doc.mappings)) {
for (const mapping of doc.mappings) {
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
}
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
logger.log('info', `Loaded ${doc.mappings.length} VLAN mappings from database`);
}
} catch (error) {
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`);
} catch (error: unknown) {
logger.log('warn', `Failed to load VLAN mappings from database: ${(error as Error).message}`);
}
}
/**
* Save mappings to storage
* Save mappings to database
*/
private async saveMappings(): Promise<void> {
if (!this.storageManager) {
return;
}
try {
const mappings = Array.from(this.mappings.values());
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
} catch (error) {
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
let doc = await VlanMappingsDoc.load();
if (!doc) {
doc = new VlanMappingsDoc();
}
doc.mappings = mappings;
await doc.save();
} catch (error: unknown) {
logger.log('error', `Failed to save VLAN mappings to database: ${(error as Error).message}`);
}
}
}

View File

@@ -6,7 +6,7 @@
* - VLAN assignment based on MAC addresses
* - OUI (vendor prefix) pattern matching for device categorization
* - RADIUS accounting for session tracking and billing
* - Integration with StorageManager for persistence
* - Integration with smartdata document classes for persistence
*/
export * from './classes.radius.server.js';

View File

@@ -37,7 +37,7 @@ const router = new DcRouter({
});
await router.start();
// OpsServer dashboard at http://localhost:3000
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
// Graceful shutdown
await router.stop();
@@ -60,6 +60,9 @@ ts/
│ └── documents/ # Cached document models
├── config/ # Configuration utilities
├── errors/ # Error classes and retry logic
├── http3/ # HTTP/3 (QUIC) route augmentation
│ ├── index.ts # Barrel export
│ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
├── monitoring/ # MetricsManager (SmartMetrics integration)
├── opsserver/ # OpsServer dashboard + API handlers
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
@@ -71,7 +74,10 @@ ts/
│ ├── email.handler.ts # Email operations
│ ├── certificate.handler.ts # Certificate management
│ ├── radius.handler.ts # RADIUS management
── remoteingress.handler.ts # Remote ingress edge + token management
── remoteingress.handler.ts # Remote ingress edge + token management
│ ├── route-management.handler.ts # Programmatic route CRUD
│ ├── api-token.handler.ts # API token management
│ └── security.handler.ts # Security metrics + connections
├── radius/ # RADIUS server integration
├── remoteingress/ # Remote ingress hub integration
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
@@ -96,6 +102,9 @@ export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
// Remote Ingress
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
// HTTP/3
export type { IHttp3Config } from './http3/index.js';
```
## Key Classes
@@ -112,6 +121,7 @@ The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle o
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
| `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
@@ -126,7 +136,7 @@ Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks c
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

@@ -1,13 +1,11 @@
import * as plugins from '../plugins.js';
import type { StorageManager } from '../storage/classes.storagemanager.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
const STORAGE_PREFIX = '/remote-ingress/';
import { RemoteIngressEdgeDoc } from '../db/index.js';
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
function extractPorts(portRange: number | number[] | Array<{ from: number; to: number }>): number[] {
function extractPorts(portRange: number | Array<number | { from: number; to: number }>): number[] {
const ports = new Set<number>();
if (typeof portRange === 'number') {
ports.add(portRange);
@@ -27,33 +25,40 @@ function extractPorts(portRange: number | number[] | Array<{ from: number; to: n
/**
* Manages CRUD for remote ingress edge registrations.
* Persists edge configs via StorageManager and provides
* Persists edge configs via smartdata document classes and provides
* the allowed edges list for the Rust hub.
*/
export class RemoteIngressManager {
private storageManager: StorageManager;
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
constructor(storageManager: StorageManager) {
this.storageManager = storageManager;
constructor() {
}
/**
* Load all edge registrations from storage into memory.
* Load all edge registrations from the database into memory.
*/
public async initialize(): Promise<void> {
const keys = await this.storageManager.list(STORAGE_PREFIX);
for (const key of keys) {
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
if (edge) {
// Migration: old edges without autoDerivePorts default to true
if ((edge as any).autoDerivePorts === undefined) {
edge.autoDerivePorts = true;
await this.storageManager.setJSON(key, edge);
}
this.edges.set(edge.id, edge);
const docs = await RemoteIngressEdgeDoc.findAll();
for (const doc of docs) {
// Migration: old edges without autoDerivePorts default to true
if ((doc as any).autoDerivePorts === undefined) {
doc.autoDerivePorts = true;
await doc.save();
}
const edge: IRemoteIngress = {
id: doc.id,
name: doc.name,
secret: doc.secret,
listenPorts: doc.listenPorts,
listenPortsUdp: doc.listenPortsUdp,
enabled: doc.enabled,
autoDerivePorts: doc.autoDerivePorts,
tags: doc.tags,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
this.edges.set(edge.id, edge);
}
}
@@ -94,6 +99,38 @@ export class RemoteIngressManager {
return [...ports].sort((a, b) => a - b);
}
/**
* Derive UDP listen ports for an edge from routes with transport 'udp' or 'all'.
* These ports need UDP listeners on the edge (e.g. for QUIC/HTTP3).
*/
public deriveUdpPortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
const ports = new Set<number>();
for (const route of this.routes) {
if (!route.remoteIngress?.enabled) continue;
// Apply edge filter if present
const filter = route.remoteIngress.edgeFilter;
if (filter && filter.length > 0) {
const idMatch = filter.includes(edgeId);
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
if (!idMatch && !tagMatch) continue;
}
// Only include ports from routes that listen on UDP
const transport = route.match?.transport;
if (transport === 'udp' || transport === 'all') {
if (route.match?.ports) {
for (const p of extractPorts(route.match.ports)) {
ports.add(p);
}
}
}
}
return [...ports].sort((a, b) => a - b);
}
/**
* Get the effective listen ports for an edge.
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
@@ -106,6 +143,18 @@ export class RemoteIngressManager {
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
}
/**
* Get the effective UDP listen ports for an edge.
* Manual UDP ports are always included. Auto-derived UDP ports are added when autoDerivePorts is true.
*/
public getEffectiveListenPortsUdp(edge: IRemoteIngress): number[] {
const manualPorts = edge.listenPortsUdp || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
const derivedPorts = this.deriveUdpPortsForEdge(edge.id, edge.tags);
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
}
/**
* Get manual and derived port breakdown for an edge (used in API responses).
* Derived ports exclude any ports already present in the manual list.
@@ -145,7 +194,9 @@ export class RemoteIngressManager {
updatedAt: now,
};
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
const doc = new RemoteIngressEdgeDoc();
Object.assign(doc, edge);
await doc.save();
this.edges.set(id, edge);
return edge;
}
@@ -189,7 +240,11 @@ export class RemoteIngressManager {
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
this.edges.set(id, edge);
return edge;
}
@@ -201,7 +256,10 @@ export class RemoteIngressManager {
if (!this.edges.has(id)) {
return false;
}
await this.storageManager.delete(`${STORAGE_PREFIX}${id}`);
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
await doc.delete();
}
this.edges.delete(id);
return true;
}
@@ -218,7 +276,11 @@ export class RemoteIngressManager {
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
this.edges.set(id, edge);
return edge.secret;
}
@@ -241,15 +303,18 @@ export class RemoteIngressManager {
/**
* Get the list of allowed edges (enabled only) for the Rust hub.
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
*/
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[] }> {
const result: Array<{ id: string; secret: string; listenPorts: number[] }> = [];
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
result.push({
id: edge.id,
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
});
}
}

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
import { LRUCache } from 'lru-cache';
import { CachedIPReputation } from '../db/documents/classes.cached.ip.reputation.js';
/**
* Reputation check result information
@@ -52,7 +52,7 @@ export interface IIPReputationOptions {
highRiskThreshold?: number; // Score below this is high risk
mediumRiskThreshold?: number; // Score below this is medium risk
lowRiskThreshold?: number; // Score below this is low risk
enableLocalCache?: boolean; // Whether to persist cache to disk (default: true)
enableLocalCache?: boolean; // Whether to persist cache to database (default: true)
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
enableIPInfo?: boolean; // Whether to use IP info service (default: true)
}
@@ -61,11 +61,10 @@ export interface IIPReputationOptions {
* Class for checking IP reputation of inbound email senders
*/
export class IPReputationChecker {
private static instance: IPReputationChecker;
private static instance: IPReputationChecker | undefined;
private reputationCache: LRUCache<string, IReputationResult>;
private options: Required<IIPReputationOptions>;
private storageManager?: any; // StorageManager instance
// Default DNSBL servers
private static readonly DEFAULT_DNSBL_SERVERS = [
'zen.spamhaus.org', // Spamhaus
@@ -73,13 +72,13 @@ export class IPReputationChecker {
'b.barracudacentral.org', // Barracuda
'spam.dnsbl.sorbs.net', // SORBS
'dnsbl.sorbs.net', // SORBS (expanded)
'cbl.abuseat.org', // Composite Blocking List
'cbl.abuseat.org', // Composite Blocking List
'xbl.spamhaus.org', // Spamhaus XBL
'pbl.spamhaus.org', // Spamhaus PBL
'dnsbl-1.uceprotect.net', // UCEPROTECT
'psbl.surriel.com' // PSBL
];
// Default options
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
maxCacheSize: 10000,
@@ -92,58 +91,51 @@ export class IPReputationChecker {
enableDNSBL: true,
enableIPInfo: true
};
/**
* Constructor for IPReputationChecker
* @param options Configuration options
* @param storageManager Optional StorageManager instance for persistence
*/
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
constructor(options: IIPReputationOptions = {}) {
// Merge with default options
this.options = {
...IPReputationChecker.DEFAULT_OPTIONS,
...options
};
this.storageManager = storageManager;
// If no storage manager provided, log warning
if (!storageManager && this.options.enableLocalCache) {
logger.log('warn',
'⚠️ WARNING: IPReputationChecker initialized without StorageManager.\n' +
' IP reputation cache will only be stored to filesystem.\n' +
' Consider passing a StorageManager instance for better storage flexibility.'
);
}
// Initialize reputation cache
this.reputationCache = new LRUCache<string, IReputationResult>({
max: this.options.maxCacheSize,
ttl: this.options.cacheTTL, // Cache TTL
});
// Load cache from disk if enabled
// Load persisted reputations into in-memory cache
if (this.options.enableLocalCache) {
// Fire and forget the load operation
this.loadCache().catch(error => {
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
this.loadCacheFromDb().catch((error: unknown) => {
logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
});
}
}
/**
* Get the singleton instance of the checker
* @param options Configuration options
* @param storageManager Optional StorageManager instance for persistence
* @returns Singleton instance
*/
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
public static getInstance(options: IIPReputationOptions = {}): IPReputationChecker {
if (!IPReputationChecker.instance) {
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
IPReputationChecker.instance = new IPReputationChecker(options);
}
return IPReputationChecker.instance;
}
/**
* Reset the singleton instance (for shutdown/testing)
*/
public static resetInstance(): void {
IPReputationChecker.instance = undefined;
}
/**
* Check an IP address's reputation
* @param ip IP address to check
@@ -156,8 +148,8 @@ export class IPReputationChecker {
logger.log('warn', `Invalid IP address format: ${ip}`);
return this.createErrorResult(ip, 'Invalid IP address format');
}
// Check cache first
// Check in-memory LRU cache first (fast path)
const cachedResult = this.reputationCache.get(ip);
if (cachedResult) {
logger.log('info', `Using cached reputation data for IP ${ip}`, {
@@ -166,7 +158,7 @@ export class IPReputationChecker {
});
return cachedResult;
}
// Initialize empty result
const result: IReputationResult = {
score: 100, // Start with perfect score
@@ -176,65 +168,64 @@ export class IPReputationChecker {
isVPN: false,
timestamp: Date.now()
};
// Check IP against DNS blacklists if enabled
if (this.options.enableDNSBL) {
const dnsblResult = await this.checkDNSBL(ip);
// Update result with DNSBL information
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
result.isSpam = dnsblResult.listCount > 0;
result.blacklists = dnsblResult.lists;
}
// Get additional IP information if enabled
if (this.options.enableIPInfo) {
const ipInfo = await this.getIPInfo(ip);
// Update result with IP info
result.country = ipInfo.country;
result.asn = ipInfo.asn;
result.org = ipInfo.org;
// Adjust score based on IP type
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
// Set proxy flags
result.isProxy = ipInfo.type === IPType.PROXY;
result.isTor = ipInfo.type === IPType.TOR;
result.isVPN = ipInfo.type === IPType.VPN;
}
}
// Ensure score is between 0 and 100
result.score = Math.max(0, Math.min(100, result.score));
// Update cache with result
// Update in-memory LRU cache
this.reputationCache.set(ip, result);
// Save cache if enabled
// Persist to database if enabled (fire and forget)
if (this.options.enableLocalCache) {
// Fire and forget the save operation
this.saveCache().catch(error => {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
this.persistReputationToDb(ip, result).catch((error: unknown) => {
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
});
}
// Log the reputation check
this.logReputationCheck(ip, result);
return result;
} catch (error) {
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
} catch (error: unknown) {
logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
ip,
stack: error.stack
stack: (error as Error).stack
});
return this.createErrorResult(ip, error.message);
return this.createErrorResult(ip, (error as Error).message);
}
}
/**
* Check an IP against DNS blacklists
* @param ip IP address to check
@@ -247,42 +238,42 @@ export class IPReputationChecker {
try {
// Reverse the IP for DNSBL queries
const reversedIP = this.reverseIP(ip);
const results = await Promise.allSettled(
this.options.dnsblServers.map(async (server) => {
try {
const lookupDomain = `${reversedIP}.${server}`;
await plugins.dns.promises.resolve(lookupDomain);
return server; // IP is listed in this DNSBL
} catch (error) {
if (error.code === 'ENOTFOUND') {
} catch (error: unknown) {
if ((error as any).code === 'ENOTFOUND') {
return null; // IP is not listed in this DNSBL
}
throw error; // Other error
}
})
);
// Extract successful lookups (listed in DNSBL)
const lists = results
.filter((result): result is PromiseFulfilledResult<string> =>
.filter((result): result is PromiseFulfilledResult<string> =>
result.status === 'fulfilled' && result.value !== null
)
.map(result => result.value);
return {
listCount: lists.length,
lists
};
} catch (error) {
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Error checking DNSBL for ${ip}: ${(error as Error).message}`);
return {
listCount: 0,
lists: []
};
}
}
/**
* Get information about an IP address
* @param ip IP address to check
@@ -297,16 +288,16 @@ export class IPReputationChecker {
try {
// In a real implementation, this would use an IP data service API
// For this implementation, we'll use a simplified approach
// Check if it's a known Tor exit node (simplified)
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
// Check if it's a known VPN (simplified)
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
// Check if it's a known proxy (simplified)
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
// Determine IP type
let type = IPType.UNKNOWN;
if (isTor) {
@@ -329,7 +320,7 @@ export class IPReputationChecker {
type = IPType.RESIDENTIAL;
}
}
// Return the information
return {
country: this.determineCountry(ip), // Simplified, would use geolocation service
@@ -337,14 +328,14 @@ export class IPReputationChecker {
org: this.determineOrg(ip), // Simplified, would use real org data
type
};
} catch (error) {
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Error getting IP info for ${ip}: ${(error as Error).message}`);
return {
type: IPType.UNKNOWN
};
}
}
/**
* Simplified method to determine country from IP
* In a real implementation, this would use a geolocation database or service
@@ -359,7 +350,7 @@ export class IPReputationChecker {
if (ip.startsWith('171.')) return 'DE';
return 'XX'; // Unknown
}
/**
* Simplified method to determine organization from IP
* In a real implementation, this would use an IP-to-org database or service
@@ -375,7 +366,7 @@ export class IPReputationChecker {
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
return 'Unknown';
}
/**
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
* @param ip IP address to reverse
@@ -384,7 +375,7 @@ export class IPReputationChecker {
private reverseIP(ip: string): string {
return ip.split('.').reverse().join('.');
}
/**
* Create an error result for when reputation check fails
* @param ip IP address
@@ -402,7 +393,7 @@ export class IPReputationChecker {
error: errorMessage
};
}
/**
* Validate IP address format
* @param ip IP address to validate
@@ -413,7 +404,7 @@ export class IPReputationChecker {
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipv4Pattern.test(ip);
}
/**
* Log reputation check to security logger
* @param ip IP address
@@ -427,7 +418,7 @@ export class IPReputationChecker {
} else if (result.score < this.options.mediumRiskThreshold) {
logLevel = SecurityLogLevel.INFO;
}
// Log the check
SecurityLogger.getInstance().logEvent({
level: logLevel,
@@ -446,116 +437,76 @@ export class IPReputationChecker {
success: !result.isSpam
});
}
/**
* Save cache to disk or storage manager
* Persist a single IP reputation result to the database via CachedIPReputation
*/
private async saveCache(): Promise<void> {
private async persistReputationToDb(ip: string, result: IReputationResult): Promise<void> {
try {
// Convert cache entries to serializable array
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
ip,
data
}));
// Only save if we have entries
if (entries.length === 0) {
return;
}
const cacheData = JSON.stringify(entries);
// Save to storage manager if available
if (this.storageManager) {
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
const data = {
score: result.score,
isSpam: result.isSpam,
isProxy: result.isProxy,
isTor: result.isTor,
isVPN: result.isVPN,
country: result.country,
asn: result.asn,
org: result.org,
blacklists: result.blacklists,
};
const existing = await CachedIPReputation.findByIP(ip);
if (existing) {
existing.updateReputation(data);
await existing.save();
} else {
// Fall back to filesystem
const cacheDir = plugins.path.join(paths.dataDir, 'security');
plugins.fsUtils.ensureDirSync(cacheDir);
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
plugins.fsUtils.toFsSync(cacheData, cacheFile);
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
const doc = CachedIPReputation.fromReputationData(ip, data);
await doc.save();
}
} catch (error) {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
}
}
/**
* Load cache from disk or storage manager
* Load persisted reputations from CachedIPReputation documents into the in-memory LRU cache
*/
private async loadCache(): Promise<void> {
private async loadCacheFromDb(): Promise<void> {
try {
let cacheData: string | null = null;
let fromFilesystem = false;
// Try to load from storage manager first
if (this.storageManager) {
try {
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
if (!cacheData) {
// Check if data exists in filesystem and migrate it
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
if (plugins.fs.existsSync(cacheFile)) {
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
fromFilesystem = true;
// Migrate to storage manager
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
// Optionally delete the old file after successful migration
try {
plugins.fs.unlinkSync(cacheFile);
logger.log('info', 'Old cache file removed after migration');
} catch (deleteError) {
logger.log('warn', `Could not delete old cache file: ${deleteError.message}`);
}
}
}
} catch (error) {
logger.log('error', `Error loading from StorageManager: ${error.message}`);
}
} else {
// No storage manager, load from filesystem
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
if (plugins.fs.existsSync(cacheFile)) {
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
fromFilesystem = true;
const docs = await CachedIPReputation.getInstances({});
let loadedCount = 0;
for (const doc of docs) {
// Skip expired documents
if (doc.isExpired()) {
continue;
}
const result: IReputationResult = {
score: doc.score,
isSpam: doc.isSpam,
isProxy: doc.isProxy,
isTor: doc.isTor,
isVPN: doc.isVPN,
country: doc.country || undefined,
asn: doc.asn || undefined,
org: doc.org || undefined,
blacklists: doc.blacklists || [],
timestamp: doc.lastAccessedAt?.getTime() ?? doc.createdAt?.getTime() ?? Date.now(),
};
this.reputationCache.set(doc.ipAddress, result);
loadedCount++;
}
// Parse and restore cache if data was found
if (cacheData) {
const entries = JSON.parse(cacheData);
// Validate and filter entries
const now = Date.now();
const validEntries = entries.filter(entry => {
const age = now - entry.data.timestamp;
return age < this.options.cacheTTL; // Only load entries that haven't expired
});
// Restore cache
for (const entry of validEntries) {
this.reputationCache.set(entry.ip, entry.data);
}
const source = fromFilesystem ? 'disk' : 'StorageManager';
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
if (loadedCount > 0) {
logger.log('info', `Loaded ${loadedCount} IP reputation cache entries from database`);
}
} catch (error) {
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
} catch (error: unknown) {
logger.log('error', `Failed to load IP reputation cache from database: ${(error as Error).message}`);
}
}
/**
* Get the risk level for a reputation score
* @param score Reputation score (0-100)
@@ -572,21 +523,4 @@ export class IPReputationChecker {
return 'trusted';
}
}
/**
* Update the storage manager after instantiation
* This is useful when the storage manager is not available at construction time
* @param storageManager The StorageManager instance to use
*/
public updateStorageManager(storageManager: any): void {
this.storageManager = storageManager;
logger.log('info', 'IPReputationChecker storage manager updated');
// If cache is enabled and we have entries, save them to the new storage manager
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
this.saveCache().catch(error => {
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
});
}
}
}
}

View File

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

View File

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

View File

@@ -1,400 +0,0 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
// Promisify filesystem operations
const readFile = plugins.util.promisify(plugins.fs.readFile);
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
const unlink = plugins.util.promisify(plugins.fs.unlink);
const rename = plugins.util.promisify(plugins.fs.rename);
const readdir = plugins.util.promisify(plugins.fs.readdir);
/**
* Storage configuration interface
*/
export interface IStorageConfig {
/** Filesystem path for storage */
fsPath?: string;
/** Custom read function */
readFunction?: (key: string) => Promise<string>;
/** Custom write function */
writeFunction?: (key: string, value: string) => Promise<void>;
}
/**
* Storage backend type
*/
export type StorageBackend = 'filesystem' | 'custom' | 'memory';
/**
* Central storage manager for DcRouter
* Provides unified key-value storage with multiple backend support
*/
export class StorageManager {
private backend: StorageBackend;
private memoryStore: Map<string, string> = new Map();
private config: IStorageConfig;
private fsBasePath?: string;
constructor(config?: IStorageConfig) {
this.config = config || {};
// Check if both fsPath and custom functions are provided
if (config?.fsPath && (config?.readFunction || config?.writeFunction)) {
console.warn(
'⚠️ WARNING: Both fsPath and custom read/write functions are configured.\n' +
' Using custom read/write functions. fsPath will be ignored.'
);
}
// Determine backend based on configuration
if (config?.readFunction && config?.writeFunction) {
this.backend = 'custom';
} else if (config?.fsPath) {
// Set up internal read/write functions for filesystem
this.backend = 'custom'; // Use custom backend with internal functions
this.fsBasePath = plugins.path.resolve(config.fsPath);
this.ensureDirectory(this.fsBasePath);
// Set up internal filesystem read/write functions
this.config.readFunction = async (key: string) => {
return this.fsRead(key);
};
this.config.writeFunction = async (key: string, value: string) => {
await this.fsWrite(key, value);
};
} else {
this.backend = 'memory';
this.showMemoryWarning();
}
logger.log('info', `StorageManager initialized with ${this.backend} backend`);
}
/**
* Show warning when using memory backend
*/
private showMemoryWarning(): void {
console.warn(
'⚠️ WARNING: StorageManager is using in-memory storage.\n' +
' Data will be lost when the process restarts.\n' +
' Configure storage.fsPath or storage functions for persistence.'
);
}
/**
* Ensure directory exists for filesystem backend
*/
private async ensureDirectory(dirPath: string): Promise<void> {
try {
await plugins.fsUtils.ensureDir(dirPath);
} catch (error) {
logger.log('error', `Failed to create storage directory: ${error.message}`);
throw error;
}
}
/**
* Validate and sanitize storage key
*/
private validateKey(key: string): string {
if (!key || typeof key !== 'string') {
throw new Error('Storage key must be a non-empty string');
}
// Ensure key starts with /
if (!key.startsWith('/')) {
key = '/' + key;
}
// Remove any dangerous path elements
key = key.replace(/\.\./g, '').replace(/\/+/g, '/');
return key;
}
/**
* Convert key to filesystem path
*/
private keyToPath(key: string): string {
if (!this.fsBasePath) {
throw new Error('Filesystem base path not configured');
}
// Remove leading slash and convert to path
const relativePath = key.substring(1);
return plugins.path.join(this.fsBasePath, relativePath);
}
/**
* Internal filesystem read function
*/
private async fsRead(key: string): Promise<string> {
const filePath = this.keyToPath(key);
try {
const content = await readFile(filePath, 'utf8');
return content;
} catch (error) {
if (error.code === 'ENOENT') {
return null;
}
throw error;
}
}
/**
* Internal filesystem write function
*/
private async fsWrite(key: string, value: string): Promise<void> {
const filePath = this.keyToPath(key);
const dir = plugins.path.dirname(filePath);
// Ensure directory exists
await plugins.fsUtils.ensureDir(dir);
// Write atomically with temp file
const tempPath = `${filePath}.tmp`;
await writeFile(tempPath, value, 'utf8');
await rename(tempPath, filePath);
}
/**
* Get value by key
*/
async get(key: string): Promise<string | null> {
key = this.validateKey(key);
try {
switch (this.backend) {
case 'custom': {
if (!this.config.readFunction) {
throw new Error('Read function not configured');
}
try {
return await this.config.readFunction(key);
} catch (error) {
// Assume null if read fails (key doesn't exist)
return null;
}
}
case 'memory': {
return this.memoryStore.get(key) || null;
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage get error for key ${key}: ${error.message}`);
throw error;
}
}
/**
* Set value by key
*/
async set(key: string, value: string): Promise<void> {
key = this.validateKey(key);
if (typeof value !== 'string') {
throw new Error('Storage value must be a string');
}
try {
switch (this.backend) {
case 'filesystem': {
const filePath = this.keyToPath(key);
const dirPath = plugins.path.dirname(filePath);
// Ensure directory exists
await plugins.fsUtils.ensureDir(dirPath);
// Write atomically
const tempPath = filePath + '.tmp';
await writeFile(tempPath, value, 'utf8');
await rename(tempPath, filePath);
break;
}
case 'custom': {
if (!this.config.writeFunction) {
throw new Error('Write function not configured');
}
await this.config.writeFunction(key, value);
break;
}
case 'memory': {
this.memoryStore.set(key, value);
break;
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage set error for key ${key}: ${error.message}`);
throw error;
}
}
/**
* Delete value by key
*/
async delete(key: string): Promise<void> {
key = this.validateKey(key);
try {
switch (this.backend) {
case 'filesystem': {
const filePath = this.keyToPath(key);
try {
await unlink(filePath);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
break;
}
case 'custom': {
// Try to delete by setting empty value
if (this.config.writeFunction) {
await this.config.writeFunction(key, '');
}
break;
}
case 'memory': {
this.memoryStore.delete(key);
break;
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage delete error for key ${key}: ${error.message}`);
throw error;
}
}
/**
* List keys by prefix
*/
async list(prefix?: string): Promise<string[]> {
prefix = prefix ? this.validateKey(prefix) : '/';
try {
switch (this.backend) {
case 'custom': {
// If we have fsBasePath, this is actually filesystem backend
if (this.fsBasePath) {
const basePath = this.keyToPath(prefix);
const keys: string[] = [];
const walkDir = async (dir: string, baseDir: string): Promise<void> => {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = plugins.path.join(dir, entry.name);
if (entry.isDirectory()) {
await walkDir(fullPath, baseDir);
} else if (entry.isFile()) {
// Convert path back to key
const relativePath = plugins.path.relative(this.fsBasePath!, fullPath);
const key = '/' + relativePath.replace(/\\/g, '/');
if (key.startsWith(prefix)) {
keys.push(key);
}
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};
await walkDir(basePath, basePath);
return keys.sort();
} else {
// True custom backends need to implement their own listing
logger.log('warn', 'List operation not supported for custom backend');
return [];
}
}
case 'memory': {
const keys: string[] = [];
for (const key of this.memoryStore.keys()) {
if (key.startsWith(prefix)) {
keys.push(key);
}
}
return keys.sort();
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage list error for prefix ${prefix}: ${error.message}`);
throw error;
}
}
/**
* Check if key exists
*/
async exists(key: string): Promise<boolean> {
key = this.validateKey(key);
try {
const value = await this.get(key);
return value !== null;
} catch (error) {
return false;
}
}
/**
* Get storage backend type
*/
getBackend(): StorageBackend {
// If we're using custom backend with fsBasePath, report it as filesystem
if (this.backend === 'custom' && this.fsBasePath) {
return 'filesystem' as StorageBackend;
}
return this.backend;
}
/**
* JSON helper: Get and parse JSON value
*/
async getJSON<T = any>(key: string): Promise<T | null> {
const value = await this.get(key);
if (value === null || value.trim() === '') {
return null;
}
try {
return JSON.parse(value) as T;
} catch (error) {
logger.log('error', `Failed to parse JSON for key ${key}: ${error.message}`);
throw error;
}
}
/**
* JSON helper: Set value as JSON
*/
async setJSON(key: string, value: any): Promise<void> {
const jsonString = JSON.stringify(value, null, 2);
await this.set(key, jsonString);
}
}

View File

@@ -1,2 +0,0 @@
// Storage module exports
export * from './classes.storagemanager.js';

View File

@@ -0,0 +1,430 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { VpnServerKeysDoc, VpnClientDoc } from '../db/index.js';
export interface IVpnManagerConfig {
/** VPN subnet CIDR (default: '10.8.0.0/24') */
subnet?: string;
/** WireGuard UDP listen port (default: 51820) */
wgListenPort?: number;
/** DNS servers pushed to VPN clients */
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
/** Compute per-client AllowedIPs based on the client's server-defined tags.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
}
/**
* Manages the SmartVPN server lifecycle and VPN client CRUD.
* Persists server keys and client registrations via smartdata document classes.
*/
export class VpnManager {
private config: IVpnManagerConfig;
private vpnServer?: plugins.smartvpn.VpnServer;
private clients: Map<string, VpnClientDoc> = new Map();
private serverKeys?: VpnServerKeysDoc;
constructor(config: IVpnManagerConfig) {
this.config = config;
}
/** The VPN subnet CIDR. */
public getSubnet(): string {
return this.config.subnet || '10.8.0.0/24';
}
/** Whether the VPN server is running. */
public get running(): boolean {
return this.vpnServer?.running ?? false;
}
/**
* Start the VPN server.
* Loads or generates server keys, loads persisted clients, starts VpnServer.
*/
public async start(): Promise<void> {
// Load or generate server keys
this.serverKeys = await this.loadOrGenerateServerKeys();
// Load persisted clients
await this.loadPersistedClients();
// Build client entries for the daemon
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
for (const client of this.clients.values()) {
clientEntries.push({
clientId: client.clientId,
publicKey: client.noisePublicKey,
wgPublicKey: client.wgPublicKey,
enabled: client.enabled,
serverDefinedClientTags: client.serverDefinedClientTags,
description: client.description,
assignedIp: client.assignedIp,
expiresAt: client.expiresAt,
});
}
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: 'socket',
transportMode: 'all',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: true,
destinationPolicy: this.config.destinationPolicy
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
clientAllowedIPs: [subnet],
};
await this.vpnServer.start(serverConfig);
// Create initial clients from config (idempotent — skip already-persisted)
if (this.config.initialClients) {
for (const initial of this.config.initialClients) {
if (!this.clients.has(initial.clientId)) {
const bundle = await this.createClient({
clientId: initial.clientId,
serverDefinedClientTags: initial.serverDefinedClientTags,
description: initial.description,
});
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
}
}
}
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
/**
* Stop the VPN server.
*/
public async stop(): Promise<void> {
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
} catch {
// Ignore stop errors
}
this.vpnServer.stop();
this.vpnServer = undefined;
}
logger.log('info', 'VPN server stopped');
}
// ── Client CRUD ────────────────────────────────────────────────────────
/**
* Create a new VPN client. Returns the config bundle (secrets only shown once).
*/
public async createClient(opts: {
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
const bundle = await this.vpnServer.createClient({
clientId: opts.clientId,
serverDefinedClientTags: opts.serverDefinedClientTags,
description: opts.description,
});
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
// Persist client entry (including WG private key for export/QR)
const doc = new VpnClientDoc();
doc.clientId = bundle.entry.clientId;
doc.enabled = bundle.entry.enabled ?? true;
doc.serverDefinedClientTags = bundle.entry.serverDefinedClientTags;
doc.description = bundle.entry.description;
doc.assignedIp = bundle.entry.assignedIp;
doc.noisePublicKey = bundle.entry.publicKey;
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.expiresAt = bundle.entry.expiresAt;
this.clients.set(doc.clientId, doc);
await this.persistClient(doc);
this.config.onClientChanged?.();
return bundle;
}
/**
* Remove a VPN client.
*/
public async removeClient(clientId: string): Promise<void> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
await this.vpnServer.removeClient(clientId);
const doc = this.clients.get(clientId);
this.clients.delete(clientId);
if (doc) {
await doc.delete();
}
this.config.onClientChanged?.();
}
/**
* List all registered clients (without secrets).
*/
public listClients(): VpnClientDoc[] {
return [...this.clients.values()];
}
/**
* Enable a client.
*/
public async enableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.enableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = true;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Disable a client.
*/
public async disableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.disableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = false;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Update a client's metadata (description, tags) without rotating keys.
*/
public async updateClient(clientId: string, update: {
description?: string;
serverDefinedClientTags?: string[];
}): Promise<void> {
const client = this.clients.get(clientId);
if (!client) throw new Error(`Client not found: ${clientId}`);
if (update.description !== undefined) client.description = update.description;
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
client.updatedAt = Date.now();
await this.persistClient(client);
this.config.onClientChanged?.();
}
/**
* Rotate a client's keys. Returns the new config bundle.
*/
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId);
// Update persisted entry with new keys (including private key for export/QR)
const client = this.clients.get(clientId);
if (client) {
client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || '';
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
client.updatedAt = Date.now();
await this.persistClient(client);
}
return bundle;
}
/**
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
*/
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
if (!this.vpnServer) throw new Error('VPN server not running');
let config = await this.vpnServer.exportClientConfig(clientId, format);
if (format === 'wireguard') {
const persisted = this.clients.get(clientId);
// Inject stored WG private key so exports produce valid, scannable configs
if (persisted?.wgPrivateKey) {
config = config.replace(
'[Interface]\n',
`[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`,
);
}
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs) {
const clientTags = persisted?.serverDefinedClientTags || [];
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
config = config.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
}
return config;
}
// ── Tag-based access control ───────────────────────────────────────────
/**
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
*/
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
const ips: string[] = [];
for (const client of this.clients.values()) {
if (!client.enabled || !client.assignedIp) continue;
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
ips.push(client.assignedIp);
}
}
return ips;
}
// ── Status and telemetry ───────────────────────────────────────────────
/**
* Get server status.
*/
public async getStatus(): Promise<plugins.smartvpn.IVpnStatus | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatus();
}
/**
* Get server statistics.
*/
public async getStatistics(): Promise<plugins.smartvpn.IVpnServerStatistics | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatistics();
}
/**
* List currently connected clients.
*/
public async getConnectedClients(): Promise<plugins.smartvpn.IVpnClientInfo[]> {
if (!this.vpnServer) return [];
return this.vpnServer.listClients();
}
/**
* Get telemetry for a specific client.
*/
public async getClientTelemetry(clientId: string): Promise<plugins.smartvpn.IVpnClientTelemetry | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getClientTelemetry(clientId);
}
/**
* Get server public keys (for display/info).
*/
public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null {
if (!this.serverKeys) return null;
return {
noisePublicKey: this.serverKeys.noisePublicKey,
wgPublicKey: this.serverKeys.wgPublicKey,
};
}
// ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
const stored = await VpnServerKeysDoc.load();
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
logger.log('info', 'Loaded VPN server keys from storage');
return stored;
}
// Generate new keys via the daemon
const tempServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
await tempServer.start();
const noiseKeys = await tempServer.generateKeypair();
const wgKeys = await tempServer.generateWgKeypair();
tempServer.stop();
const doc = stored || new VpnServerKeysDoc();
doc.noisePrivateKey = noiseKeys.privateKey;
doc.noisePublicKey = noiseKeys.publicKey;
doc.wgPrivateKey = wgKeys.privateKey;
doc.wgPublicKey = wgKeys.publicKey;
await doc.save();
logger.log('info', 'Generated and persisted new VPN server keys');
return doc;
}
private async loadPersistedClients(): Promise<void> {
const docs = await VpnClientDoc.findAll();
for (const doc of docs) {
// Migrate legacy `tags` → `serverDefinedClientTags`
if (!doc.serverDefinedClientTags && (doc as any).tags) {
doc.serverDefinedClientTags = (doc as any).tags;
(doc as any).tags = undefined;
await doc.save();
}
this.clients.set(doc.clientId, doc);
}
if (this.clients.size > 0) {
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
}
}
private async persistClient(client: VpnClientDoc): Promise<void> {
await client.save();
}
}

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

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

View File

@@ -0,0 +1,157 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class ApiToken {
private clientRef: DcRouterApiClient;
// Data from IApiTokenInfo
public id: string;
public name: string;
public scopes: interfaces.data.TApiTokenScope[];
public createdAt: number;
public expiresAt: number | null;
public lastUsedAt: number | null;
public enabled: boolean;
/** Only set on creation or roll. Not persisted on server side. */
public tokenValue?: string;
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IApiTokenInfo, tokenValue?: string) {
this.clientRef = clientRef;
this.id = data.id;
this.name = data.name;
this.scopes = data.scopes;
this.createdAt = data.createdAt;
this.expiresAt = data.expiresAt;
this.lastUsedAt = data.lastUsedAt;
this.enabled = data.enabled;
this.tokenValue = tokenValue;
}
public async revoke(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken',
this.clientRef.buildRequestPayload({ id: this.id }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to revoke token');
}
}
public async roll(): Promise<string> {
const response = await this.clientRef.request<interfaces.requests.IReq_RollApiToken>(
'rollApiToken',
this.clientRef.buildRequestPayload({ id: this.id }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to roll token');
}
this.tokenValue = response.tokenValue;
return response.tokenValue!;
}
public async toggle(enabled: boolean): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken',
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to toggle token');
}
this.enabled = enabled;
}
}
export class ApiTokenBuilder {
private clientRef: DcRouterApiClient;
private tokenName: string = '';
private tokenScopes: interfaces.data.TApiTokenScope[] = [];
private tokenExpiresInDays?: number | null;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public setName(name: string): this {
this.tokenName = name;
return this;
}
public setScopes(scopes: interfaces.data.TApiTokenScope[]): this {
this.tokenScopes = scopes;
return this;
}
public addScope(scope: interfaces.data.TApiTokenScope): this {
if (!this.tokenScopes.includes(scope)) {
this.tokenScopes.push(scope);
}
return this;
}
public setExpiresInDays(days: number | null): this {
this.tokenExpiresInDays = days;
return this;
}
public async save(): Promise<ApiToken> {
const response = await this.clientRef.request<interfaces.requests.IReq_CreateApiToken>(
'createApiToken',
this.clientRef.buildRequestPayload({
name: this.tokenName,
scopes: this.tokenScopes,
expiresInDays: this.tokenExpiresInDays,
}) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to create API token');
}
return new ApiToken(
this.clientRef,
{
id: response.tokenId!,
name: this.tokenName,
scopes: this.tokenScopes,
createdAt: Date.now(),
expiresAt: this.tokenExpiresInDays
? Date.now() + this.tokenExpiresInDays * 24 * 60 * 60 * 1000
: null,
lastUsedAt: null,
enabled: true,
},
response.tokenValue,
);
}
}
export class ApiTokenManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(): Promise<ApiToken[]> {
const response = await this.clientRef.request<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens',
this.clientRef.buildRequestPayload() as any,
);
return response.tokens.map((t) => new ApiToken(this.clientRef, t));
}
public async create(options: {
name: string;
scopes: interfaces.data.TApiTokenScope[];
expiresInDays?: number | null;
}): Promise<ApiToken> {
return this.build()
.setName(options.name)
.setScopes(options.scopes)
.setExpiresInDays(options.expiresInDays ?? null)
.save();
}
public build(): ApiTokenBuilder {
return new ApiTokenBuilder(this.clientRef);
}
}

View File

@@ -0,0 +1,123 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class Certificate {
private clientRef: DcRouterApiClient;
// Data from ICertificateInfo
public domain: string;
public routeNames: string[];
public status: interfaces.requests.TCertificateStatus;
public source: interfaces.requests.TCertificateSource;
public tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
public expiryDate?: string;
public issuer?: string;
public issuedAt?: string;
public error?: string;
public canReprovision: boolean;
public backoffInfo?: {
failures: number;
retryAfter?: string;
lastError?: string;
};
constructor(clientRef: DcRouterApiClient, data: interfaces.requests.ICertificateInfo) {
this.clientRef = clientRef;
this.domain = data.domain;
this.routeNames = data.routeNames;
this.status = data.status;
this.source = data.source;
this.tlsMode = data.tlsMode;
this.expiryDate = data.expiryDate;
this.issuer = data.issuer;
this.issuedAt = data.issuedAt;
this.error = data.error;
this.canReprovision = data.canReprovision;
this.backoffInfo = data.backoffInfo;
}
public async reprovision(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to reprovision certificate');
}
}
public async delete(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to delete certificate');
}
}
public async export(): Promise<{
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
} | undefined> {
const response = await this.clientRef.request<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
this.clientRef.buildRequestPayload({ domain: this.domain }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to export certificate');
}
return response.cert;
}
}
export interface ICertificateSummary {
total: number;
valid: number;
expiring: number;
expired: number;
failed: number;
unknown: number;
}
export class CertificateManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(): Promise<{ certificates: Certificate[]; summary: ICertificateSummary }> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetCertificateOverview>(
'getCertificateOverview',
this.clientRef.buildRequestPayload() as any,
);
return {
certificates: response.certificates.map((c) => new Certificate(this.clientRef, c)),
summary: response.summary,
};
}
public async import(cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
}): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
this.clientRef.buildRequestPayload({ cert }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to import certificate');
}
}
}

View File

@@ -0,0 +1,17 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class ConfigManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async get(section?: string): Promise<interfaces.requests.IReq_GetConfiguration['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration',
this.clientRef.buildRequestPayload({ section }) as any,
);
}
}

View File

@@ -0,0 +1,112 @@
import * as plugins from './plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
import { RouteManager } from './classes.route.js';
import { CertificateManager } from './classes.certificate.js';
import { ApiTokenManager } from './classes.apitoken.js';
import { RemoteIngressManager } from './classes.remoteingress.js';
import { StatsManager } from './classes.stats.js';
import { ConfigManager } from './classes.config.js';
import { LogManager } from './classes.logs.js';
import { EmailManager } from './classes.email.js';
import { RadiusManager } from './classes.radius.js';
export interface IDcRouterApiClientOptions {
baseUrl: string;
apiToken?: string;
}
export class DcRouterApiClient {
public baseUrl: string;
public apiToken?: string;
public identity?: interfaces.data.IIdentity;
// Resource managers
public routes: RouteManager;
public certificates: CertificateManager;
public apiTokens: ApiTokenManager;
public remoteIngress: RemoteIngressManager;
public stats: StatsManager;
public config: ConfigManager;
public logs: LogManager;
public emails: EmailManager;
public radius: RadiusManager;
constructor(options: IDcRouterApiClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.apiToken = options.apiToken;
this.routes = new RouteManager(this);
this.certificates = new CertificateManager(this);
this.apiTokens = new ApiTokenManager(this);
this.remoteIngress = new RemoteIngressManager(this);
this.stats = new StatsManager(this);
this.config = new ConfigManager(this);
this.logs = new LogManager(this);
this.emails = new EmailManager(this);
this.radius = new RadiusManager(this);
}
// =====================
// Auth
// =====================
public async login(username: string, password: string): Promise<interfaces.data.IIdentity> {
const response = await this.request<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
{ username, password },
);
if (response.identity) {
this.identity = response.identity;
}
return response.identity!;
}
public async logout(): Promise<void> {
await this.request<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
{ identity: this.identity! },
);
this.identity = undefined;
}
public async verifyIdentity(): Promise<{ valid: boolean; identity?: interfaces.data.IIdentity }> {
const response = await this.request<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
{ identity: this.identity! },
);
if (response.identity) {
this.identity = response.identity;
}
return response;
}
// =====================
// Internal request helper
// =====================
public async request<T extends plugins.typedrequestInterfaces.ITypedRequest>(
method: string,
requestData: T['request'],
): Promise<T['response']> {
const typedRequest = new plugins.typedrequest.TypedRequest<T>(
`${this.baseUrl}/typedrequest`,
method,
);
return typedRequest.fire(requestData);
}
/**
* Build a request payload with identity and optional API token auto-injected.
*/
public buildRequestPayload(extra: Record<string, any> = {}): Record<string, any> {
const payload: Record<string, any> = { ...extra };
if (this.identity) {
payload.identity = this.identity;
}
if (this.apiToken) {
payload.apiToken = this.apiToken;
}
return payload;
}
}

View File

@@ -0,0 +1,77 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class Email {
private clientRef: DcRouterApiClient;
// Data from IEmail
public id: string;
public direction: interfaces.requests.TEmailDirection;
public status: interfaces.requests.TEmailStatus;
public from: string;
public to: string;
public subject: string;
public timestamp: string;
public messageId: string;
public size: string;
constructor(clientRef: DcRouterApiClient, data: interfaces.requests.IEmail) {
this.clientRef = clientRef;
this.id = data.id;
this.direction = data.direction;
this.status = data.status;
this.from = data.from;
this.to = data.to;
this.subject = data.subject;
this.timestamp = data.timestamp;
this.messageId = data.messageId;
this.size = data.size;
}
public async getDetail(): Promise<interfaces.requests.IEmailDetail | null> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
this.clientRef.buildRequestPayload({ emailId: this.id }) as any,
);
return response.email;
}
public async resend(): Promise<{ success: boolean; newQueueId?: string }> {
const response = await this.clientRef.request<interfaces.requests.IReq_ResendEmail>(
'resendEmail',
this.clientRef.buildRequestPayload({ emailId: this.id }) as any,
);
return response;
}
}
export class EmailManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(): Promise<Email[]> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetAllEmails>(
'getAllEmails',
this.clientRef.buildRequestPayload() as any,
);
return response.emails.map((e) => new Email(this.clientRef, e));
}
public async getDetail(emailId: string): Promise<interfaces.requests.IEmailDetail | null> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
this.clientRef.buildRequestPayload({ emailId }) as any,
);
return response.email;
}
public async resend(emailId: string): Promise<{ success: boolean; newQueueId?: string }> {
return this.clientRef.request<interfaces.requests.IReq_ResendEmail>(
'resendEmail',
this.clientRef.buildRequestPayload({ emailId }) as any,
);
}
}

View File

@@ -0,0 +1,37 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class LogManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async getRecent(options?: {
level?: 'debug' | 'info' | 'warn' | 'error';
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
limit?: number;
offset?: number;
search?: string;
timeRange?: string;
}): Promise<interfaces.requests.IReq_GetRecentLogs['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetRecentLogs>(
'getRecentLogs',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getStream(options?: {
follow?: boolean;
filters?: {
level?: string[];
category?: string[];
};
}): Promise<interfaces.requests.IReq_GetLogStream['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetLogStream>(
'getLogStream',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
}

View File

@@ -0,0 +1,180 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
// =====================
// Sub-managers
// =====================
export class RadiusClientManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(): Promise<Array<{
name: string;
ipRange: string;
description?: string;
enabled: boolean;
}>> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetRadiusClients>(
'getRadiusClients',
this.clientRef.buildRequestPayload() as any,
);
return response.clients;
}
public async set(client: {
name: string;
ipRange: string;
secret: string;
description?: string;
enabled: boolean;
}): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_SetRadiusClient>(
'setRadiusClient',
this.clientRef.buildRequestPayload({ client }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to set RADIUS client');
}
}
public async remove(name: string): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRadiusClient>(
'removeRadiusClient',
this.clientRef.buildRequestPayload({ name }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to remove RADIUS client');
}
}
}
export class RadiusVlanManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(): Promise<interfaces.requests.IReq_GetVlanMappings['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetVlanMappings>(
'getVlanMappings',
this.clientRef.buildRequestPayload() as any,
);
}
public async set(mapping: {
mac: string;
vlan: number;
description?: string;
enabled: boolean;
}): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_SetVlanMapping>(
'setVlanMapping',
this.clientRef.buildRequestPayload({ mapping }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to set VLAN mapping');
}
}
public async remove(mac: string): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveVlanMapping>(
'removeVlanMapping',
this.clientRef.buildRequestPayload({ mac }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to remove VLAN mapping');
}
}
public async updateConfig(options: {
defaultVlan?: number;
allowUnknownMacs?: boolean;
}): Promise<{ defaultVlan: number; allowUnknownMacs: boolean }> {
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateVlanConfig>(
'updateVlanConfig',
this.clientRef.buildRequestPayload(options) as any,
);
if (!response.success) {
throw new Error('Failed to update VLAN config');
}
return response.config;
}
public async testAssignment(mac: string): Promise<interfaces.requests.IReq_TestVlanAssignment['response']> {
return this.clientRef.request<interfaces.requests.IReq_TestVlanAssignment>(
'testVlanAssignment',
this.clientRef.buildRequestPayload({ mac }) as any,
);
}
}
export class RadiusSessionManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(filter?: {
username?: string;
nasIpAddress?: string;
vlanId?: number;
}): Promise<interfaces.requests.IReq_GetRadiusSessions['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetRadiusSessions>(
'getRadiusSessions',
this.clientRef.buildRequestPayload({ filter }) as any,
);
}
public async disconnect(sessionId: string, reason?: string): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_DisconnectRadiusSession>(
'disconnectRadiusSession',
this.clientRef.buildRequestPayload({ sessionId, reason }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to disconnect session');
}
}
}
// =====================
// Main RADIUS Manager
// =====================
export class RadiusManager {
private clientRef: DcRouterApiClient;
public clients: RadiusClientManager;
public vlans: RadiusVlanManager;
public sessions: RadiusSessionManager;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
this.clients = new RadiusClientManager(clientRef);
this.vlans = new RadiusVlanManager(clientRef);
this.sessions = new RadiusSessionManager(clientRef);
}
public async getAccountingSummary(
startTime: number,
endTime: number,
): Promise<interfaces.requests.IReq_GetRadiusAccountingSummary['response']['summary']> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetRadiusAccountingSummary>(
'getRadiusAccountingSummary',
this.clientRef.buildRequestPayload({ startTime, endTime }) as any,
);
return response.summary;
}
public async getStatistics(): Promise<interfaces.requests.IReq_GetRadiusStatistics['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetRadiusStatistics>(
'getRadiusStatistics',
this.clientRef.buildRequestPayload() as any,
);
}
}

View File

@@ -0,0 +1,185 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class RemoteIngress {
private clientRef: DcRouterApiClient;
// Data from IRemoteIngress
public id: string;
public name: string;
public secret: string;
public listenPorts: number[];
public enabled: boolean;
public autoDerivePorts: boolean;
public tags?: string[];
public createdAt: number;
public updatedAt: number;
public effectiveListenPorts?: number[];
public manualPorts?: number[];
public derivedPorts?: number[];
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IRemoteIngress) {
this.clientRef = clientRef;
this.id = data.id;
this.name = data.name;
this.secret = data.secret;
this.listenPorts = data.listenPorts;
this.enabled = data.enabled;
this.autoDerivePorts = data.autoDerivePorts;
this.tags = data.tags;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this.effectiveListenPorts = data.effectiveListenPorts;
this.manualPorts = data.manualPorts;
this.derivedPorts = data.derivedPorts;
}
public async update(changes: {
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
tags?: string[];
}): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress',
this.clientRef.buildRequestPayload({ id: this.id, ...changes }) as any,
);
if (!response.success) {
throw new Error('Failed to update remote ingress');
}
// Update local state from response
const edge = response.edge;
this.name = edge.name;
this.listenPorts = edge.listenPorts;
this.enabled = edge.enabled;
this.autoDerivePorts = edge.autoDerivePorts;
this.tags = edge.tags;
this.updatedAt = edge.updatedAt;
this.effectiveListenPorts = edge.effectiveListenPorts;
this.manualPorts = edge.manualPorts;
this.derivedPorts = edge.derivedPorts;
}
public async delete(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress',
this.clientRef.buildRequestPayload({ id: this.id }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to delete remote ingress');
}
}
public async regenerateSecret(): Promise<string> {
const response = await this.clientRef.request<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret',
this.clientRef.buildRequestPayload({ id: this.id }) as any,
);
if (!response.success) {
throw new Error('Failed to regenerate secret');
}
this.secret = response.secret;
return response.secret;
}
public async getConnectionToken(hubHost?: string): Promise<string | undefined> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
'getRemoteIngressConnectionToken',
this.clientRef.buildRequestPayload({ edgeId: this.id, hubHost }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to get connection token');
}
return response.token;
}
}
export class RemoteIngressBuilder {
private clientRef: DcRouterApiClient;
private edgeName: string = '';
private edgeListenPorts?: number[];
private edgeAutoDerivePorts?: boolean;
private edgeTags?: string[];
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public setName(name: string): this {
this.edgeName = name;
return this;
}
public setListenPorts(ports: number[]): this {
this.edgeListenPorts = ports;
return this;
}
public setAutoDerivePorts(auto: boolean): this {
this.edgeAutoDerivePorts = auto;
return this;
}
public setTags(tags: string[]): this {
this.edgeTags = tags;
return this;
}
public async save(): Promise<RemoteIngress> {
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress',
this.clientRef.buildRequestPayload({
name: this.edgeName,
listenPorts: this.edgeListenPorts,
autoDerivePorts: this.edgeAutoDerivePorts,
tags: this.edgeTags,
}) as any,
);
if (!response.success) {
throw new Error('Failed to create remote ingress');
}
return new RemoteIngress(this.clientRef, response.edge);
}
}
export class RemoteIngressManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(): Promise<RemoteIngress[]> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngresses>(
'getRemoteIngresses',
this.clientRef.buildRequestPayload() as any,
);
return response.edges.map((e) => new RemoteIngress(this.clientRef, e));
}
public async getStatuses(): Promise<interfaces.data.IRemoteIngressStatus[]> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus',
this.clientRef.buildRequestPayload() as any,
);
return response.statuses;
}
public async create(options: {
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
tags?: string[];
}): Promise<RemoteIngress> {
const builder = this.build().setName(options.name);
if (options.listenPorts) builder.setListenPorts(options.listenPorts);
if (options.autoDerivePorts !== undefined) builder.setAutoDerivePorts(options.autoDerivePorts);
if (options.tags) builder.setTags(options.tags);
return builder.save();
}
public build(): RemoteIngressBuilder {
return new RemoteIngressBuilder(this.clientRef);
}
}

View File

@@ -0,0 +1,203 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
export class Route {
private clientRef: DcRouterApiClient;
// Data from IMergedRoute
public routeConfig: IRouteConfig;
public source: 'hardcoded' | 'programmatic';
public enabled: boolean;
public overridden: boolean;
public storedRouteId?: string;
public createdAt?: number;
public updatedAt?: number;
// Convenience accessors
public get name(): string {
return this.routeConfig.name || '';
}
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
this.clientRef = clientRef;
this.routeConfig = data.route;
this.source = data.source;
this.enabled = data.enabled;
this.overridden = data.overridden;
this.storedRouteId = data.storedRouteId;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
public async update(changes: Partial<IRouteConfig>): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
'updateRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to update route');
}
}
public async delete(): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
'deleteRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to delete route');
}
}
public async toggle(enabled: boolean): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to toggle route');
}
this.enabled = enabled;
}
public async setOverride(enabled: boolean): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to set route override');
}
this.overridden = true;
this.enabled = enabled;
}
public async removeOverride(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to remove route override');
}
this.overridden = false;
}
}
export class RouteBuilder {
private clientRef: DcRouterApiClient;
private routeConfig: Partial<IRouteConfig> = {};
private isEnabled: boolean = true;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public setName(name: string): this {
this.routeConfig.name = name;
return this;
}
public setMatch(match: IRouteConfig['match']): this {
this.routeConfig.match = match;
return this;
}
public setAction(action: IRouteConfig['action']): this {
this.routeConfig.action = action;
return this;
}
public setTls(tls: IRouteConfig['action']['tls']): this {
if (!this.routeConfig.action) {
this.routeConfig.action = { type: 'forward' } as IRouteConfig['action'];
}
this.routeConfig.action!.tls = tls;
return this;
}
public setEnabled(enabled: boolean): this {
this.isEnabled = enabled;
return this;
}
public async save(): Promise<Route> {
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRoute>(
'createRoute',
this.clientRef.buildRequestPayload({
route: this.routeConfig as IRouteConfig,
enabled: this.isEnabled,
}) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to create route');
}
// Return a Route instance by re-fetching the list
// The created route is programmatic, so we find it by storedRouteId
const { routes } = await new RouteManager(this.clientRef).list();
const created = routes.find((r) => r.storedRouteId === response.storedRouteId);
if (created) {
return created;
}
// Fallback: construct from known data
return new Route(this.clientRef, {
route: this.routeConfig as IRouteConfig,
source: 'programmatic',
enabled: this.isEnabled,
overridden: false,
storedRouteId: response.storedRouteId,
});
}
}
export class RouteManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async list(): Promise<{ routes: Route[]; warnings: interfaces.data.IRouteWarning[] }> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetMergedRoutes>(
'getMergedRoutes',
this.clientRef.buildRequestPayload() as any,
);
return {
routes: response.routes.map((r) => new Route(this.clientRef, r)),
warnings: response.warnings,
};
}
public async create(routeConfig: IRouteConfig, enabled?: boolean): Promise<Route> {
const response = await this.clientRef.request<interfaces.requests.IReq_CreateRoute>(
'createRoute',
this.clientRef.buildRequestPayload({ route: routeConfig, enabled: enabled ?? true }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to create route');
}
return new Route(this.clientRef, {
route: routeConfig,
source: 'programmatic',
enabled: enabled ?? true,
overridden: false,
storedRouteId: response.storedRouteId,
});
}
public build(): RouteBuilder {
return new RouteBuilder(this.clientRef);
}
}

View File

@@ -0,0 +1,111 @@
import * as interfaces from '../ts_interfaces/index.js';
import type { DcRouterApiClient } from './classes.dcrouterapiclient.js';
type TTimeRange = '1h' | '6h' | '24h' | '7d' | '30d';
export class StatsManager {
private clientRef: DcRouterApiClient;
constructor(clientRef: DcRouterApiClient) {
this.clientRef = clientRef;
}
public async getServer(options?: {
timeRange?: TTimeRange;
includeHistory?: boolean;
}): Promise<interfaces.requests.IReq_GetServerStatistics['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetServerStatistics>(
'getServerStatistics',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getEmail(options?: {
timeRange?: TTimeRange;
domain?: string;
includeDetails?: boolean;
}): Promise<interfaces.requests.IReq_GetEmailStatistics['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetEmailStatistics>(
'getEmailStatistics',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getDns(options?: {
timeRange?: TTimeRange;
domain?: string;
includeQueryTypes?: boolean;
}): Promise<interfaces.requests.IReq_GetDnsStatistics['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetDnsStatistics>(
'getDnsStatistics',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getRateLimits(options?: {
domain?: string;
ip?: string;
includeBlocked?: boolean;
}): Promise<interfaces.requests.IReq_GetRateLimitStatus['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetRateLimitStatus>(
'getRateLimitStatus',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getSecurity(options?: {
timeRange?: TTimeRange;
includeDetails?: boolean;
}): Promise<interfaces.requests.IReq_GetSecurityMetrics['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetSecurityMetrics>(
'getSecurityMetrics',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getConnections(options?: {
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
state?: string;
}): Promise<interfaces.requests.IReq_GetActiveConnections['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetActiveConnections>(
'getActiveConnections',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getQueues(options?: {
queueName?: string;
}): Promise<interfaces.requests.IReq_GetQueueStatus['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetQueueStatus>(
'getQueueStatus',
this.clientRef.buildRequestPayload(options || {}) as any,
);
}
public async getHealth(detailed?: boolean): Promise<interfaces.requests.IReq_GetHealthStatus['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetHealthStatus>(
'getHealthStatus',
this.clientRef.buildRequestPayload({ detailed }) as any,
);
}
public async getNetwork(): Promise<interfaces.requests.IReq_GetNetworkStats['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats',
this.clientRef.buildRequestPayload() as any,
);
}
public async getCombined(sections?: {
server?: boolean;
email?: boolean;
dns?: boolean;
security?: boolean;
network?: boolean;
}): Promise<interfaces.requests.IReq_GetCombinedMetrics['response']> {
return this.clientRef.request<interfaces.requests.IReq_GetCombinedMetrics>(
'getCombinedMetrics',
this.clientRef.buildRequestPayload({ sections }) as any,
);
}
}

15
ts_apiclient/index.ts Normal file
View File

@@ -0,0 +1,15 @@
// Main client
export { DcRouterApiClient, type IDcRouterApiClientOptions } from './classes.dcrouterapiclient.js';
// Resource classes
export { Route, RouteBuilder, RouteManager } from './classes.route.js';
export { Certificate, CertificateManager, type ICertificateSummary } from './classes.certificate.js';
export { ApiToken, ApiTokenBuilder, ApiTokenManager } from './classes.apitoken.js';
export { RemoteIngress, RemoteIngressBuilder, RemoteIngressManager } from './classes.remoteingress.js';
export { Email, EmailManager } from './classes.email.js';
// Read-only managers
export { StatsManager } from './classes.stats.js';
export { ConfigManager } from './classes.config.js';
export { LogManager } from './classes.logs.js';
export { RadiusManager, RadiusClientManager, RadiusVlanManager, RadiusSessionManager } from './classes.radius.js';

8
ts_apiclient/plugins.ts Normal file
View File

@@ -0,0 +1,8 @@
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
export {
typedrequest,
typedrequestInterfaces,
};

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