Compare commits

..

462 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
610d691244 v6.13.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 21:35:18 +00:00
c88410ea53 feat(remoteingress): include listenPorts for allowed edges sent to the Rust hub and always resync allowed edges when edge properties change 2026-02-18 21:35:18 +00:00
9cbdd24281 v6.12.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 18:47:18 +00:00
dce1de8c4b feat(remote-ingress): add Remote Ingress hub integration, OpsServer UI, APIs, and docs 2026-02-18 18:47:18 +00:00
86e6c4f600 v6.11.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-18 06:05:46 +00:00
0618755236 feat(remoteingress): add ability to generate remote ingress connection tokens and UI copy action; add hubDomain config option; update remoteingress dependency to ^3.1.1 2026-02-18 06:05:46 +00:00
b21f3385e1 v6.10.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 17:49:12 +00:00
dd61e0c962 feat(ops-view-certificates): Make Export and Delete actions available inline (inRow) as well as in the context menu; bump @design.estate/dees-catalog to ^3.43.0 2026-02-17 17:49:12 +00:00
ac3a42fc41 v6.9.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 16:28:33 +00:00
c23f16149c feat(certificates): add certificate import, export, and deletion support (server handlers, request types, and UI) 2026-02-17 16:28:33 +00:00
529a4bae00 v6.8.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 14:17:18 +00:00
49606ae007 feat(remote-ingress): support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI 2026-02-17 14:17:18 +00:00
31a6510d8b v6.7.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 11:56:54 +00:00
b5e760ae07 feat(remote-ingress): Support auto-derived effective listen ports, make listenPorts optional, add toggle action and refine remote ingress creation/management UI 2026-02-17 11:56:54 +00:00
ea32babaac v6.6.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 10:57:27 +00:00
a4ddedaf46 fix(icons): standardize icon identifiers to lucide-prefixed names across operational views 2026-02-17 10:57:27 +00:00
7ce09c53ca v6.6.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 10:55:31 +00:00
69be2295f1 feat(remoteingress): derive effective remote ingress listen ports from route configs and expose them via ops API 2026-02-17 10:55:31 +00:00
018efa32f6 v6.5.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 22:42:30 +00:00
2530918dc6 v6.4.5
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 17:47:43 +00:00
0b09ea1573 fix(remoteingress): mark remote ingress data actions as row actions and bump @design.estate/dees-catalog dependency 2026-02-16 17:47:43 +00:00
21157477b4 v6.4.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 14:50:44 +00:00
fcf36e5cd5 fix(deps): bump @push.rocks/smartproxy to ^25.7.3 2026-02-16 14:50:44 +00:00
f5740fa565 v6.4.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:44:38 +00:00
4a9fba53a9 fix(deps): bump @push.rocks/smartproxy to ^25.7.2 2026-02-16 13:44:38 +00:00
da61adc9a2 v6.4.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:32:24 +00:00
616066ffd0 fix(smartproxy): bump @push.rocks/smartproxy to ^25.7.1 2026-02-16 13:32:24 +00:00
bd5cccb405 v6.4.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 13:16:50 +00:00
fbade85cda fix(deps): bump dependencies: @push.rocks/smartproxy to ^25.7.0 and @serve.zone/remoteingress to ^3.0.2 2026-02-16 13:16:50 +00:00
9060d26f3a v6.4.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 11:25:16 +00:00
c889141ec3 feat(remoteingress): add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI 2026-02-16 11:25:16 +00:00
fb472f353c v6.3.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:52:38 +00:00
090bd747e1 feat(dcrouter): add configurable baseDir and centralized path resolution; use resolved data paths for storage, cache and DNS 2026-02-16 09:52:38 +00:00
4d77a94bbb v6.2.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:02:57 +00:00
7f5284b10f fix(deps): bump @push.rocks/smartproxy to ^25.5.0 2026-02-16 09:02:57 +00:00
9cd5db2d81 v6.2.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 02:50:25 +00:00
de0b7d1fe0 fix(dcrouter): persist proxy certificate validity dates and improve certificate status initialization 2026-02-16 02:50:25 +00:00
4e32745a8f v6.2.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 01:58:39 +00:00
121573de2f fix(certs): Populate certificate status for cert-store-loaded certificates after SmartProxy startup and check proxy-certs in opsserver certificate handler 2026-02-16 01:58:39 +00:00
cd957526e2 v6.2.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:56:41 +00:00
7aa5f07731 fix(smartacme,storage): Respect wildcard domain requests when retrieving certificates and treat empty/whitespace storage values as null in getJSON 2026-02-16 00:56:41 +00:00
5b6f7b30c3 v6.2.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:26:35 +00:00
18cc21a49e feat(ts_web): add Certificate Management documentation and ops-view-certificates reference 2026-02-16 00:26:35 +00:00
46fa2f6ade v6.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:22:23 +00:00
0a6315f177 feat(certs): integrate smartacme v9 for ACME certificate provisioning and add certificate management features, docs, dashboard views, API endpoints, and per-domain backoff scheduler 2026-02-16 00:22:23 +00:00
841f99e19d v6.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-15 16:03:13 +00:00
8e9de46cd2 BREAKING CHANGE(certs): Introduce domain-centric certificate provisioning with per-domain exponential backoff and a staggered serial scheduler; add domain-based reprovision API and UI backoff display; change certificate overview API to be domain-first and include backoff info; bump related deps. 2026-02-15 16:03:13 +00:00
2d44528345 v5.5.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-14 14:27:59 +00:00
28a38252da feat(certs): persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting 2026-02-14 14:27:58 +00:00
140 changed files with 19625 additions and 7591 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,63 +1,73 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "5.4.6",
"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.42.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": "^8.0.0",
"@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.10",
"@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.2.2",
"@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",
"@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": [
@@ -97,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"
]
}

5975
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": {

771
readme.md

File diff suppressed because it is too large Load Diff

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: '5.4.6',
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

@@ -0,0 +1,165 @@
import { logger } from './logger.js';
import { CertBackoffDoc } from './db/index.js';
interface IBackoffEntry {
failures: number;
lastFailure: string; // ISO string
retryAfter: string; // ISO string
lastError?: string;
}
/**
* Manages certificate provisioning scheduling with:
* - Per-domain exponential backoff persisted via CertBackoffDoc
*
* Note: Serial stagger queue was removed — smartacme v9 handles
* concurrency, per-domain dedup, and rate limiting internally.
*/
export class CertProvisionScheduler {
private maxBackoffHours: number;
// In-memory backoff cache (mirrors storage for fast lookups)
private backoffCache = new Map<string, IBackoffEntry>();
constructor(
options?: { maxBackoffHours?: number }
) {
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
}
/**
* Sanitized domain key for storage lookups
*/
private sanitizeDomain(domain: string): string {
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* 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 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 null;
}
/**
* Save backoff entry to both cache and database
*/
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
this.backoffCache.set(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.
* 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);
if (retryAfter.getTime() > Date.now()) {
return true;
}
// Backoff has expired — prune the stale entry
this.backoffCache.delete(domain);
return false;
}
/**
* Record a provisioning failure for a domain.
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
*/
async recordFailure(domain: string, error?: string): Promise<void> {
const existing = await this.loadBackoff(domain);
const failures = (existing?.failures ?? 0) + 1;
// Exponential backoff: failures^2 hours, capped
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
const entry: IBackoffEntry = {
failures,
lastFailure: new Date().toISOString(),
retryAfter: retryAfter.toISOString(),
lastError: error,
};
await this.saveBackoff(domain, entry);
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
}
/**
* Clear backoff for a domain (on success or manual override)
*/
async clearBackoff(domain: string): Promise<void> {
this.backoffCache.delete(domain);
try {
const sanitized = this.sanitizeDomain(domain);
const doc = await CertBackoffDoc.findByDomain(sanitized);
if (doc) {
await doc.delete();
}
} catch {
// 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
*/
async getBackoffInfo(domain: string): Promise<{
failures: number;
retryAfter?: string;
lastError?: string;
} | null> {
const entry = await this.loadBackoff(domain);
if (!entry) return null;
// Only return if still in backoff — prune expired entries
const retryAfter = new Date(entry.retryAfter);
if (retryAfter.getTime() <= Date.now()) {
this.backoffCache.delete(domain);
return null;
}
return {
failures: entry.failures,
retryAfter: entry.retryAfter,
lastError: entry.lastError,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
import * as plugins from './plugins.js';
import { AcmeCertDoc } from './db/index.js';
/**
* 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 {
constructor() {}
async init(): Promise<void> {}
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
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> {
const doc = await AcmeCertDoc.findByDomain(domainName);
if (doc) {
await doc.delete();
}
}
async close(): Promise<void> {}
async wipe(): Promise<void> {
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,9 +5,36 @@ 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
export * from './radius/index.js';
export const runCli = async () => {};
// Remote Ingress module
export * from './remoteingress/index.js';
// 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,27 +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;
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);
}
@@ -30,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,
});
@@ -41,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);
@@ -59,11 +84,19 @@ export class OpsServer {
this.radiusHandler = new handlers.RadiusHandler(this);
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
this.certificateHandler = new handlers.CertificateHandler(this);
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
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,24 +26,77 @@ export class CertificateHandler {
)
);
// Reprovision Certificate
this.typedrouter.addTypedHandler(
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Legacy route-based reprovision (backward compat)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
return this.reprovisionCertificate(dataArg.routeName);
return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
return this.reprovisionCertificateDomain(dataArg.domain);
}
)
);
// Delete certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
return this.deleteCertificate(dataArg.domain);
}
)
);
// Export certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
return this.exportCertificate(dataArg.domain);
}
)
);
// Import certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
return this.importCertificate(dataArg.cert);
}
)
);
}
/**
* Build domain-centric certificate overview.
* Instead of one row per route, we produce one row per unique domain.
*/
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) return [];
const routes = smartProxy.routeManager.getRoutes();
const certificates: interfaces.requests.ICertificateInfo[] = [];
// Phase 1: Collect unique domains with their associated route info
const domainMap = new Map<string, {
routeNames: string[];
source: interfaces.requests.TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
canReprovision: boolean;
}>();
for (const route of routes) {
if (!route.name) continue;
@@ -58,7 +114,6 @@ export class CertificateHandler {
// Determine source
let source: interfaces.requests.TCertificateSource = 'none';
if (tls.certificate === 'auto') {
// Check if a certProvisionFunction is configured
if ((smartProxy.settings as any).certProvisionFunction) {
source = 'provision-function';
} else {
@@ -68,15 +123,44 @@ export class CertificateHandler {
source = 'static';
}
// Start with unknown status
const canReprovision = source === 'acme' || source === 'provision-function';
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
for (const domain of routeDomains) {
const existing = domainMap.get(domain);
if (existing) {
// Add this route name to the existing domain entry
if (!existing.routeNames.includes(route.name)) {
existing.routeNames.push(route.name);
}
// Upgrade source if more specific
if (existing.source === 'none' && source !== 'none') {
existing.source = source;
existing.canReprovision = canReprovision;
}
} else {
domainMap.set(domain, {
routeNames: [route.name],
source,
tlsMode,
canReprovision,
});
}
}
}
// Phase 2: Resolve status for each unique domain
const certificates: interfaces.requests.ICertificateInfo[] = [];
for (const [domain, info] of domainMap) {
let status: interfaces.requests.TCertificateStatus = 'unknown';
let expiryDate: string | undefined;
let issuedAt: string | undefined;
let issuer: string | undefined;
let error: string | undefined;
// Check event-based status from DcRouter's certificateStatusMap
const eventStatus = dcRouter.certificateStatusMap.get(route.name);
// Check event-based status from certificateStatusMap (now keyed by domain)
const eventStatus = dcRouter.certificateStatusMap.get(domain);
if (eventStatus) {
status = eventStatus.status;
expiryDate = eventStatus.expiryDate;
@@ -87,10 +171,10 @@ export class CertificateHandler {
}
}
// Try Rust-side certificate status if no event data
if (status === 'unknown') {
// Try SmartProxy certificate status if no event data
if (status === 'unknown' && info.routeNames.length > 0) {
try {
const rustStatus = await smartProxy.getCertificateStatus(route.name);
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
if (rustStatus) {
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
if (rustStatus.issuer) issuer = rustStatus.issuer;
@@ -104,7 +188,34 @@ export class CertificateHandler {
}
}
// Compute status from expiry date if we have one and status is still valid/unknown
// Check persisted cert data from smartdata document classes
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
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 (proxyDoc?.publicKey) {
// certStore has the cert — parse PEM for expiry
try {
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 (acmeDoc || proxyDoc) {
status = 'valid';
issuer = 'cert-store';
}
}
// Compute status from expiry date
if (expiryDate && (status === 'valid' || status === 'unknown')) {
const expiry = new Date(expiryDate);
const now = new Date();
@@ -120,23 +231,36 @@ export class CertificateHandler {
}
// Static certs with no other info default to 'valid'
if (source === 'static' && status === 'unknown') {
if (info.source === 'static' && status === 'unknown') {
status = 'valid';
}
const canReprovision = source === 'acme' || source === 'provision-function';
// ACME/provision-function routes with no cert data are still provisioning
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
status = 'provisioning';
}
// Phase 3: Attach backoff info
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
if (dcRouter.certProvisionScheduler) {
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
if (bi) {
backoffInfo = bi;
}
}
certificates.push({
routeName: route.name,
domains: routeDomains,
domain,
routeNames: info.routeNames,
status,
source,
tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough',
source: info.source,
tlsMode: info.tlsMode,
expiryDate,
issuer,
issuedAt,
error,
canReprovision,
canReprovision: info.canReprovision,
backoffInfo,
});
}
@@ -166,7 +290,10 @@ export class CertificateHandler {
return summary;
}
private async reprovisionCertificate(routeName: string): Promise<{ success: boolean; message?: string }> {
/**
* Legacy route-based reprovisioning
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
@@ -176,11 +303,211 @@ export class CertificateHandler {
try {
await smartProxy.provisionCertificate(routeName);
// Clear event-based status so it gets refreshed
dcRouter.certificateStatusMap.delete(routeName);
// Clear event-based status for domains in this route
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err) {
return { success: false, message: err.message || 'Failed to reprovision certificate' };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
}
}
/**
* Domain-based reprovisioning — clears backoff first, then triggers provision
*/
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear backoff for this domain (user override)
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
// Clear status map entry so it gets refreshed
dcRouter.certificateStatusMap.delete(domain);
// Try to provision via SmartAcme directly
if (dcRouter.smartAcme) {
try {
await dcRouter.smartAcme.getCertificateForDomain(domain);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
// Fallback: try provisioning via the first matching route
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length > 0) {
try {
await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
return { success: false, message: `No routes found for domain '${domain}'` };
}
/**
* Delete certificate data for a domain from storage
*/
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
// Delete from smartdata document classes
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (acmeDoc) {
await acmeDoc.delete();
}
// 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();
}
}
// Clear from in-memory status map
dcRouter.certificateStatusMap.delete(domain);
// Clear backoff info
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
return { success: true, message: `Certificate data deleted for '${domain}'` };
}
/**
* Export certificate data for a domain as ICert-shaped JSON
*/
private async exportCertificate(domain: string): Promise<{
success: boolean;
cert?: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
message?: string;
}> {
const cleanDomain = domain.replace(/^\*\.?/, '');
// Try AcmeCertDoc first (has full ICert fields)
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
return {
success: true,
cert: {
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 ProxyCertDoc with original domain, then clean domain
let proxyDoc = await ProxyCertDoc.findByDomain(domain);
if (!proxyDoc || !proxyDoc.publicKey) {
proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
}
if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
return {
success: true,
cert: {
id: plugins.crypto.randomUUID(),
domainName: domain,
created: proxyDoc.validFrom || Date.now(),
validUntil: proxyDoc.validUntil || 0,
privateKey: proxyDoc.privateKey,
publicKey: proxyDoc.publicKey,
csr: '',
},
};
}
return { success: false, message: `No certificate data found for '${domain}'` };
}
/**
* Import a certificate from ICert-shaped JSON
*/
private async importCertificate(cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
}): Promise<{ success: boolean; message?: string }> {
// Validate PEM content
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
}
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
}
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
// Save to 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 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, {
status: 'valid',
source: 'static',
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
routeNames: [],
});
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
}
}

View File

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

@@ -5,4 +5,8 @@ export * from './security.handler.js';
export * from './stats.handler.js';
export * from './radius.handler.js';
export * from './email-ops.handler.js';
export * from './certificate.handler.js';
export * from './certificate.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

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

View File

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

View File

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

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

View File

@@ -23,9 +23,11 @@ export {
// @serve.zone scope
import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as remoteingress from '@serve.zone/remoteingress';
export {
servezoneInterfaces
servezoneInterfaces,
remoteingress,
}
// @api.global scope
@@ -45,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';
@@ -87,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';

156
ts/readme.md Normal file
View File

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

View File

@@ -0,0 +1,323 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
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 | Array<number | { from: number; to: number }>): number[] {
const ports = new Set<number>();
if (typeof portRange === 'number') {
ports.add(portRange);
} else if (Array.isArray(portRange)) {
for (const entry of portRange) {
if (typeof entry === 'number') {
ports.add(entry);
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
for (let p = entry.from; p <= entry.to; p++) {
ports.add(p);
}
}
}
}
return [...ports].sort((a, b) => a - b);
}
/**
* Manages CRUD for remote ingress edge registrations.
* Persists edge configs via smartdata document classes and provides
* the allowed edges list for the Rust hub.
*/
export class RemoteIngressManager {
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
constructor() {
}
/**
* Load all edge registrations from the database into memory.
*/
public async initialize(): Promise<void> {
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);
}
}
/**
* Store the current route configs for port derivation.
*/
public setRoutes(routes: IDcRouterRouteConfig[]): void {
this.routes = routes;
}
/**
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
* When edgeFilter is absent, the route applies to all edges.
*/
public derivePortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
const ports = new Set<number>();
for (const route of this.routes) {
if (!route.remoteIngress?.enabled) continue;
// Apply edge filter if present
const filter = route.remoteIngress.edgeFilter;
if (filter && filter.length > 0) {
const idMatch = filter.includes(edgeId);
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
if (!idMatch && !tagMatch) continue;
}
// Extract ports from the route match
if (route.match?.ports) {
for (const p of extractPorts(route.match.ports)) {
ports.add(p);
}
}
}
return [...ports].sort((a, b) => a - b);
}
/**
* 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.
*/
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
const manualPorts = edge.listenPorts || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags);
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
}
/**
* Get 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.
*/
public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } {
const manual = edge.listenPorts || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return { manual, derived: [] };
const manualSet = new Set(manual);
const allDerived = this.derivePortsForEdge(edge.id, edge.tags);
const derived = allDerived.filter((p) => !manualSet.has(p));
return { manual, derived };
}
/**
* Create a new edge registration.
*/
public async createEdge(
name: string,
listenPorts: number[] = [],
tags?: string[],
autoDerivePorts: boolean = true,
): Promise<IRemoteIngress> {
const id = plugins.uuid.v4();
const secret = plugins.crypto.randomBytes(32).toString('hex');
const now = Date.now();
const edge: IRemoteIngress = {
id,
name,
secret,
listenPorts,
enabled: true,
autoDerivePorts,
tags: tags || [],
createdAt: now,
updatedAt: now,
};
const doc = new RemoteIngressEdgeDoc();
Object.assign(doc, edge);
await doc.save();
this.edges.set(id, edge);
return edge;
}
/**
* Get an edge by ID.
*/
public getEdge(id: string): IRemoteIngress | undefined {
return this.edges.get(id);
}
/**
* Get all edge registrations.
*/
public getAllEdges(): IRemoteIngress[] {
return Array.from(this.edges.values());
}
/**
* Update an edge registration.
*/
public async updateEdge(
id: string,
updates: {
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
tags?: string[];
},
): Promise<IRemoteIngress | null> {
const edge = this.edges.get(id);
if (!edge) {
return null;
}
if (updates.name !== undefined) edge.name = updates.name;
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
this.edges.set(id, edge);
return edge;
}
/**
* Delete an edge registration.
*/
public async deleteEdge(id: string): Promise<boolean> {
if (!this.edges.has(id)) {
return false;
}
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
await doc.delete();
}
this.edges.delete(id);
return true;
}
/**
* Regenerate the secret for an edge.
*/
public async regenerateSecret(id: string): Promise<string | null> {
const edge = this.edges.get(id);
if (!edge) {
return null;
}
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
edge.updatedAt = Date.now();
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
this.edges.set(id, edge);
return edge.secret;
}
/**
* Verify an edge's secret using constant-time comparison.
*/
public verifySecret(id: string, secret: string): boolean {
const edge = this.edges.get(id);
if (!edge) {
return false;
}
const expected = Buffer.from(edge.secret);
const provided = Buffer.from(secret);
if (expected.length !== provided.length) {
return false;
}
return plugins.crypto.timingSafeEqual(expected, provided);
}
/**
* Get the list of allowed edges (enabled only) for the Rust hub.
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
*/
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 } : {}),
});
}
}
return result;
}
}

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js';
import { Email, type Core } from '@push.rocks/smartmta';
type IAttachment = Core.IAttachment;
@@ -61,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>;
@@ -183,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
@@ -252,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)
@@ -265,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}`
};
}
}
@@ -619,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 '';
}
}
@@ -693,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
@@ -716,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) {
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';

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