Compare commits

...

232 Commits

Author SHA1 Message Date
jkunz 1912feffe5 v13.38.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m45s
2026-05-29 17:57:08 +00:00
jkunz 9077b3dad6 feat(dns): support explicit DNS bind interface configuration 2026-05-29 17:56:33 +00:00
jkunz d09ac51c5b v13.37.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m10s
2026-05-29 15:21:54 +00:00
jkunz 9d7975721d fix(packaging): exclude assets from compiled and published artifacts 2026-05-29 15:21:22 +00:00
jkunz 667d62b456 v13.37.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 4m28s
2026-05-29 14:52:42 +00:00
jkunz 90b1ca8de3 fix(release): configure pnpm registry for release workflow 2026-05-29 14:45:22 +00:00
jkunz 17d824d718 v13.37.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 20s
2026-05-29 14:05:26 +00:00
jkunz 06a8636aee feat(distribution): add binary installer 2026-05-29 13:58:05 +00:00
jkunz 4bf08c1fc3 fix(distribution): sync Deno binary import map 2026-05-29 10:43:12 +00:00
jkunz 7e721c54d0 feat(distribution): add CLI binary distribution and improve DNS challenge handling 2026-05-29 10:38:54 +00:00
jkunz e6aa5a1dd2 v13.36.3
Docker (tags) / release (push) Failing after 1s
2026-05-29 08:42:32 +00:00
jkunz bbe18e1413 fix(deps): bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts 2026-05-29 08:42:14 +00:00
jkunz e2a10bdc3c v13.36.2
Docker (tags) / release (push) Failing after 1s
2026-05-29 04:00:16 +00:00
jkunz 42a5f6df7b fix(dns): preserve parallel ACME TXT challenges and mixed-case DNS queries 2026-05-29 03:59:59 +00:00
jkunz c61d832b43 v13.36.1
Docker (tags) / release (push) Failing after 1s
2026-05-28 14:39:36 +00:00
jkunz 872a822ed7 fix(remoteingress): bump @serve.zone/remoteingress to ^4.18.0 2026-05-28 14:38:57 +00:00
jkunz 34bfd1528b v13.36.0
Docker (tags) / release (push) Failing after 1s
2026-05-28 08:48:03 +00:00
jkunz be38808795 feat(network): add top connected ASN activity to network monitoring 2026-05-28 08:47:12 +00:00
jkunz b9ae4ac344 v13.35.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 05:12:13 +00:00
jkunz 37adcc9ddc feat(vpn): use authenticated VPN route grants 2026-05-24 05:11:48 +00:00
jkunz ac118397f9 v13.34.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 23:45:34 +00:00
jkunz 8188b4712c feat(vpn): allow target profiles to grant non-vpnOnly routes by live client source IP 2026-05-21 23:44:01 +00:00
jkunz 27d077feed v13.33.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 01:56:32 +00:00
jkunz 98913c1977 feat(security): add queued IP intelligence observation and filtered retrieval for network and security views 2026-05-21 01:56:17 +00:00
jkunz ca5c57a329 v13.32.1
Docker (tags) / release (push) Failing after 1s
2026-05-20 16:24:44 +00:00
jkunz 707fbc2413 fix(opsserver,vpn): tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules 2026-05-20 16:24:30 +00:00
jkunz a0c9d40e87 fix(deps): update smartproxy for Alpine compatibility 2026-05-20 15:15:34 +00:00
jkunz 2a73973eda fix(deps): update smartdb for Alpine compatibility 2026-05-20 13:46:01 +00:00
jkunz f0069f87e2 v13.32.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 22:24:40 +00:00
jkunz 77c1738390 feat(ops-auth): add scoped API token auth across ops endpoints 2026-05-19 22:24:37 +00:00
jkunz 53d7c5350e v13.31.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 17:06:52 +00:00
jkunz 7986d01245 feat(opsserver): add admin user create/delete management and default hosted idp.global auth support 2026-05-19 17:06:50 +00:00
jkunz 0b01a4c26b v13.30.0
Docker (tags) / release (push) Failing after 1s
2026-05-18 16:09:40 +00:00
jkunz 407c8eef8a feat(docs): document first-admin bootstrap flow and update authentication examples 2026-05-18 16:09:26 +00:00
jkunz aa0ef2f033 v13.29.1
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:43:14 +00:00
jkunz 7819f09625 fix(smartconfig): enable npm publishing in smartconfig 2026-05-14 00:42:58 +00:00
jkunz 3f8c0c4219 v13.29.0
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:37:15 +00:00
jkunz 70fcd46d52 feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication 2026-05-14 00:30:09 +00:00
jkunz 47a1f5d7db fix(vpn): harden VPN route access and wireguard client configuration handling 2026-05-13 13:42:12 +00:00
jkunz 67b9fb536c v13.28.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 22:35:07 +00:00
jkunz 8dd0c3def9 feat(gateway-clients): add managed gateway client administration and token-bound route ownership 2026-05-09 22:35:07 +00:00
jkunz d73b250382 v13.27.1
Docker (tags) / release (push) Failing after 1s
2026-05-09 20:02:45 +00:00
jkunz 1c1d55ab8a fix(docker): configure pnpm to use the verdaccio registry during Docker builds 2026-05-09 20:02:45 +00:00
jkunz 2596303c06 v13.27.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 17:30:37 +00:00
jkunz f78bddaede feat(api-token-manager): seed and rotate the environment-managed admin API token during initialization 2026-05-09 17:30:37 +00:00
jkunz a2887d6266 v13.26.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 11:53:45 +00:00
jkunz 97505935bb feat(gateway-clients): add policy-based gateway client tokens and gateway client route and DNS management endpoints 2026-05-09 11:53:45 +00:00
jkunz 7e3b89d9b4 fix: remove default dcrouter admin password 2026-05-08 16:24:45 +00:00
jkunz 7bb6559748 docs: refresh readme and legal info 2026-05-07 20:22:12 +00:00
jkunz 5fbe2eb80b feat: add workapp mail sync API 2026-04-29 16:29:38 +00:00
jkunz a22cc1c0eb feat: add workhoster gateway API 2026-04-29 15:18:14 +00:00
jkunz 4ea339b85a fix: modernize docker publishing 2026-04-29 10:03:34 +00:00
jkunz df9cc3e49b v13.25.0
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-04-26 20:49:57 +00:00
jkunz 7f3ab2499d feat(security): compile network ranges and CIDR arrays into edge firewall policies 2026-04-26 20:49:57 +00:00
jkunz 89ab918826 v13.24.0
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-04-26 19:51:08 +00:00
jkunz e5c3578163 feat(security): add security policy management and IP intelligence operations to the ops UI 2026-04-26 19:51:08 +00:00
jkunz 1567606c49 v13.23.0
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-04-26 15:15:27 +00:00
jkunz af31982d58 feat(security): add managed security policies with IP intelligence and remote ingress firewall propagation 2026-04-26 15:15:27 +00:00
jkunz a322308623 v13.22.0
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-04-26 12:14:51 +00:00
jkunz ec5374900c feat(remoteingress): add remote ingress performance configuration and expose tunnel transport metrics 2026-04-26 12:14:51 +00:00
jkunz 49ce265d7e fix(deps): bump @push.rocks/smartproxy to ^27.8.2 2026-04-26 11:32:57 +00:00
jkunz 63729697c5 v13.21.1
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-04-26 09:29:29 +00:00
jkunz ce93b726ef fix(deps): bump @push.rocks/smartproxy to ^27.8.1 2026-04-26 09:29:29 +00:00
jkunz 1c3aa89f8d v13.21.0
Docker (tags) / security (push) Failing after 10s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-25 20:37:28 +00:00
jkunz b3751abd17 feat(monitoring): improve network activity metrics with live domain request rates and backend identifiers 2026-04-25 20:37:28 +00:00
jkunz 97017ede98 chore(deps): update serve.zone interfaces 2026-04-25 14:01:26 +00:00
jkunz 4b928b038e v13.20.2
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-04-17 14:28:19 +00:00
jkunz a466b88408 fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates 2026-04-17 14:28:19 +00:00
jkunz e26ea9e114 v13.20.1
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-04-17 13:43:13 +00:00
jkunz c5ca95b6f5 fix(docs): refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance 2026-04-17 13:43:13 +00:00
jkunz 1f25ca4095 v13.20.0
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-04-17 06:17:49 +00:00
jkunz 2891e5d3ee feat(routes): add remote ingress controls and preserve-port targeting for route configuration 2026-04-17 06:17:49 +00:00
jkunz 152110c877 v13.19.1
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-04-16 22:21:07 +00:00
jkunz d780e02928 fix(routes): preserve inline target ports when clearing network target references 2026-04-16 22:21:07 +00:00
jkunz 8bbaf26813 v13.19.0
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-04-15 19:59:04 +00:00
jkunz 39f449cbe4 feat(routes,email): persist system DNS routes with runtime hydration and add reusable email ops DNS helpers 2026-04-15 19:59:04 +00:00
jkunz e0386beb15 v13.18.0
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-04-14 13:11:48 +00:00
jkunz 1d7e5495fa feat(email): add persistent smartmta storage and runtime-managed email domain syncing 2026-04-14 13:11:48 +00:00
jkunz 9a378ae87f v13.17.9
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-04-14 09:33:41 +00:00
jkunz 58fbc2b1e4 fix(monitoring): align domain activity metrics with id-keyed route data 2026-04-14 09:33:41 +00:00
jkunz 20ea0ce683 v13.17.8
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-04-14 01:16:37 +00:00
jkunz bcea93753b fix(opsserver): align certificate status handling with the updated smartproxy response format 2026-04-14 01:16:37 +00:00
jkunz 848515e424 v13.17.7
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-04-14 00:56:31 +00:00
jkunz 38c9978969 fix(repo): no changes to commit 2026-04-14 00:56:31 +00:00
jkunz ee863b8178 v13.17.6
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-04-14 00:53:26 +00:00
jkunz 9bb5a8bcc1 fix(dns,routes): keep DoH socket-handler routes runtime-only and prune stale persisted entries 2026-04-14 00:53:26 +00:00
jkunz 5aa07e81c7 v13.17.5
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-04-13 23:02:42 +00:00
jkunz aec8b72ca3 fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior 2026-04-13 23:02:42 +00:00
jkunz 466654ee4c v13.17.3
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-04-13 19:46:12 +00:00
jkunz f1a11e3f6a fix(ops-view-routes): sync route filter toggle selection via component changeSubject 2026-04-13 19:46:12 +00:00
jkunz e193b3a8eb v13.17.2
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-04-13 19:17:46 +00:00
jkunz 1bbf31605c fix(monitoring): exclude unconfigured routes from domain activity aggregation 2026-04-13 19:17:46 +00:00
jkunz f2cfa923a0 v13.17.1
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-04-13 19:15:46 +00:00
jkunz cdc77305e5 fix(monitoring): stop allocating route metrics to domains when no request data exists 2026-04-13 19:15:46 +00:00
jkunz 835537f789 v13.17.0
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-04-13 19:12:56 +00:00
jkunz 754b223f62 feat(monitoring,network-ui,routes): add request-based domain activity metrics and split routes into user and system views 2026-04-13 19:12:56 +00:00
jkunz 0a39d50d20 v13.16.2
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-04-13 18:51:41 +00:00
jkunz de7b9f7ec5 fix(deps): bump @push.rocks/smartproxy to ^27.6.0 2026-04-13 18:51:41 +00:00
jkunz bd959464c7 v13.16.1
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-04-13 18:08:36 +00:00
jkunz 36b629676f fix(migrations): use exact smartdata collection names in route unification migration 2026-04-13 18:08:36 +00:00
jkunz 19398ea836 v13.16.0
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-04-13 17:38:23 +00:00
jkunz 4aba8cc353 feat(routes): unify route storage and management across config, email, dns, and API origins 2026-04-13 17:38:23 +00:00
jkunz 5fd036eeb6 v13.15.1
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-04-13 12:15:11 +00:00
jkunz cfcb66f1ee fix(monitoring): improve domain activity aggregation for multi-domain and wildcard routes 2026-04-13 12:15:11 +00:00
jkunz 501f4f9de6 v13.15.0
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-04-13 12:07:13 +00:00
jkunz fa926eb10b feat(stats): add typed network stats response fields for bandwidth, domain activity, and protocol distribution 2026-04-13 12:07:13 +00:00
jkunz f2d0a9ec1b v13.14.0
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-04-13 11:04:15 +00:00
jkunz 035173702d feat(network): add bandwidth-ranked IP and domain activity metrics to network monitoring 2026-04-13 11:04:15 +00:00
jkunz 07a3365496 v13.13.0
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-04-13 09:47:19 +00:00
jkunz 1c4f7dbb11 feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling 2026-04-13 09:47:19 +00:00
jkunz 1fdff79dd0 v13.12.0
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-04-12 23:46:31 +00:00
jkunz 59b52d08fa feat(email-domains): support creating email domains on optional subdomains 2026-04-12 23:46:31 +00:00
jkunz 2cdc392a40 v13.11.0
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-04-12 22:09:20 +00:00
jkunz 433047bbf1 feat(email-domains): add email domain management with DNS provisioning, validation, and ops dashboard support 2026-04-12 22:09:20 +00:00
jkunz 0b81c95de2 v13.10.0
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-04-12 20:43:57 +00:00
jkunz 196e5dfc1b feat(web-ui): standardize settings views for ACME and email security panels 2026-04-12 20:43:57 +00:00
jkunz 60d095cd78 v13.9.2
Docker (tags) / security (push) Failing after 2m58s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 19:42:07 +00:00
jkunz 2861511d20 fix(web-ui): improve form field descriptions and align certificate settings with tile components 2026-04-12 19:42:07 +00:00
jkunz b582d44502 v13.9.1
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-04-08 15:26:12 +00:00
jkunz 36a2ebc94e fix(network-ui): enable flashing table updates for network activity, remote ingress, and VPN views 2026-04-08 15:26:12 +00:00
jkunz ed52a3188d v13.9.0
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-04-08 14:54:49 +00:00
jkunz 93cc5c7b06 feat(dns): add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local 2026-04-08 14:54:49 +00:00
jkunz 5689e93665 v13.8.0
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-04-08 13:12:20 +00:00
jkunz c224028495 feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI 2026-04-08 13:12:20 +00:00
jkunz 4fbe01823b v13.7.1
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-04-08 12:06:08 +00:00
jkunz 34ba2c9f02 fix(repo): no changes to commit 2026-04-08 12:06:08 +00:00
jkunz 52aed0e96e v13.7.0
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-04-08 11:11:53 +00:00
jkunz ea2e618990 feat(dns-providers): add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows 2026-04-08 11:11:53 +00:00
jkunz 140637a307 v13.6.0
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-04-08 11:08:18 +00:00
jkunz 21c80e173d feat(dns): add db-backed DNS provider, domain, and record management with ops UI support 2026-04-08 11:08:18 +00:00
jkunz e77fe9451e v13.5.0
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-04-08 09:01:08 +00:00
jkunz 7971bd249e feat(opsserver-access): add admin user listing to the access dashboard 2026-04-08 09:01:08 +00:00
jkunz 6099563acd v13.4.2
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-04-08 08:29:30 +00:00
jkunz bf4c181026 fix(repo): no changes to commit 2026-04-08 08:29:30 +00:00
jkunz d9d12427d3 v13.4.1
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-04-08 08:28:00 +00:00
jkunz 91aa9a7228 fix(repo): no changes to commit 2026-04-08 08:28:00 +00:00
jkunz 877356b247 v13.4.0
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-04-08 08:24:55 +00:00
jkunz 2325f01cde feat(web-ui): reorganize dashboard views into grouped navigation with new email, access, and network subviews 2026-04-08 08:24:55 +00:00
jkunz 00fdadb088 v13.3.0
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-04-08 07:45:26 +00:00
jkunz 2b76e05a40 feat(web-ui): reorganize network and security views into tabbed subviews with route-aware navigation 2026-04-08 07:45:26 +00:00
jkunz 1b37944aab v13.2.2
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-04-08 07:13:01 +00:00
jkunz 35a01a6981 fix(project): no changes to commit 2026-04-08 07:13:01 +00:00
jkunz 3058706d2a v13.2.1
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-04-08 07:12:16 +00:00
jkunz 0e4d6a3c0c fix(project): no changes to commit 2026-04-08 07:12:16 +00:00
jkunz 2bc2475878 v13.2.0
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-04-08 07:11:21 +00:00
jkunz 37eab7c7b1 feat(ops-ui): add column filters to operations tables across admin views 2026-04-08 07:11:21 +00:00
jkunz 8ab7343606 v13.1.3
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-04-08 00:56:02 +00:00
jkunz f04feec273 fix(certificate-handler): preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains 2026-04-08 00:56:02 +00:00
jkunz d320590ce2 v13.1.2
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-04-07 22:46:22 +00:00
jkunz 0ee57f433b fix(deps): bump @serve.zone/catalog to ^2.12.3 2026-04-07 22:46:22 +00:00
jkunz b28b5eea84 v13.1.1
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-04-07 22:28:22 +00:00
jkunz 27d7489af9 fix(deps): bump catalog-related dependencies to newer patch and minor releases 2026-04-07 22:28:22 +00:00
jkunz 940c7dc92e v13.1.0
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-04-07 21:02:37 +00:00
jkunz 7fa6d82e58 feat(vpn,target-profiles,migrations): add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips 2026-04-07 21:02:37 +00:00
jkunz f29ed9757e fix(target-profile-manager): enhance domain matching to support bidirectional checks 2026-04-06 11:56:55 +00:00
jkunz ad45d1b8b9 v13.0.11
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-04-06 10:23:19 +00:00
jkunz 68473f8550 fix(routing): serialize route updates and correct VPN-gated route application 2026-04-06 10:23:18 +00:00
jkunz 07cfe76cac v13.0.10
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-04-06 08:08:23 +00:00
jkunz 3775957bf2 fix(repo): no changes to commit 2026-04-06 08:08:23 +00:00
jkunz 31ce18a025 v13.0.9
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-04-06 08:07:25 +00:00
jkunz 0cccec5526 fix(repo): no changes to commit 2026-04-06 08:07:25 +00:00
jkunz 0373f02f86 v13.0.8
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-04-06 08:05:07 +00:00
jkunz 52dac0339f fix(ops-view-vpn): show target profile names in VPN forms and load profile candidates for autocomplete 2026-04-06 08:05:07 +00:00
jkunz b6f7f5f63f v13.0.7
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-04-06 07:51:25 +00:00
jkunz 6271bb1079 fix(vpn,target-profiles): refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists 2026-04-06 07:51:25 +00:00
jkunz 0fa65f31c3 fix(ops-view-targetprofiles): ensure routes are loaded before showing profile dialogs 2026-04-05 13:48:08 +00:00
jkunz 93d6c7d341 v13.0.6
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-04-05 11:29:47 +00:00
jkunz b2ccd54079 fix(certificates): resolve base-domain certificate lookups and route profile list inputs 2026-04-05 11:29:47 +00:00
jkunz 4e9b09616d v13.0.5
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-04-05 10:13:09 +00:00
jkunz ddb420835e fix(ts_web): replace custom section heading component with dees-heading across ops views 2026-04-05 10:13:09 +00:00
jkunz 505fd044c0 v13.0.4
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-04-05 03:54:39 +00:00
jkunz 7711204fef fix(deps): bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases 2026-04-05 03:54:39 +00:00
jkunz d7b6fbb241 v13.0.3
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-04-05 03:28:40 +00:00
jkunz a670b27a1c fix(deps): bump @push.rocks/smartdb to ^2.5.2 2026-04-05 03:28:40 +00:00
jkunz c2f57b086f v13.0.2
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-04-05 02:50:56 +00:00
jkunz 083f16d7b4 fix(deps): bump smartdata, smartdb, and catalog dependencies 2026-04-05 02:50:56 +00:00
jkunz 2994b6e686 v13.0.1
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-04-05 01:33:27 +00:00
jkunz ba15c169d7 fix(deps): bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies 2026-04-05 01:33:27 +00:00
jkunz bbd5707711 v13.0.0
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-04-05 00:37:37 +00:00
jkunz 1ddf83b28d BREAKING CHANGE(vpn): replace tag-based VPN access control with source and target profiles 2026-04-05 00:37:37 +00:00
jkunz 25365678e0 v12.10.0
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-04-04 21:23:16 +00:00
jkunz 96d215fc66 feat(routes): add TLS configuration controls for route create and edit flows 2026-04-04 21:23:16 +00:00
jkunz 648ba9e61d v12.9.4
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-04-04 20:18:34 +00:00
jkunz fcc1d9fede fix(deps): bump @push.rocks/smartdb to ^2.3.1 2026-04-04 20:18:34 +00:00
jkunz 336e8aa4cc v12.9.3
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-04-04 19:29:49 +00:00
jkunz c8f19cf783 fix(route-management): include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs 2026-04-04 19:29:49 +00:00
jkunz 12b2cc11da v12.9.2
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-04-04 19:03:11 +00:00
jkunz ffcc35be64 fix(config-ui): handle missing HTTP/3 config safely and standardize overview section headings 2026-04-04 19:03:11 +00:00
jkunz 59e0d41bdb v12.9.1
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-04-04 17:08:05 +00:00
jkunz 9509d87b1e fix(monitoring): update SmartProxy and use direct connection protocol metrics access 2026-04-04 17:08:05 +00:00
jkunz b835e2d0eb v12.9.0
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-04-04 16:45:02 +00:00
jkunz 6c3d8714a2 feat(monitoring): add frontend and backend protocol distribution metrics to network stats 2026-04-04 16:45:02 +00:00
jkunz 94f53f0259 v12.8.1
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-04-04 11:00:03 +00:00
jkunz 1004f8579f fix(ops-view-routes): correct route form dropdown selection handling for security profiles and network targets 2026-04-04 11:00:03 +00:00
jkunz a77ec6884a v12.8.0
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-04-03 19:08:46 +00:00
jkunz 6112e4e884 feat(certificates): add force renew option for domain certificate reprovisioning 2026-04-03 19:08:46 +00:00
jkunz 4a6913d4bb v12.7.0
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-04-03 14:11:17 +00:00
jkunz f6a9e344e5 feat(opsserver): add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode 2026-04-03 14:11:17 +00:00
jkunz b3296c6522 v12.6.6
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-04-03 13:53:20 +00:00
jkunz 10a2b922d3 fix(deps): bump @design.estate/dees-catalog to ^3.52.3 2026-04-03 13:53:20 +00:00
jkunz ee5cdde225 v12.6.5
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-04-03 13:40:52 +00:00
jkunz d2e9efccd0 fix(deps): bump @design.estate/dees-catalog to ^3.52.2 2026-04-03 13:40:51 +00:00
jkunz a07901a28a v12.6.4
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-04-03 13:02:07 +00:00
jkunz a3954d6eb5 fix(deps): bump @design.estate/dees-catalog to ^3.52.0 2026-04-03 13:02:07 +00:00
jkunz 9685fcd89d v12.6.3
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-04-03 12:39:02 +00:00
jkunz 74c23ce5ff fix(deps): bump @types/node and @design.estate/dees-catalog patch versions 2026-04-03 12:39:02 +00:00
jkunz 746fbb15e6 v12.6.2
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-04-03 10:48:26 +00:00
jkunz 415065b246 fix(deps): bump @design.estate/dees-catalog to ^3.51.1 2026-04-03 10:48:26 +00:00
jkunz 30aeef7bbd v12.6.1
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-04-03 10:15:10 +00:00
jkunz dba1c70fa7 fix(repo): no changes to commit 2026-04-03 10:15:10 +00:00
jkunz f9cfb3d36b v12.6.0
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-04-03 10:14:52 +00:00
jkunz 43b92b784d feat(certificates): add confirmation before force renewing valid certificates from the certificate actions menu 2026-04-03 10:14:52 +00:00
jkunz b62a322c54 v12.5.2
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-04-03 08:19:02 +00:00
jkunz a3a64e9a02 fix(repo): no changes to commit 2026-04-03 08:19:02 +00:00
jkunz 491e51f40b v12.5.1
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-04-03 08:18:28 +00:00
jkunz b46247d9cb fix(ops-view-network): centralize traffic chart timing constants for consistent rolling window updates 2026-04-03 08:18:28 +00:00
jkunz 9c0e46ff4e v12.5.0
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-04-02 22:55:57 +00:00
jkunz f62bc4a526 feat(ops-view-routes): add priority support and list-based domain editing for routes 2026-04-02 22:55:57 +00:00
jkunz 8f23600ec1 v12.4.0
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-04-02 22:37:49 +00:00
jkunz 141f185fbf feat(routes): add route edit and delete actions to the ops routes view 2026-04-02 22:37:49 +00:00
jkunz 6f4a5f19e7 v12.3.0
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-04-02 20:31:08 +00:00
jkunz 9d8354e58f feat(docs,ops-dashboard): document unified database and reusable security profile and network target management 2026-04-02 20:31:08 +00:00
jkunz 947637eed7 v12.2.6
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-04-02 18:49:52 +00:00
jkunz 5202c2ea27 fix(ops-ui): improve operations table actions and modal form handling for profiles and network targets 2026-04-02 18:49:52 +00:00
jkunz 6684dc43da v12.2.5
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-04-02 17:59:51 +00:00
jkunz 04ec387ce5 fix(dcrouter): sync allowed tunnel edges when merged routes change 2026-04-02 17:59:51 +00:00
jkunz f145798f39 v12.2.4
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-04-02 17:27:05 +00:00
jkunz 55f5465a9a fix(routes): support profile and target metadata in route creation and refresh remote ingress routes after config initialization 2026-04-02 17:27:05 +00:00
jkunz 0577f45ced v12.2.3
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-04-02 16:27:35 +00:00
jkunz 7d23617f15 fix(repo): no changes to commit 2026-04-02 16:27:35 +00:00
jkunz 02415f8c53 v12.2.2
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-04-02 16:26:24 +00:00
jkunz 73a47e5a97 fix(route-config): sync applied routes to remote ingress manager after route updates 2026-04-02 16:26:24 +00:00
207 changed files with 28415 additions and 8563 deletions
+10 -46
View File
@@ -1,4 +1,4 @@
name: Docker (tags)
name: Docker (non-tag pushes)
on:
push:
@@ -8,42 +8,10 @@ on:
env:
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}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
@@ -54,18 +22,14 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test
run: pnpm test
- name: Test build
run: |
npmci npm prepare
npmci node install stable
npmci npm install
npmci command npm run build
- name: Build image
run: tsdocker build
- name: Test image
run: tsdocker test
+14 -77
View File
@@ -8,73 +8,13 @@ on:
env:
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}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci command npm run build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:dbase_dind
image: code.foss.global/host.today/ht-docker-dbase:szci
steps:
- uses: actions/checkout@v3
@@ -82,23 +22,20 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @git.zone/tsdocker
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Release
run: |
tsdocker login
tsdocker build
tsdocker push
- name: Login to registries
run: tsdocker login
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
- name: List images
run: tsdocker list
steps:
- uses: actions/checkout@v3
- name: Build images
run: tsdocker build
- name: Trigger
run: npmci trigger
- name: Test images
run: tsdocker test
- name: Push to code.foss.global
run: tsdocker push code.foss.global
+140
View File
@@ -0,0 +1,140 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Configure pnpm registry
run: pnpm config set registry https://verdaccio.lossless.digital/
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Verify package.json version matches tag
run: |
PACKAGE_VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "package.json version: $PACKAGE_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
exit 1
fi
- name: Test package
run: pnpm test
- name: Build binary artifacts
run: pnpm run build:binary
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
cd ../..
- name: Pack npm artifact
run: |
mkdir -p dist/package
pnpm pack --pack-destination dist/package
ls -lh dist/package
- name: Extract changelog for this version
run: |
VERSION="${{ steps.version.outputs.version }}"
if [ -f changelog.md ]; then
awk "/## $VERSION/,/## /" changelog.md | sed '$d' > /tmp/release_notes.md || true
fi
if [ ! -s /tmp/release_notes.md ]; then
cat > /tmp/release_notes.md << EOF
## DcRouter $VERSION
NodeNext package build plus self-extracting Linux binaries.
### Artifacts
- npm package tarball
- dcrouter-linux-x64
- dcrouter-linux-arm64
- SHA256SUMS.txt
EOF
fi
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$EXISTING_RELEASE_ID"
sleep 2
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"DcRouter $VERSION\",
\"body\": $(jq -Rs . /tmp/release_notes.md),
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
for artifact in dist/package/* dist/binaries/*; do
[ -f "$artifact" ] || continue
filename=$(basename "$artifact")
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$artifact" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$RELEASE_ID/assets?name=$filename"
done
- name: Release Summary
run: |
echo "Release ${{ steps.version.outputs.version }} complete"
ls -lh dist/package
ls -lh dist/binaries
+56 -20
View File
@@ -23,14 +23,39 @@
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": ["./html/**/*.html"]
"includeFiles": [
"./html/**/*.html"
]
}
]
},
"@git.zone/tsdeno": {
"compileTargets": [
{
"name": "dcrouter-linux-x64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "x86_64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
},
{
"name": "dcrouter-linux-arm64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "aarch64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
}
]
},
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service",
"module": {
"githost": "gitlab.com",
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "dcrouter",
"description": "A traffic router intended to be gating your datacenter.",
@@ -60,26 +85,37 @@
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
"targets": {
"git": {
"enabled": true,
"remote": "origin"
},
"npm": {
"enabled": true,
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"docker": {
"enabled": true,
"engine": "tsdocker"
}
}
}
},
"@ship.zone/szci": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
},
"npmRegistryUrl": "verdaccio.lossless.digital"
},
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registries": [
"code.foss.global"
],
"registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter",
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
"code.foss.global": "serve.zone/dcrouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
}
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@ship.zone/szci": {}
}
+17
View File
@@ -0,0 +1,17 @@
# Agent Instructions for dcrouter
## Database & Migrations
### Collection Names
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
- `StoredRouteDoc` → collection `StoredRouteDoc`
- `TargetProfileDoc` → collection `TargetProfileDoc`
- `RouteDoc` → collection `RouteDoc`
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
### Migration Rules
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
- NEVER put migration logic in application code (services, managers, startup hooks).
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.
+10 -6
View File
@@ -1,12 +1,18 @@
# gitzone dockerfile_service
## STAGE 1 // BUILD
FROM code.foss.global/host.today/ht-docker-node:lts AS build
COPY ./ /app
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm config set registry https://verdaccio.lossless.digital/
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
RUN pnpm install --frozen-lockfile
COPY . ./
RUN pnpm run build
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
RUN rm -rf .pnpm-store
RUN pnpm prune --prod
## STAGE 2 // PRODUCTION
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
@@ -18,12 +24,10 @@ WORKDIR /app
COPY --from=build /app /app
ENV DCROUTER_MODE=OCI_CONTAINER
ENV NODE_ENV=production
ENV DCROUTER_HEAP_SIZE=512
ENV UV_THREADPOOL_SIZE=16
RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
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"
+4
View File
@@ -0,0 +1,4 @@
process.env.CLI_CALL = 'true';
const cliTool = await import('../dist_ts/index.js');
await cliTool.runCli();
+799 -1
View File
@@ -1,5 +1,803 @@
# Changelog
## Pending
## 2026-05-29 - 13.38.0
### Features
- support explicit DNS bind interface configuration (dns)
- Add a dnsBindInterface option to override the embedded DNS UDP bind address.
- Read DCROUTER_DNS_BIND_INTERFACE from OCI container configuration and document it in CLI help.
- Add test coverage for explicit DNS bind interface handling in OCI config.
## 2026-05-29 - 13.37.2
### Fixes
- exclude assets from compiled and published artifacts (packaging)
- Removed assets from the Deno compile include list.
- Removed assets from the npm package files list.
## 2026-05-29 - 13.37.1
### Fixes
- configure pnpm registry for release workflow (release)
- Sets the pnpm registry before dependency installation so release builds resolve packages from the configured registry.
## 2026-05-29 - 13.37.0
### Features
- add CLI binary distribution (distribution)
- Add dcrouter bin entry, Deno compile targets, binary entrypoint, and tag-driven release workflow for Linux artifacts.
- Add --version and --help handling to the CLI for safe package and binary smoke tests.
- Keep the Deno binary import map aligned with the current SmartDNS and SmartProxy runtime dependencies.
- add one-line installer and Docker distribution docs (distribution)
- Add an install.sh flow that installs Linux x64 and arm64 release binaries by default with a NodeNext source-build fallback.
- Document installer modes, binary artifact names, and the published multi-arch Docker image.
## 2026-05-29 - 13.36.3
### Fixes
- update SmartProxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
- bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts (deps)
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
## 2026-05-29 - 13.36.2
### Fixes
- preserve parallel ACME DNS-01 TXT challenges and consume case-insensitive DNS matching (dns,certificates)
- Keep exact and wildcard SAN challenge TXT records at the same owner name instead of deleting sibling challenge values.
- Match local dcrouter-hosted DNS records case-insensitively so DNS 0x20 mixed-case queries keep resolving.
- Update @push.rocks/smartdns to 7.9.3 for case-insensitive handler matching in the embedded DNS server.
- preserve parallel ACME TXT challenges and mixed-case DNS queries (dns)
- Remove only matching ACME DNS-01 TXT challenge values during setup and cleanup so parallel challenges can coexist.
- Resolve locally hosted DNS records case-insensitively while preserving the query name casing in responses.
- Bump @push.rocks/smartdns to ^7.9.3.
## 2026-05-28 - 13.36.1
### Fixes
- consume RemoteIngress 4.18.0 tunnel performance improvements (remoteingress)
- Update @serve.zone/remoteingress to 4.18.0 so DcRouter uses zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix.
- bump @serve.zone/remoteingress to ^4.18.0 (remoteingress)
- Updates @serve.zone/remoteingress from ^4.17.1 to ^4.18.0.
- Consumes zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix from RemoteIngress.
## 2026-05-28 - 13.36.0
### Features
- add top connected ASN activity to Network Activity (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose ASN activity through network stats and combined metrics APIs.
- Add a Network Activity table with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
- add top connected ASN activity to network monitoring (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose top ASN activity through network stats and combined metrics API responses.
- Add a Network Activity table for top ASNs with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
## 2026-05-24 - 13.35.0
### Features
- switch VPN route authorization to authenticated SmartVPN metadata (vpn)
- configure SmartVPN to forward real client source IPs plus VPN metadata through trusted PROXY v2 headers
- map target profiles to SmartProxy VPN client grants instead of mutating route source IP allow lists
- keep live VPN client source IP tracking as status/UI data while SmartProxy enforces source policy per connection
## 2026-05-21 - 13.34.0
### Features
- allow VPN target profiles to grant routes by live client source IP (vpn)
- Add an opt-in target profile flag that evaluates non-vpnOnly route source security against the VPN client's real connecting IP.
- Track live VPN client source IPs from smartvpn remote addresses and WireGuard peer endpoints, refreshing routes when they change.
- Expose the setting and current source IPs in the Ops UI with regression coverage for source-IP matching behavior.
- allow target profiles to grant non-vpnOnly routes by live client source IP (vpn)
- add an opt-in target profile flag to match route source security against a VPN client's real connecting IP
- track live client source IPs from VPN remote addresses and WireGuard peer endpoints and re-apply routes when they change
- expose source IP access settings and current client source IPs through the ops API and UI
- add regression tests for source-IP route matching, block-list handling, vpnOnly exclusions, and WireGuard endpoint refresh
## 2026-05-21 - 13.33.0
### Features
- add queued IP intelligence observation and filtered retrieval for network and security views (security)
- Queue observed public IPs from network metrics with throttled background enrichment instead of awaiting lookups during stats collection.
- Allow listing IP intelligence records by specific IP addresses and limit through the security handler and request interface.
- Update web app state to refresh IP intelligence asynchronously in the background and preserve current UI state during refreshes.
- Improve security policy manager observation handling so forced refresh waits for in-flight lookups before fetching updated intelligence.
## 2026-05-20 - 13.32.1
### Fixes
- tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules (opsserver,vpn)
- Block ephemeral admin bootstrap login and user listing until the configured database is ready, and report bootstrap availability accurately in admin status responses.
- Preserve persisted admin accounts across OpsServer restarts with added regression coverage.
- Merge matching VPN client IPs into restricted non-vpnOnly route allow lists without duplicating entries.
- Handle string and wildcard route domains consistently when resolving target profile access and VPN client matches.
## 2026-05-19 - 13.32.0
### Features
- add scoped API token auth across ops endpoints (ops-auth)
- introduces a shared requireOpsAuth helper that validates JWT identities and API tokens with scope and admin-policy checks
- applies explicit per-endpoint authorization across config, logs, stats, security, VPN, RADIUS, remote ingress, users, API tokens, and related ops handlers
- extends request interfaces and UI scope definitions to support apiToken-based access and adds tests for auth behavior and migration bridging
## 2026-05-19 - 13.31.0
### Features
- add admin user create/delete management and default hosted idp.global auth support (opsserver)
- adds admin-only createUser and deleteUser typed requests with safeguards against deleting the current user or last active admin
- updates the ops users UI to create and delete users, show richer account details, and support optional idp.global login during account creation
- treats idp.global as available by default via the hosted https://idp.global endpoint while keeping URL settings as optional overrides
- adds VPN-only route controls and indicators in the ops routes UI
## 2026-05-18 - 13.30.0
### Features
- document first-admin bootstrap flow and update authentication examples (docs)
- Add README guidance for explicit initial admin creation on DB-backed instances across the main package, API client, interfaces, and web dashboard docs.
- Update authentication examples to use persisted admin email/password credentials instead of the old default admin login.
- Refresh dependency versions in package.json to align documentation with current package releases.
## 2026-05-14 - 13.29.1
### Fixes
- enable npm publishing in smartconfig (smartconfig)
- Sets the npm integration flag to true in .smartconfig.json
- Keeps the configured Verdaccio and npmjs registries unchanged
## 2026-05-14 - 13.29.0
### Fixes
- harden VPN route access and wireguard client configuration handling (vpn)
- Fail closed for vpnOnly routes when no VPN client IPs are available by replacing allow lists and enforcing a block-all fallback
- Refresh route application and VPN client security after target profile creation so profile changes take effect immediately
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
### Features
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
- introduces bootstrap status and initial admin creation endpoints for OpsServer
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
- updates the web dashboard to prompt creation of the first persisted admin account
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
add managed gateway client administration and token-bound route ownership
- introduce persistent gateway client management with create, update, delete, list, and scoped token creation flows
- add gateway client context and ownership resolution so token-bound clients can sync routes without spoofing another client
- surface gateway client administration in the ops dashboard with a new Access > Gateway Clients view
- mark certificate provisioning backoff failures as failed and expose root-cause errors with DNS management guidance in the certificates view
## 2026-05-09 - 13.27.1 - fix(docker)
configure pnpm to use the verdaccio registry during Docker builds
- Adds a pnpm registry configuration step before dependency installation in the Dockerfile.
- Ensures container builds resolve packages from the configured Verdaccio registry.
## 2026-05-09 - 13.27.0 - feat(api-token-manager)
seed and rotate the environment-managed admin API token during initialization
- Add initialization support for DCROUTER_ADMIN_API_TOKEN with validation, persistence, and admin policy assignment
- Ensure the environment-managed token is updated when the configured raw token changes
- Refactor token hashing into a shared helper and add coverage for seeding, validation, redaction, and rotation behavior
## 2026-05-09 - 13.26.0 - feat(gateway-clients)
add policy-based gateway client tokens and gateway client route and DNS management endpoints
- Introduces API token policies with admin and gatewayClient roles, capability checks, hostname restrictions, and allowed route targets.
- Adds gateway client request and data interfaces for domains, DNS records, route sync, and ownership metadata while keeping workhoster aliases for compatibility.
- Extends route metadata normalization to prefer gatewayClient ownership and updates generated route names and test coverage accordingly.
## 2026-04-26 - 13.25.0 - feat(security)
compile network ranges and CIDR arrays into edge firewall policies
- add support for storing intelligence network CIDR arrays alongside single network ranges
- convert start-end IPv4 ranges into CIDR blocks when compiling security policies
- always return an explicit remote ingress firewall snapshot with a blockedIps array
- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots
## 2026-04-26 - 13.24.0 - feat(security)
add security policy management and IP intelligence operations to the ops UI
- adds typed request endpoints to fetch compiled security policy, list audit events, and force-refresh IP intelligence
- introduces dedicated security policy state and actions for loading, creating, updating, deleting, and refreshing security data
- enhances the network activity view with IP intelligence columns, detail dialogs, and block-rule actions
- expands the security blocked view into a full management interface for rules, compiled policy, IP intelligence, and audit history
## 2026-04-26 - 13.23.0 - feat(security)
add managed security policies with IP intelligence and remote ingress firewall propagation
- introduces a SecurityPolicyManager that observes public IPs, stores IP intelligence, compiles block policies, and audits policy changes
- adds database documents and shared interfaces for security block rules, IP intelligence records, and security policy audit events
- exposes ops/admin request handlers to list IP intelligence and create, update, or delete security block rules
- applies merged security policies to SmartProxy and propagates firewall snapshots to remote ingress edges and tunnel synchronization
## 2026-04-26 - 13.22.0 - feat(remoteingress)
add remote ingress performance configuration and expose tunnel transport metrics
- upgrade @serve.zone/remoteingress to support performance tuning and richer tunnel status data
- pass remote ingress performance settings through router startup and config APIs
- serialize allowed-edge sync operations and await route update hooks to avoid tunnel sync races
- expose UDP listen ports and transport, flow control, queue, and traffic metrics in remote ingress APIs and ops UI
## 2026-04-26 - 13.21.1 - fix(deps)
bump @push.rocks/smartproxy to ^27.8.1
- Updates @push.rocks/smartproxy from ^27.8.0 to ^27.8.1 in package.json.
## 2026-04-25 - 13.21.0 - feat(monitoring)
improve network activity metrics with live domain request rates and backend identifiers
- use SmartProxy per-domain live request rates to rank and attribute domain activity metrics, while retaining lifetime request totals as fallback data
- separate aggregate backend rows from protocol cache rows with stable ids so cached protocol entries no longer duplicate active backend connection counts
- expose frontend and backend protocol distributions plus aggregated connectionCount fields through ops and web network views
## 2026-04-17 - 13.20.2 - fix(vpn)
handle VPN forwarding mode downgrades and support runtime VPN config updates
- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode
- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router
- improve VPN operations UI target profile rendering and loading behavior for create and edit flows
## 2026-04-17 - 13.20.1 - fix(docs)
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance
- Reworks the main README with updated positioning, quick-start examples, route ownership guidance, configuration notes, automation examples, and OCI bootstrap details
- Expands package-specific readmes for the runtime, API client, interfaces, migrations, and web dashboard to better describe exports, behavior, and usage
- Standardizes documentation references such as subpath import guidance and LICENSE link casing across readmes
## 2026-04-17 - 13.20.0 - feat(routes)
add remote ingress controls and preserve-port targeting for route configuration
- Allow route updates to remove optional top-level properties by treating null values like remoteIngress as explicit clears.
- Add route form support for preserving the matched incoming port when forwarding to backend targets.
- Add remote ingress enablement and edge filter controls to route create/edit views.
- Cover remoteIngress removal behavior with a runtime route manager test.
## 2026-04-16 - 13.19.1 - fix(routes)
preserve inline target ports when clearing network target references
- Normalize route metadata so empty reference fields are removed instead of persisted.
- Allow the routes UI to clear source profile and network target references explicitly during edits.
- Disable inline target host and port inputs when a network target is selected and validate target ports when using manual targets.
- Add runtime route tests covering removal of a network target reference while keeping the edited inline target port.
## 2026-04-15 - 13.19.0 - feat(routes,email)
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
## 2026-04-14 - 13.18.0 - feat(email)
add persistent smartmta storage and runtime-managed email domain syncing
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
## 2026-04-14 - 13.17.9 - fix(monitoring)
align domain activity metrics with id-keyed route data
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
- Add a regression test covering domain activity aggregation for routes identified only by id.
- Update the network activity UI to show formatted total connection counts in the active connections card.
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
- bump @push.rocks/smartproxy to ^27.7.3
- enable verbose output for the test script
## 2026-04-14 - 13.17.7 - fix(repo)
no changes to commit
## 2026-04-14 - 13.17.6 - fix(dns,routes)
keep DoH socket-handler routes runtime-only and prune stale persisted entries
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
normalize target profile route references and stabilize VPN host-IP client routing behavior
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
sync route filter toggle selection via component changeSubject
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
## 2026-04-13 - 13.17.2 - fix(monitoring)
exclude unconfigured routes from domain activity aggregation
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
## 2026-04-13 - 13.17.1 - fix(monitoring)
stop allocating route metrics to domains when no request data exists
- Removes the equal-split fallback for shared routes in MetricsManager.
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
add request-based domain activity metrics and split routes into user and system views
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
## 2026-04-13 - 13.16.2 - fix(deps)
bump @push.rocks/smartproxy to ^27.6.0
- updates @push.rocks/smartproxy from ^27.5.0 to ^27.6.0 in package.json
## 2026-04-13 - 13.16.1 - fix(migrations)
use exact smartdata collection names in route unification migration
- Update the 13.16.0 migration to rename StoredRouteDoc to RouteDoc using case-sensitive collection names
- Apply the origin backfill against the RouteDoc collection and drop RouteOverrideDoc with matching class-name casing
- Clarify migration description and comments to reflect smartdata's exact class-name collection mapping
## 2026-04-13 - 13.16.0 - feat(routes)
unify route storage and management across config, email, dns, and API origins
- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking
- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI
- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data
## 2026-04-13 - 13.15.1 - fix(monitoring)
improve domain activity aggregation for multi-domain and wildcard routes
- map route metrics across all configured domains instead of only the first domain
- resolve wildcard domain patterns against active protocol cache entries
- distribute shared route traffic across matched domains and preserve fallback reporting for routes without domain configuration
## 2026-04-13 - 13.15.0 - feat(stats)
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
- updates app state to use a typed getNetworkStats request instead of casting the response to any
## 2026-04-13 - 13.14.0 - feat(network)
add bandwidth-ranked IP and domain activity metrics to network monitoring
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
## 2026-04-13 - 13.13.0 - feat(dns)
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI
- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones
- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server
## 2026-04-12 - 13.12.0 - feat(email-domains)
support creating email domains on optional subdomains
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
## 2026-04-12 - 13.11.0 - feat(email-domains)
add email domain management with DNS provisioning, validation, and ops dashboard support
- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation.
- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains.
- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records.
## 2026-04-12 - 13.10.0 - feat(web-ui)
standardize settings views for ACME and email security panels
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
## 2026-04-12 - 13.9.2 - fix(web-ui)
improve form field descriptions and align certificate settings with tile components
- Refines labels and adds descriptive helper text across API token, DNS, domain, route, edge, target profile, and VPN forms for clearer operator input
- Updates the DNS provider form to surface provider and credential guidance through built-in input metadata instead of custom help blocks
- Restyles the certificates ACME settings section to use tile-based layout and improves related form wording and file upload metadata
- Refreshes the Cloudflare DNS provider description and bumps UI-related dependencies
## 2026-04-08 - 13.9.1 - fix(network-ui)
enable flashing table updates for network activity, remote ingress, and VPN views
- adds stable row keys to dees-table instances so existing rows can be diffed correctly
- enables flash highlighting for changed rows and cells across network activity, top IPs, backends, remote ingress edges, and VPN clients
- updates network activity request data on every refresh so live metrics like duration and byte counts visibly refresh
## 2026-04-08 - 13.9.0 - feat(dns)
add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local
- Expose a synthetic built-in "DcRouter" provider in provider listings and block create, edit, delete, test, and external domain listing operations for it
- Rename domain and record source semantics from "manual" to "dcrouter" and "local" across backend, interfaces, and UI
- Add database migrations to convert existing DomainDoc.source and DnsRecordDoc.source values to the new naming
- Update domain creation flows and provider UI labels to reflect dcrouter-hosted authoritative domains
## 2026-04-08 - 13.8.0 - feat(acme)
add DB-backed ACME configuration management and OpsServer certificate settings UI
- introduces a singleton AcmeConfig manager and document persisted in the database, with first-boot seeding from legacy tls.contactEmail and smartProxyConfig.acme options
- updates SmartProxy startup to read live ACME settings from the database and only enable DNS-01 challenge wiring when ACME is configured and enabled
- adds authenticated OpsServer typed request endpoints and API token scopes for reading and updating ACME configuration
- adds web app state and a certificates view card/modal for viewing and editing ACME settings from the Domains certificate UI
## 2026-04-08 - 13.7.1 - fix(repo)
no changes to commit
## 2026-04-08 - 13.7.0 - feat(dns-providers)
add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
- Introduce shared DNS provider type descriptors and credential field metadata to drive provider forms dynamically.
- Add a reusable dns-provider-form component and update provider create/edit dialogs to use typed provider selection and credential handling.
- Remove Cloudflare-specific ACME helper exposure and clarify provider-agnostic DNS manager and factory documentation for future provider implementations.
## 2026-04-08 - 13.6.0 - feat(dns)
add db-backed DNS provider, domain, and record management with ops UI support
- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support
- replace constructor-based ACME DNS challenge configuration with provider records stored in the database
- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records
- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
add admin user listing to the access dashboard
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
- update access routing to include the new users subview and improve related table filtering and section headings
## 2026-04-08 - 13.4.2 - fix(repo)
no changes to commit
## 2026-04-08 - 13.4.1 - fix(repo)
no changes to commit
## 2026-04-08 - 13.4.0 - feat(web-ui)
reorganize dashboard views into grouped navigation with new email, access, and network subviews
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
## 2026-04-08 - 13.3.0 - feat(web-ui)
reorganize network and security views into tabbed subviews with route-aware navigation
- add URL-based subview support in app state and router for network and security sections
- group routes, source profiles, network targets, and target profiles under the network view with tab navigation
- split security into dedicated overview, blocked IPs, authentication, and email security subviews
- update configuration navigation to deep-link directly to the network routes subview
## 2026-04-08 - 13.2.2 - fix(project)
no changes to commit
## 2026-04-08 - 13.2.1 - fix(project)
no changes to commit
## 2026-04-08 - 13.2.0 - feat(ops-ui)
add column filters to operations tables across admin views
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
- add regression tests for certificate domain derivation and force-renew wildcard handling
## 2026-04-07 - 13.1.2 - fix(deps)
bump @serve.zone/catalog to ^2.12.3
- Updates @serve.zone/catalog from ^2.12.0 to ^2.12.3 in package.json
## 2026-04-07 - 13.1.1 - fix(deps)
bump catalog-related dependencies to newer patch and minor releases
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips
- runs smartmigration at startup before configuration is loaded and adds a migration for target profile targets from host to ip
- changes VPN client routing to always force traffic through SmartProxy while allowing direct target bypasses from target profiles
- supports domain-scoped VPN ipAllowList entries for vpnOnly routes based on matching target profile domains
- updates certificate reprovisioning to reapply routes so renewed certificates are loaded into the running proxy
- removes the forceDestinationSmartproxy VPN client option from API, persistence, manager, and web UI
## 2026-04-06 - 13.0.11 - fix(routing)
serialize route updates and correct VPN-gated route application
- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites.
- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely.
- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently.
- Reference resolution now expands network targets with multiple hosts into multiple route targets.
- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI.
## 2026-04-06 - 13.0.10 - fix(repo)
no changes to commit
## 2026-04-06 - 13.0.9 - fix(repo)
no changes to commit
## 2026-04-06 - 13.0.8 - fix(ops-view-vpn)
show target profile names in VPN forms and load profile candidates for autocomplete
- fetch target profiles when the VPN operations view connects so profile data is available in the UI
- replace comma-separated target profile ID inputs with a restricted autocomplete list based on available target profiles
- map stored target profile IDs to profile names for table and detail displays, while resolving selected names back to IDs on save
## 2026-04-06 - 13.0.7 - fix(vpn,target-profiles)
refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists
- Adds direct target IP resolution from target profiles so forced SmartProxy clients can bypass rewriting for explicit profile targets.
- Refreshes running VPN client security policies after target profile updates or deletions to keep destination access rules in sync.
## 2026-04-05 - 13.0.6 - fix(certificates)
resolve base-domain certificate lookups and route profile list inputs
- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably.
- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling.
- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references.
## 2026-04-05 - 13.0.5 - fix(ts_web)
replace custom section heading component with dees-heading across ops views
- updates all operations dashboard views to use <dees-heading level="2"> for section titles
- removes the unused shared ops-sectionheading component export and source file
- bumps UI and data layer dependencies to compatible patch/minor releases
## 2026-04-05 - 13.0.4 - fix(deps)
bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases
- Updates @push.rocks/smartdata from ^7.1.4 to ^7.1.5
- Updates @push.rocks/smartdb from ^2.5.2 to ^2.5.4
## 2026-04-05 - 13.0.3 - fix(deps)
bump @push.rocks/smartdb to ^2.5.2
- Updates @push.rocks/smartdb from ^2.5.1 to ^2.5.2 in package.json.
## 2026-04-05 - 13.0.2 - fix(deps)
bump smartdata, smartdb, and catalog dependencies
- updates @push.rocks/smartdata from ^7.1.3 to ^7.1.4
- updates @push.rocks/smartdb from ^2.4.1 to ^2.5.1
- updates @serve.zone/catalog from ^2.11.1 to ^2.11.2
## 2026-04-05 - 13.0.1 - fix(deps)
bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies
- updates @design.estate/dees-catalog from ^3.55.6 to ^3.59.1
- updates @push.rocks/smartdb from ^2.3.1 to ^2.4.1
## 2026-04-05 - 13.0.0 - BREAKING CHANGE(vpn)
replace tag-based VPN access control with source and target profiles
- Renames Security Profiles to Source Profiles across APIs, persistence, route metadata, tests, and UI.
- Adds TargetProfile management, storage, API handlers, and dashboard views to define VPN-accessible domains, targets, and route references.
- Replaces route-level vpn configuration with vpnOnly and switches VPN clients from serverDefinedClientTags to targetProfileIds for access resolution.
- Updates route application and VPN AllowedIPs generation to derive client access from matching target profiles instead of tags.
## 2026-04-04 - 12.10.0 - feat(routes)
add TLS configuration controls for route create and edit flows
- Adds TLS mode and certificate selection to the route create and edit dialogs, including support for custom PEM key/certificate input.
- Allows route updates to explicitly remove nested TLS settings by treating null action properties as deletions during route patch merging.
- Bumps @design.estate/dees-catalog to ^3.55.6 and @serve.zone/catalog to ^2.11.1.
## 2026-04-04 - 12.9.4 - fix(deps)
bump @push.rocks/smartdb to ^2.3.1
- updates the @push.rocks/smartdb dependency from ^2.1.1 to ^2.3.1
## 2026-04-04 - 12.9.3 - fix(route-management)
include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs
- Scans enabled stored/programmatic routes for VPN domain matches when resolving client access domains.
- Replaces generic smartproxy route typings with IDcRouterRouteConfig across route management and stored route models.
- Updates @push.rocks/smartproxy to ^27.4.0.
## 2026-04-04 - 12.9.2 - fix(config-ui)
handle missing HTTP/3 config safely and standardize overview section headings
- Prevents route augmentation logic from failing when HTTP/3 configuration is undefined by using optional chaining.
- Updates the operations overview to use dees-heading components for activity, email, DNS, RADIUS, and VPN section headings.
- Bumps @push.rocks/smartproxy from ^27.2.0 to ^27.3.1.
## 2026-04-04 - 12.9.1 - fix(monitoring)
update SmartProxy and use direct connection protocol metrics access
- bump @push.rocks/smartproxy from ^27.1.0 to ^27.2.0
- replace fallback any-based access with direct frontend and backend protocol metric calls in MetricsManager
## 2026-04-04 - 12.9.0 - feat(monitoring)
add frontend and backend protocol distribution metrics to network stats
- Expose frontend and backend protocol distribution data in monitoring metrics, stats responses, and shared interfaces.
- Render protocol distribution donut charts in the ops network view using the new stats fields.
- Preserve existing stored certificate IDs when updating certificate records by domain.
- Bump @design.estate/dees-catalog to ^3.55.5 for the new chart component support.
## 2026-04-04 - 12.8.1 - fix(ops-view-routes)
correct route form dropdown selection handling for security profiles and network targets
- Update route edit and create forms to use selectedOption for dropdowns backed by the newer dees-catalog version
- Normalize submitted dropdown values to extract option keys before storing securityProfileRef and networkTargetRef
- Refresh documentation to reflect expanded stats coverage for network, RADIUS, and VPN metrics
## 2026-04-03 - 12.8.0 - feat(certificates)
add force renew option for domain certificate reprovisioning
- pass an optional forceRenew flag through certificate reprovision requests from the UI to the ops handler
- use smartacme forceRenew support and return renewal-specific success messages
- update the SmartAcme dependency to version ^9.4.0
## 2026-04-03 - 12.7.0 - feat(opsserver)
add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode
- Expose RADIUS and VPN sections in the combined stats API and shared TypeScript interfaces
- Populate frontend app state and overview tiles with RADIUS authentication, session, traffic, and VPN client metrics
- Replace simulated follow-mode log events with real log buffer tailing and timestamp-based incremental streaming
- Use commit metadata for reported server version instead of a hardcoded value
## 2026-04-03 - 12.6.6 - fix(deps)
bump @design.estate/dees-catalog to ^3.52.3
- Updates @design.estate/dees-catalog from ^3.52.2 to ^3.52.3 in package.json
## 2026-04-03 - 12.6.5 - fix(deps)
bump @design.estate/dees-catalog to ^3.52.2
- Updates the @design.estate/dees-catalog dependency from ^3.52.0 to ^3.52.2 in package.json.
## 2026-04-03 - 12.6.4 - fix(deps)
bump @design.estate/dees-catalog to ^3.52.0
- Updates the @design.estate/dees-catalog dependency from ^3.51.2 to ^3.52.0 in package.json.
## 2026-04-03 - 12.6.3 - fix(deps)
bump @types/node and @design.estate/dees-catalog patch versions
- updates @types/node from ^25.5.1 to ^25.5.2
- updates @design.estate/dees-catalog from ^3.51.1 to ^3.51.2
## 2026-04-03 - 12.6.2 - fix(deps)
bump @design.estate/dees-catalog to ^3.51.1
- Updates @design.estate/dees-catalog from ^3.51.0 to ^3.51.1 in package.json
## 2026-04-03 - 12.6.1 - fix(repo)
no changes to commit
## 2026-04-03 - 12.6.0 - feat(certificates)
add confirmation before force renewing valid certificates from the certificate actions menu
- Expose the Reprovision action in the certificate context menu
- Prompt for confirmation when reprovisioning a certificate that is still valid
- Update dees-catalog and @types/node dependencies
## 2026-04-03 - 12.5.2 - fix(repo)
no changes to commit
## 2026-04-03 - 12.5.1 - fix(ops-view-network)
centralize traffic chart timing constants for consistent rolling window updates
- Defines shared constants for the chart window, update interval, and maximum buffered data points
- Replaces hardcoded traffic history sizes and timer intervals with derived values across initialization, history loading, and live updates
- Keeps the chart rolling window configuration aligned with the in-memory traffic buffer
## 2026-04-02 - 12.5.0 - feat(ops-view-routes)
add priority support and list-based domain editing for routes
- Adds a priority field to route create and edit forms so route matching order can be configured.
- Replaces comma-separated domain text input with a list-based domain editor and updates form handling to persist domains as arrays.
## 2026-04-02 - 12.4.0 - feat(routes)
add route edit and delete actions to the ops routes view
- introduces an update route action in web app state and refreshes merged routes after changes
- adds edit and delete handlers with modal-based confirmation and route form inputs for programmatic routes
- enables realtime chart window configuration in network and overview dashboards
- bumps @serve.zone/catalog to ^2.11.0
## 2026-04-02 - 12.3.0 - feat(docs,ops-dashboard)
document unified database and reusable security profile and network target management
- Update project and interface documentation to replace separate storage/cache configuration with a unified database model
- Document new security profile and network target APIs, data models, and dashboard capabilities
- Add a global dashboard warning when the database is disabled so unavailable management features are clearly indicated
- Bump @design.estate/dees-catalog and @serve.zone/catalog to support the updated dashboard experience
## 2026-04-02 - 12.2.6 - fix(ops-ui)
improve operations table actions and modal form handling for profiles and network targets
- adds section headings for the Security Profiles and Network Targets views
- updates edit and delete actions to support in-row table actions in addition to context menus
- makes create and edit dialogs query forms safely from modal content and adds early returns when forms are unavailable
- enables the database configuration in the development watch server
## 2026-04-02 - 12.2.5 - fix(dcrouter)
sync allowed tunnel edges when merged routes change
- Triggers tunnelManager.syncAllowedEdges() after route updates are applied
- Keeps derived ports in the Rust hub binary aligned with merged route changes
## 2026-04-02 - 12.2.4 - fix(routes)
support profile and target metadata in route creation and refresh remote ingress routes after config initialization
- Re-applies routes to the remote ingress manager after config managers finish to avoid missing DB-backed routes during initialization
- Fetches profiles and targets when opening or authenticating into the routes view so route creation dropdowns are populated
- Includes selected security profile and network target metadata when creating programmatic routes and displays that metadata in route details
- Improves security profile forms by switching IP allow/block lists to list inputs instead of comma-separated text fields
- Updates UI dependencies including smartdb, dees-catalog, and serve.zone catalog
## 2026-04-02 - 12.2.3 - fix(repo)
no changes to commit
## 2026-04-02 - 12.2.2 - fix(route-config)
sync applied routes to remote ingress manager after route updates
- add an optional route-applied callback to RouteConfigManager
- forward merged SmartProxy routes to RemoteIngressManager whenever routes are updated
## 2026-04-02 - 12.2.1 - fix(web-ui)
align dees-table props and action handlers in security profile and network target views
@@ -2004,4 +2802,4 @@ Applied a core fix.
- Fixed core functionality for version 1.0.1
–––––––––––––––––––––––
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.38.0",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
"dist_serve"
]
},
"imports": {
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.11.1",
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.18.0",
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
"lru-cache": "npm:lru-cache@^11.4.0",
"qrcode": "npm:qrcode@^1.5.4",
"uuid": "npm:uuid@^14.0.0"
}
}
Executable
+359
View File
@@ -0,0 +1,359 @@
#!/bin/bash
# DcRouter Installer Script
# Installs the self-extracting Linux binary by default, or builds the NodeNext
# source package when --source is specified.
#
# Usage:
# Binary install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
#
# Source install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
#
# Options:
# -h, --help Show this help message
# --version VERSION Install a specific tag/version (e.g. vX.Y.Z)
# --install-dir DIR Installation directory (default: /opt/dcrouter)
# --binary Install release binary (default)
# --source Clone the tag and build the NodeNext package locally
set -euo pipefail
SHOW_HELP=0
SPECIFIED_VERSION=""
INSTALL_DIR="/opt/dcrouter"
INSTALL_MODE="binary"
GITEA_BASE_URL="https://code.foss.global"
GITEA_REPO="serve.zone/dcrouter"
SERVICE_NAME="dcrouter"
BIN_DIR="/usr/local/bin"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
SHOW_HELP=1
shift
;;
--version)
if [[ $# -lt 2 ]]; then
echo "Error: --version requires a value"
exit 1
fi
SPECIFIED_VERSION="$2"
shift 2
;;
--install-dir)
if [[ $# -lt 2 ]]; then
echo "Error: --install-dir requires a value"
exit 1
fi
INSTALL_DIR="$2"
shift 2
;;
--binary)
INSTALL_MODE="binary"
shift
;;
--source)
INSTALL_MODE="source"
shift
;;
*)
echo "Unknown option: $1"
echo "Use -h or --help for usage information"
exit 1
;;
esac
done
if [[ $SHOW_HELP -eq 1 ]]; then
echo "DcRouter Installer Script"
echo "Installs DcRouter as a self-extracting binary or NodeNext source build."
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --version VERSION Install a specific tag/version (e.g. vX.Y.Z)"
echo " --install-dir DIR Installation directory (default: /opt/dcrouter)"
echo " --binary Install release binary (default)"
echo " --source Clone the tag and build the NodeNext package locally"
echo ""
echo "Examples:"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --version vX.Y.Z"
exit 0
fi
if [[ "$EUID" -ne 0 ]]; then
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
exit 1
fi
case "$INSTALL_DIR" in
""|"/")
echo "Error: unsafe install directory: $INSTALL_DIR"
exit 1
;;
esac
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command not found: $1"
exit 1
fi
}
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return
fi
if command -v corepack >/dev/null 2>&1; then
corepack enable
fi
if ! command -v pnpm >/dev/null 2>&1; then
echo "Error: pnpm is required for --source installs. Install Node.js with corepack/pnpm first."
exit 1
fi
}
make_executable_if_present() {
if [[ -f "$1" ]]; then
chmod 0755 "$1"
fi
}
get_latest_version() {
echo "Fetching latest release version from Gitea..." >&2
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
local response
if ! response=$(curl -fsSL "$api_url" 2>/dev/null); then
echo "Error: Failed to fetch latest release information from Gitea API" >&2
echo "URL: $api_url" >&2
exit 1
fi
local version
version=$(printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [[ -z "$version" ]]; then
echo "Error: Could not determine latest version from API response" >&2
exit 1
fi
echo "$version"
}
detect_binary_name() {
local os
local arch
os=$(uname -s)
arch=$(uname -m)
if [[ "$os" != "Linux" ]]; then
echo "Error: binary installer currently supports Linux only. Use --source for this platform." >&2
exit 1
fi
case "$arch" in
x86_64|amd64)
echo "dcrouter-linux-x64"
;;
aarch64|arm64)
echo "dcrouter-linux-arm64"
;;
*)
echo "Error: unsupported architecture for binary install: $arch. Use --source." >&2
exit 1
;;
esac
}
echo "================================================"
echo " DcRouter Installation Script"
echo "================================================"
echo ""
require_command curl
require_command sed
if [[ -n "$SPECIFIED_VERSION" ]]; then
VERSION="$SPECIFIED_VERSION"
echo "Installing specified version: $VERSION"
else
VERSION=$(get_latest_version)
echo "Installing latest version: $VERSION"
fi
echo "Install mode: $INSTALL_MODE"
echo ""
SOURCE_REF="$VERSION"
REPO_URL="${GITEA_BASE_URL}/${GITEA_REPO}.git"
TEMP_DIR=$(mktemp -d)
SOURCE_DIR="$TEMP_DIR/source"
BACKUP_DIR=""
SERVICE_WAS_RUNNING=0
SERVICE_STOPPED=0
SYSTEMD_AVAILABLE=0
cleanup_temp() {
rm -rf "$TEMP_DIR"
}
trap cleanup_temp EXIT
if command -v systemctl >/dev/null 2>&1; then
SYSTEMD_AVAILABLE=1
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
SERVICE_WAS_RUNNING=1
fi
fi
restore_previous_installation() {
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
echo "Restoring previous installation from $BACKUP_DIR..."
rm -rf "$INSTALL_DIR" || true
mv "$BACKUP_DIR" "$INSTALL_DIR" || true
if [[ -f "$INSTALL_DIR/dcrouter" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter" || true
elif [[ -f "$INSTALL_DIR/cli.js" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter" || true
fi
fi
}
restart_previous_service_on_error() {
if [[ $SERVICE_STOPPED -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Installation failed after stopping DcRouter; restarting previous service..."
systemctl start "$SERVICE_NAME" || true
fi
}
handle_install_error() {
trap - ERR
restore_previous_installation
restart_previous_service_on_error
}
trap handle_install_error ERR
stop_service_if_running() {
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
echo "Stopping DcRouter service..."
systemctl stop "$SERVICE_NAME"
SERVICE_STOPPED=1
fi
}
move_previous_installation() {
mkdir -p "$(dirname "$INSTALL_DIR")"
if [[ -d "$INSTALL_DIR" ]]; then
BACKUP_DIR="${INSTALL_DIR}.previous.$$"
echo "Moving previous installation to $BACKUP_DIR"
mv "$INSTALL_DIR" "$BACKUP_DIR"
fi
}
install_source_build() {
require_command git
require_command node
ensure_pnpm
echo "Cloning DcRouter source from $REPO_URL ($SOURCE_REF)..."
git clone --depth 1 --branch "$SOURCE_REF" "$REPO_URL" "$SOURCE_DIR"
echo "Installing dependencies..."
pnpm --dir "$SOURCE_DIR" install --frozen-lockfile
echo "Building DcRouter..."
pnpm --dir "$SOURCE_DIR" run build
echo "Validating built CLI..."
node "$SOURCE_DIR/cli.js" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing source build to $INSTALL_DIR"
mv "$SOURCE_DIR" "$INSTALL_DIR"
make_executable_if_present "$INSTALL_DIR/cli.js"
make_executable_if_present "$INSTALL_DIR/cli.ts.js"
make_executable_if_present "$INSTALL_DIR/cli.child.js"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter"
}
install_release_binary() {
local binary_name
local download_url
local temp_file
binary_name=$(detect_binary_name)
download_url="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${binary_name}"
temp_file="$TEMP_DIR/$binary_name"
echo "Downloading DcRouter binary: $download_url"
curl -fSL "$download_url" -o "$temp_file"
chmod 0755 "$temp_file"
echo "Validating downloaded binary..."
"$temp_file" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing binary to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
install -m 0755 "$temp_file" "$INSTALL_DIR/dcrouter"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter"
}
if [[ "$INSTALL_MODE" == "source" ]]; then
install_source_build
else
install_release_binary
fi
echo "Symlink created: $BIN_DIR/dcrouter"
if ! "$BIN_DIR/dcrouter" --version >/dev/null; then
echo "Error: Installed DcRouter CLI failed validation"
restore_previous_installation
restart_previous_service_on_error
exit 1
fi
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
rm -rf "$BACKUP_DIR"
fi
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Restarting DcRouter service..."
systemctl restart "$SERVICE_NAME"
SERVICE_STOPPED=0
echo "Service restarted successfully."
echo ""
fi
trap - ERR
echo "================================================"
echo " DcRouter Installation Complete!"
echo "================================================"
echo ""
echo "Installation details:"
echo " Install directory: $INSTALL_DIR"
echo " Symlink location: $BIN_DIR/dcrouter"
echo " Version: $VERSION"
echo " Mode: $INSTALL_MODE"
echo ""
echo "Get started:"
echo ""
echo " dcrouter --version"
echo " dcrouter --help"
echo ""
+49 -44
View File
@@ -1,9 +1,12 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "12.2.1",
"version": "13.38.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
"dcrouter": "./cli.js"
},
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js",
@@ -12,63 +15,68 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --logfile --timeout 60)",
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build": "(tsbuild tsfolders --allowimplicitany && pnpm run bundle)",
"build:binary": "(pnpm run build && tsdeno compile)",
"build:docker": "tsdocker build --verbose",
"release:docker": "tsdocker push --verbose",
"bundle": "(tsbundle)",
"watch": "tswatch"
},
"devDependencies": {
"@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"
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsdeno": "^1.4.0",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest": "^3.3.1",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@api.global/typedsocket": "^4.1.3",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.49.1",
"@design.estate/dees-catalog": "^3.83.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@idp.global/sdk": "^1.3.1",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@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/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.10.1",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartjwt": "^2.2.2",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartmigration": "1.4.1",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.1.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.11.1",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.1",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.18.0",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.2.7",
"lru-cache": "^11.4.0",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"keywords": [
"mail service",
@@ -96,25 +104,22 @@
"VLAN assignment",
"MAC authentication"
],
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
},
"packageManager": "pnpm@10.11.0",
"files": [
"ts/**/*",
"binary/**/*",
"ts_web/**/*",
"ts_apiclient/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"dist_ts_apiclient/**/*",
"assets/**/*",
"cli.js",
"cli.ts.js",
"cli.child.js",
"cli.child.ts",
"deno.json",
"tsconfig.json",
".smartconfig.json",
"readme.md"
]
+2844 -2718
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
mongodb-memory-server: true
puppeteer: true
+214 -1551
View File
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { OpsServer } from '../ts/opsserver/index.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const testPort = 3110;
const baseUrl = `http://localhost:${testPort}/typedrequest`;
const bootstrapPassword = 'temporary-bootstrap-password';
const persistedPassword = 'persisted-admin-password';
let previousAdminPassword: string | undefined;
let opsServer: OpsServer;
let testDb: DcRouterDb;
let storagePath: string;
let dbName: string;
let bootstrapIdentity: interfaces.data.IIdentity;
let persistedIdentity: interfaces.data.IIdentity;
let createdUserId: string;
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
baseUrl,
'getAdminBootstrapStatus',
);
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
baseUrl,
'adminLoginWithUsernameAndPassword',
);
const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
options: {
opsServerPort: portArg,
dbConfig: { enabled: true },
adminAuth: {
idpClient: {
loginWithEmailAndPassword: async () => ({
jwt: 'idp-jwt',
refreshToken: 'idp-refresh-token',
user: {
id: 'idp-user-1',
data: {
name: 'Wrong IdP User',
username: 'wrong@example.com',
email: 'wrong@example.com',
status: 'active',
connectedOrgs: [],
},
},
}),
stop: async () => {},
},
},
},
typedrouter: new plugins.typedrequest.TypedRouter(),
dcRouterDb: dcRouterDbArg,
});
const restartOpsServer = async () => {
await opsServer.stop();
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
};
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
testDb = DcRouterDb.getInstance({
storagePath,
dbName,
});
await testDb.start();
await testDb.getDb().mongoDb.createCollection('__test_init');
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
});
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(true);
expect(status.hasPersistentAdmin).toEqual(false);
expect(status.needsBootstrap).toEqual(true);
expect(status.ephemeralAdminAvailable).toEqual(true);
expect(status.idpGlobalConfigured).toEqual(true);
});
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
const response = await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
if (!response.identity) {
throw new Error('Expected bootstrap login identity');
}
bootstrapIdentity = response.identity;
expect(bootstrapIdentity.role).toEqual('admin');
});
tap.test('creates the initial persisted admin explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
baseUrl,
'createInitialAdminUser',
);
const response = await request.fire({
identity: bootstrapIdentity,
email: 'Admin@Example.com',
name: 'Persisted Admin',
password: persistedPassword,
enableIdpGlobalAuth: true,
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('admin');
expect(response.user?.authSources).toContain('local');
expect(response.user?.authSources).toContain('idp.global');
if (!response.identity) {
throw new Error('Expected persisted admin identity');
}
persistedIdentity = response.identity;
});
tap.test('disables bootstrap mode after persisted admin exists', async () => {
const status = await createStatusRequest().fire({});
expect(status.hasPersistentAdmin).toEqual(true);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
});
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const response = await request.fire({ identity: bootstrapIdentity });
expect(response.valid).toEqual(false);
});
tap.test('authenticates the persisted admin locally by normalized email', async () => {
const response = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!response.identity) {
throw new Error('Expected persisted admin login identity');
}
expect(response.identity.userId).toEqual(persistedIdentity.userId);
});
tap.test('persists users across OpsServer restart', async () => {
const oldPersistedIdentity = persistedIdentity;
await restartOpsServer();
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity });
expect(verifyResponse.valid).toEqual(false);
const loginResponse = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!loginResponse.identity) {
throw new Error('Expected persisted admin login identity after restart');
}
expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId);
persistedIdentity = loginResponse.identity;
});
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin@example.com',
password: 'idp-password',
authSource: 'idp.global',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('creates a persisted non-admin user explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateUser>(baseUrl, 'createUser');
const response = await request.fire({
identity: persistedIdentity,
email: 'operator@example.com',
name: 'Operator User',
role: 'user',
password: 'operator-password',
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('user');
expect(response.user?.email).toEqual('operator@example.com');
if (!response.user?.id) {
throw new Error('Expected created user id');
}
createdUserId = response.user.id;
});
tap.test('rejects deleting the current persisted admin user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: persistedIdentity.userId,
});
expect(response.success).toEqual(false);
});
tap.test('deletes a persisted non-current user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: createdUserId,
});
expect(response.success).toEqual(true);
});
tap.test('lists persisted users without password material', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
const response = await request.fire({ identity: persistedIdentity });
expect(response.users.length).toEqual(1);
expect(response.users[0].email).toEqual('Admin@Example.com');
expect((response.users[0] as any).password).toBeUndefined();
});
tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => {
await testDb.stop();
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
await opsServer.stop();
await testDb.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
if (previousAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
}
});
tap.test('does not offer bootstrap while configured database is unavailable', async () => {
const unavailablePort = 3111;
const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`;
const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password';
DcRouterDb.resetInstance();
const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any);
try {
await unavailableOpsServer.start();
const status = await new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
unavailableBaseUrl,
'getAdminBootstrapStatus',
).fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
unavailableBaseUrl,
'adminLoginWithUsernameAndPassword',
).fire({
username: 'admin',
password: 'unavailable-bootstrap-password',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
} finally {
await unavailableOpsServer.stop();
DcRouterDb.resetInstance();
if (previousUnavailableAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword;
}
}
});
export default tap.start();
+75
View File
@@ -0,0 +1,75 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ApiTokenManager } from '../ts/config/classes.api-token-manager.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
tap.test('ApiTokenManager seeds and rotates an env admin API token', async () => {
const previousToken = process.env.DCROUTER_ADMIN_API_TOKEN;
const previousName = process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
const testDb = await createTestDb();
try {
const rawToken1 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
const rawToken2 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken1;
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = 'Onebox Managed Admin';
const manager = new ApiTokenManager();
await manager.initialize();
const token1 = await manager.validateToken(rawToken1);
expect(token1?.id).toEqual('env-admin-token');
expect(token1?.name).toEqual('Onebox Managed Admin');
expect(token1?.policy?.role).toEqual('admin');
expect(manager.hasScope(token1!, 'tokens:manage')).toEqual(true);
const listedToken = manager.listTokens().find((token) => token.id === 'env-admin-token') as any;
expect(listedToken.tokenHash).toBeUndefined();
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken2;
const rotatedManager = new ApiTokenManager();
await rotatedManager.initialize();
expect(await rotatedManager.validateToken(rawToken1)).toBeNull();
const token2 = await rotatedManager.validateToken(rawToken2);
expect(token2?.id).toEqual('env-admin-token');
expect(token2?.policy?.role).toEqual('admin');
} finally {
if (previousToken === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN = previousToken;
}
if (previousName === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = previousName;
}
await testDb.cleanup();
}
});
export default tap.start();
+4 -46
View File
@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
},
source: 'programmatic',
id: 'route-123',
enabled: true,
overridden: false,
storedRouteId: 'route-123',
origin: 'api',
createdAt: 1000,
updatedAt: 2000,
});
expect(route.name).toEqual('test-route');
expect(route.source).toEqual('programmatic');
expect(route.id).toEqual('route-123');
expect(route.enabled).toEqual(true);
expect(route.overridden).toEqual(false);
expect(route.storedRouteId).toEqual('route-123');
expect(route.origin).toEqual('api');
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
// =============================================================================
+196
View File
@@ -0,0 +1,196 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
// ──────────────────────────────────────────────────────────────────────────────
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
// Used by the force-renew sibling-propagation logic to identify which routes
// share a single underlying ACME certificate.
// ──────────────────────────────────────────────────────────────────────────────
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
});
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('example.com')).toEqual('example.com');
});
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
});
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
// This is the core property: outline.task.vc and *.task.vc must yield
// the same cert identity, otherwise sibling propagation cannot work.
const subdomain = deriveCertDomainName('outline.task.vc');
const wildcard = deriveCertDomainName('*.task.vc');
expect(subdomain).toEqual(wildcard);
});
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
// Matches smartacme's "deeper domains not supported" behavior.
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
});
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
expect(deriveCertDomainName('vc')).toBeUndefined();
expect(deriveCertDomainName('')).toBeUndefined();
});
// ──────────────────────────────────────────────────────────────────────────────
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
//
// This is the regression test for Bug 1: previously the call passed only
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
// and break every sibling subdomain.
// ──────────────────────────────────────────────────────────────────────────────
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
function makeStubOpsServer(opts: {
routes: Array<{ name: string; domains: string[] }>;
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
}) {
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
const router = {
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
};
const routes = opts.routes.map((r) => ({
name: r.name,
match: { domains: r.domains, ports: 443 },
action: { type: 'forward', tls: { certificate: 'auto' } },
}));
const dcRouterRef: any = {
smartProxy: {
routeManager: { getRoutes: () => routes },
},
smartAcme: opts.smartAcmeStub,
findRouteNamesForDomain: (domain: string) =>
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
certificateStatusMap: new Map<string, any>(),
certProvisionScheduler: null,
routeConfigManager: null,
};
const opsServerRef: any = {
viewRouter: router,
adminRouter: router,
dcRouterRef,
};
return { opsServerRef, dcRouterRef, captured };
}
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [
{ name: 'outline-route', domains: ['outline.task.vc'] },
{ name: 'pr-route', domains: ['pr.task.vc'] },
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
// Return a cert object shaped like SmartacmeCert
return {
id: 'test-id',
domainName: 'task.vc',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
};
},
},
});
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
dcRouterRef.smartProxy.updateRoutes = async () => {};
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
const handler = new CertificateHandler(opsServerRef);
// Invoke the private reprovision method directly. The Bug 1 fix is verified
// by inspecting the captured smartAcme call options regardless of whether
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
// The Bug 1 fix is verified by the captured smartAcme call regardless.
expect(calls.length).toBeGreaterThanOrEqual(1);
expect(calls[0].domain).toEqual('outline.task.vc');
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
});
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [
{ name: 'wildcard-route', domains: ['*.task.vc'] },
],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
return {
id: 'test-id',
domainName: 'task.vc',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
};
},
},
});
dcRouterRef.smartProxy.updateRoutes = async () => {};
const handler = new CertificateHandler(opsServerRef);
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
expect(calls.length).toBeGreaterThanOrEqual(1);
expect(calls[0].domain).toEqual('*.task.vc');
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
});
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
return {} as any;
},
},
});
dcRouterRef.smartProxy.updateRoutes = async () => {};
const handler = new CertificateHandler(opsServerRef);
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
// applyRoutes and lets the cert provisioning pipeline handle it.
expect(calls.length).toEqual(0);
});
export default tap.start();
+201
View File
@@ -0,0 +1,201 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
import { AcmeCertDoc, DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-cert-api-token-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const makeApiTokenManager = (scopes: TScope[]) => {
const token = {
id: 'token-1',
name: 'certificate-test-token',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
};
};
const setupHandler = (scopes: TScope[], options?: {
routes?: any[];
certProvisionScheduler?: any;
certProvisionFunction?: (...args: any[]) => any;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async () => null,
adminIdentityGuard: {
exec: async () => false,
},
},
dcRouterRef: {
apiTokenManager: makeApiTokenManager(scopes),
certificateStatusMap: new Map(),
smartProxy: {
settings: options?.certProvisionFunction ? {
certProvisionFunction: options.certProvisionFunction,
} : {},
routeManager: { getRoutes: () => options?.routes ?? [] },
getCertificateStatus: async () => null,
},
certProvisionScheduler: options?.certProvisionScheduler ?? null,
},
};
new CertificateHandler(opsServerRef);
return { typedrouter, opsServerRef };
};
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const testDbPromise = createTestDb();
tap.test('CertificateHandler allows API-token export with certificates:read', async () => {
await testDbPromise;
const certDoc = new AcmeCertDoc();
certDoc.id = 'cert-1';
certDoc.domainName = 'example.com';
certDoc.created = 1;
certDoc.validUntil = 2;
certDoc.privateKey = '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----';
certDoc.publicKey = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----';
certDoc.csr = '';
await certDoc.save();
const { typedrouter } = setupHandler(['certificates:read']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect(result.response.cert.domainName).toEqual('example.com');
expect(result.response.cert.privateKey).toContain('BEGIN PRIVATE KEY');
expect(result.response.cert.publicKey).toContain('BEGIN CERTIFICATE');
});
tap.test('CertificateHandler rejects API-token export without certificates:read', async () => {
const { typedrouter } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error?.text).toEqual('insufficient scope');
});
tap.test('CertificateHandler allows API-token import with certificates:write', async () => {
await testDbPromise;
const { typedrouter, opsServerRef } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'importCertificate', {
apiToken: 'valid-token',
cert: {
id: 'cert-2',
domainName: 'imported.example.com',
created: 3,
validUntil: 4,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
},
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect((await AcmeCertDoc.findByDomain('imported.example.com'))?.id).toEqual('cert-2');
expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid');
});
tap.test('CertificateHandler reports active certificate backoff as failed with root cause', async () => {
await testDbPromise;
const lastError = 'DNS-01 failed for stack.gallery: DnsManager: no managed domain found for _acme-challenge.stack.gallery.';
const retryAfter = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const { typedrouter } = setupHandler(['certificates:read'], {
certProvisionFunction: async () => 'http01',
certProvisionScheduler: {
getBackoffInfo: async (domain: string) => domain === 'stack.gallery'
? { failures: 11, retryAfter, lastError }
: null,
},
routes: [
{
name: 'stack-gallery',
match: { domains: ['stack.gallery'] },
action: {
tls: {
mode: 'terminate',
certificate: 'auto',
},
},
},
],
});
const result = await fireTypedRequest(typedrouter, 'getCertificateOverview', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.summary.failed).toEqual(1);
expect(result.response.certificates[0].status).toEqual('failed');
expect(result.response.certificates[0].error).toEqual(lastError);
expect(result.response.certificates[0].backoffInfo.failures).toEqual(11);
});
tap.test('cleanup test db', async () => {
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();
+79
View File
@@ -0,0 +1,79 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ConfigHandler } from '../ts/opsserver/handlers/config.handler.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeOpsServer = (scopes: interfaces.data.TApiTokenScope[]) => {
const router = new plugins.typedrequest.TypedRouter();
const token = {
id: 'token-1',
name: 'config-token',
tokenHash: 'hash',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
const opsServerRef = {
viewRouter: router,
adminHandler: {
validateIdentity: async () => null,
},
dcRouterRef: {
options: {
dbConfig: { enabled: false },
},
resolvedPaths: {
dcrouterHomeDir: '/tmp/dcrouter-home',
dataDir: '/tmp/dcrouter-data',
defaultTsmDbPath: '/tmp/dcrouter-data/db',
},
detectedPublicIp: null,
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: interfaces.data.TApiTokenScope) => storedTokenArg.scopes.includes(scopeArg),
},
},
} as any;
new ConfigHandler(opsServerRef);
return router;
};
tap.test('ConfigHandler accepts API token with config:read', async () => {
const router = makeOpsServer(['config:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.config.system.baseDir).toEqual('/tmp/dcrouter-home');
});
tap.test('ConfigHandler rejects API token without config:read', async () => {
const router = makeOpsServer(['logs:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+3
View File
@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
// Verify unified email server was initialized
expect(router.emailServer).toBeTruthy();
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
// Stop the router
await router.stop();
+469
View File
@@ -0,0 +1,469 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DnsRecordDoc, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js';
import { logger } from '../ts/logger.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const record of await DnsRecordDoc.findAll()) {
await record.delete();
}
for (const route of await RouteDoc.findAll()) {
await route.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
tap.test('DnsManager keeps parallel ACME TXT challenges for the same host', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const dnsManager = new DnsManager({});
const provider = dnsManager.buildAcmeConvenientDnsProvider().convenience as any;
const hostName = '_acme-challenge.blog.central.eu';
await provider.acmeSetDnsChallenge({ hostName, challenge: 'first-token' });
await provider.acmeSetDnsChallenge({ hostName, challenge: 'second-token' });
const recordsAfterSet = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterSet.map((record) => record.value).sort()).toEqual([
'first-token',
'second-token',
]);
await provider.acmeRemoveDnsChallenge({ hostName, challenge: 'first-token' });
const recordsAfterRemove = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterRemove.map((record) => record.value)).toEqual(['second-token']);
});
tap.test('DnsManager local records answer mixed-case DNS queries', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const registeredHandlers: Array<(question: { name: string; type: string }) => any> = [];
const dnsManager = new DnsManager({});
dnsManager.dnsServer = {
registerHandler: (_name: string, _types: string[], handler: (question: { name: string; type: string }) => any) => {
registeredHandlers.push(handler);
},
} as any;
await dnsManager.createRecord({
domainId: domain.id,
name: '_acme-challenge.central.eu',
type: 'TXT',
value: 'challenge-token',
ttl: 120,
createdBy: 'test',
});
const answer = registeredHandlers[0]?.({
name: '_aCMe-challeNge.Central.Eu',
type: 'txt',
});
expect(answer).toEqual({
name: '_aCMe-challeNge.Central.Eu',
type: 'TXT',
class: 'IN',
ttl: 120,
data: 'challenge-token',
});
});
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const persistedRoutes = await RouteDoc.findAll();
expect(persistedRoutes.length).toEqual(2);
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
const mergedRoutes = routeManager.getMergedRoutes().routes;
expect(mergedRoutes.length).toEqual(2);
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
expect(appliedRoutes.length).toEqual(1);
for (const routeSet of appliedRoutes) {
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
expect(dnsQueryRoute).toBeDefined();
expect(resolveRoute).toBeDefined();
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
}
});
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const staleDnsQueryRoute = new RouteDoc();
staleDnsQueryRoute.id = 'stale-doh-query';
staleDnsQueryRoute.route = {
name: 'dns-over-https-dns-query',
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/dns-query',
},
action: {
type: 'socket-handler' as any,
} as any,
};
staleDnsQueryRoute.enabled = true;
staleDnsQueryRoute.createdAt = Date.now();
staleDnsQueryRoute.updatedAt = Date.now();
staleDnsQueryRoute.createdBy = 'test';
staleDnsQueryRoute.origin = 'dns';
await staleDnsQueryRoute.save();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const remainingRoutes = await RouteDoc.findAll();
expect(remainingRoutes.length).toEqual(2);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
expect(queryRoute?.id).toEqual('stale-doh-query');
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
});
tap.test('RouteConfigManager only allows toggling system routes', async () => {
await testDbPromise;
await clearTestState();
const smartProxy = {
updateRoutes: async (_routes: any[]) => {
return;
},
};
const routeManager = new RouteConfigManager(() => smartProxy as any);
await routeManager.initialize([
{
name: 'system-config-route',
match: {
ports: [443],
domains: ['app.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any,
], [], []);
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
expect(systemRoute).toBeDefined();
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
route: { name: 'renamed-system-route' } as any,
});
expect(updateResult.success).toEqual(false);
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
expect(deleteResult.success).toEqual(false);
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
expect(toggleResult.success).toEqual(true);
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
});
tap.test('RouteConfigManager clears a network target ref and keeps the edited inline target port', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const resolver = new ReferenceResolver();
(resolver as any).targets.set('target-1', {
id: 'target-1',
name: 'SSH TARGET',
host: '10.0.0.5',
port: 443,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'test',
});
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
resolver,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'ssh-route',
match: { ports: [22] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 22 }],
},
} as any,
'test-user',
true,
{ networkTargetRef: 'target-1' },
);
expect((await RouteDoc.findById(routeId))?.route.action.targets?.[0].port).toEqual(443);
expect((await RouteDoc.findById(routeId))?.metadata?.networkTargetRef).toEqual('target-1');
const updateResult = await routeManager.updateRoute(routeId, {
route: {
action: {
targets: [{ host: '127.0.0.1', port: 29424 }],
},
} as any,
metadata: {
networkTargetRef: '',
networkTargetName: '',
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.action.targets?.[0].host).toEqual('127.0.0.1');
expect(storedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(storedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(storedRoute?.metadata?.networkTargetName).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(mergedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(mergedRoute?.metadata?.networkTargetName).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424);
});
tap.test('RouteConfigManager clears remote ingress config when route patch sets it to null', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'remote-ingress-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a', 'blue'],
},
} as any,
'test-user',
);
const updateResult = await routeManager.updateRoute(routeId, {
route: {
remoteIngress: null,
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.remoteIngress).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.remoteIngress).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
});
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
await testDbPromise;
await clearTestState();
const originalLog = logger.log.bind(logger);
const warningMessages: string[] = [];
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
if (level === 'warn') {
warningMessages.push(message);
}
return originalLog(level, message, context || {});
};
try {
const existingDomain = new DomainDoc();
existingDomain.id = 'existing-domain';
existingDomain.name = 'example.com';
existingDomain.source = 'dcrouter';
existingDomain.authoritative = true;
existingDomain.createdAt = Date.now();
existingDomain.updatedAt = Date.now();
existingDomain.createdBy = 'test';
await existingDomain.save();
const dnsManager = new DnsManager({
dnsNsDomains: ['ns1.example.com'],
dnsScopes: ['example.com'],
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
smartProxyConfig: { routes: [] },
});
await dnsManager.start();
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
),
).toEqual(true);
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
),
).toEqual(false);
} finally {
(logger as any).log = originalLog;
}
});
tap.test('cleanup test db', async () => {
await clearTestState();
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();
+65
View File
@@ -0,0 +1,65 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { buildEmailDnsRecords } from '../ts/email/index.js';
tap.test('buildEmailDnsRecords uses the configured mail hostname for MX and includes DKIM when provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.com',
hostname: 'mail.example.com',
selector: 'selector1',
dkimValue: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
statuses: {
mx: 'valid',
spf: 'missing',
dkim: 'valid',
dmarc: 'unchecked',
},
});
expect(records).toEqual([
{
type: 'MX',
name: 'example.com',
value: '10 mail.example.com',
status: 'valid',
},
{
type: 'TXT',
name: 'example.com',
value: 'v=spf1 a mx ~all',
status: 'missing',
},
{
type: 'TXT',
name: 'selector1._domainkey.example.com',
value: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
status: 'valid',
},
{
type: 'TXT',
name: '_dmarc.example.com',
value: 'v=DMARC1; p=none; rua=mailto:dmarc@example.com',
status: 'unchecked',
},
]);
});
tap.test('buildEmailDnsRecords omits DKIM when no value is provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.net',
hostname: 'smtp.example.net',
mxPriority: 20,
});
expect(records.map((record) => record.name)).toEqual([
'example.net',
'example.net',
'_dmarc.example.net',
]);
expect(records[0].value).toEqual('20 smtp.example.net');
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();
+193
View File
@@ -0,0 +1,193 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { EmailDomainManager } from '../ts/email/index.js';
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const emailDomain of await EmailDomainDoc.findAll()) {
await emailDomain.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
const doc = new DomainDoc();
doc.id = id;
doc.name = name;
doc.source = source;
doc.authoritative = source === 'dcrouter';
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.createdBy = 'test';
await doc.save();
return doc;
};
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
ports: [2525],
hostname: 'mail.example.com',
domains: [
{
domain: 'static.example.com',
dnsMode: 'external-dns',
},
],
routes: [],
});
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
const updateCalls: Array<{ domains?: any[] }> = [];
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
emailServer: {
updateOptions: (options: { domains?: any[] }) => {
updateCalls.push(options);
},
},
};
const manager = new EmailDomainManager(dcRouterStub);
await manager.start();
const created = await manager.createEmailDomain({
linkedDomainId: linkedDomain.id,
subdomain: 'mail',
dkimSelector: 'selector1',
rotateKeys: true,
rotationIntervalDays: 30,
});
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
expect(domainsAfterCreate.length).toEqual(2);
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
expect(managedDomain).toBeTruthy();
expect(managedDomain?.dnsMode).toEqual('external-dns');
expect(managedDomain?.dkim?.selector).toEqual('selector1');
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
await manager.updateEmailDomain(created.id, {
rotateKeys: false,
rateLimits: {
outbound: {
messagesPerMinute: 10,
},
},
});
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
await manager.deleteEmailDomain(created.id);
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
});
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
};
const manager = new EmailDomainManager(dcRouterStub);
let error: Error | undefined;
try {
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
} catch (err: unknown) {
error = err as Error;
}
expect(error?.message).toEqual('Email domain already configured for static.example.com');
});
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
const stored = new EmailDomainDoc();
stored.id = 'managed-email-domain';
stored.domain = 'mail.managed.example.com';
stored.linkedDomainId = linkedDomain.id;
stored.subdomain = 'mail';
stored.dkim = {
selector: 'default',
keySize: 2048,
rotateKeys: false,
rotationIntervalDays: 90,
};
stored.dnsStatus = {
mx: 'unchecked',
spf: 'unchecked',
dkim: 'unchecked',
dmarc: 'unchecked',
};
stored.createdAt = new Date().toISOString();
stored.updatedAt = new Date().toISOString();
await stored.save();
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
};
const manager = new EmailDomainManager(dcRouterStub);
await manager.start();
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
expect(managedDomain?.dnsMode).toEqual('internal-dns');
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearTestState();
await testDb.cleanup();
await tap.stopForcefully();
});
export default tap.start();
+175
View File
@@ -0,0 +1,175 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { DcRouter } from '../ts/index.js';
import * as interfaces from '../ts_interfaces/index.js';
const TEST_PORT = 3201;
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
const TEST_ADMIN_PASSWORD = 'test-admin-password';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let removedQueueItemId: string | undefined;
let lastEnqueueArgs: any[] | undefined;
const queueItems = [
{
id: 'failed-email-1',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'delivered-email-1',
status: 'delivered',
attempts: 1,
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
testDcRouter = new DcRouter({
opsServerPort: TEST_PORT,
dbConfig: { enabled: false },
});
await testDcRouter.start();
testDcRouter.emailServer = {
getQueueItems: () => [...queueItems],
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
deliveryQueue: {
enqueue: async (...args: any[]) => {
lastEnqueueArgs = args;
return 'resent-queue-id';
},
removeItem: async (id: string) => {
removedQueueItemId = id;
return true;
},
},
} as any;
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login as admin for email API tests', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
BASE_URL,
'adminLoginWithUsernameAndPassword',
);
const response = await loginRequest.fire({
username: 'admin',
password: TEST_ADMIN_PASSWORD,
});
const responseIdentity = response.identity;
expect(responseIdentity).toBeDefined();
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
expect(adminIdentity.jwt).toBeTruthy();
});
tap.test('should return queued emails through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetAllEmails>(BASE_URL, 'getAllEmails');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.emails.map((email) => email.id)).toEqual(['delivered-email-1', 'failed-email-1']);
expect(response.emails[0].status).toEqual('delivered');
expect(response.emails[1].status).toEqual('bounced');
});
tap.test('should return email detail through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetEmailDetail>(BASE_URL, 'getEmailDetail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.email?.toList).toEqual(['recipient@example.net']);
expect(response.email?.cc).toEqual(['copy@example.net']);
expect(response.email?.rejectionReason).toEqual('550 mailbox unavailable');
expect(response.email?.headers).toEqual({ 'x-test': '1' });
});
tap.test('should expose queue status through the stats API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetQueueStatus>(BASE_URL, 'getQueueStatus');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.queues.length).toEqual(1);
expect(response.queues[0].size).toEqual(0);
expect(response.queues[0].processing).toEqual(1);
expect(response.queues[0].failed).toEqual(1);
expect(response.queues[0].retrying).toEqual(1);
expect(response.totalItems).toEqual(3);
});
tap.test('should resend failed email through the admin email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ResendEmail>(BASE_URL, 'resendEmail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.success).toEqual(true);
expect(response.newQueueId).toEqual('resent-queue-id');
expect(removedQueueItemId).toEqual('failed-email-1');
expect(lastEnqueueArgs?.[0]).toEqual(queueItems[0].processingResult);
});
tap.test('should stop DCRouter after email API tests', async () => {
await testDcRouter.stop();
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();
+107
View File
@@ -0,0 +1,107 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EmailOpsHandler } from '../ts/opsserver/handlers/email-ops.handler.js';
import { StatsHandler } from '../ts/opsserver/handlers/stats.handler.js';
const createRouterStub = () => ({
addTypedHandler: (_handler: unknown) => {},
});
const queueItems = [
{
id: 'older-failed',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'newer-delivered',
status: 'delivered',
attempts: 1,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('EmailOpsHandler maps queue items using public email server APIs', async () => {
const opsHandler = new EmailOpsHandler({
viewRouter: createRouterStub(),
adminRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueItems: () => queueItems,
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
},
},
} as any);
const emails = (opsHandler as any).getAllQueueEmails();
expect(emails.map((email: any) => email.id)).toEqual(['newer-delivered', 'older-failed']);
expect(emails[0].status).toEqual('delivered');
expect(emails[1].status).toEqual('bounced');
expect(emails[0].messageId).toEqual('message-newer');
const detail = (opsHandler as any).getEmailDetail('older-failed');
expect(detail?.toList).toEqual(['recipient@example.net']);
expect(detail?.cc).toEqual(['copy@example.net']);
expect(detail?.rejectionReason).toEqual('550 mailbox unavailable');
expect(detail?.headers).toEqual({ 'x-test': '1' });
});
tap.test('StatsHandler reports queue status using public email server APIs', async () => {
const statsHandler = new StatsHandler({
viewRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
getQueueItems: () => queueItems,
},
},
} as any);
const queueStatus = await (statsHandler as any).getQueueStatus();
expect(queueStatus.pending).toEqual(0);
expect(queueStatus.active).toEqual(1);
expect(queueStatus.failed).toEqual(1);
expect(queueStatus.retrying).toEqual(1);
expect(queueStatus.items.map((item: any) => item.id)).toEqual(['newer-delivered', 'older-failed']);
expect(queueStatus.items[1].nextRetry).toEqual(new Date('2026-04-14T10:00:00.000Z').getTime());
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();
+9
View File
@@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => {
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
} catch (error) {
expect(error).toBeInstanceOf(PlatformError);
if (!(error instanceof PlatformError)) {
throw error;
}
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
expect(error.context.operation).toEqual('testExecution');
}
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
}
);
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toEqual('Critical error');
expect(attempts).toEqual(1); // Should only attempt once
}
@@ -262,6 +268,9 @@ tap.test('Error handling can be combined with retry for robust operations', asyn
// Should not reach here
expect(false).toEqual(true);
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toContain('Flaky failure');
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
}
+28 -14
View File
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3102,
@@ -25,18 +27,22 @@ tap.test('should login with admin credentials and receive JWT', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin'
password: testAdminPassword
});
expect(response).toHaveProperty('identity');
expect(response.identity).toHaveProperty('jwt');
expect(response.identity).toHaveProperty('userId');
expect(response.identity).toHaveProperty('name');
expect(response.identity).toHaveProperty('expiresAt');
expect(response.identity).toHaveProperty('role');
expect(response.identity.role).toEqual('admin');
identity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
expect(responseIdentity).toHaveProperty('jwt');
expect(responseIdentity).toHaveProperty('userId');
expect(responseIdentity).toHaveProperty('name');
expect(responseIdentity).toHaveProperty('expiresAt');
expect(responseIdentity).toHaveProperty('role');
expect(responseIdentity.role).toEqual('admin');
identity = responseIdentity;
console.log('JWT:', identity.jwt);
});
@@ -53,7 +59,11 @@ tap.test('should verify valid JWT identity', async () => {
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response).toHaveProperty('identity');
expect(response.identity.userId).toEqual(identity.userId);
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected verify response to include identity');
}
expect(responseIdentity.userId).toEqual(identity.userId);
});
tap.test('should reject invalid JWT', async () => {
@@ -86,8 +96,12 @@ tap.test('should verify JWT matches identity data', async () => {
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
expect(response.identity.userId).toEqual(identity.userId);
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected verify response to include identity');
}
expect(responseIdentity.expiresAt).toEqual(identity.expiresAt);
expect(responseIdentity.userId).toEqual(identity.userId);
});
tap.test('should handle logout', async () => {
@@ -129,4 +143,4 @@ tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();
export default tap.start();
+329
View File
@@ -0,0 +1,329 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
const emptyProtocolDistribution = {
h1Active: 0,
h1Total: 0,
h2Active: 0,
h2Total: 0,
h3Active: 0,
h3Total: 0,
wsActive: 0,
wsTotal: 0,
otherActive: 0,
otherTotal: 0,
};
function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>;
domainRequestsByIP: Map<string, Map<string, number>>;
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
backendMetrics?: Map<string, any>;
protocolCache?: any[];
requestsTotal?: number;
connectionsByIP?: Map<string, number>;
throughputByIP?: Map<string, { in: number; out: number }>;
}) {
const connectionsByIP = args.connectionsByIP || new Map<string, number>();
const throughputByIP = args.throughputByIP || new Map<string, { in: number; out: number }>();
return {
connections: {
active: () => 0,
total: () => 0,
byRoute: () => args.connectionsByRoute,
byIP: () => connectionsByIP,
topIPs: (limit = 10) => Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, count]) => ({ ip, count })),
domainRequestsByIP: () => args.domainRequestsByIP,
topDomainRequests: () => [],
frontendProtocols: () => emptyProtocolDistribution,
backendProtocols: () => emptyProtocolDistribution,
},
throughput: {
instant: () => ({ in: 0, out: 0 }),
recent: () => ({ in: 0, out: 0 }),
average: () => ({ in: 0, out: 0 }),
custom: () => ({ in: 0, out: 0 }),
history: () => [],
byRoute: () => args.throughputByRoute,
byIP: () => throughputByIP,
},
requests: {
perSecond: () => 0,
perMinute: () => 0,
total: () => args.requestsTotal || 0,
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
},
totals: {
bytesIn: () => 0,
bytesOut: () => 0,
connections: () => 0,
},
backends: {
byBackend: () => args.backendMetrics || new Map<string, any>(),
protocols: () => new Map<string, string>(),
topByErrors: () => [],
detectedProtocols: () => args.protocolCache || [],
},
};
}
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map([
['route-id-only', 4],
]),
throughputByRoute: new Map([
['route-id-only', { in: 1200, out: 2400 }],
]),
domainRequestsByIP: new Map([
['192.0.2.10', new Map([
['alpha.example.com', 3],
['beta.example.com', 1],
])],
]),
requestsTotal: 4,
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [
{
id: 'route-id-only',
match: {
ports: [443],
domains: ['alpha.example.com', 'beta.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
},
],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
expect(alpha).toBeDefined();
expect(beta).toBeDefined();
expect(alpha!.requestCount).toEqual(3);
expect(alpha!.routeCount).toEqual(1);
expect(alpha!.activeConnections).toEqual(3);
expect(alpha!.bytesInPerSecond).toEqual(900);
expect(alpha!.bytesOutPerSecond).toEqual(1800);
expect(beta!.requestCount).toEqual(1);
expect(beta!.routeCount).toEqual(1);
expect(beta!.activeConnections).toEqual(1);
expect(beta!.bytesInPerSecond).toEqual(300);
expect(beta!.bytesOutPerSecond).toEqual(600);
});
tap.test('MetricsManager prefers live domain request rates for current activity', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map([
['route-id-only', 10],
]),
throughputByRoute: new Map([
['route-id-only', { in: 1000, out: 1000 }],
]),
domainRequestsByIP: new Map([
['192.0.2.10', new Map([
['alpha.example.com', 1000],
['beta.example.com', 1],
])],
]),
domainRequestRates: new Map([
['alpha.example.com', { perSecond: 0, lastMinute: 0 }],
['beta.example.com', { perSecond: 5, lastMinute: 60 }],
]),
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [
{
id: 'route-id-only',
match: {
ports: [443],
domains: ['alpha.example.com', 'beta.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
},
],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
expect(alpha!.activeConnections).toEqual(0);
expect(alpha!.requestsPerSecond).toEqual(0);
expect(beta!.activeConnections).toEqual(10);
expect(beta!.requestsPerSecond).toEqual(5);
expect(beta!.bytesInPerSecond).toEqual(1000);
});
tap.test('MetricsManager does not duplicate backend active counts onto protocol cache rows', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
backendMetrics: new Map([
['192.0.2.1:443', {
protocol: 'h2',
activeConnections: 257,
totalConnections: 1000,
connectErrors: 1,
handshakeErrors: 2,
requestErrors: 3,
avgConnectTimeMs: 4,
poolHitRate: 0.9,
h2Failures: 5,
}],
]),
protocolCache: [
{
host: '192.0.2.1',
port: 443,
domain: 'alpha.example.com',
protocol: 'h2',
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
ageSecs: 1,
},
{
host: '192.0.2.1',
port: 443,
domain: 'beta.example.com',
protocol: 'h2',
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
ageSecs: 1,
},
],
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443');
const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:'));
expect(aggregate!.activeConnections).toEqual(257);
expect(cacheRows.length).toEqual(2);
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
});
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['1.1.1.1', 2],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['1.1.1.1', { in: 1500, out: 1000 }],
]),
});
const queuedIps: string[][] = [];
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
listIpIntelligence: async () => [],
},
} as any);
await manager.getNetworkStats();
expect(queuedIps).toHaveLength(1);
expect(queuedIps[0]).toContain('8.8.8.8');
expect(queuedIps[0]).toContain('1.1.1.1');
});
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['8.8.4.4', 3],
['1.1.1.1', 5],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['8.8.4.4', { in: 700, out: 350 }],
['1.1.1.1', { in: 2000, out: 1000 }],
]),
});
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: () => undefined,
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
},
} as any);
const stats = await manager.getNetworkStats();
expect(stats.topASNs).toHaveLength(2);
expect(stats.topASNs[0].asn).toEqual(15169);
expect(stats.topASNs[0].organization).toEqual('Google LLC');
expect(stats.topASNs[0].activeConnections).toEqual(7);
expect(stats.topASNs[0].ipCount).toEqual(2);
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
expect(stats.topASNs[1].asn).toEqual(13335);
expect(stats.topASNs[1].activeConnections).toEqual(5);
});
export default tap.start();
+69
View File
@@ -0,0 +1,69 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createMigrationRunner } from '../ts_migrations/index.js';
function setPath(target: Record<string, any>, path: string, value: unknown): void {
const parts = path.split('.');
let cursor = target;
for (const part of parts.slice(0, -1)) {
cursor[part] = cursor[part] || {};
cursor = cursor[part];
}
cursor[parts[parts.length - 1]] = value;
}
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
for (const [key, value] of Object.entries(set)) {
setPath(document, key, value);
}
}
function createFakeDb(currentVersion: string) {
const ledgerDocument = {
nameId: 'smartmigration:smartmigration',
data: {
currentVersion,
steps: {},
lock: { holder: null, acquiredAt: null, expiresAt: null },
checkpoints: {},
},
};
const emptyCollection = {
find: () => ({
async *[Symbol.asyncIterator]() {},
}),
updateMany: async () => ({ modifiedCount: 0 }),
};
const ledgerCollection = {
createIndex: async () => undefined,
findOne: async () => structuredClone(ledgerDocument),
findOneAndUpdate: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return structuredClone(ledgerDocument);
},
updateOne: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
return {
mongoDb: {
collection: (name: string) =>
name === 'SmartdataEasyStore' ? ledgerCollection : emptyCollection,
},
};
}
tap.test('migration runner bridges old package-version targets without real schema steps', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.31.0');
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.31.0');
expect(result.stepsApplied).toHaveLength(3);
});
export default tap.start();
+20
View File
@@ -0,0 +1,20 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { getOciContainerConfig } from '../ts_oci_container/index.js';
tap.test('OCI config should accept explicit DNS bind interface', async () => {
const previousValue = process.env.DCROUTER_DNS_BIND_INTERFACE;
process.env.DCROUTER_DNS_BIND_INTERFACE = '192.168.190.3';
try {
const config = getOciContainerConfig();
expect(config.dnsBindInterface).toEqual('192.168.190.3');
} finally {
if (previousValue === undefined) {
delete process.env.DCROUTER_DNS_BIND_INTERFACE;
} else {
process.env.DCROUTER_DNS_BIND_INTERFACE = previousValue;
}
}
});
export default tap.start();
+126
View File
@@ -0,0 +1,126 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { requireOpsAuth } from '../ts/opsserver/helpers/auth.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const makeIdentity = (role: string = 'user'): interfaces.data.IIdentity => ({
jwt: `jwt-${role}`,
userId: `${role}-user`,
name: role,
expiresAt: Date.now() + 3600000,
role,
});
const makeOpsServer = (options: {
identityRole?: string | null;
tokenScopes?: TScope[];
tokenPolicy?: interfaces.data.IApiTokenPolicy;
}) => {
const token = {
id: 'token-1',
name: 'test-token',
tokenHash: 'hash',
scopes: options.tokenScopes || [],
policy: options.tokenPolicy,
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
createdBy: 'token-user',
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
adminHandler: {
validateIdentity: async (identityArg?: interfaces.data.IIdentity) => {
if (!identityArg || options.identityRole === null) return null;
return { ...identityArg, role: options.identityRole || identityArg.role || 'user' };
},
},
dcRouterRef: {
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: TScope) => {
if (storedTokenArg.policy?.role === 'admin') return true;
return storedTokenArg.scopes.includes('*') || storedTokenArg.scopes.includes(scopeArg) || Boolean(storedTokenArg.policy?.scopes?.includes(scopeArg));
},
},
},
} as any;
};
const getErrorText = (errorArg: unknown) => {
return (errorArg as any).errorText || (errorArg as any).text || (errorArg as Error).message;
};
tap.test('requireOpsAuth accepts valid JWT identity for read endpoints', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'config:read' },
);
expect(auth.type).toEqual('identity');
expect(auth.userId).toEqual('user-user');
expect(auth.isAdmin).toEqual(false);
});
tap.test('requireOpsAuth rejects non-admin JWT identity for admin identity requirements', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'routes:write', requireAdminIdentity: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin identity required');
});
tap.test('requireOpsAuth accepts scoped API tokens', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'logs:read' },
);
expect(auth.type).toEqual('apiToken');
expect(auth.userId).toEqual('token-user');
});
tap.test('requireOpsAuth rejects API tokens without the required scope', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'stats:read' },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('insufficient scope');
});
tap.test('requireOpsAuth requires admin policy for sensitive API-token operations', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['tokens:manage'] }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin API token required');
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenPolicy: { role: 'admin' } }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
expect(auth.isAdmin).toEqual(true);
});
export default tap.start();
+8 -2
View File
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3101,
@@ -25,11 +27,15 @@ tap.test('should login as admin', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
password: testAdminPassword,
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
});
tap.test('should respond to health status request', async () => {
+8 -2
View File
@@ -5,8 +5,10 @@ import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3103,
@@ -25,11 +27,15 @@ tap.test('should login as admin', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin'
password: testAdminPassword
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
console.log('Admin logged in with JWT');
});
+20 -20
View File
@@ -1,13 +1,13 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import type { ISecurityProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Helpers: access private maps for direct unit testing without DB
// ============================================================================
function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void {
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
@@ -15,7 +15,7 @@ function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void
(resolver as any).targets.set(target.id, target);
}
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile {
function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
return {
id: 'profile-1',
name: 'STANDARD',
@@ -72,14 +72,14 @@ tap.test('should list empty profiles and targets initially', async () => {
expect(resolver.listTargets().length).toEqual(0);
});
// ---- Security profile resolution ----
// ---- Source profile resolution ----
tap.test('should resolve security profile onto a route', async () => {
tap.test('should resolve source profile onto a route', async () => {
const profile = makeProfile();
injectProfile(resolver, profile);
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
@@ -87,7 +87,7 @@ tap.test('should resolve security profile onto a route', async () => {
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.maxConnections).toEqual(1000);
expect(result.metadata.securityProfileName).toEqual('STANDARD');
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
@@ -98,7 +98,7 @@ tap.test('should merge inline route security with profile security', async () =>
maxConnections: 5000,
},
});
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
@@ -117,7 +117,7 @@ tap.test('should deduplicate IP lists during merge', async () => {
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
},
});
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
@@ -128,13 +128,13 @@ tap.test('should deduplicate IP lists during merge', async () => {
tap.test('should handle missing profile gracefully', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' };
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
const result = resolver.resolveRoute(route, metadata);
// Route should be unchanged
expect(result.route.security).toBeUndefined();
expect(result.metadata.securityProfileName).toBeUndefined();
expect(result.metadata.sourceProfileName).toBeUndefined();
});
// ---- Profile inheritance ----
@@ -161,7 +161,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
injectProfile(resolver, extendedProfile);
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' };
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
const result = resolver.resolveRoute(route, metadata);
@@ -170,7 +170,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
// maxConnections from base (extended doesn't override)
expect(result.route.security!.maxConnections).toEqual(500);
expect(result.metadata.securityProfileName).toEqual('EXTENDED');
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
});
tap.test('should detect circular profile inheritance', async () => {
@@ -190,7 +190,7 @@ tap.test('should detect circular profile inheritance', async () => {
injectProfile(resolver, profileB);
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' };
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
// Should not infinite loop — resolves what it can
const result = resolver.resolveRoute(route, metadata);
@@ -232,7 +232,7 @@ tap.test('should handle missing target gracefully', async () => {
tap.test('should resolve both profile and target simultaneously', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
securityProfileRef: 'profile-1',
sourceProfileRef: 'profile-1',
networkTargetRef: 'target-1',
};
@@ -247,7 +247,7 @@ tap.test('should resolve both profile and target simultaneously', async () => {
expect(result.route.action.targets![0].port).toEqual(443);
// Both names recorded
expect(result.metadata.securityProfileName).toEqual('STANDARD');
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.metadata.networkTargetName).toEqual('INFRA');
});
@@ -268,7 +268,7 @@ tap.test('should skip resolution when no metadata refs', async () => {
tap.test('should be idempotent — resolving twice gives same result', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
securityProfileRef: 'profile-1',
sourceProfileRef: 'profile-1',
networkTargetRef: 'target-1',
};
@@ -288,7 +288,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
id: 'route-a',
route: makeRoute({ name: 'route-a' }),
enabled: true,
metadata: { securityProfileRef: 'profile-1' },
metadata: { sourceProfileRef: 'profile-1' },
});
storedRoutes.set('route-b', {
id: 'route-b',
@@ -300,7 +300,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
id: 'route-c',
route: makeRoute({ name: 'route-c' }),
enabled: true,
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' },
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
});
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
@@ -320,7 +320,7 @@ tap.test('should get profile usage for a specific profile ID', async () => {
id: 'route-x',
route: makeRoute({ name: 'my-route' }),
enabled: true,
metadata: { securityProfileRef: 'profile-1' },
metadata: { sourceProfileRef: 'profile-1' },
});
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
+200
View File
@@ -0,0 +1,200 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js';
import { SecurityPolicyManager } from '../ts/security/index.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const rule of await SecurityBlockRuleDoc.findAll()) {
await rule.delete();
}
for (const record of await IpIntelligenceDoc.findAll()) {
await record.delete();
}
for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) {
await event.delete();
}
};
const createIntelligenceResult = (asn: number) => ({
asn,
asnOrg: `ASN ${asn}`,
registrantOrg: null,
registrantCountry: null,
networkRange: null,
networkCidrs: null,
abuseContact: null,
country: null,
countryCode: 'US',
city: null,
latitude: null,
longitude: null,
accuracyRadius: null,
timezone: null,
});
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
await manager.createBlockRule({
type: 'cidr',
value: '203.0.113.0 - 203.0.113.255',
reason: 'test range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']);
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall.blockedIps).toEqual(['203.0.113.0/24']);
});
tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.23';
intelligenceDoc.asn = 64500;
intelligenceDoc.asnOrg = 'Example Network';
intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127';
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64500',
reason: 'test asn range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']);
});
tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.130';
intelligenceDoc.asn = 64501;
intelligenceDoc.asnOrg = 'Example Split Network';
intelligenceDoc.networkRange = null;
intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24'];
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64501',
reason: 'test asn cidr array',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']);
});
tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall).toEqual({ blockedIps: [] });
});
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = ipAddress;
intelligenceDoc.asn = asn;
intelligenceDoc.asnOrg = `ASN ${asn}`;
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
}
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
expect(records).toHaveLength(1);
expect(records[0].ipAddress).toEqual('1.1.1.1');
});
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
let releaseFirstLookup!: () => void;
let lookupCount = 0;
(manager as any).smartNetwork = {
getIpIntelligence: async () => {
lookupCount++;
if (lookupCount === 1) {
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
return createIntelligenceResult(64500);
}
return createIntelligenceResult(64501);
},
stop: async () => {},
};
const backgroundObservation = manager.observeIp('8.8.8.8');
await new Promise((resolve) => setTimeout(resolve, 10));
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
releaseFirstLookup();
const record = await forcedRefresh;
await backgroundObservation;
expect(lookupCount).toEqual(2);
expect(record?.asn).toEqual(64501);
});
tap.test('cleanup security policy test db', async () => {
const dbHandle = await testDbPromise;
await clearTestState();
await dbHandle.cleanup();
});
export default tap.start();
@@ -0,0 +1,31 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { SmartMtaStorageManager } from '../ts/email/index.js';
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage');
tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => {
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
const storageManager = new SmartMtaStorageManager(tempDir);
await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata');
await storageManager.set('/email/dkim/example.com/default/public.key', 'public');
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata');
const keys = await storageManager.list('/email/dkim/example.com/');
expect(keys).toEqual([
'/email/dkim/example.com/default/metadata',
'/email/dkim/example.com/default/public.key',
]);
await storageManager.delete('/email/dkim/example.com/default/metadata');
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull();
});
tap.test('cleanup', async () => {
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
await tap.stopForcefully();
});
export default tap.start();
@@ -5,6 +5,7 @@ import * as interfaces from '../ts_interfaces/index.js';
const TEST_PORT = 3200;
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
const TEST_ADMIN_PASSWORD = 'test-admin-password';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
@@ -14,6 +15,7 @@ let adminIdentity: interfaces.data.IIdentity;
// ============================================================================
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
testDcRouter = new DcRouter({
opsServerPort: TEST_PORT,
dbConfig: { enabled: false },
@@ -31,21 +33,25 @@ tap.test('should login as admin', async () => {
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
password: TEST_ADMIN_PASSWORD,
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
});
// ============================================================================
// Security Profile endpoints (graceful fallbacks when resolver unavailable)
// Source Profile endpoints (graceful fallbacks when resolver unavailable)
// ============================================================================
tap.test('should return empty profiles list when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
TEST_URL,
'getSecurityProfiles'
'getSourceProfiles'
);
const response = await req.fire({
@@ -57,9 +63,9 @@ tap.test('should return empty profiles list when resolver not initialized', asyn
});
tap.test('should return null for single profile when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>(
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfile>(
TEST_URL,
'getSecurityProfile'
'getSourceProfile'
);
const response = await req.fire({
@@ -71,9 +77,9 @@ tap.test('should return null for single profile when resolver not initialized',
});
tap.test('should return failure for create profile when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>(
const req = new TypedRequest<interfaces.requests.IReq_CreateSourceProfile>(
TEST_URL,
'createSecurityProfile'
'createSourceProfile'
);
const response = await req.fire({
@@ -87,9 +93,9 @@ tap.test('should return failure for create profile when resolver not initialized
});
tap.test('should return empty profile usage when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>(
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfileUsage>(
TEST_URL,
'getSecurityProfileUsage'
'getSourceProfileUsage'
);
const response = await req.fire({
@@ -170,9 +176,9 @@ tap.test('should return empty target usage when resolver not initialized', async
// ============================================================================
tap.test('should reject unauthenticated profile requests', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
TEST_URL,
'getSecurityProfiles'
'getSourceProfiles'
);
try {
+471
View File
@@ -0,0 +1,471 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js';
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
(manager as any).forwardingModeOverride = undefined;
(manager as any).vpnServer = { running: true };
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(true);
expect(stopCalls).toEqual(1);
expect(startCalls).toEqual(1);
expect((manager as any).resolvedForwardingMode).toEqual('socket');
});
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
const manager = new VpnManager({ forwardingMode: 'hybrid' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(false);
expect(stopCalls).toEqual(0);
expect(startCalls).toEqual(0);
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
});
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
const dcRouter = new DcRouter({
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
vpnConfig: { enabled: false },
});
let stopCalls = 0;
let setupCalls = 0;
let applyCalls = 0;
const resolverValues: Array<unknown> = [];
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientAccessResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
applyCalls++;
},
};
(dcRouter as any).setupVpnServer = async () => {
setupCalls++;
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
};
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
expect(stopCalls).toEqual(1);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(0);
expect(typeof resolverValues.at(-1)).toEqual('function');
await dcRouter.updateVpnConfig({ enabled: false });
expect(stopCalls).toEqual(2);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(1);
expect(resolverValues.at(-1)).toBeUndefined();
expect(dcRouter.vpnManager).toBeUndefined();
});
tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => {
const manager = new RouteConfigManager(() => undefined);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: { ipAllowList: ['*'] },
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['*']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
});
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['client-1'],
);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['*', '203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
});
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['client-1'],
);
const route = {
name: 'shared-private-route',
match: { domains: ['app.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
});
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('*.hagen.team');
expect(accessSpec.domains).toContain('app.hagen.team');
});
tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'blocked-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: {
ipAllowList: ['203.0.113.0/24'],
ipBlockList: ['*'],
},
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual([]);
});
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'public-route',
match: { domains: 'public.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'vpn-only-route',
vpnOnly: true,
match: { domains: 'private.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'source-reachable-app',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.0/24'] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('app.example.com');
});
tap.test('VpnManager normalizes real remote addresses', async () => {
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
});
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
const manager = new VpnManager({});
let sourceIpChangeCalls = 0;
(manager as any).config.onClientSourceIpsChanged = () => {
sourceIpChangeCalls++;
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
]);
(manager as any).vpnServer = {
listClients: async () => ([
{
clientId: 'runtime-client-1',
registeredClientId: 'client-1',
assignedIp: '10.8.0.2',
transportType: 'wireguard',
},
]),
listWgPeers: async () => ([
{
publicKey: 'wg-public-key',
allowedIps: ['10.8.0.2/32'],
endpoint: '198.51.100.44:61234',
bytesSent: 0,
bytesReceived: 0,
packetsSent: 0,
packetsReceived: 0,
},
]),
};
const changed = await manager.refreshClientSourceIps();
const changedAgain = await manager.refreshClientSourceIps();
expect(changed).toEqual(true);
expect(changedAgain).toEqual(false);
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
expect(sourceIpChangeCalls).toEqual(1);
});
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({
serverEndpoint: 'vpn.example.com',
getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'],
});
(manager as any).vpnServer = {
rotateClientKey: async () => ({
entry: {
clientId: 'client-1',
publicKey: 'noise-public-key',
wgPublicKey: 'wg-public-key',
},
wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n',
secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' },
}),
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }],
]);
(manager as any).persistClient = async () => {};
const bundle = await manager.rotateClientKey('client-1');
expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32');
});
export default tap.start()
+175
View File
@@ -0,0 +1,175 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { WorkAppMailManager } from '../ts/email/classes.workapp-mail-manager.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
class MemoryStorageManager {
public store = new Map<string, string>();
public async get(key: string): Promise<string | null> {
return this.store.get(key) || null;
}
public async set(key: string, value: string): Promise<void> {
this.store.set(key, value);
}
}
const createDcRouterStub = () => {
const storageManager = new MemoryStorageManager();
const emailConfig: IUnifiedEmailServerOptions = {
hostname: 'mail.example.com',
ports: [25, 587, 465],
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns',
},
],
routes: [
{
name: 'operator-route',
match: { recipients: 'ops@example.com' },
action: { type: 'reject', reject: { code: 550, message: 'not here' } },
},
],
auth: {
users: [{ username: 'operator', password: 'secret' }],
},
};
const dcRouterRef: any = {
storageManager,
options: { emailConfig },
emailServer: {
updateOptions: (patch: Partial<IUnifiedEmailServerOptions>) => {
dcRouterRef.options.emailConfig = {
...dcRouterRef.options.emailConfig,
...patch,
};
},
},
updateEmailRoutes: async (routes: IUnifiedEmailServerOptions['routes']) => {
dcRouterRef.options.emailConfig.routes = routes;
},
};
return { dcRouterRef, storageManager };
};
tap.test('WorkAppMailManager syncs SMTP identity and inbound smartmta route', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
const createResult = await manager.syncMailIdentity({
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
localPart: 'Hello',
domain: 'Example.com',
inbound: {
enabled: true,
targetHost: '10.0.0.2',
targetPort: 2525,
},
}, 'tester');
expect(createResult.success).toEqual(true);
expect(createResult.action).toEqual('created');
expect(createResult.identity?.address).toEqual('hello@example.com');
expect(createResult.identity?.smtp.username.startsWith('workapp-')).toEqual(true);
expect((createResult.identity as any).smtpPassword).toBeUndefined();
expect(createResult.smtpCredentials?.password.length).toBeGreaterThan(20);
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
expect(generatedRoute.match.recipients).toEqual('hello@example.com');
expect(generatedRoute.action.forward.host).toEqual('10.0.0.2');
expect(generatedRoute.action.forward.port).toEqual(2525);
expect(generatedRoute.action.forward.addHeaders['X-Dcrouter-WorkApp-Id']).toEqual('app-1');
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name === 'operator-route')).toEqual(true);
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
expect(generatedUser.password).toEqual(createResult.smtpCredentials?.password);
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
const listResult = await manager.listMailIdentities({ workAppId: 'app-1' });
expect(listResult.length).toEqual(1);
expect(listResult[0].address).toEqual('hello@example.com');
});
tap.test('WorkAppMailManager updates, resets credentials, and deletes identities', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
const ownership = {
workHosterType: 'onebox' as const,
workHosterId: 'box-1',
workAppId: 'app-1',
};
const createResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
}, 'tester');
const firstPassword = createResult.smtpCredentials!.password;
const updateResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
inbound: { enabled: true, targetHost: '10.0.0.3', targetPort: 2526 },
}, 'tester');
expect(updateResult.action).toEqual('updated');
expect(updateResult.smtpCredentials).toBeUndefined();
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
expect(generatedUser.password).toEqual(firstPassword);
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
expect(generatedRoute.action.forward.host).toEqual('10.0.0.3');
const resetResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
resetSmtpPassword: true,
}, 'tester');
expect(resetResult.smtpCredentials?.password !== firstPassword).toEqual(true);
const deleteResult = await manager.syncMailIdentity({
ownership,
localPart: 'hello',
domain: 'example.com',
delete: true,
}, 'tester');
expect(deleteResult.action).toEqual('deleted');
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username.startsWith('workapp-'))).toEqual(false);
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
});
tap.test('WorkAppMailManager applies persisted identities to startup email config', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
await manager.syncMailIdentity({
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
localPart: 'hello',
domain: 'example.com',
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
}, 'tester');
const baseStartupConfig: IUnifiedEmailServerOptions = {
hostname: 'mail.example.com',
ports: [25],
domains: [{ domain: 'example.com', dnsMode: 'external-dns' }],
routes: [],
};
const startupConfig = await manager.applyStoredIdentitiesToEmailConfig(baseStartupConfig);
expect(startupConfig.routes.some((route) => route.name.startsWith('workapp-mail-'))).toEqual(true);
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
});
export default tap.start();
+565
View File
@@ -0,0 +1,565 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { WorkHosterHandler } from '../ts/opsserver/handlers/workhoster.handler.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeApiTokenManager = (
scopes: TScope[],
policy?: interfaces.data.IApiTokenPolicy,
) => {
const token = {
id: 'token-1',
name: 'workhoster-test-token',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
policy,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
if (storedToken.policy?.role === 'admin') return true;
const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient';
const gatewayClientAllowedScopes = new Set<TScope>([
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
]);
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false;
if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true;
const scopes = new Set(storedToken.scopes);
for (const policyScope of storedToken.policy?.scopes || []) {
scopes.add(policyScope);
}
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'],
'workhosters:read': ['gateway-clients:read'],
'workhosters:write': ['gateway-clients:write'],
};
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
},
};
};
const makeRouteConfigManager = () => {
const routes = new Map<string, interfaces.data.IRoute>();
let nextRouteNumber = 1;
return {
routes,
manager: {
findApiRouteByExternalKey: (externalKey: string) => {
return Array.from(routes.values()).find((route) =>
route.origin === 'api' && route.metadata?.externalKey === externalKey,
);
},
createRoute: async (
route: interfaces.data.IDcRouterRouteConfig,
createdBy: string,
enabled = true,
metadata?: interfaces.data.IRouteMetadata,
) => {
const id = `route-${nextRouteNumber++}`;
routes.set(id, {
id,
route,
enabled,
createdBy,
createdAt: Date.now(),
updatedAt: Date.now(),
origin: 'api',
metadata,
});
return id;
},
updateRoute: async (
id: string,
patch: {
route?: Partial<interfaces.data.IDcRouterRouteConfig>;
enabled?: boolean;
metadata?: Partial<interfaces.data.IRouteMetadata>;
},
) => {
const storedRoute = routes.get(id);
if (!storedRoute) return { success: false, message: 'Route not found' };
if (patch.route) {
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
}
if (patch.enabled !== undefined) {
storedRoute.enabled = patch.enabled;
}
if (patch.metadata) {
storedRoute.metadata = { ...storedRoute.metadata, ...patch.metadata };
}
storedRoute.updatedAt = Date.now();
return { success: true };
},
deleteRoute: async (id: string) => {
const deleted = routes.delete(id);
return deleted ? { success: true } : { success: false, message: 'Route not found' };
},
},
};
};
const setupHandler = (options: {
scopes: TScope[];
policy?: interfaces.data.IApiTokenPolicy;
isAdmin?: boolean;
dcRouterRef?: Record<string, any>;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
? { ...identity, role: 'admin' }
: identity,
adminIdentityGuard: {
exec: async () => Boolean(options.isAdmin),
},
},
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
...options.dcRouterRef,
},
};
new WorkHosterHandler(opsServerRef);
return { typedrouter, opsServerRef };
};
tap.test('WorkHosterHandler exposes capabilities and managed domains with workhosters:read', async () => {
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {
remoteIngressConfig: { enabled: true },
dnsScopes: ['example.com'],
http3: { enabled: false },
},
routeConfigManager: {
getMergedRoutes: () => ({ routes: [] }),
},
smartProxy: {},
emailDomainManager: {},
emailServer: {},
dnsManager: {
listDomains: async () => [
{ id: 'domain-1', name: 'example.com', source: 'dcrouter', authoritative: true },
{ id: 'domain-2', name: 'provider.example', source: 'provider', providerId: 'cloudflare-1', authoritative: false },
],
toPublicDomain: (domainDoc: any) => ({
...domainDoc,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
}),
},
},
});
const capabilitiesResult = await fireTypedRequest(typedrouter, 'getGatewayCapabilities', {
apiToken: 'valid-token',
});
expect(capabilitiesResult.error).toBeUndefined();
expect(capabilitiesResult.response.capabilities.routes.idempotentSync).toEqual(true);
expect(capabilitiesResult.response.capabilities.domains.read).toEqual(true);
expect(capabilitiesResult.response.capabilities.certificates.export).toEqual(true);
expect(capabilitiesResult.response.capabilities.email.inbound).toEqual(true);
expect(capabilitiesResult.response.capabilities.remoteIngress.enabled).toEqual(true);
expect(capabilitiesResult.response.capabilities.dns.authoritative).toEqual(true);
expect(capabilitiesResult.response.capabilities.http3.enabled).toEqual(false);
const domainsResult = await fireTypedRequest(typedrouter, 'getWorkHosterDomains', {
apiToken: 'valid-token',
});
expect(domainsResult.error).toBeUndefined();
expect(domainsResult.response.domains.length).toEqual(2);
expect(domainsResult.response.domains[0].capabilities.canCreateSubdomains).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canManageDnsRecords).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canIssueCertificates).toEqual(true);
expect(domainsResult.response.domains[1].capabilities.canHostEmail).toEqual(true);
});
tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:write', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['workhosters:write'],
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const ownership: interfaces.data.IWorkAppRouteOwnership = {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
hostname: 'app.example.com',
};
const createResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
route: {
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.2', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(createResult.error).toBeUndefined();
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(1);
const createdRoute = routeConfig.routes.get('route-1')!;
expect(createdRoute.createdBy).toEqual('token-user');
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
expect(createdRoute.metadata).toEqual({
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
gatewayClientAppId: 'app-1',
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
});
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
enabled: false,
route: {
name: 'updated-workapp-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.3', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(updateResult.error).toBeUndefined();
expect(updateResult.response).toEqual({ success: true, action: 'updated', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(1);
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
delete: true,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response).toEqual({ success: true, action: 'deleted', routeId: 'route-1' });
expect(routeConfig.routes.size).toEqual(0);
const unchangedResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership,
delete: true,
});
expect(unchangedResult.error).toBeUndefined();
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
});
tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => {
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
hostnamePatterns: ['*.example.com'],
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
capabilities: {
readDomains: true,
readDnsRecords: true,
syncRoutes: true,
},
},
dcRouterRef: { options: {} },
});
const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' });
expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']);
expect(result.response.context.capabilities.syncRoutes).toEqual(true);
});
tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:write'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
hostnamePatterns: ['*.example.com'],
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
capabilities: { syncRoutes: true },
},
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
apiToken: 'valid-token',
ownership: {
appId: 'app-1',
hostname: 'app.example.com',
},
route: {
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.2', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(createResult.error).toBeUndefined();
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy');
expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com');
const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
apiToken: 'valid-token',
ownership: {
gatewayClientType: 'onebox',
gatewayClientId: 'other-box',
appId: 'app-1',
hostname: 'app.example.com',
},
delete: true,
});
expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership');
});
tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => {
const identity: interfaces.data.IIdentity = {
jwt: 'admin-jwt',
userId: 'admin-user',
name: 'admin',
expiresAt: Date.now() + 3600000,
};
const gatewayClient: interfaces.data.IGatewayClient = {
id: 'onebox-main',
type: 'onebox',
name: 'Main Onebox',
hostnamePatterns: ['*.apps.example.com'],
allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }],
capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true },
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'admin-user',
};
let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined;
const { typedrouter } = setupHandler({
scopes: [],
isAdmin: true,
dcRouterRef: {
options: {},
gatewayClientManager: {
listClients: async () => [gatewayClient],
getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null,
},
apiTokenManager: {
listTokens: () => [{
id: 'token-1',
name: 'token',
scopes: ['gateway-clients:read'],
policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } },
createdAt: 1,
expiresAt: null,
lastUsedAt: null,
enabled: true,
}],
createToken: async (
_name: string,
_scopes: TScope[],
_expiresInDays: number | null,
_createdBy: string,
policy?: interfaces.data.IApiTokenPolicy,
) => {
createdTokenPolicy = policy;
return { id: 'new-token', rawToken: 'dcr_created' };
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity });
expect(listResult.error).toBeUndefined();
expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1);
const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', {
identity,
gatewayClientId: 'onebox-main',
});
expect(tokenResult.error).toBeUndefined();
expect(tokenResult.response.tokenValue).toEqual('dcr_created');
expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' });
expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]);
});
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const result = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
hostname: 'app.example.com',
},
delete: true,
});
expect(result.error?.text).toEqual('insufficient scope');
expect(routeConfig.routes.size).toEqual(0);
});
tap.test('WorkHosterHandler exposes and syncs WorkApp mail identities', async () => {
const syncedRequests: Array<{ data: any; userId: string }> = [];
const identity: interfaces.data.IWorkAppMailIdentity = {
id: 'mail-1',
externalKey: 'onebox:box-1:app-1:hello@example.com',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
inbound: {
enabled: true,
targetHost: '10.0.0.2',
targetPort: 2525,
},
smtp: {
enabled: true,
username: 'workapp-user',
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['workhosters:read', 'workhosters:write'],
dcRouterRef: {
options: {},
workAppMailManager: {
listMailIdentities: async (filter: any) => filter.workAppId === 'app-1' ? [identity] : [],
syncMailIdentity: async (data: any, userId: string) => {
syncedRequests.push({ data, userId });
return {
success: true,
action: 'created',
identity,
smtpCredentials: {
username: 'workapp-user',
password: 'generated-password',
},
};
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'getWorkAppMailIdentities', {
apiToken: 'valid-token',
ownership: { workAppId: 'app-1' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.identities).toEqual([identity]);
const syncResult = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
apiToken: 'valid-token',
ownership: identity.ownership,
localPart: 'hello',
domain: 'example.com',
inbound: identity.inbound,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncResult.response.smtpCredentials.password).toEqual('generated-password');
expect(syncedRequests[0].userId).toEqual('token-user');
});
tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write', async () => {
const { typedrouter } = setupHandler({
scopes: ['workhosters:read'],
dcRouterRef: {
options: {},
workAppMailManager: {
syncMailIdentity: async () => ({ success: true }),
},
},
});
const result = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
apiToken: 'valid-token',
ownership: {
workHosterType: 'onebox',
workHosterId: 'box-1',
workAppId: 'app-1',
},
localPart: 'hello',
domain: 'example.com',
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
node --input-type=module <<'NODE'
import fs from 'node:fs';
const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8'));
const checks = {
packageVersion: readJson('/app/package.json').version,
interfacesVersion: readJson('/app/node_modules/@serve.zone/interfaces/package.json').version,
remoteingressVersion: readJson('/app/node_modules/@serve.zone/remoteingress/package.json').version,
hasCli: fs.existsSync('/app/cli.js'),
hasWebBundle: fs.existsSync('/app/dist_serve/bundle.js'),
};
await import('/app/dist_ts/index.js');
if (checks.packageVersion !== '13.25.0') {
throw new Error(`Unexpected dcrouter package version ${checks.packageVersion}`);
}
if (checks.interfacesVersion !== '5.4.6') {
throw new Error(`Unexpected interfaces version ${checks.interfacesVersion}`);
}
if (checks.remoteingressVersion !== '4.17.1') {
throw new Error(`Unexpected remoteingress version ${checks.remoteingressVersion}`);
}
if (!checks.hasCli) {
throw new Error('Missing cli.js');
}
if (!checks.hasWebBundle) {
throw new Error('Missing web bundle');
}
console.log(JSON.stringify(checks));
NODE
+6 -7
View File
@@ -29,13 +29,13 @@ const devRouter = new DcRouter({
name: 'vpn-internal-app',
match: { ports: [18080], domains: ['internal.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
vpn: { enabled: true },
vpnOnly: 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'] },
vpnOnly: true,
},
] as any[],
},
@@ -44,13 +44,12 @@ const devRouter = new DcRouter({
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' },
{ clientId: 'dev-laptop', description: 'Developer laptop' },
{ clientId: 'ci-runner', description: 'CI/CD pipeline' },
{ clientId: 'admin-desktop', description: 'Admin workstation' },
],
},
// Disable db/mongo for dev
dbConfig: { enabled: false },
dbConfig: { enabled: true },
});
console.log('Starting DcRouter in development mode...');
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '12.2.1',
version: '13.38.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+1
View File
@@ -0,0 +1 @@
export * from './manager.acme-config.js';
+182
View File
@@ -0,0 +1,182 @@
import { logger } from '../logger.js';
import { AcmeConfigDoc } from '../db/documents/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
/**
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
*
* Lifecycle:
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
*
* Reload semantics: updates take effect on the next dcrouter restart because
* `SmartAcme` is instantiated once in `setupSmartProxy()`. `renewThresholdDays`
* applies immediately to the next renewal check. See
* `ts_web/elements/domains/ops-view-certificates.ts` for the UI warning.
*/
export class AcmeConfigManager {
private cached: IAcmeConfig | null = null;
constructor(private options: IDcRouterOptions) {}
public async start(): Promise<void> {
logger.log('info', 'AcmeConfigManager: starting');
let doc = await AcmeConfigDoc.load();
if (!doc) {
// First-boot path: seed from legacy constructor fields if present.
const seed = this.deriveSeedFromOptions();
if (seed) {
doc = await this.createSeedDoc(seed);
logger.log(
'info',
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
);
} else {
logger.log(
'info',
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
);
}
} else if (this.deriveSeedFromOptions()) {
logger.log(
'warn',
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
);
}
this.cached = doc ? this.toPlain(doc) : null;
if (this.cached) {
logger.log(
'info',
`AcmeConfigManager: loaded ACME config (accountEmail=${this.cached.accountEmail}, enabled=${this.cached.enabled}, useProduction=${this.cached.useProduction})`,
);
}
}
public async stop(): Promise<void> {
this.cached = null;
}
/**
* Returns the current ACME config, or null if not configured.
* In-memory — does not hit the DB.
*/
public getConfig(): IAcmeConfig | null {
return this.cached;
}
/**
* True if there is an enabled ACME config. Used by `setupSmartProxy()` to
* decide whether to instantiate SmartAcme.
*/
public hasEnabledConfig(): boolean {
return this.cached !== null && this.cached.enabled;
}
/**
* Upsert the ACME config. All fields are optional; missing fields are
* preserved from the existing row (or defaulted if there is no row yet).
*/
public async updateConfig(
args: Partial<Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>>,
updatedBy: string,
): Promise<IAcmeConfig> {
let doc = await AcmeConfigDoc.load();
const now = Date.now();
if (!doc) {
doc = new AcmeConfigDoc();
doc.configId = 'acme-config';
doc.accountEmail = args.accountEmail ?? '';
doc.enabled = args.enabled ?? true;
doc.useProduction = args.useProduction ?? true;
doc.autoRenew = args.autoRenew ?? true;
doc.renewThresholdDays = args.renewThresholdDays ?? 30;
} else {
if (args.accountEmail !== undefined) doc.accountEmail = args.accountEmail;
if (args.enabled !== undefined) doc.enabled = args.enabled;
if (args.useProduction !== undefined) doc.useProduction = args.useProduction;
if (args.autoRenew !== undefined) doc.autoRenew = args.autoRenew;
if (args.renewThresholdDays !== undefined) doc.renewThresholdDays = args.renewThresholdDays;
}
doc.updatedAt = now;
doc.updatedBy = updatedBy;
await doc.save();
this.cached = this.toPlain(doc);
return this.cached;
}
// ==========================================================================
// Internal helpers
// ==========================================================================
/**
* Build a seed object from the legacy constructor fields. Returns null
* if the user has not provided any of them.
*
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
* (full form). `smartProxyConfig.acme` wins when both are present.
*/
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
const acme = this.options.smartProxyConfig?.acme;
const tls = this.options.tls;
// Prefer the explicit smartProxyConfig.acme block if present.
if (acme?.accountEmail) {
return {
accountEmail: acme.accountEmail,
enabled: acme.enabled !== false,
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays ?? 30,
};
}
// Fall back to the short tls.contactEmail form.
if (tls?.contactEmail) {
return {
accountEmail: tls.contactEmail,
enabled: true,
useProduction: true,
autoRenew: true,
renewThresholdDays: 30,
};
}
return null;
}
private async createSeedDoc(
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
): Promise<AcmeConfigDoc> {
const doc = new AcmeConfigDoc();
doc.configId = 'acme-config';
doc.accountEmail = seed.accountEmail;
doc.enabled = seed.enabled;
doc.useProduction = seed.useProduction;
doc.autoRenew = seed.autoRenew;
doc.renewThresholdDays = seed.renewThresholdDays;
doc.updatedAt = Date.now();
doc.updatedBy = 'seed';
await doc.save();
return doc;
}
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
return {
accountEmail: doc.accountEmail,
enabled: doc.enabled,
useProduction: doc.useProduction,
autoRenew: doc.autoRenew,
renewThresholdDays: doc.renewThresholdDays,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
};
}
}
+669 -278
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -29,9 +29,9 @@ export class StorageBackedCertManager implements plugins.smartacme.ICertManager
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
if (!doc) {
doc = new AcmeCertDoc();
doc.id = cert.id;
doc.domainName = cert.domainName;
}
doc.id = cert.id;
doc.created = cert.created;
doc.privateKey = cert.privateKey;
doc.publicKey = cert.publicKey;
+73 -4
View File
@@ -2,12 +2,15 @@ import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { ApiTokenDoc } from '../db/index.js';
import type {
IApiTokenPolicy,
IStoredApiToken,
IApiTokenInfo,
TApiTokenScope,
} from '../../ts_interfaces/data/route-management.js';
const TOKEN_PREFIX_STR = 'dcr_';
const ENV_ADMIN_TOKEN_ID = 'env-admin-token';
const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env';
export class ApiTokenManager {
private tokens = new Map<string, IStoredApiToken>();
@@ -16,6 +19,7 @@ export class ApiTokenManager {
public async initialize(): Promise<void> {
await this.loadTokens();
await this.ensureEnvAdminToken();
if (this.tokens.size > 0) {
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
}
@@ -33,13 +37,14 @@ export class ApiTokenManager {
scopes: TApiTokenScope[],
expiresInDays: number | null,
createdBy: string,
policy?: IApiTokenPolicy,
): 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 tokenHash = this.hashToken(rawToken);
const now = Date.now();
const stored: IStoredApiToken = {
@@ -47,6 +52,7 @@ export class ApiTokenManager {
name,
tokenHash,
scopes,
policy,
createdAt: now,
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
lastUsedAt: null,
@@ -67,7 +73,7 @@ export class ApiTokenManager {
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');
const hash = this.hashToken(rawToken);
for (const stored of this.tokens.values()) {
if (stored.tokenHash === hash) {
@@ -87,7 +93,31 @@ export class ApiTokenManager {
* Check if a token has a specific scope.
*/
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
return token.scopes.includes(scope);
if (token.policy?.role === 'admin') return true;
const isGatewayClientToken = token.policy?.role === 'gatewayClient';
const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
]);
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
return false;
}
if (!isGatewayClientToken && token.scopes.includes('*')) return true;
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
if (scopes.has(scope)) return true;
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'],
'workhosters:read': ['gateway-clients:read'],
'workhosters:write': ['gateway-clients:write'],
};
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
}
/**
@@ -100,6 +130,7 @@ export class ApiTokenManager {
id: stored.id,
name: stored.name,
scopes: stored.scopes,
policy: stored.policy,
createdAt: stored.createdAt,
expiresAt: stored.expiresAt,
lastUsedAt: stored.lastUsedAt,
@@ -134,7 +165,7 @@ export class ApiTokenManager {
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
stored.tokenHash = this.hashToken(rawToken);
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
return { id, rawToken };
@@ -165,6 +196,7 @@ export class ApiTokenManager {
name: doc.name,
tokenHash: doc.tokenHash,
scopes: doc.scopes,
policy: doc.policy,
createdAt: doc.createdAt,
expiresAt: doc.expiresAt,
lastUsedAt: doc.lastUsedAt,
@@ -175,12 +207,48 @@ export class ApiTokenManager {
}
}
private async ensureEnvAdminToken(): Promise<void> {
const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim();
if (!rawToken) return;
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) {
throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`);
}
if (rawToken.length < TOKEN_PREFIX_STR.length + 32) {
throw new Error('DCROUTER_ADMIN_API_TOKEN is too short');
}
const now = Date.now();
const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID);
const stored: IStoredApiToken = {
id: ENV_ADMIN_TOKEN_ID,
name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token',
tokenHash: this.hashToken(rawToken),
scopes: ['*'],
policy: { role: 'admin' },
createdAt: existing?.createdAt || now,
expiresAt: null,
lastUsedAt: existing?.lastUsedAt || null,
createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY,
enabled: true,
};
this.tokens.set(stored.id, stored);
await this.persistToken(stored);
logger.log('info', `Environment admin API token ensured (id: ${stored.id})`);
}
private hashToken(rawToken: string): string {
return plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
}
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.policy = stored.policy;
existing.createdAt = stored.createdAt;
existing.expiresAt = stored.expiresAt;
existing.lastUsedAt = stored.lastUsedAt;
@@ -193,6 +261,7 @@ export class ApiTokenManager {
doc.name = stored.name;
doc.tokenHash = stored.tokenHash;
doc.scopes = stored.scopes;
doc.policy = stored.policy;
doc.createdAt = stored.createdAt;
doc.expiresAt = stored.expiresAt;
doc.lastUsedAt = stored.lastUsedAt;
+117
View File
@@ -0,0 +1,117 @@
import * as plugins from '../plugins.js';
import { GatewayClientDoc } from '../db/index.js';
import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js';
const defaultCapabilities: IGatewayClient['capabilities'] = {
readDomains: true,
readDnsRecords: true,
syncRoutes: true,
syncDnsRecords: false,
requestCertificates: false,
};
export class GatewayClientManager {
public async initialize(): Promise<void> {}
public async listClients(): Promise<IGatewayClient[]> {
const docs = await GatewayClientDoc.findAll();
return docs.map((doc) => this.toPublicClient(doc));
}
public async getClient(id: string): Promise<IGatewayClient | null> {
const doc = await GatewayClientDoc.findById(id);
return doc ? this.toPublicClient(doc) : null;
}
public async createClient(options: {
id?: string;
type: IGatewayClient['type'];
name: string;
description?: string;
hostnamePatterns?: string[];
allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
capabilities?: IGatewayClient['capabilities'];
createdBy: string;
}): Promise<IGatewayClient> {
const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`);
if (!id) {
throw new Error('gateway client id is required');
}
if (await GatewayClientDoc.findById(id)) {
throw new Error('gateway client already exists');
}
const now = Date.now();
const doc = new GatewayClientDoc();
doc.id = id;
doc.type = options.type;
doc.name = options.name.trim();
doc.description = options.description?.trim() || undefined;
doc.hostnamePatterns = this.normalizeStringList(options.hostnamePatterns || []);
doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []);
doc.capabilities = { ...defaultCapabilities, ...(options.capabilities || {}) };
doc.enabled = true;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = options.createdBy;
await doc.save();
return this.toPublicClient(doc);
}
public async updateClient(
id: string,
patch: Partial<Pick<IGatewayClient, 'name' | 'description' | 'hostnamePatterns' | 'allowedRouteTargets' | 'capabilities' | 'enabled'>>,
): Promise<IGatewayClient | null> {
const doc = await GatewayClientDoc.findById(id);
if (!doc) return null;
if (patch.name !== undefined) doc.name = patch.name.trim();
if (patch.description !== undefined) doc.description = patch.description.trim() || undefined;
if (patch.hostnamePatterns !== undefined) doc.hostnamePatterns = this.normalizeStringList(patch.hostnamePatterns);
if (patch.allowedRouteTargets !== undefined) doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(patch.allowedRouteTargets);
if (patch.capabilities !== undefined) doc.capabilities = { ...defaultCapabilities, ...patch.capabilities };
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
doc.updatedAt = Date.now();
await doc.save();
return this.toPublicClient(doc);
}
public async deleteClient(id: string): Promise<boolean> {
const doc = await GatewayClientDoc.findById(id);
if (!doc) return false;
await doc.delete();
return true;
}
private normalizeId(id: string): string {
return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
private normalizeStringList(values: string[]): string[] {
return values.map((value) => value.trim().toLowerCase()).filter(Boolean);
}
private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] {
return targets
.map((target) => ({
host: target.host.trim().toLowerCase(),
ports: target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535),
}))
.filter((target) => target.host && target.ports.length > 0);
}
private toPublicClient(doc: GatewayClientDoc): IGatewayClient {
return {
id: doc.id,
type: doc.type,
name: doc.name,
description: doc.description,
hostnamePatterns: doc.hostnamePatterns || [],
allowedRouteTargets: doc.allowedRouteTargets || [],
capabilities: doc.capabilities || {},
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
}
+50 -49
View File
@@ -1,18 +1,18 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
import type {
ISecurityProfile,
ISourceProfile,
INetworkTarget,
IRouteMetadata,
IStoredRoute,
IRoute,
IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
export class ReferenceResolver {
private profiles = new Map<string, ISecurityProfile>();
private profiles = new Map<string, ISourceProfile>();
private targets = new Map<string, INetworkTarget>();
// =========================================================================
@@ -38,7 +38,7 @@ export class ReferenceResolver {
const id = plugins.uuid.v4();
const now = Date.now();
const profile: ISecurityProfile = {
const profile: ISourceProfile = {
id,
name: data.name,
description: data.description,
@@ -51,17 +51,17 @@ export class ReferenceResolver {
this.profiles.set(id, profile);
await this.persistProfile(profile);
logger.log('info', `Created security profile '${profile.name}' (${id})`);
logger.log('info', `Created source profile '${profile.name}' (${id})`);
return id;
}
public async updateProfile(
id: string,
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<{ affectedRouteIds: string[] }> {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Security profile '${id}' not found`);
throw new Error(`Source profile '${id}' not found`);
}
if (patch.name !== undefined) profile.name = patch.name;
@@ -71,7 +71,7 @@ export class ReferenceResolver {
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
logger.log('info', `Updated source profile '${profile.name}' (${id})`);
// Find routes referencing this profile
const affectedRouteIds = await this.findRoutesByProfileRef(id);
@@ -81,11 +81,11 @@ export class ReferenceResolver {
public async deleteProfile(
id: string,
force: boolean,
storedRoutes?: Map<string, IStoredRoute>,
storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id);
if (!profile) {
return { success: false, message: `Security profile '${id}' not found` };
return { success: false, message: `Source profile '${id}' not found` };
}
// Check usage
@@ -101,7 +101,7 @@ export class ReferenceResolver {
}
// Delete from DB
const doc = await SecurityProfileDoc.findById(id);
const doc = await SourceProfileDoc.findById(id);
if (doc) await doc.delete();
this.profiles.delete(id);
@@ -110,34 +110,34 @@ export class ReferenceResolver {
await this.clearProfileRefsOnRoutes(affectedIds);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted security profile '${profile.name}' (${id})`);
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
}
return { success: true };
}
public getProfile(id: string): ISecurityProfile | undefined {
public getProfile(id: string): ISourceProfile | undefined {
return this.profiles.get(id);
}
public getProfileByName(name: string): ISecurityProfile | undefined {
public getProfileByName(name: string): ISourceProfile | undefined {
for (const profile of this.profiles.values()) {
if (profile.name === name) return profile;
}
return undefined;
}
public listProfiles(): ISecurityProfile[] {
public listProfiles(): ISourceProfile[] {
return [...this.profiles.values()];
}
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) {
usage.set(profile.id, []);
}
for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.securityProfileRef;
const ref = stored.metadata?.sourceProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
@@ -147,11 +147,11 @@ export class ReferenceResolver {
public getProfileUsageForId(
profileId: string,
storedRoutes: Map<string, IStoredRoute>,
storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
if (stored.metadata?.sourceProfileRef === profileId) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
@@ -214,7 +214,7 @@ export class ReferenceResolver {
public async deleteTarget(
id: string,
force: boolean,
storedRoutes?: Map<string, IStoredRoute>,
storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> {
const target = this.targets.get(id);
if (!target) {
@@ -263,7 +263,7 @@ export class ReferenceResolver {
public getTargetUsageForId(
targetId: string,
storedRoutes: Map<string, IStoredRoute>,
storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
@@ -280,7 +280,7 @@ export class ReferenceResolver {
/**
* Resolve references for a single route.
* Materializes security profile and/or network target into the route's fields.
* Materializes source profile and/or network target into the route's fields.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
@@ -289,33 +289,34 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.securityProfileRef) {
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
if (resolvedMetadata.sourceProfileRef) {
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
// Merge: profile provides base, route's inline values override
route = {
...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security),
};
resolvedMetadata.securityProfileName = profile?.name;
resolvedMetadata.sourceProfileName = profile?.name;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
}
}
if (resolvedMetadata.networkTargetRef) {
const target = this.targets.get(resolvedMetadata.networkTargetRef);
if (target) {
const hosts = Array.isArray(target.host) ? target.host : [target.host];
route = {
...route,
action: {
...route.action,
targets: [{
host: target.host as string,
targets: hosts.map((h) => ({
host: h,
port: target.port,
}],
})),
},
};
resolvedMetadata.networkTargetName = target.name;
@@ -333,30 +334,30 @@ export class ReferenceResolver {
// =========================================================================
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll();
const docs = await RouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.map((doc) => doc.id);
}
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll();
const docs = await RouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
.map((doc) => doc.id);
}
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
if (stored.metadata?.sourceProfileRef === profileId) {
ids.push(routeId);
}
}
return ids;
}
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) {
@@ -367,10 +368,10 @@ export class ReferenceResolver {
}
// =========================================================================
// Private: security profile resolution with inheritance
// Private: source profile resolution with inheritance
// =========================================================================
private resolveSecurityProfile(
private resolveSourceProfile(
profileId: string,
visited: Set<string> = new Set(),
depth: number = 0,
@@ -396,7 +397,7 @@ export class ReferenceResolver {
// Resolve parent profiles first (top-down, later overrides earlier)
if (profile.extendsProfiles?.length) {
for (const parentId of profile.extendsProfiles) {
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1);
const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
if (parentSecurity) {
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
}
@@ -453,7 +454,7 @@ export class ReferenceResolver {
// =========================================================================
private async loadProfiles(): Promise<void> {
const docs = await SecurityProfileDoc.findAll();
const docs = await SourceProfileDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.profiles.set(doc.id, {
@@ -469,7 +470,7 @@ export class ReferenceResolver {
}
}
if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
}
}
@@ -494,8 +495,8 @@ export class ReferenceResolver {
}
}
private async persistProfile(profile: ISecurityProfile): Promise<void> {
const existingDoc = await SecurityProfileDoc.findById(profile.id);
private async persistProfile(profile: ISourceProfile): Promise<void> {
const existingDoc = await SourceProfileDoc.findById(profile.id);
if (existingDoc) {
existingDoc.name = profile.name;
existingDoc.description = profile.description;
@@ -504,7 +505,7 @@ export class ReferenceResolver {
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
const doc = new SecurityProfileDoc();
const doc = new SourceProfileDoc();
doc.id = profile.id;
doc.name = profile.name;
doc.description = profile.description;
@@ -546,12 +547,12 @@ export class ReferenceResolver {
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId);
const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
securityProfileRef: undefined,
securityProfileName: undefined,
sourceProfileRef: undefined,
sourceProfileName: undefined,
};
doc.updatedAt = Date.now();
await doc.save();
@@ -561,7 +562,7 @@ export class ReferenceResolver {
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId);
const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
+455 -200
View File
@@ -1,9 +1,8 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
import { RouteDoc } from '../db/index.js';
import type {
IStoredRoute,
IRouteOverride,
IRoute,
IMergedRoute,
IRouteWarning,
IRouteMetadata,
@@ -12,65 +11,108 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
export interface IRouteMutationResult {
success: boolean;
message?: string;
}
/**
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
*/
class RouteUpdateMutex {
private locked = false;
private queue: Array<() => void> = [];
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
await new Promise<void>((resolve) => {
if (!this.locked) {
this.locked = true;
resolve();
} else {
this.queue.push(resolve);
}
});
try {
return await fn();
} finally {
this.locked = false;
const next = this.queue.shift();
if (next) {
this.locked = true;
next();
}
}
}
}
export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>();
private overrides = new Map<string, IRouteOverride>();
private routes = new Map<string, IRoute>();
private warnings: IRouteWarning[] = [];
private routeUpdateMutex = new RouteUpdateMutex();
constructor(
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
) {}
/** Expose stored routes map for reference resolution lookups. */
public getStoredRoutes(): Map<string, IStoredRoute> {
return this.storedRoutes;
/** Expose routes map for reference resolution lookups. */
public getRoutes(): Map<string, IRoute> {
return this.routes;
}
public getRoute(id: string): IRoute | undefined {
return this.routes.get(id);
}
public setVpnClientAccessResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
): void {
this.getVpnClientAccessForRoute = resolver;
}
/**
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
* Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
*/
public async initialize(): Promise<void> {
await this.loadStoredRoutes();
await this.loadOverrides();
public async initialize(
configRoutes: IDcRouterRouteConfig[] = [],
emailRoutes: IDcRouterRouteConfig[] = [],
dnsRoutes: IDcRouterRouteConfig[] = [],
): Promise<void> {
await this.loadRoutes();
await this.seedRoutes(configRoutes, 'config');
await this.seedRoutes(emailRoutes, 'email');
await this.seedRoutes(dnsRoutes, 'dns');
this.computeWarnings();
this.logWarnings();
await this.applyRoutes();
}
// =========================================================================
// Merged view
// Route listing
// =========================================================================
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);
for (const route of this.routes.values()) {
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,
metadata: stored.metadata,
route: route.route,
id: route.id,
enabled: route.enabled,
origin: route.origin,
systemKey: route.systemKey,
createdAt: route.createdAt,
updatedAt: route.updatedAt,
metadata: route.metadata,
});
}
@@ -78,11 +120,11 @@ export class RouteConfigManager {
}
// =========================================================================
// Programmatic route CRUD
// Route CRUD
// =========================================================================
public async createRoute(
route: plugins.smartproxy.IRouteConfig,
route: IDcRouterRouteConfig,
createdBy: string,
enabled = true,
metadata?: IRouteMetadata,
@@ -92,28 +134,29 @@ export class RouteConfigManager {
// Ensure route has a name
if (!route.name) {
route.name = `programmatic-${id.slice(0, 8)}`;
route.name = `route-${id.slice(0, 8)}`;
}
// Resolve references if metadata has refs and resolver is available
let resolvedMetadata = metadata;
if (metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, metadata);
let resolvedMetadata = this.normalizeRouteMetadata(metadata);
if (resolvedMetadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
route = resolved.route;
resolvedMetadata = resolved.metadata;
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
}
const stored: IStoredRoute = {
const stored: IRoute = {
id,
route,
enabled,
createdAt: now,
updatedAt: now,
createdBy,
origin: 'api',
metadata: resolvedMetadata,
};
this.storedRoutes.set(id, stored);
this.routes.set(id, stored);
await this.persistRoute(stored);
await this.applyRoutes();
return id;
@@ -122,187 +165,353 @@ export class RouteConfigManager {
public async updateRoute(
id: string,
patch: {
route?: Partial<plugins.smartproxy.IRouteConfig>;
route?: Partial<IDcRouterRouteConfig>;
enabled?: boolean;
metadata?: Partial<IRouteMetadata>;
},
): Promise<boolean> {
const stored = this.storedRoutes.get(id);
if (!stored) return false;
): Promise<IRouteMutationResult> {
const stored = this.routes.get(id);
if (!stored) {
return { success: false, message: 'Route not found' };
}
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
&& patch.metadata === undefined;
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
return {
success: false,
message: 'System routes are managed by the system and can only be toggled',
};
}
if (patch.route) {
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
const mergedAction = patch.route.action
? { ...stored.route.action, ...patch.route.action }
: stored.route.action;
// Handle explicit null to remove nested action properties (e.g., tls: null)
if (patch.route.action) {
for (const [key, val] of Object.entries(patch.route.action)) {
if (val === null) {
delete (mergedAction as any)[key];
}
}
}
const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
// Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null)
for (const [key, val] of Object.entries(patch.route)) {
if (val === null && key !== 'action' && key !== 'match') {
delete (mergedRoute as any)[key];
}
}
stored.route = mergedRoute;
}
if (patch.enabled !== undefined) {
stored.enabled = patch.enabled;
}
if (patch.metadata !== undefined) {
stored.metadata = { ...stored.metadata, ...patch.metadata };
stored.metadata = this.normalizeRouteMetadata({
...stored.metadata,
...patch.metadata,
});
}
// Re-resolve if metadata refs exist and resolver is available
if (stored.metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
await this.applyRoutes();
return true;
return { success: 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);
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
const stored = this.routes.get(id);
if (!stored) {
return { success: false, message: 'Route not found' };
}
if (stored.origin !== 'api') {
return {
success: false,
message: 'System routes are managed by the system and cannot be deleted',
};
}
this.routes.delete(id);
const doc = await RouteDoc.findById(id);
if (doc) await doc.delete();
await this.applyRoutes();
return true;
return { success: true };
}
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
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();
public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
for (const route of this.routes.values()) {
if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
return route;
}
}
this.computeWarnings();
await this.applyRoutes();
return undefined;
}
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: seed routes from constructor config
// =========================================================================
/**
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
* Deletes stale DB routes whose origin matches but name is not in the seed set.
*/
private async seedRoutes(
seedRoutes: IDcRouterRouteConfig[],
origin: 'config' | 'email' | 'dns',
): Promise<void> {
const seedSystemKeys = new Set<string>();
const seedNames = new Set<string>();
let seeded = 0;
let updated = 0;
for (const route of seedRoutes) {
const name = route.name || '';
if (name) {
seedNames.add(name);
}
const systemKey = this.buildSystemRouteKey(origin, route);
if (systemKey) {
seedSystemKeys.add(systemKey);
}
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
if (existingId) {
// Update route config but preserve enabled state
const existing = this.routes.get(existingId)!;
existing.route = route;
existing.systemKey = systemKey;
existing.updatedAt = Date.now();
await this.persistRoute(existing);
updated++;
} else {
// Insert new seed route
const id = plugins.uuid.v4();
const now = Date.now();
const newRoute: IRoute = {
id,
route,
enabled: true,
createdAt: now,
updatedAt: now,
createdBy: 'system',
origin,
systemKey,
};
this.routes.set(id, newRoute);
await this.persistRoute(newRoute);
seeded++;
}
}
// Delete stale routes: same origin but name not in current seed set
const staleIds: string[] = [];
for (const [id, r] of this.routes) {
if (r.origin !== origin) continue;
const routeName = r.route.name || '';
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
if (!matchesSeedSystemKey && !matchesSeedName) {
staleIds.push(id);
}
}
for (const id of staleIds) {
this.routes.delete(id);
const doc = await RouteDoc.findById(id);
if (doc) await doc.delete();
}
if (seeded > 0 || updated > 0 || staleIds.length > 0) {
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
}
}
// =========================================================================
// 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,
metadata: doc.metadata,
});
private buildSystemRouteKey(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
): string | undefined {
const name = route.name?.trim();
if (!name) return undefined;
return `${origin}:${name}`;
}
private findExistingSeedRouteId(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
systemKey?: string,
): string | undefined {
const routeName = route.name || '';
for (const [id, storedRoute] of this.routes) {
if (storedRoute.origin !== origin) continue;
if (systemKey && storedRoute.systemKey === systemKey) {
return id;
}
if (storedRoute.route.name === routeName) {
return id;
}
}
if (this.storedRoutes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
return undefined;
}
private async loadRoutes(): Promise<void> {
const docs = await RouteDoc.findAll();
for (const doc of docs) {
if (!doc.id) continue;
const storedRoute: IRoute = {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
systemKey: doc.systemKey,
metadata: this.normalizeRouteMetadata(doc.metadata),
};
this.routes.set(doc.id, storedRoute);
}
if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
}
}
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);
private async persistRoute(stored: IRoute): Promise<void> {
const existingDoc = await RouteDoc.findById(stored.id);
if (existingDoc) {
existingDoc.route = stored.route;
existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin;
existingDoc.systemKey = stored.systemKey;
existingDoc.metadata = stored.metadata;
await existingDoc.save();
} else {
const doc = new StoredRouteDoc();
const doc = new RouteDoc();
doc.id = stored.id;
doc.route = stored.route;
doc.enabled = stored.enabled;
doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy;
doc.origin = stored.origin;
doc.systemKey = stored.systemKey;
doc.metadata = stored.metadata;
await doc.save();
}
}
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
if (!metadata) {
return undefined;
}
const normalizeString = (value?: string): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
networkTargetRef: normalizeString(metadata.networkTargetRef),
sourceProfileName: normalizeString(metadata.sourceProfileName),
networkTargetName: normalizeString(metadata.networkTargetName),
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
? metadata.lastResolvedAt
: undefined,
ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
? metadata.ownerType
: undefined,
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
? metadata.gatewayClientType
: metadata.workHosterType,
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
? metadata.workHosterType
: metadata.gatewayClientType,
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
externalKey: normalizeString(metadata.externalKey),
};
if (!normalized.sourceProfileRef) {
normalized.sourceProfileName = undefined;
}
if (!normalized.networkTargetRef) {
normalized.networkTargetName = undefined;
}
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
normalized.gatewayClientType = undefined;
normalized.gatewayClientId = undefined;
normalized.gatewayClientAppId = undefined;
normalized.workHosterType = undefined;
normalized.workHosterId = undefined;
normalized.workAppId = undefined;
normalized.externalKey = undefined;
} else {
normalized.ownerType = 'gatewayClient';
normalized.workHosterType = normalized.gatewayClientType;
normalized.workHosterId = normalized.gatewayClientId;
normalized.workAppId = normalized.gatewayClientAppId;
}
if (Object.values(normalized).every((value) => value === undefined)) {
return undefined;
}
return normalized;
}
// =========================================================================
// 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)) {
for (const route of this.routes.values()) {
if (!route.enabled) {
const name = route.route.name || route.id;
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',
type: 'disabled-route',
routeName: name,
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
message: `Route '${name}' (id: ${route.id}) is disabled`,
});
}
}
@@ -326,12 +535,12 @@ export class RouteConfigManager {
if (!this.referenceResolver || routeIds.length === 0) return;
for (const routeId of routeIds) {
const stored = this.storedRoutes.get(routeId);
const stored = this.routes.get(routeId);
if (!stored?.metadata) continue;
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
stored.updatedAt = Date.now();
await this.persistRoute(stored);
}
@@ -341,58 +550,104 @@ export class RouteConfigManager {
}
// =========================================================================
// Private: apply merged routes to SmartProxy
// Apply routes to SmartProxy
// =========================================================================
public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
await this.routeUpdateMutex.runExclusive(async () => {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
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 });
// Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
enabledRoutes.push(this.prepareStoredRouteForApply(route));
}
enabledRoutes.push(injectVpn(route));
}
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
await smartProxy.updateRoutes(enabledRoutes);
// Notify listeners (e.g. RemoteIngressManager) of the route set
if (this.onRoutesApplied) {
await this.onRoutesApplied(enabledRoutes);
}
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
});
}
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
}
private prepareRouteForApply(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
let preparedRoute = route;
const http3Config = this.getHttp3Config?.();
if (http3Config?.enabled !== false) {
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
}
await smartProxy.updateRoutes(enabledRoutes);
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
return this.injectVpnSecurity(preparedRoute, routeId);
}
private injectVpnSecurity(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const dcRoute = route as IDcRouterRouteConfig;
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
return route;
}
const existingVpnSecurity = route.security?.vpn || {};
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
existingVpnSecurity.allowedClients || [],
vpnEntries,
);
return {
...route,
security: {
...route.security,
vpn: {
...existingVpnSecurity,
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
allowedClients: mergedAllowedClients,
},
},
};
}
private mergeVpnClientAllowEntries(
existingEntries: TVpnClientAllowEntry[],
vpnEntries: TVpnClientAllowEntry[],
): TVpnClientAllowEntry[] {
const merged: TVpnClientAllowEntry[] = [];
const seen = new Set<string>();
for (const entry of [...existingEntries, ...vpnEntries]) {
const key = typeof entry === 'string'
? `client:${entry}`
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
return merged;
}
}
+575
View File
@@ -0,0 +1,575 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
/**
* Manages TargetProfiles (target-side: what can be accessed).
* TargetProfiles define what resources a VPN client can reach:
* domains, specific IP:port targets, and/or direct route references.
*/
export class TargetProfileManager {
private profiles = new Map<string, ITargetProfile>();
constructor(
private getAllRoutes?: () => Map<string, IRoute>,
) {}
// =========================================================================
// Lifecycle
// =========================================================================
public async initialize(): Promise<void> {
await this.loadProfiles();
}
// =========================================================================
// CRUD
// =========================================================================
public async createProfile(data: {
name: string;
description?: string;
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
for (const existing of this.profiles.values()) {
if (existing.name === data.name) {
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
}
}
const id = plugins.uuid.v4();
const now = Date.now();
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
const profile: ITargetProfile = {
id,
name: data.name,
description: data.description,
domains: data.domains,
targets: data.targets,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
};
this.profiles.set(id, profile);
await this.persistProfile(profile);
logger.log('info', `Created target profile '${profile.name}' (${id})`);
return id;
}
public async updateProfile(
id: string,
patch: Partial<Omit<ITargetProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<void> {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Target profile '${id}' not found`);
}
if (patch.name !== undefined && patch.name !== profile.name) {
for (const existing of this.profiles.values()) {
if (existing.id !== id && existing.name === patch.name) {
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
}
}
}
if (patch.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description;
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
if (patch.allowRoutesByClientSourceIp !== undefined) {
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
}
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Updated target profile '${profile.name}' (${id})`);
}
public async deleteProfile(
id: string,
force?: boolean,
): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id);
if (!profile) {
return { success: false, message: `Target profile '${id}' not found` };
}
// Check if any VPN clients reference this profile
const clients = await VpnClientDoc.findAll();
const referencingClients = clients.filter(
(c) => c.targetProfileIds?.includes(id),
);
if (referencingClients.length > 0 && !force) {
return {
success: false,
message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`,
};
}
// Delete from DB
const doc = await TargetProfileDoc.findById(id);
if (doc) await doc.delete();
this.profiles.delete(id);
if (referencingClients.length > 0) {
// Remove profile ref from clients
for (const client of referencingClients) {
client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id);
client.updatedAt = Date.now();
await client.save();
}
logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`);
} else {
logger.log('info', `Deleted target profile '${profile.name}' (${id})`);
}
return { success: true };
}
public getProfile(id: string): ITargetProfile | undefined {
return this.profiles.get(id);
}
/**
* Normalize stored route references to route IDs when they can be resolved
* uniquely against the current route registry.
*/
public async normalizeAllRouteRefs(): Promise<void> {
const allRoutes = this.getAllRoutes?.();
if (!allRoutes?.size) return;
for (const profile of this.profiles.values()) {
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
profile.routeRefs,
allRoutes,
'bestEffort',
);
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
profile.routeRefs = normalizedRouteRefs;
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
}
}
public listProfiles(): ITargetProfile[] {
return [...this.profiles.values()];
}
/**
* Get which VPN clients reference a target profile.
*/
public async getProfileUsage(profileId: string): Promise<Array<{ clientId: string; description?: string }>> {
const clients = await VpnClientDoc.findAll();
return clients
.filter((c) => c.targetProfileIds?.includes(profileId))
.map((c) => ({ clientId: c.clientId, description: c.description }));
}
// =========================================================================
// Direct target IPs (bypass SmartProxy)
// =========================================================================
/**
* For a set of target profile IDs, collect all explicit target IPs.
* These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
* connect to them directly through the tunnel.
*/
public getDirectTargetIps(targetProfileIds: string[]): string[] {
const ips = new Set<string>();
for (const profileId of targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile?.targets?.length) continue;
for (const t of profile.targets) {
ips.add(t.ip);
}
}
return [...ips];
}
// =========================================================================
// Core matching: route → VPN client grants
// =========================================================================
/**
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
*
* Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains. Profiles can also opt
* into source-policy routes; SmartProxy evaluates the real source IP per connection.
*/
public getMatchingVpnClients(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(),
): TVpnClientAllowEntry[] {
const entries: TVpnClientAllowEntry[] = [];
const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
if (!client.enabled || !client.clientId) continue;
if (!client.targetProfileIds?.length) continue;
// Collect scoped domains from all matching profiles for this client
let fullAccess = false;
const scopedDomains = new Set<string>();
for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
if (matchResult === 'full') {
fullAccess = true;
break; // No need to check more profiles
}
if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d);
}
if (
profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(route)
) {
fullAccess = true;
break;
}
}
if (fullAccess) {
entries.push(client.clientId);
} else if (scopedDomains.size > 0) {
entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
}
}
return entries;
}
/**
* For a given client (by its targetProfileIds), compute the set of
* domains and target IPs it can access. Used for WireGuard AllowedIPs.
*/
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: Map<string, IRoute>,
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
// Collect all access specifiers from assigned profiles
for (const profileId of targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
// Direct domain entries
if (profile.domains?.length) {
for (const d of profile.domains) {
domains.add(d);
}
}
// Direct target IP entries
if (profile.targets?.length) {
for (const t of profile.targets) {
targetIps.add(t.ip);
}
}
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
const dcRoute = route.route as IDcRouterRouteConfig;
const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId,
profile,
routeNameIndex,
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(dcRoute);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
}
return {
domains: [...domains],
targetIps: [...targetIps],
};
}
// =========================================================================
// Private: matching logic
// =========================================================================
/**
* Check if a route matches a profile (boolean convenience wrapper).
*/
private routeMatchesProfile(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
routeNameIndex: Map<string, string[]>,
): boolean {
const routeDomains = this.getRouteDomains(route);
const result = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
return result !== 'none';
}
/**
* Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
* or 'none' (no match).
*
* - routeRefs / target matches → 'full' (explicit reference = full access)
* - domain match where profile domains are a subset of route wildcard → 'scoped'
* - domain match where domains are identical or profile is a wildcard → 'full'
*/
private routeMatchesProfileDetailed(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
routeDomains: string[],
routeNameIndex: Map<string, string[]>,
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
// 1. Route reference match → full access
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
const matchingRouteIds = routeNameIndex.get(route.name) || [];
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
return 'full';
}
}
}
// 2. Domain match
if (profile.domains?.length && routeDomains.length) {
const matchedProfileDomains: string[] = [];
for (const profileDomain of profile.domains) {
for (const routeDomain of routeDomains) {
if (this.domainMatchesPattern(routeDomain, profileDomain) ||
this.domainMatchesPattern(profileDomain, routeDomain)) {
matchedProfileDomains.push(profileDomain);
break; // This profileDomain matched, move to the next
}
}
}
if (matchedProfileDomains.length > 0) {
// Check if profile domains cover the route entirely (same wildcards = full access)
const isFullCoverage = routeDomains.every((rd) =>
matchedProfileDomains.some((pd) =>
rd === pd || this.domainMatchesPattern(rd, pd),
),
);
if (isFullCoverage) return 'full';
// Profile domains are a subset → scoped access to those specific domains
return { type: 'scoped', domains: matchedProfileDomains };
}
}
// 3. Target match (host + port) → full access (precise by nature)
if (profile.targets?.length) {
const routeTargets = (route.action as any)?.targets;
if (Array.isArray(routeTargets)) {
for (const profileTarget of profile.targets) {
for (const routeTarget of routeTargets) {
const routeHost = routeTarget.host;
const routePort = routeTarget.port;
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
return 'full';
}
}
}
}
}
return 'none';
}
/**
* Check if a domain matches a pattern.
* - '*.example.com' matches 'sub.example.com', 'a.b.example.com'
* - 'example.com' matches only 'example.com'
*/
private domainMatchesPattern(domain: string, pattern: string): boolean {
if (pattern === domain) return true;
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1); // '.example.com'
return domain.endsWith(suffix) && domain.length > suffix.length;
}
return false;
}
private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
const security = (route as any).security;
const blockEntries = Array.isArray(security?.ipBlockList)
? security.ipBlockList
: security?.ipBlockList
? [security.ipBlockList]
: [];
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
}
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains;
if (!domains) return [];
return Array.isArray(domains) ? domains : [domains];
}
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
}
private normalizeRouteRefsAgainstRoutes(
routeRefs: string[] | undefined,
allRoutes: Map<string, IRoute>,
mode: 'strict' | 'bestEffort',
): string[] | undefined {
if (!routeRefs?.length) return undefined;
if (!allRoutes.size) return [...new Set(routeRefs)];
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
const normalizedRefs = new Set<string>();
for (const routeRef of routeRefs) {
if (allRoutes.has(routeRef)) {
normalizedRefs.add(routeRef);
continue;
}
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
if (matchingRouteIds.length === 1) {
normalizedRefs.add(matchingRouteIds[0]);
continue;
}
if (mode === 'bestEffort') {
normalizedRefs.add(routeRef);
continue;
}
if (matchingRouteIds.length > 1) {
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
}
throw new Error(`Route reference '${routeRef}' not found`);
}
return [...normalizedRefs];
}
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
const routeNameIndex = new Map<string, string[]>();
for (const [routeId, route] of allRoutes) {
const routeName = route.route.name;
if (!routeName) continue;
const matchingRouteIds = routeNameIndex.get(routeName) || [];
matchingRouteIds.push(routeId);
routeNameIndex.set(routeName, matchingRouteIds);
}
return routeNameIndex;
}
private sameStringArray(left?: string[], right?: string[]): boolean {
if (!left?.length && !right?.length) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadProfiles(): Promise<void> {
const docs = await TargetProfileDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.profiles.set(doc.id, {
id: doc.id,
name: doc.name,
description: doc.description,
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
}
}
private async persistProfile(profile: ITargetProfile): Promise<void> {
const existingDoc = await TargetProfileDoc.findById(profile.id);
if (existingDoc) {
existingDoc.name = profile.name;
existingDoc.description = profile.description;
existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs;
existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
const doc = new TargetProfileDoc();
doc.id = profile.id;
doc.name = profile.name;
doc.description = profile.description;
doc.domains = profile.domains;
doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs;
doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
await doc.save();
}
}
}
+3 -1
View File
@@ -2,5 +2,7 @@
export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { GatewayClientManager } from './classes.gateway-client-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { DbSeeder } from './classes.db-seeder.js';
export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
@@ -0,0 +1,49 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
/**
* Singleton ACME configuration document. One row per dcrouter instance,
* keyed on the fixed `configId = 'acme-config'` following the
* `VpnServerKeysDoc` pattern.
*
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
* constructor fields. Managed via the OpsServer UI at
* **Domains > Certificates > Settings**.
*/
@plugins.smartdata.Collection(() => getDb())
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public configId: string = 'acme-config';
@plugins.smartdata.svDb()
public accountEmail: string = '';
@plugins.smartdata.svDb()
public enabled: boolean = true;
@plugins.smartdata.svDb()
public useProduction: boolean = true;
@plugins.smartdata.svDb()
public autoRenew: boolean = true;
@plugins.smartdata.svDb()
public renewThresholdDays: number = 30;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<AcmeConfigDoc | null> {
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
}
}
+4 -1
View File
@@ -1,6 +1,6 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
import type { IApiTokenPolicy, TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@@ -19,6 +19,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, A
@plugins.smartdata.svDb()
public scopes!: TApiTokenScope[];
@plugins.smartdata.svDb()
public policy?: IApiTokenPolicy;
@plugins.smartdata.svDb()
public createdAt!: number;
@@ -0,0 +1,63 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type {
TDnsProviderType,
TDnsProviderStatus,
TDnsProviderCredentials,
} from '../../../ts_interfaces/data/dns-provider.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public type!: TDnsProviderType;
/**
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
*/
@plugins.smartdata.svDb()
public credentials!: TDnsProviderCredentials;
@plugins.smartdata.svDb()
public status: TDnsProviderStatus = 'untested';
@plugins.smartdata.svDb()
public lastTestedAt?: number;
@plugins.smartdata.svDb()
public lastError?: string;
@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<DnsProviderDoc | null> {
return await DnsProviderDoc.getInstance({ id });
}
public static async findAll(): Promise<DnsProviderDoc[]> {
return await DnsProviderDoc.getInstances({});
}
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
return await DnsProviderDoc.getInstances({ type });
}
}
+62
View File
@@ -0,0 +1,62 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public domainId!: string;
/** FQDN of the record (e.g. 'www.example.com'). */
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public type!: TDnsRecordType;
@plugins.smartdata.svDb()
public value!: string;
@plugins.smartdata.svDb()
public ttl: number = 300;
@plugins.smartdata.svDb()
public proxied?: boolean;
@plugins.smartdata.svDb()
public source!: TDnsRecordSource;
@plugins.smartdata.svDb()
public providerRecordId?: string;
@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<DnsRecordDoc | null> {
return await DnsRecordDoc.getInstance({ id });
}
public static async findAll(): Promise<DnsRecordDoc[]> {
return await DnsRecordDoc.getInstances({});
}
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
return await DnsRecordDoc.getInstances({ domainId });
}
}
+66
View File
@@ -0,0 +1,66 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
/** FQDN — kept lowercased on save. */
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public source!: TDomainSource;
@plugins.smartdata.svDb()
public providerId?: string;
@plugins.smartdata.svDb()
public authoritative: boolean = false;
@plugins.smartdata.svDb()
public nameservers?: string[];
@plugins.smartdata.svDb()
public externalZoneId?: string;
@plugins.smartdata.svDb()
public lastSyncedAt?: number;
@plugins.smartdata.svDb()
public description?: string;
@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<DomainDoc | null> {
return await DomainDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<DomainDoc | null> {
return await DomainDoc.getInstance({ name: name.toLowerCase() });
}
public static async findAll(): Promise<DomainDoc[]> {
return await DomainDoc.getInstances({});
}
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
return await DomainDoc.getInstances({ providerId });
}
}
@@ -0,0 +1,56 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type {
IEmailDomainDkim,
IEmailDomainRateLimits,
IEmailDomainDnsStatus,
} from '../../../ts_interfaces/data/email-domain.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public domain: string = '';
@plugins.smartdata.svDb()
public linkedDomainId: string = '';
@plugins.smartdata.svDb()
public subdomain?: string;
@plugins.smartdata.svDb()
public dkim!: IEmailDomainDkim;
@plugins.smartdata.svDb()
public rateLimits?: IEmailDomainRateLimits;
@plugins.smartdata.svDb()
public dnsStatus!: IEmailDomainDnsStatus;
@plugins.smartdata.svDb()
public createdAt!: string;
@plugins.smartdata.svDb()
public updatedAt!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<EmailDomainDoc | null> {
return await EmailDomainDoc.getInstance({ id });
}
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
}
public static async findAll(): Promise<EmailDomainDoc[]> {
return await EmailDomainDoc.getInstances({});
}
}
@@ -0,0 +1,54 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayClientDoc, GatewayClientDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public type!: TGatewayClientType;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public hostnamePatterns: string[] = [];
@plugins.smartdata.svDb()
public allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']> = [];
@plugins.smartdata.svDb()
public capabilities: NonNullable<IApiTokenPolicy['capabilities']> = {};
@plugins.smartdata.svDb()
public enabled: boolean = true;
@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<GatewayClientDoc | null> {
return await GatewayClientDoc.getInstance({ id });
}
public static async findAll(): Promise<GatewayClientDoc[]> {
return await GatewayClientDoc.getInstances({});
}
}
@@ -0,0 +1,78 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntelligenceDoc, IpIntelligenceDoc> implements IIpIntelligenceRecord {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public ipAddress!: string;
@plugins.smartdata.svDb()
public asn: number | null = null;
@plugins.smartdata.svDb()
public asnOrg: string | null = null;
@plugins.smartdata.svDb()
public registrantOrg: string | null = null;
@plugins.smartdata.svDb()
public registrantCountry: string | null = null;
@plugins.smartdata.svDb()
public networkRange: string | null = null;
@plugins.smartdata.svDb()
public networkCidrs: string[] | null = null;
@plugins.smartdata.svDb()
public abuseContact: string | null = null;
@plugins.smartdata.svDb()
public country: string | null = null;
@plugins.smartdata.svDb()
public countryCode: string | null = null;
@plugins.smartdata.svDb()
public city: string | null = null;
@plugins.smartdata.svDb()
public latitude: number | null = null;
@plugins.smartdata.svDb()
public longitude: number | null = null;
@plugins.smartdata.svDb()
public accuracyRadius: number | null = null;
@plugins.smartdata.svDb()
public timezone: string | null = null;
@plugins.smartdata.svDb()
public firstSeenAt: number = Date.now();
@plugins.smartdata.svDb()
public lastSeenAt: number = Date.now();
@plugins.smartdata.svDb()
public updatedAt: number = Date.now();
@plugins.smartdata.svDb()
public seenCount: number = 0;
constructor() {
super();
}
public static async findByIp(ipAddress: string): Promise<IpIntelligenceDoc | null> {
return await IpIntelligenceDoc.getInstance({ ipAddress });
}
public static async findAll(): Promise<IpIntelligenceDoc[]> {
return await IpIntelligenceDoc.getInstances({});
}
}
@@ -1,32 +0,0 @@
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({});
}
}
+61
View File
@@ -0,0 +1,61 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public route!: IDcRouterRouteConfig;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
@plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb()
public systemKey?: string;
@plugins.smartdata.svDb()
public metadata?: IRouteMetadata;
constructor() {
super();
}
public static async findById(id: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ id });
}
public static async findAll(): Promise<RouteDoc[]> {
return await RouteDoc.getInstances({});
}
public static async findByName(name: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ 'route.name': name });
}
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
return await RouteDoc.getInstances({ origin });
}
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ systemKey });
}
}
@@ -0,0 +1,52 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc<SecurityBlockRuleDoc, SecurityBlockRuleDoc> implements ISecurityBlockRule {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public type!: TSecurityBlockRuleType;
@plugins.smartdata.svDb()
public value!: string;
@plugins.smartdata.svDb()
public matchMode?: TSecurityBlockRuleMatchMode;
@plugins.smartdata.svDb()
public enabled: boolean = true;
@plugins.smartdata.svDb()
public reason?: string;
@plugins.smartdata.svDb()
public createdAt: number = Date.now();
@plugins.smartdata.svDb()
public updatedAt: number = Date.now();
@plugins.smartdata.svDb()
public createdBy: string = 'system';
constructor() {
super();
}
public static async findById(id: string): Promise<SecurityBlockRuleDoc | null> {
return await SecurityBlockRuleDoc.getInstance({ id });
}
public static async findAll(): Promise<SecurityBlockRuleDoc[]> {
return await SecurityBlockRuleDoc.getInstances({});
}
public static async findEnabled(): Promise<SecurityBlockRuleDoc[]> {
return await SecurityBlockRuleDoc.getInstances({ enabled: true });
}
}
@@ -0,0 +1,33 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc<SecurityPolicyAuditDoc, SecurityPolicyAuditDoc> implements ISecurityPolicyAuditEvent {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public action!: string;
@plugins.smartdata.svDb()
public actor!: string;
@plugins.smartdata.svDb()
public details!: Record<string, unknown>;
@plugins.smartdata.svDb()
public createdAt: number = Date.now();
constructor() {
super();
}
public static async findRecent(limit = 100): Promise<SecurityPolicyAuditDoc[]> {
const docs = await SecurityPolicyAuditDoc.getInstances({});
return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
}
}
@@ -5,7 +5,7 @@ import type { IRouteSecurity } from '../../../ts_interfaces/data/route-managemen
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> {
export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@@ -35,15 +35,11 @@ export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<Securit
super();
}
public static async findById(id: string): Promise<SecurityProfileDoc | null> {
return await SecurityProfileDoc.getInstance({ id });
public static async findById(id: string): Promise<SourceProfileDoc | null> {
return await SourceProfileDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<SecurityProfileDoc | null> {
return await SecurityProfileDoc.getInstance({ name });
}
public static async findAll(): Promise<SecurityProfileDoc[]> {
return await SecurityProfileDoc.getInstances({});
public static async findAll(): Promise<SourceProfileDoc[]> {
return await SourceProfileDoc.getInstances({});
}
}
@@ -1,42 +0,0 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.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;
@plugins.smartdata.svDb()
public metadata?: IRouteMetadata;
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({});
}
}
@@ -0,0 +1,51 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetProfileDoc, TargetProfileDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public domains?: string[];
@plugins.smartdata.svDb()
public targets?: ITargetProfileTarget[];
@plugins.smartdata.svDb()
public routeRefs?: string[];
@plugins.smartdata.svDb()
public allowRoutesByClientSourceIp?: 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<TargetProfileDoc | null> {
return await TargetProfileDoc.getInstance({ id });
}
public static async findAll(): Promise<TargetProfileDoc[]> {
return await TargetProfileDoc.getInstances({});
}
}
+1 -12
View File
@@ -13,7 +13,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
public enabled!: boolean;
@plugins.smartdata.svDb()
public serverDefinedClientTags?: string[];
public targetProfileIds?: string[];
@plugins.smartdata.svDb()
public description?: string;
@@ -39,9 +39,6 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
@plugins.smartdata.svDb()
public expiresAt?: string;
@plugins.smartdata.svDb()
public forceDestinationSmartproxy: boolean = true;
@plugins.smartdata.svDb()
public destinationAllowList?: string[];
@@ -67,15 +64,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
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 });
}
}
+18 -3
View File
@@ -1,12 +1,16 @@
// Cached/TTL document classes
export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js';
export * from './classes.ip-intelligence.doc.js';
export * from './classes.security-block-rule.doc.js';
export * from './classes.security-policy-audit.doc.js';
// Config document classes
export * from './classes.stored-route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.route.doc.js';
export * from './classes.api-token.doc.js';
export * from './classes.security-profile.doc.js';
export * from './classes.gateway-client.doc.js';
export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js';
export * from './classes.network-target.doc.js';
// VPN document classes
@@ -24,3 +28,14 @@ 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';
// DNS / Domain management document classes
export * from './classes.dns-provider.doc.js';
export * from './classes.domain.doc.js';
export * from './classes.dns-record.doc.js';
// ACME configuration (singleton)
export * from './classes.acme-config.doc.js';
// Email domain management
export * from './classes.email-domain.doc.js';
+2
View File
@@ -0,0 +1,2 @@
export * from './manager.dns.js';
export * from './providers/index.js';
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../logger.js';
import type {
IDnsProviderClient,
IConnectionTestResult,
IProviderRecord,
IProviderRecordInput,
} from './interfaces.js';
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
/**
* Cloudflare implementation of IDnsProviderClient.
*
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
* an internal record id, which we surface as `providerRecordId` so the rest
* of the system can issue updates and deletes without ambiguity (Cloudflare
* can have multiple records of the same name+type).
*/
export class CloudflareDnsProvider implements IDnsProviderClient {
private cfAccount: plugins.cloudflare.CloudflareAccount;
constructor(apiToken: string) {
if (!apiToken) {
throw new Error('CloudflareDnsProvider: apiToken is required');
}
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
}
public async testConnection(): Promise<IConnectionTestResult> {
try {
// Listing zones is the lightest-weight call that proves the token works.
await this.cfAccount.zoneManager.listZones();
return { ok: true };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
return { ok: false, error: message };
}
}
public async listDomains(): Promise<IProviderDomainListing[]> {
const zones = await this.cfAccount.zoneManager.listZones();
return zones.map((zone) => ({
name: zone.name,
externalId: zone.id,
nameservers: zone.name_servers ?? [],
}));
}
public async listRecords(domain: string): Promise<IProviderRecord[]> {
const records = await this.cfAccount.recordManager.listRecords(domain);
return records
.filter((r) => this.isSupportedType(r.type))
.map((r) => ({
providerRecordId: r.id,
name: r.name,
type: r.type as TDnsRecordType,
value: r.content,
ttl: r.ttl,
proxied: r.proxied,
}));
}
public async createRecord(
domain: string,
record: IProviderRecordInput,
): Promise<IProviderRecord> {
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
const apiRecord: any = {
zone_id: zoneId,
type: record.type,
name: record.name,
content: record.value,
ttl: record.ttl ?? 1, // 1 = automatic
};
if (record.proxied !== undefined) {
apiRecord.proxied = record.proxied;
}
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
return {
providerRecordId: created.id,
name: created.name,
type: created.type as TDnsRecordType,
value: created.content,
ttl: created.ttl,
proxied: created.proxied,
};
}
public async updateRecord(
domain: string,
providerRecordId: string,
record: IProviderRecordInput,
): Promise<IProviderRecord> {
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
const apiRecord: any = {
zone_id: zoneId,
type: record.type,
name: record.name,
content: record.value,
ttl: record.ttl ?? 1,
};
if (record.proxied !== undefined) {
apiRecord.proxied = record.proxied;
}
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
providerRecordId,
apiRecord,
);
return {
providerRecordId: updated.id,
name: updated.name,
type: updated.type as TDnsRecordType,
value: updated.content,
ttl: updated.ttl,
proxied: updated.proxied,
};
}
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
zone_id: zoneId,
});
}
private isSupportedType(type: string): boolean {
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { IDnsProviderClient } from './interfaces.js';
import type {
TDnsProviderType,
TDnsProviderCredentials,
} from '../../../ts_interfaces/data/dns-provider.js';
import { CloudflareDnsProvider } from './cloudflare.provider.js';
/**
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
*
* @throws if the provider type is not supported.
*
* ## Adding a new provider (e.g. Route53)
*
* 1. **Type union** — extend `TDnsProviderType` in
* `ts_interfaces/data/dns-provider.ts` (e.g. `'cloudflare' | 'route53'`).
* 2. **Credentials interface** — add `IRoute53Credentials` and append it to
* the `TDnsProviderCredentials` discriminated union.
* 3. **Descriptor** — append a new entry to `dnsProviderTypeDescriptors` so
* the OpsServer UI picks up the new type and renders the right credential
* form fields automatically.
* 4. **Provider class** — create `ts/dns/providers/route53.provider.ts`
* implementing `IDnsProviderClient`.
* 5. **Factory case** — add a new `case 'route53':` below. The
* `_exhaustive: never` line will fail to compile until you do.
* 6. **Index** — re-export the new class from `ts/dns/providers/index.ts`.
*/
export function createDnsProvider(
type: TDnsProviderType,
credentials: TDnsProviderCredentials,
): IDnsProviderClient {
switch (type) {
case 'cloudflare': {
if (credentials.type !== 'cloudflare') {
throw new Error(
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
);
}
return new CloudflareDnsProvider(credentials.apiToken);
}
case 'dcrouter': {
// The built-in DcRouter pseudo-provider has no runtime client — dcrouter
// itself serves the records via the embedded smartdns.DnsServer. This
// case exists only to satisfy the exhaustive switch; it should never
// actually run because the handler layer rejects any CRUD that would
// result in a DnsProviderDoc with type: 'dcrouter'.
throw new Error(
`createDnsProvider: 'dcrouter' is a built-in pseudo-provider — no runtime client exists. ` +
`This call indicates a DnsProviderDoc with type: 'dcrouter' was persisted, which should never happen.`,
);
}
default: {
// If you see a TypeScript error here after extending TDnsProviderType,
// add a `case` for the new type above. The `never` enforces exhaustiveness.
const _exhaustive: never = type;
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './interfaces.js';
export * from './cloudflare.provider.js';
export * from './factory.js';
+67
View File
@@ -0,0 +1,67 @@
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
/**
* A DNS record as seen at a provider's API. The `providerRecordId` field
* is the provider's internal identifier, used for subsequent updates and
* deletes (since providers can have multiple records of the same name+type).
*/
export interface IProviderRecord {
providerRecordId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl: number;
proxied?: boolean;
}
/**
* Input shape for creating / updating a DNS record at a provider.
*/
export interface IProviderRecordInput {
name: string;
type: TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
}
/**
* Outcome of a connection test against a provider's API.
*/
export interface IConnectionTestResult {
ok: boolean;
error?: string;
}
/**
* Pluggable DNS provider client interface. One implementation per provider type
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
* instantiated by `createDnsProvider()` in factory.ts.
*
* NOT a smartdata interface — this is the *runtime* client. The persisted
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
*/
export interface IDnsProviderClient {
/** Lightweight check that credentials are valid and the API is reachable. */
testConnection(): Promise<IConnectionTestResult>;
/** List all DNS zones visible to this provider account. */
listDomains(): Promise<IProviderDomainListing[]>;
/** List all DNS records for a zone (FQDN). */
listRecords(domain: string): Promise<IProviderRecord[]>;
/** Create a new DNS record at the provider; returns the created record (with id). */
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
/** Update an existing record by provider id; returns the updated record. */
updateRecord(
domain: string,
providerRecordId: string,
record: IProviderRecordInput,
): Promise<IProviderRecord>;
/** Delete a record by provider id. */
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
}
+438
View File
@@ -0,0 +1,438 @@
import * as plugins from '../plugins.js';
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
import { logger } from '../logger.js';
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
import type { DnsManager } from '../dns/manager.dns.js';
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
import { buildEmailDnsRecords } from './email-dns-records.js';
/**
* EmailDomainManager — orchestrates email domain setup.
*
* Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
* (record creation for dcrouter-hosted and provider-managed zones) to provide
* a single entry point for setting up an email domain from A to Z.
*/
export class EmailDomainManager {
private dcRouter: any; // DcRouter — avoids circular import
private readonly baseEmailDomains: IEmailDomainConfig[];
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
private get dnsManager(): DnsManager | undefined {
return this.dcRouter.dnsManager;
}
private get dkimCreator(): any | undefined {
return this.dcRouter.emailServer?.dkimCreator;
}
private get emailHostname(): string {
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
}
public async start(): Promise<void> {
await this.syncManagedDomainsToRuntime();
}
public async stop(): Promise<void> {}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
public async getAll(): Promise<IEmailDomain[]> {
const docs = await EmailDomainDoc.findAll();
return docs.map((d) => this.docToInterface(d));
}
public async getById(id: string): Promise<IEmailDomain | null> {
const doc = await EmailDomainDoc.findById(id);
return doc ? this.docToInterface(doc) : null;
}
public async getByDomain(domainName: string): Promise<IEmailDomain | null> {
const doc = await EmailDomainDoc.findByDomain(domainName);
return doc ? this.docToInterface(doc) : null;
}
public async ensureEmailDomainForDomainName(domainName: string): Promise<IEmailDomain | null> {
const normalizedDomain = domainName.trim().toLowerCase();
const existing = await this.getByDomain(normalizedDomain);
if (existing) return existing;
if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
if (!linkedDomain) {
throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
}
const subdomain = normalizedDomain === linkedDomain.name
? undefined
: normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
return await this.createEmailDomain({
linkedDomainId: linkedDomain.id,
subdomain,
});
}
public async createEmailDomain(opts: {
linkedDomainId: string;
subdomain?: string;
dkimSelector?: string;
dkimKeySize?: number;
rotateKeys?: boolean;
rotationIntervalDays?: number;
}): Promise<IEmailDomain> {
// Resolve the linked DNS domain
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
if (!domainDoc) {
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
}
const baseDomain = domainDoc.name;
const subdomain = opts.subdomain?.trim() || undefined;
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
// Check for duplicates
if (this.isDomainAlreadyConfigured(domainName)) {
throw new Error(`Email domain already configured for ${domainName}`);
}
const existing = await EmailDomainDoc.findByDomain(domainName);
if (existing) {
throw new Error(`Email domain already exists for ${domainName}`);
}
const selector = opts.dkimSelector || 'default';
const keySize = opts.dkimKeySize || 2048;
const now = new Date().toISOString();
// Generate DKIM keys
let publicKey: string | undefined;
if (this.dkimCreator) {
try {
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
// Extract public key from the DNS record value
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
publicKey = match ? match[1] : undefined;
logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
} catch (err: unknown) {
logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
}
}
// Create the document
const doc = new EmailDomainDoc();
doc.id = plugins.smartunique.shortId();
doc.domain = domainName.toLowerCase();
doc.linkedDomainId = opts.linkedDomainId;
doc.subdomain = subdomain;
doc.dkim = {
selector,
keySize,
publicKey,
rotateKeys: opts.rotateKeys ?? false,
rotationIntervalDays: opts.rotationIntervalDays ?? 90,
};
doc.dnsStatus = {
mx: 'unchecked',
spf: 'unchecked',
dkim: 'unchecked',
dmarc: 'unchecked',
};
doc.createdAt = now;
doc.updatedAt = now;
await doc.save();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain created: ${domainName}`);
return this.docToInterface(doc);
}
public async updateEmailDomain(
id: string,
changes: {
rotateKeys?: boolean;
rotationIntervalDays?: number;
rateLimits?: IEmailDomain['rateLimits'];
},
): Promise<void> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
doc.updatedAt = new Date().toISOString();
await doc.save();
await this.syncManagedDomainsToRuntime();
}
public async deleteEmailDomain(id: string): Promise<void> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
await doc.delete();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain deleted: ${doc.domain}`);
}
// ---------------------------------------------------------------------------
// DNS record computation
// ---------------------------------------------------------------------------
/**
* Compute the 4 required DNS records for an email domain.
*/
public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
const domain = doc.domain;
const selector = doc.dkim.selector;
const hostname = this.emailHostname;
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
if (this.dkimCreator) {
try {
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
dkimValue = dnsRecord.value;
} catch (err: unknown) {
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
}
}
return buildEmailDnsRecords({
domain,
hostname,
selector,
dkimValue,
statuses: doc.dnsStatus,
});
}
// ---------------------------------------------------------------------------
// DNS provisioning
// ---------------------------------------------------------------------------
/**
* Auto-create missing DNS records via the linked domain's DNS path.
*/
public async provisionDnsRecords(id: string): Promise<number> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
if (!this.dnsManager) throw new Error('DnsManager not available');
const requiredRecords = await this.getRequiredDnsRecords(id);
const domainId = doc.linkedDomainId;
// Get existing DNS records for the linked domain
const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
let provisioned = 0;
for (const required of requiredRecords) {
// Check if a matching record already exists
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
if (!exists) {
try {
await this.dnsManager.createRecord({
domainId,
name: required.name,
type: required.type as any,
value: required.value,
ttl: 3600,
createdBy: 'email-domain-manager',
});
provisioned++;
logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
} catch (err: unknown) {
logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
}
}
}
// Re-validate after provisioning
await this.validateDns(id);
return provisioned;
}
// ---------------------------------------------------------------------------
// DNS validation
// ---------------------------------------------------------------------------
/**
* Validate DNS records via live lookups.
*/
public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
const domain = doc.domain;
const selector = doc.dkim.selector;
const resolver = new plugins.dns.promises.Resolver();
// MX check
const requiredRecords = await this.getRequiredDnsRecords(id);
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
// SPF check
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
// DKIM check
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
// DMARC check
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
doc.updatedAt = new Date().toISOString();
await doc.save();
return this.getRequiredDnsRecords(id);
}
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
return false;
}
return record.value.trim() === required.value.trim();
}
private async checkMx(
resolver: plugins.dns.promises.Resolver,
domain: string,
expectedValue?: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveMx(domain);
if (!records || records.length === 0) {
return 'missing';
}
if (!expectedValue) {
return 'valid';
}
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
return found ? 'valid' : 'invalid';
} catch {
return 'missing';
}
}
private async checkTxtRecord(
resolver: plugins.dns.promises.Resolver,
name: string,
expectedValue?: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveTxt(name);
const flat = records.map((r) => r.join(''));
if (flat.length === 0) {
return 'missing';
}
if (!expectedValue) {
return 'valid';
}
const found = flat.some((record) => record.trim() === expectedValue.trim());
return found ? 'valid' : 'invalid';
} catch {
return 'missing';
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private docToInterface(doc: EmailDomainDoc): IEmailDomain {
return {
id: doc.id,
domain: doc.domain,
linkedDomainId: doc.linkedDomainId,
subdomain: doc.subdomain,
dkim: doc.dkim,
rateLimits: doc.rateLimits,
dnsStatus: doc.dnsStatus,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
private isDomainAlreadyConfigured(domainName: string): boolean {
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
.map((domainConfig) => domainConfig.domain.toLowerCase());
return configuredDomains.includes(domainName.toLowerCase());
}
private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
const domains = await DomainDoc.findAll();
return domains
.filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
.sort((a, b) => b.name.length - a.name.length)[0] || null;
}
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
const docs = await EmailDomainDoc.findAll();
const managedConfigs: IEmailDomainConfig[] = [];
for (const doc of docs) {
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
if (!linkedDomain) {
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
continue;
}
managedConfigs.push({
domain: doc.domain,
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
dkim: {
selector: doc.dkim.selector,
keySize: doc.dkim.keySize,
rotateKeys: doc.dkim.rotateKeys,
rotationInterval: doc.dkim.rotationIntervalDays,
},
rateLimits: doc.rateLimits,
});
}
return managedConfigs;
}
public async syncManagedDomainsToRuntime(): Promise<void> {
if (!this.dcRouter.options?.emailConfig) {
return;
}
const mergedDomains = new Map<string, IEmailDomainConfig>();
for (const domainConfig of this.baseEmailDomains) {
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
for (const managedConfig of await this.buildManagedDomainConfigs()) {
const key = managedConfig.domain.toLowerCase();
if (mergedDomains.has(key)) {
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
continue;
}
mergedDomains.set(key, managedConfig);
}
const domains = Array.from(mergedDomains.values());
this.dcRouter.options.emailConfig.domains = domains;
if (this.dcRouter.emailServer) {
this.dcRouter.emailServer.updateOptions({ domains });
}
}
}
@@ -0,0 +1,108 @@
import * as plugins from '../plugins.js';
import type { IStorageManagerLike } from '@push.rocks/smartmta';
export class SmartMtaStorageManager implements IStorageManagerLike {
private readonly resolvedRootDir: string;
constructor(private rootDir: string) {
this.resolvedRootDir = plugins.path.resolve(rootDir);
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
}
private normalizeKey(key: string): string {
return key.replace(/^\/+/, '').replace(/\\/g, '/');
}
private resolvePathForKey(key: string): string {
const normalizedKey = this.normalizeKey(key);
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
if (
resolvedPath !== this.resolvedRootDir
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
) {
throw new Error(`Storage key escapes root directory: ${key}`);
}
return resolvedPath;
}
private toStorageKey(filePath: string): string {
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
return `/${relativePath}`;
}
public async get(key: string): Promise<string | null> {
const filePath = this.resolvePathForKey(key);
try {
return await plugins.fs.promises.readFile(filePath, 'utf8');
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
}
public async set(key: string, value: string): Promise<void> {
const filePath = this.resolvePathForKey(key);
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
}
public async list(prefix: string): Promise<string[]> {
const prefixPath = this.resolvePathForKey(prefix);
try {
const stat = await plugins.fs.promises.stat(prefixPath);
if (stat.isFile()) {
return [this.toStorageKey(prefixPath)];
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const results: string[] = [];
const walk = async (currentPath: string): Promise<void> => {
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = plugins.path.join(currentPath, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
} else if (entry.isFile()) {
results.push(this.toStorageKey(entryPath));
}
}
};
await walk(prefixPath);
return results.sort();
}
public async delete(key: string): Promise<void> {
const targetPath = this.resolvePathForKey(key);
try {
const stat = await plugins.fs.promises.stat(targetPath);
if (stat.isDirectory()) {
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
} else {
await plugins.fs.promises.unlink(targetPath);
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
let currentDir = plugins.path.dirname(targetPath);
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
const entries = await plugins.fs.promises.readdir(currentDir);
if (entries.length > 0) {
break;
}
await plugins.fs.promises.rmdir(currentDir);
currentDir = plugins.path.dirname(currentDir);
}
}
}
+343
View File
@@ -0,0 +1,343 @@
import type {
IEmailRoute,
IUnifiedEmailServerOptions,
} from '@push.rocks/smartmta';
import * as plugins from '../plugins.js';
import type * as interfaces from '../../ts_interfaces/index.js';
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
smtpPassword: string;
}
interface IStoredWorkAppMailState {
version: 1;
identities: IStoredWorkAppMailIdentity[];
}
export class WorkAppMailManager {
private readonly storageKey = '/workhosters/mail-identities.json';
constructor(private dcRouterRef: any) {}
public async listMailIdentities(
ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
): Promise<interfaces.data.IWorkAppMailIdentity[]> {
const identities = await this.readStoredIdentities();
return identities
.filter((identity) => this.matchesOwnership(identity.ownership, ownership))
.map((identity) => this.toPublicIdentity(identity));
}
public async syncMailIdentity(
request: TSyncRequest,
createdBy: string,
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
if (!this.dcRouterRef.options.emailConfig) {
return { success: false, message: 'Email server is not configured' };
}
const ownership = this.normalizeOwnership(request.ownership);
const domain = this.normalizeDomain(request.domain);
const localPart = this.normalizeLocalPart(request.localPart);
const address = `${localPart}@${domain}`;
const externalKey = this.buildExternalKey(ownership, address);
const identities = await this.readStoredIdentities();
const existingIndex = identities.findIndex((identity) => identity.externalKey === externalKey);
if (request.delete) {
if (existingIndex < 0) {
return { success: true, action: 'unchanged' };
}
const [deletedIdentity] = identities.splice(existingIndex, 1);
await this.writeStoredIdentities(identities);
await this.applyStoredIdentitiesToRuntime(identities);
return {
success: true,
action: 'deleted',
identity: this.toPublicIdentity(deletedIdentity),
};
}
await this.ensureEmailDomainConfigured(domain);
const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
const now = Date.now();
const smtpPassword = existingIdentity && !request.resetSmtpPassword
? existingIdentity.smtpPassword
: this.generateSmtpPassword();
const identity: IStoredWorkAppMailIdentity = {
id: existingIdentity?.id || plugins.smartunique.shortId(),
externalKey,
ownership,
address,
localPart,
domain,
enabled: request.enabled ?? existingIdentity?.enabled ?? true,
displayName: request.displayName ?? existingIdentity?.displayName,
inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
smtp: {
enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
},
createdAt: existingIdentity?.createdAt || now,
updatedAt: now,
createdBy: existingIdentity?.createdBy || createdBy,
smtpPassword,
};
if (existingIndex >= 0) {
identities[existingIndex] = identity;
} else {
identities.push(identity);
}
await this.writeStoredIdentities(identities);
await this.applyStoredIdentitiesToRuntime(identities);
const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
success: true,
action: existingIndex >= 0 ? 'updated' : 'created',
identity: this.toPublicIdentity(identity),
};
if (existingIndex < 0 || request.resetSmtpPassword) {
response.smtpCredentials = this.buildSmtpCredentials(identity);
}
return response;
}
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
emailConfig: TConfig,
): Promise<TConfig> {
const identities = await this.readStoredIdentities();
return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities);
}
public async applyStoredIdentitiesToRuntime(
identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
): Promise<void> {
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
if (!emailConfig) return;
const nextConfig = this.mergeIdentitiesIntoEmailConfig(
emailConfig,
identities || await this.readStoredIdentities(),
);
this.dcRouterRef.options.emailConfig = nextConfig;
if (this.dcRouterRef.emailServer) {
this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
}
}
private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
if (!storedData) return [];
const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
return Array.isArray(parsed) ? parsed : parsed.identities || [];
}
private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
const state: IStoredWorkAppMailState = {
version: 1,
identities,
};
await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
}
private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
emailConfig: TConfig,
identities: IStoredWorkAppMailIdentity[],
): TConfig {
const generatedRoutes = identities
.filter((identity) => identity.enabled && identity.inbound?.enabled)
.map((identity) => this.buildInboundRoute(identity));
const configuredRoutes = (emailConfig.routes || [])
.filter((route) => !this.isManagedMailRouteName(route.name));
const generatedUsers = identities
.filter((identity) => identity.enabled && identity.smtp.enabled)
.map((identity) => ({
username: identity.smtp.username,
password: identity.smtpPassword,
}));
const configuredUsers = (emailConfig.auth?.users || [])
.filter((user) => !this.isManagedSmtpUsername(user.username));
return {
...emailConfig,
routes: [...configuredRoutes, ...generatedRoutes],
auth: {
...(emailConfig.auth || {}),
users: [...configuredUsers, ...generatedUsers],
},
};
}
private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
const inbound = identity.inbound!;
return {
name: this.buildRouteName(identity.externalKey),
priority: 1000,
match: {
recipients: identity.address,
},
action: {
type: 'forward',
forward: {
host: inbound.targetHost,
port: inbound.targetPort,
preserveHeaders: inbound.preserveHeaders ?? true,
addHeaders: {
'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
...(inbound.addHeaders || {}),
},
},
},
};
}
private async ensureEmailDomainConfigured(domain: string): Promise<void> {
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
return;
}
const emailDomainManager = this.dcRouterRef.emailDomainManager;
if (!emailDomainManager) {
throw new Error(`Email domain is not configured: ${domain}`);
}
if (await emailDomainManager.getByDomain(domain)) {
await emailDomainManager.syncManagedDomainsToRuntime();
return;
}
await emailDomainManager.ensureEmailDomainForDomainName(domain);
}
private normalizeOwnership(
ownership: interfaces.data.IWorkAppMailOwnership,
): interfaces.data.IWorkAppMailOwnership {
const workHosterType = ownership.workHosterType;
const workHosterId = ownership.workHosterId?.trim();
const workAppId = ownership.workAppId?.trim();
if (!['onebox', 'cloudly', 'custom'].includes(workHosterType)) {
throw new Error(`Invalid WorkHoster type: ${workHosterType}`);
}
if (!workHosterId) throw new Error('workHosterId is required');
if (!workAppId) throw new Error('workAppId is required');
return { workHosterType, workHosterId, workAppId };
}
private normalizeDomain(domain: string): string {
const normalized = domain?.trim().toLowerCase();
if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
throw new Error(`Invalid email domain: ${domain}`);
}
return normalized;
}
private normalizeLocalPart(localPart: string): string {
const normalized = localPart?.trim().toLowerCase();
if (!normalized || normalized.includes('@') || /\s/.test(normalized)) {
throw new Error(`Invalid email local part: ${localPart}`);
}
return normalized;
}
private normalizeInboundRoute(
inbound?: interfaces.data.IWorkAppMailInboundRoute,
): interfaces.data.IWorkAppMailInboundRoute | undefined {
if (!inbound) return undefined;
if (!inbound.enabled) {
return { ...inbound, enabled: false };
}
const targetHost = inbound.targetHost?.trim();
const targetPort = Number(inbound.targetPort);
if (!targetHost) throw new Error('inbound.targetHost is required when inbound routing is enabled');
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
throw new Error(`Invalid inbound.targetPort: ${inbound.targetPort}`);
}
return {
...inbound,
targetHost,
targetPort,
};
}
private matchesOwnership(
ownership: interfaces.data.IWorkAppMailOwnership,
filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
): boolean {
if (!filter) return true;
if (filter.workHosterType && filter.workHosterType !== ownership.workHosterType) return false;
if (filter.workHosterId && filter.workHosterId !== ownership.workHosterId) return false;
if (filter.workAppId && filter.workAppId !== ownership.workAppId) return false;
return true;
}
private buildExternalKey(
ownership: interfaces.data.IWorkAppMailOwnership,
address: string,
): string {
return [
ownership.workHosterType,
ownership.workHosterId,
ownership.workAppId,
address,
].join(':');
}
private buildSmtpUsername(externalKey: string): string {
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
}
private buildRouteName(externalKey: string): string {
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
}
private hashExternalKey(externalKey: string): string {
return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
}
private generateSmtpPassword(): string {
return plugins.crypto.randomBytes(24).toString('base64url');
}
private isManagedMailRouteName(routeName: string): boolean {
return routeName.startsWith('workapp-mail-');
}
private isManagedSmtpUsername(username: string): boolean {
return username.startsWith('workapp-');
}
private buildSmtpCredentials(
identity: IStoredWorkAppMailIdentity,
): interfaces.data.IWorkAppMailCredentials {
return {
username: identity.smtp.username,
password: identity.smtpPassword,
host: this.dcRouterRef.options.emailConfig?.outbound?.hostname
|| this.dcRouterRef.options.emailConfig?.hostname,
ports: {
smtp: this.dcRouterRef.options.emailConfig?.ports?.includes(25) ? 25 : undefined,
submission: this.dcRouterRef.options.emailConfig?.ports?.includes(587) ? 587 : undefined,
smtps: this.dcRouterRef.options.emailConfig?.ports?.includes(465) ? 465 : undefined,
},
};
}
private toPublicIdentity(
identity: IStoredWorkAppMailIdentity,
): interfaces.data.IWorkAppMailIdentity {
const { smtpPassword, ...publicIdentity } = identity;
return publicIdentity;
}
}
+53
View File
@@ -0,0 +1,53 @@
import type {
IEmailDnsRecord,
TDnsRecordStatus,
} from '../../ts_interfaces/data/email-domain.js';
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
export interface IBuildEmailDnsRecordsOptions {
domain: string;
hostname: string;
selector?: string;
dkimValue?: string;
mxPriority?: number;
dmarcPolicy?: string;
dmarcRua?: string;
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
}
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
const selector = options.selector || 'default';
const records: IEmailDnsRecord[] = [
{
type: 'MX',
name: options.domain,
value: `${options.mxPriority ?? 10} ${options.hostname}`,
status: statusFor('mx'),
},
{
type: 'TXT',
name: options.domain,
value: 'v=spf1 a mx ~all',
status: statusFor('spf'),
},
{
type: 'TXT',
name: `_dmarc.${options.domain}`,
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
status: statusFor('dmarc'),
},
];
if (options.dkimValue) {
records.splice(2, 0, {
type: 'TXT',
name: `${selector}._domainkey.${options.domain}`,
value: options.dkimValue,
status: statusFor('dkim'),
});
}
return records;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './classes.email-domain.manager.js';
export * from './classes.smartmta-storage-manager.js';
export * from './classes.workapp-mail-manager.js';
export * from './email-dns-records.js';
+24
View File
@@ -1,3 +1,4 @@
import { commitinfo } from './00_commitinfo_data.js';
export * from './00_commitinfo_data.js';
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
@@ -18,6 +19,29 @@ export * from './remoteingress/index.js';
export type { IHttp3Config } from './http3/index.js';
export const runCli = async () => {
const args = process.argv.slice(2);
if (args.includes('--version') || args.includes('version')) {
console.log(commitinfo.version);
return;
}
if (args.includes('--help') || args.includes('-h') || args.includes('help')) {
console.log(`dcrouter ${commitinfo.version}
Usage:
dcrouter
dcrouter --version
dcrouter --help
Environment:
DCROUTER_MODE=OCI_CONTAINER Start with OCI container configuration
DCROUTER_DNS_BIND_INTERFACE Override the embedded DNS UDP bind address
DATA_DIR=<path> Override the writable dcrouter data directory
`);
return;
}
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
+274 -36
View File
@@ -3,6 +3,7 @@ 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';
import type { IAsnActivity } from '../../ts_interfaces/data/stats.js';
export class MetricsManager {
private metricsLogger: plugins.smartlog.Smartlog;
@@ -545,7 +546,7 @@ export class MetricsManager {
// Get network metrics from SmartProxy
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', () => {
return this.metricsCache.get('networkStats', async () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
@@ -553,12 +554,17 @@ export class MetricsManager {
connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
topASNs: [] as IAsnActivity[],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0,
requestsTotal: 0,
backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>,
frontendProtocols: null,
backendProtocols: null,
};
}
@@ -572,7 +578,7 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out
};
// Get top IPs
// Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10);
// Get total data transferred
@@ -590,6 +596,11 @@ export class MetricsManager {
// Get HTTP request rates
const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total();
const domainRequestRates = proxyMetrics.requests.byDomain();
// Get frontend/backend protocol distribution
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
const backendProtocols = proxyMetrics.connections.backendProtocols() ?? null;
// Collect backend protocol data
const backendMetrics = proxyMetrics.backends.byBackend();
@@ -613,47 +624,48 @@ export class MetricsManager {
const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) {
backends.push({
id: `backend:${key}`,
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,
});
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
if (cacheEntries && cacheEntries.length > 0) {
// Protocol cache rows are domain-scoped metadata, not live backend connections.
for (const cache of cacheEntries) {
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
seenCacheKeys.add(compositeKey);
backends.push({
id: `cache:${compositeKey}`,
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,
activeConnections: 0,
totalConnections: 0,
connectErrors: 0,
handshakeErrors: 0,
requestErrors: 0,
avgConnectTimeMs: 0,
poolHitRate: 0,
h2Failures: 0,
h2Suppressed: cache.h2Suppressed,
h3Suppressed: cache.h3Suppressed,
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
@@ -672,6 +684,7 @@ export class MetricsManager {
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
if (!seenCacheKeys.has(compositeKey)) {
backends.push({
id: `cache:${compositeKey}`,
backend: `${entry.host}:${entry.port}`,
domain: entry.domain,
protocol: entry.protocol,
@@ -695,20 +708,245 @@ export class MetricsManager {
}
}
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
for (const [ip, count] of connectionsByIP) {
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
}
for (const [ip, tp] of throughputByIP) {
const existing = allIPData.get(ip);
if (existing) {
existing.bwIn = tp.in;
existing.bwOut = tp.out;
} else {
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
}
}
const topIPsByBandwidth = Array.from(allIPData.entries())
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
const observedIps = [...new Set([
...connectionsByIP.keys(),
...throughputByIP.keys(),
...topIPs.map((item) => item.ip),
...topIPsByBandwidth.map((item) => item.ip),
])];
this.dcRouter.securityPolicyManager?.queueObservedIps(observedIps);
const topASNs = await this.buildTopASNs(observedIps, allIPData);
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Aggregate per-IP domain request counts into per-domain totals
const domainRequestTotals = new Map<string, number>();
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
for (const [, domainMap] of domainRequestsByIP) {
for (const [domain, count] of domainMap) {
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
}
}
// Map canonical route key → domains from route config
const routeDomains = new Map<string, string[]>();
if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
const routeKey = route.name || route.id;
if (!routeKey || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.length > 0) {
routeDomains.set(routeKey, domains);
}
}
}
// Resolve wildcards using domains seen in request metrics
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
for (const domain of domainRequestRates.keys()) {
allKnownDomains.add(domain);
}
for (const entry of protocolCache) {
if (entry.domain) allKnownDomains.add(entry.domain);
}
// Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>();
for (const [routeKey, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(knownDomain, [routeKey]); }
}
}
} else {
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(pattern, [routeKey]); }
}
}
}
const hasLiveDomainRates = domainRequestRates.size > 0;
const getDomainWeight = (domain: string): number => {
const liveRate = domainRequestRates.get(domain);
return hasLiveDomainRates
? (liveRate?.lastMinute ?? 0)
: (domainRequestTotals.get(domain) || 0);
};
// For each route, compute the total activity weight across all resolved domains
// so we can distribute route-level throughput/connections. Prefer live domain
// request rates from SmartProxy 27.8+, falling back to lifetime counters.
const routeTotalRequests = new Map<string, number>();
for (const [domain, routeKeys] of domainToRoutes) {
const reqs = getDomainWeight(domain);
for (const routeKey of routeKeys) {
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
}
}
// Aggregate metrics per domain using request-count-proportional splitting
const domainAgg = new Map<string, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
requestCount: number;
requestsPerSecond: number;
requestsLastMinute: number;
}>();
for (const [domain, routeKeys] of domainToRoutes) {
const domainReqs = getDomainWeight(domain);
const requestRate = domainRequestRates.get(domain);
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeKey of routeKeys) {
const conns = connectionsByRoute.get(routeKey) || 0;
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
const routeTotal = routeTotalRequests.get(routeKey) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share;
totalIn += tp.in * share;
totalOut += tp.out * share;
}
domainAgg.set(domain, {
activeConnections: Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeKeys.length,
requestCount: domainRequestTotals.get(domain) || 0,
requestsPerSecond: requestRate?.perSecond ?? 0,
requestsLastMinute: requestRate?.lastMinute ?? 0,
});
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
bytesInPerSecond: data.bytesInPerSec,
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
requestCount: data.requestCount,
requestsPerSecond: data.requestsPerSecond,
requestsLastMinute: data.requestsLastMinute,
}))
.sort((a, b) => {
if (hasLiveDomainRates) {
return (b.requestsPerSecond - a.requestsPerSecond) ||
(b.requestsLastMinute - a.requestsLastMinute) ||
((b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
}
return (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond);
});
return {
connectionsByIP,
throughputRate,
topIPs,
topIPsByBandwidth,
topASNs,
totalDataTransferred,
throughputHistory,
throughputByIP,
requestsPerSecond,
requestsTotal,
backends,
frontendProtocols,
backendProtocols,
domainActivity,
};
}, 1000); // 1s cache — matches typical dashboard poll interval
}
private async buildTopASNs(
observedIps: string[],
allIPData: Map<string, { count: number; bwIn: number; bwOut: number }>,
): Promise<IAsnActivity[]> {
const manager = this.dcRouter.securityPolicyManager;
if (!manager || observedIps.length === 0) {
return [];
}
const intelligenceRecords = await manager.listIpIntelligence({
ipAddresses: observedIps,
limit: Math.max(100, observedIps.length),
});
const asnActivity = new Map<number, IAsnActivity>();
for (const record of intelligenceRecords) {
if (typeof record.asn !== 'number') continue;
const ipData = allIPData.get(record.ipAddress);
if (!ipData) continue;
const existing = asnActivity.get(record.asn);
const activity = existing || {
asn: record.asn,
organization: record.asnOrg || record.registrantOrg || `AS${record.asn}`,
country: record.countryCode || record.country || record.registrantCountry || null,
activeConnections: 0,
ipCount: 0,
bytesInPerSecond: 0,
bytesOutPerSecond: 0,
sampleIps: [],
};
activity.activeConnections += ipData.count;
activity.bytesInPerSecond += ipData.bwIn;
activity.bytesOutPerSecond += ipData.bwOut;
activity.ipCount++;
if (activity.sampleIps.length < 5) {
activity.sampleIps.push(record.ipAddress);
}
asnActivity.set(record.asn, activity);
}
return [...asnActivity.values()]
.sort((a, b) => {
const connectionDiff = b.activeConnections - a.activeConnections;
if (connectionDiff !== 0) return connectionDiff;
const bandwidthA = a.bytesInPerSecond + a.bytesOutPerSecond;
const bandwidthB = b.bytesInPerSecond + b.bytesOutPerSecond;
return bandwidthB - bandwidthA;
})
.slice(0, 10);
}
// --- Time-series helpers ---
private static minuteKey(ts: number = Date.now()): number {
@@ -851,4 +1089,4 @@ export class MetricsManager {
return { queries };
}
}
}
+25 -17
View File
@@ -3,7 +3,6 @@ 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;
@@ -12,9 +11,9 @@ export class OpsServer {
// 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 } }>();
// Grouped routers. Handlers enforce auth explicitly with per-endpoint scopes.
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
// Handler instances
public adminHandler!: handlers.AdminHandler;
@@ -29,8 +28,16 @@ export class OpsServer {
private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler;
private securityProfileHandler!: handlers.SecurityProfileHandler;
private sourceProfileHandler!: handlers.SourceProfileHandler;
private targetProfileHandler!: handlers.TargetProfileHandler;
private networkTargetHandler!: handlers.NetworkTargetHandler;
private usersHandler!: handlers.UsersHandler;
private dnsProviderHandler!: handlers.DnsProviderHandler;
private domainHandler!: handlers.DomainHandler;
private dnsRecordHandler!: handlers.DnsRecordHandler;
private acmeConfigHandler!: handlers.AcmeConfigHandler;
private emailDomainHandler!: handlers.EmailDomainHandler;
private workHosterHandler!: handlers.WorkHosterHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -64,16 +71,6 @@ export class OpsServer {
this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize();
// viewRouter middleware: requires valid identity (any logged-in user)
this.viewRouter.addMiddleware(async (typedRequest) => {
await requireValidIdentity(this.adminHandler, typedRequest.request);
});
// adminRouter middleware: requires admin identity
this.adminRouter.addMiddleware(async (typedRequest) => {
await requireAdminIdentity(this.adminHandler, typedRequest.request);
});
// Connect auth routers to the main typedrouter
this.typedrouter.addTypedRouter(this.viewRouter);
this.typedrouter.addTypedRouter(this.adminRouter);
@@ -90,13 +87,24 @@ export class OpsServer {
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
this.vpnHandler = new handlers.VpnHandler(this);
this.securityProfileHandler = new handlers.SecurityProfileHandler(this);
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
this.usersHandler = new handlers.UsersHandler(this);
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
this.domainHandler = new handlers.DomainHandler(this);
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
this.workHosterHandler = new handlers.WorkHosterHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}
public async stop() {
if (this.adminHandler) {
await this.adminHandler.stop();
}
// Clean up log handler streams and push destination before stopping the server
if (this.logsHandler) {
this.logsHandler.cleanup();
@@ -105,4 +113,4 @@ export class OpsServer {
await this.server.stop();
}
}
}
}
@@ -0,0 +1,77 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handler for the singleton `AcmeConfigDoc`.
*
* Auth: same dual-mode pattern as other handlers — admin JWT or API token
* with `acme-config:read` / `acme-config:write` scope.
*/
export class AcmeConfigHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
// Get current ACME config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
'getAcmeConfig',
async (dataArg) => {
await this.requireAuth(dataArg, 'acme-config:read');
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
if (!mgr) return { config: null };
return { config: mgr.getConfig() };
},
),
);
// Update (upsert) the ACME config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
'updateAcmeConfig',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'acme-config:write');
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
if (!mgr) {
return {
success: false,
message: 'AcmeConfigManager not initialized (DB disabled?)',
};
}
try {
const updated = await mgr.updateConfig(
{
accountEmail: dataArg.accountEmail,
enabled: dataArg.enabled,
useProduction: dataArg.useProduction,
autoRenew: dataArg.autoRenew,
renewThresholdDays: dataArg.renewThresholdDays,
},
userId,
);
return { success: true, config: updated };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}
+416 -118
View File
@@ -8,19 +8,34 @@ export interface IJwtData {
expiresAt: number;
}
type TAdminUser = {
id: string;
username: string;
email?: string;
name?: string;
role: string;
status?: 'active' | 'disabled';
authSources?: Array<'local' | 'idp.global'>;
};
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
// JWT instance
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
// Simple in-memory user storage (in production, use proper database)
// Ephemeral bootstrap users. DB-backed instances may use these only until the
// database is ready and the first persistent admin account has been created.
private users = new Map<string, {
id: string;
username: string;
password: string;
role: string;
}>();
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
private ownsIdpClient = false;
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
@@ -32,6 +47,14 @@ export class AdminHandler {
this.initializeDefaultUsers();
this.registerHandlers();
}
public async stop(): Promise<void> {
if (this.ownsIdpClient) {
await this.idpClient?.stop();
}
this.idpClient = undefined;
this.ownsIdpClient = false;
}
private async initializeJwt(): Promise<void> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
@@ -43,53 +66,232 @@ export class AdminHandler {
}
private initializeDefaultUsers(): void {
// Add default admin user
const username = process.env.DCROUTER_ADMIN_USERNAME || 'admin';
const configuredPassword = process.env.DCROUTER_ADMIN_PASSWORD;
const password = configuredPassword || plugins.crypto.randomBytes(24).toString('base64url');
const adminId = plugins.uuid.v4();
this.users.set(adminId, {
id: adminId,
username: 'admin',
password: 'admin',
username,
password,
role: 'admin',
});
if (!configuredPassword) {
console.warn(`DCRouter generated one-time admin password for ${username}: ${password}`);
}
}
/**
* Return a safe projection of the active user source — excludes password fields.
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (accountState.hasPersistentAdmin) {
const accounts = await accountState.store!.listAccounts();
return accounts.map((accountArg) => this.accountToUser(accountArg));
}
return Array.from(this.users.values()).map((user) => ({
id: user.id,
username: user.username,
role: user.role,
}));
}
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
const accountState = await this.getPersistentAccountState();
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
return {
dbEnabled: accountState.dbEnabled,
dbReady: accountState.dbReady,
hasPersistentAdmin: accountState.hasPersistentAdmin,
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
ephemeralAdminAvailable: bootstrapAvailable,
idpGlobalConfigured: this.isIdpGlobalConfigured(),
};
}
public async createInitialAdminUser(optionsArg: {
email: string;
name?: string;
password: string;
enableIdpGlobalAuth?: boolean;
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
const store = this.getAccountStore();
if (!store) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (await store.hasActiveAdminAccount()) {
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
}
const password = String(optionsArg.password || '');
if (!password) {
throw new plugins.typedrequest.TypedResponseError('password is required');
}
const email = String(optionsArg.email || '').trim();
const authSources: Array<'local' | 'idp.global'> = ['local'];
if (optionsArg.enableIdpGlobalAuth) {
authSources.push('idp.global');
}
try {
const account = await store.createAccount({
email,
name: String(optionsArg.name || '').trim() || email,
role: 'admin',
authSources,
password,
});
const user = this.accountToUser(account);
return {
success: true,
identity: await this.createIdentityForUser(user),
user,
};
} catch (error) {
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
}
}
public async createUser(optionsArg: {
email: string;
name?: string;
role: interfaces.requests.TUserManagementRole;
password: string;
enableIdpGlobalAuth?: boolean;
}): Promise<interfaces.requests.IReq_CreateUser['response']> {
const store = this.getAccountStore();
if (!store) {
return { success: false, message: 'database is not ready' };
}
if (!(await store.hasActiveAdminAccount())) {
return { success: false, message: 'initial admin bootstrap is required before creating users' };
}
const role = optionsArg.role;
if (role !== 'admin' && role !== 'user') {
return { success: false, message: 'role must be admin or user' };
}
const password = String(optionsArg.password || '');
if (!password) {
return { success: false, message: 'password is required' };
}
const authSources: Array<'local' | 'idp.global'> = ['local'];
if (optionsArg.enableIdpGlobalAuth) {
authSources.push('idp.global');
}
try {
const email = String(optionsArg.email || '').trim();
const account = await store.createAccount({
email,
name: String(optionsArg.name || '').trim() || email,
role,
authSources,
password,
});
return { success: true, user: this.accountToUser(account) };
} catch (error) {
return { success: false, message: (error as Error).message || 'failed to create user' };
}
}
public async deleteUser(optionsArg: {
id: string;
requestingUserId: string;
}): Promise<interfaces.requests.IReq_DeleteUser['response']> {
const store = this.getAccountStore();
if (!store) {
return { success: false, message: 'database is not ready' };
}
if (!(await store.hasActiveAdminAccount())) {
return { success: false, message: 'initial admin bootstrap is required before deleting users' };
}
const id = String(optionsArg.id || '').trim();
if (!id) {
return { success: false, message: 'user id is required' };
}
if (id === optionsArg.requestingUserId) {
return { success: false, message: 'cannot delete the current user' };
}
const account = await store.getAccountById(id);
if (!account) {
return { success: false, message: 'user not found' };
}
if (account.role === 'admin' && account.status === 'active') {
const activeAdmins = (await store.listAccounts()).filter(
(accountArg) => accountArg.role === 'admin' && accountArg.status === 'active',
);
if (activeAdmins.length <= 1) {
return { success: false, message: 'cannot delete the last active admin' };
}
}
const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id);
if (!doc) {
return { success: false, message: 'user not found' };
}
await doc.delete();
return { success: true };
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
'getAdminBootstrapStatus',
async (_dataArg) => this.getBootstrapStatus()
)
);
this.opsServerRef.adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
'createInitialAdminUser',
async (dataArg) => {
const isAdmin = await this.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError('admin identity required');
}
return this.createInitialAdminUser({
email: dataArg.email,
name: dataArg.name,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
});
}
)
);
// Admin Login Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
try {
// Find user by username and password
let user: { id: string; username: string; password: string; role: string } | null = null;
for (const [_, userData] of this.users) {
if (userData.username === dataArg.username && userData.password === dataArg.password) {
user = userData;
break;
}
}
const user = await this.authenticateUser({
username: dataArg.username,
password: dataArg.password,
authSource: dataArg.authSource,
});
if (!user) {
throw new plugins.typedrequest.TypedResponseError('login failed');
}
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
const jwt = await this.smartjwtInstance.createJWT({
userId: user.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
identity: {
jwt,
userId: user.id,
name: user.username,
expiresAt: expiresAtTimestamp,
role: user.role,
type: 'user',
},
identity: await this.createIdentityForUser(user),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) {
@@ -106,8 +308,10 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (dataArg) => {
// In a real implementation, you might want to blacklist the JWT
// For now, just return success
const identity = await this.validateIdentity(dataArg.identity);
if (!identity) {
throw new plugins.typedrequest.TypedResponseError('identity is not valid');
}
return {
success: true,
};
@@ -120,53 +324,8 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return {
valid: false,
};
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check if expired
if (jwtData.expiresAt < Date.now()) {
return {
valid: false,
};
}
// Check if logged in
if (jwtData.status !== 'loggedIn') {
return {
valid: false,
};
}
// Find user
const user = this.users.get(jwtData.userId);
if (!user) {
return {
valid: false,
};
}
return {
valid: true,
identity: {
jwt: dataArg.identity.jwt,
userId: user.id,
name: user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
},
};
} catch (error) {
return {
valid: false,
};
}
const identity = await this.validateIdentity(dataArg.identity);
return identity ? { valid: true, identity } : { valid: false };
}
)
);
@@ -179,36 +338,7 @@ export class AdminHandler {
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return false;
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check expiration
if (jwtData.expiresAt < Date.now()) {
return false;
}
// Check status
if (jwtData.status !== 'loggedIn') {
return false;
}
// Verify data hasn't been tampered with
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
return false;
}
if (dataArg.identity.userId !== jwtData.userId) {
return false;
}
return true;
} catch (error) {
return false;
}
return Boolean(await this.validateIdentity(dataArg.identity));
},
{
failedHint: 'identity is not valid',
@@ -223,18 +353,186 @@ export class AdminHandler {
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
// First check if identity is valid
const isValid = await this.validIdentityGuard.exec(dataArg);
if (!isValid) {
return false;
}
// Check if user has admin role
return dataArg.identity.role === 'admin';
const identity = await this.validateIdentity(dataArg.identity);
return identity?.role === 'admin';
},
{
failedHint: 'user is not admin',
name: 'adminIdentityGuard',
}
);
}
public async validateIdentity(
identityArg?: interfaces.data.IIdentity,
): Promise<interfaces.data.IIdentity | null> {
if (!identityArg?.jwt) {
return null;
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
if (jwtData.expiresAt < Date.now()) {
return null;
}
if (jwtData.status !== 'loggedIn') {
return null;
}
if (identityArg.expiresAt !== jwtData.expiresAt) {
return null;
}
if (identityArg.userId !== jwtData.userId) {
return null;
}
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return null;
}
if (identityArg.role && identityArg.role !== user.role) {
return null;
}
return {
jwt: identityArg.jwt,
userId: user.id,
name: user.name || user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
};
} catch {
return null;
}
}
private async authenticateUser(optionsArg: {
username: string;
password: string;
authSource?: interfaces.requests.TAdminLoginAuthSource;
}): Promise<TAdminUser | null> {
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (accountState.hasPersistentAdmin) {
const authService = new plugins.idpSdkServer.AccountAuthService({
store: accountState.store!,
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
});
const result = await authService.authenticate({
email: optionsArg.username,
password: optionsArg.password,
authSource: optionsArg.authSource || 'auto',
});
return result ? this.accountToUser(result.account) : null;
}
for (const [_, userData] of this.users) {
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
return userData;
}
}
return null;
}
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
return null;
}
if (accountState.hasPersistentAdmin) {
const account = await accountState.store!.getAccountById(userIdArg);
if (!account || account.status !== 'active') {
return null;
}
return this.accountToUser(account);
}
return this.users.get(userIdArg) || null;
}
private async getPersistentAccountState(): Promise<{
dbEnabled: boolean;
dbReady: boolean;
store: plugins.idpSdkServer.SmartdataAccountStore | null;
hasPersistentAdmin: boolean;
}> {
const dbEnabled = this.isPersistenceEnabled();
const store = dbEnabled ? this.getAccountStore() : null;
const dbReady = !!store;
const hasPersistentAdmin = store ? await store.hasActiveAdminAccount() : false;
return { dbEnabled, dbReady, store, hasPersistentAdmin };
}
private isPersistenceEnabled(): boolean {
return this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
}
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
if (!this.isPersistenceEnabled()) {
return null;
}
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
if (!dcRouterDb?.isReady()) {
return null;
}
if (!this.accountStore) {
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
smartdataDb: dcRouterDb.getDb(),
});
}
return this.accountStore;
}
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
if (configuredClient) {
return configuredClient;
}
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
if (!this.idpClient) {
this.idpClient = baseUrl
? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
: new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
this.ownsIdpClient = true;
}
return this.idpClient;
}
private isIdpGlobalConfigured(): boolean {
return true;
}
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
return {
id: accountArg.id,
username: accountArg.email,
email: accountArg.email,
name: accountArg.name,
role: accountArg.role,
status: accountArg.status,
authSources: accountArg.authSources,
};
}
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
const jwt = await this.smartjwtInstance.createJWT({
userId: userArg.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
jwt,
userId: userArg.id,
name: userArg.name || userArg.username,
expiresAt: expiresAtTimestamp,
role: userArg.role,
type: 'user',
};
}
}
+28 -1
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class ApiTokenHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
'createApiToken',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -25,7 +31,8 @@ export class ApiTokenHandler {
dataArg.name,
dataArg.scopes,
dataArg.expiresInDays ?? null,
dataArg.identity.userId,
auth.userId,
dataArg.policy,
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
@@ -37,6 +44,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:read',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { tokens: [] };
@@ -51,6 +63,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -66,6 +83,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
'rollApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -84,6 +106,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
+228 -47
View File
@@ -2,23 +2,58 @@ 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';
import { logger } from '../../logger.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
* @push.rocks/smartacme. Inlined here because the original is `private` on
* SmartAcme. The cert identity ('task.vc' for both 'outline.task.vc' and
* '*.task.vc') is what AcmeCertDoc is keyed by, so two route domains with
* the same identity share the same underlying ACME cert.
*
* Returns undefined for domains with 4+ levels (matching smartacme's
* "deeper domains not supported" behavior) and for malformed inputs.
*
* Exported for unit testing.
*/
export function deriveCertDomainName(domain: string): string | undefined {
if (domain.startsWith('*.')) {
return domain.slice(2);
}
const parts = domain.split('.');
if (parts.length < 2 || parts.length > 3) return undefined;
return parts.slice(-2).join('.');
}
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;
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
private registerHandlers(): void {
const router = this.typedrouter;
// Get Certificate Overview
viewRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
'getCertificateOverview',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:read');
const certificates = await this.buildCertificateOverview();
const summary = this.buildSummary(certificates);
return { certificates, summary };
@@ -26,53 +61,56 @@ export class CertificateHandler {
)
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Legacy route-based reprovision (backward compat)
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
return this.reprovisionCertificateDomain(dataArg.domain);
await this.requireAuth(dataArg, 'certificates:write');
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
}
)
);
// Delete certificate
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.deleteCertificate(dataArg.domain);
}
)
);
// Export certificate
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:read');
return this.exportCertificate(dataArg.domain);
}
)
);
// Import certificate
adminRouter.addTypedHandler(
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.importCertificate(dataArg.cert);
}
)
@@ -176,12 +214,11 @@ export class CertificateHandler {
try {
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
if (rustStatus) {
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
if (rustStatus.issuer) issuer = rustStatus.issuer;
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
status = rustStatus.status;
if (rustStatus.expiresAt > 0) {
expiryDate = new Date(rustStatus.expiresAt).toISOString();
}
if (rustStatus.source) issuer = rustStatus.source;
status = rustStatus.isValid ? 'valid' : 'expired';
}
} catch {
// Rust bridge may not support this command yet — ignore
@@ -191,7 +228,11 @@ export class CertificateHandler {
// Check persisted cert data from smartdata document classes
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
// SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
if (acmeDoc?.validUntil) {
@@ -249,6 +290,11 @@ export class CertificateHandler {
}
}
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
status = 'failed';
error = error || backoffInfo.lastError;
}
certificates.push({
domain,
routeNames: info.routeNames,
@@ -291,7 +337,12 @@ export class CertificateHandler {
}
/**
* Legacy route-based reprovisioning
* Legacy route-based reprovisioning. Kept for backward compatibility with
* older clients that send `reprovisionCertificate` typed-requests.
*
* Like reprovisionCertificateDomain, this triggers the full route apply
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
@@ -301,13 +352,19 @@ export class CertificateHandler {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear event-based status for domains in this route so the
// certificate-issued event can refresh them
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
try {
await smartProxy.provisionCertificate(routeName);
// Clear event-based status for domains in this route
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err: unknown) {
@@ -316,9 +373,18 @@ export class CertificateHandler {
}
/**
* Domain-based reprovisioning — clears backoff first, then triggers provision
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
* cert (when forceRenew is set), then re-applies routes so the running Rust
* proxy actually picks up the new cert.
*
* Why applyRoutes (not smartProxy.provisionCertificate)?
* smartProxy.provisionCertificate(routeName) routes through the Rust ACME
* path, which is forcibly disabled whenever certProvisionFunction is set
* (smart-proxy.ts:168-171). The only path that re-invokes
* certProvisionFunction → bridge.loadCertificate is updateRoutes(), which
* we trigger via routeConfigManager.applyRoutes().
*/
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
@@ -331,31 +397,143 @@ export class CertificateHandler {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
// Clear status map entry so it gets refreshed
// Find routes matching this domain — fail early if none exist
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length === 0) {
return { success: false, message: `No routes found for domain '${domain}'` };
}
// If forceRenew, order a fresh cert from ACME now so it's already in
// AcmeCertDoc by the time certProvisionFunction is invoked below.
//
// includeWildcard: when forcing a non-wildcard subdomain renewal, we still
// want the wildcard SAN in the order so the new cert keeps covering every
// sibling. Without this, smartacme defaults to includeWildcard: false and
// the re-issued cert would have only the base domain as SAN, breaking every
// sibling subdomain that was previously covered by the same wildcard cert.
if (forceRenew && dcRouter.smartAcme) {
let newCert: plugins.smartacme.Cert;
try {
newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
forceRenew: true,
includeWildcard: !domain.startsWith('*.'),
});
} catch (err: unknown) {
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
}
// Propagate the freshly-issued cert PEM to every sibling route domain that
// shares the same cert identity. Without this, the rust hot-swap (keyed by
// exact domain in `loaded_certs`) only fires for the clicked route via the
// fire-and-forget cert provisioning path, leaving siblings serving the
// stale in-memory cert until the next background reload completes.
try {
await this.propagateCertToSiblings(domain, newCert);
} catch (err: unknown) {
// Best-effort: failure here doesn't undo the cert issuance, just log.
logger.log('warn', `Failed to propagate force-renewed cert to siblings of ${domain}: ${(err as Error).message}`);
}
}
// Clear status map entry so it gets refreshed by the certificate-issued event
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}` };
// Trigger the full route apply pipeline:
// applyRoutes → updateRoutes → provisionCertificatesViaCallback →
// certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
// bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
// certificate-issued event → certificateStatusMap updated
try {
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
// Fallback when DB is disabled and there is no RouteConfigManager
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
/**
* After a force-renew, walk every route in the smartproxy that resolves to
* the same cert identity as `forcedDomain` and write the freshly-issued cert
* PEM into ProxyCertDoc for each. This guarantees that the next applyRoutes
* → provisionCertificatesViaCallback iteration will hot-swap every sibling's
* rust loaded_certs entry with the new (correct) PEM, rather than relying on
* the in-memory cert returned by smartacme's per-domain cache.
*
* Why this is necessary:
* Rust's `loaded_certs` is a HashMap<domain, TlsCertConfig>. Each
* bridge.loadCertificate(domain, ...) only swaps that one entry. The
* fire-and-forget cert provisioning path triggered by updateRoutes does
* eventually iterate every auto-cert route, but it returns the cached
* (broken pre-fix) cert from smartacme's per-domain mutex. With this
* helper, ProxyCertDoc is updated synchronously to the correct PEM before
* applyRoutes runs, so even the transient window stays consistent.
*/
private async propagateCertToSiblings(
forcedDomain: string,
newCert: plugins.smartacme.Cert,
): Promise<void> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) return;
const certIdentity = deriveCertDomainName(forcedDomain);
if (!certIdentity) return;
// Collect every route domain whose cert identity matches.
const affected = new Set<string>();
for (const route of smartProxy.routeManager.getRoutes()) {
if (!route.match.domains) continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const routeDomain of routeDomains) {
if (deriveCertDomainName(routeDomain) === certIdentity) {
affected.add(routeDomain);
}
}
}
// Fallback: try provisioning via the first matching route
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length > 0) {
if (affected.size === 0) return;
// Parse expiry from PEM (defense-in-depth — same pattern as
// ts/classes.dcrouter.ts:988-995 and the existing certStore.save callback).
let validUntil = newCert.validUntil;
let validFrom: number | undefined;
if (newCert.publicKey) {
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}` };
}
const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
validUntil = new Date(x509.validTo).getTime();
validFrom = new Date(x509.validFrom).getTime();
} catch { /* fall back to smartacme's value */ }
}
return { success: false, message: `No routes found for domain '${domain}'` };
// Persist new cert PEM under each affected route domain
for (const routeDomain of affected) {
let doc = await ProxyCertDoc.findByDomain(routeDomain);
if (!doc) {
doc = new ProxyCertDoc();
doc.domain = routeDomain;
}
doc.publicKey = newCert.publicKey;
doc.privateKey = newCert.privateKey;
doc.ca = '';
doc.validUntil = validUntil || 0;
doc.validFrom = validFrom || 0;
await doc.save();
// Clear status so the next event refresh shows the new cert
dcRouter.certificateStatusMap.delete(routeDomain);
}
logger.log(
'info',
`Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`,
);
}
/**
@@ -364,9 +542,12 @@ export class CertificateHandler {
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
// Delete from smartdata document classes
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
// Delete from smartdata document classes (try base domain first, then exact)
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (acmeDoc) {
await acmeDoc.delete();
}
+13 -1
View File
@@ -2,6 +2,7 @@ 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';
import { requireOpsAuth } from '../helpers/auth.js';
export class ConfigHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,7 @@ export class ConfigHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'config:read' });
const config = await this.getConfiguration();
return {
config,
@@ -123,6 +125,15 @@ export class ConfigHandler {
ttl: r.ttl,
}));
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
let dnsChallengeEnabled = false;
try {
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
} catch {
dnsChallengeEnabled = false;
}
const dns: interfaces.requests.IConfigData['dns'] = {
enabled: !!dcRouter.dnsServer,
port: 53,
@@ -130,7 +141,7 @@ export class ConfigHandler {
scopes: opts.dnsScopes || [],
recordCount: dnsRecords.length,
records: dnsRecords,
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
dnsChallenge: dnsChallengeEnabled,
};
// --- TLS ---
@@ -197,6 +208,7 @@ export class ConfigHandler {
hubDomain: riCfg?.hubDomain || null,
tlsMode,
connectedEdgeIps,
performance: riCfg?.performance,
};
return {
@@ -0,0 +1,180 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD + connection-test handlers for DnsProviderDoc.
*
* Auth: same dual-mode pattern as TargetProfileHandler — admin JWT or
* API token with the appropriate `dns-providers:read|write` scope.
*/
export class DnsProviderHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
// Get all providers — prepends the built-in DcRouter pseudo-provider
// so operators see a uniform "who serves this?" list that includes the
// authoritative dcrouter alongside external accounts.
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProviders>(
'getDnsProviders',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
const synthetic: interfaces.data.IDnsProviderPublic = {
id: interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID,
name: 'DcRouter',
type: 'dcrouter',
status: 'ok',
createdAt: 0,
updatedAt: 0,
createdBy: 'system',
hasCredentials: false,
builtIn: true,
};
const real = dnsManager ? await dnsManager.listProviders() : [];
return { providers: [synthetic, ...real] };
},
),
);
// Get single provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProvider>(
'getDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { provider: null };
return { provider: await dnsManager.getProvider(dataArg.id) };
},
),
);
// Create provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsProvider>(
'createDnsProvider',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'dns-providers:write');
if (dataArg.type === 'dcrouter') {
return {
success: false,
message: 'cannot create built-in provider',
};
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) {
return { success: false, message: 'DnsManager not initialized (DB disabled?)' };
}
const id = await dnsManager.createProvider({
name: dataArg.name,
type: dataArg.type,
credentials: dataArg.credentials,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsProvider>(
'updateDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:write');
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return { success: false, message: 'cannot edit built-in provider' };
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
const ok = await dnsManager.updateProvider(dataArg.id, {
name: dataArg.name,
credentials: dataArg.credentials,
});
return ok ? { success: true } : { success: false, message: 'Provider not found' };
},
),
);
// Delete provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsProvider>(
'deleteDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:write');
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return { success: false, message: 'cannot delete built-in provider' };
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false);
},
),
);
// Test provider connection
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestDnsProvider>(
'testDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return {
ok: false,
error: 'built-in provider has no external connection to test',
testedAt: Date.now(),
};
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) {
return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() };
}
return await dnsManager.testProvider(dataArg.id);
},
),
);
// List domains visible to a provider's account (without importing them)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListProviderDomains>(
'listProviderDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
if (dataArg.providerId === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return {
success: false,
message: 'built-in provider has no external domain listing — use "Add DcRouter Domain" instead',
};
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {
const domains = await dnsManager.listProviderDomains(dataArg.providerId);
return { success: true, domains };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handlers for DnsRecordDoc.
*/
export class DnsRecordHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
// Get records by domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
'getDnsRecords',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { records: [] };
const docs = await dnsManager.listRecordsForDomain(dataArg.domainId);
return { records: docs.map((d) => dnsManager.toPublicRecord(d)) };
},
),
);
// Get single record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecord>(
'getDnsRecord',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { record: null };
const doc = await dnsManager.getRecord(dataArg.id);
return { record: doc ? dnsManager.toPublicRecord(doc) : null };
},
),
);
// Create record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
'createDnsRecord',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'dns-records:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.createRecord({
domainId: dataArg.domainId,
name: dataArg.name,
type: dataArg.type,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
createdBy: userId,
});
},
),
);
// Update record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
'updateDnsRecord',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.updateRecord({
id: dataArg.id,
name: dataArg.name,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
});
},
),
);
// Delete record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
'deleteDnsRecord',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.deleteRecord(dataArg.id);
},
),
);
}
}
+162
View File
@@ -0,0 +1,162 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handlers for DomainDoc.
*/
export class DomainHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
// Get all domains
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
'getDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { domains: [] };
const docs = await dnsManager.listDomains();
return { domains: docs.map((d) => dnsManager.toPublicDomain(d)) };
},
),
);
// Get single domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
'getDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { domain: null };
const doc = await dnsManager.getDomain(dataArg.id);
return { domain: doc ? dnsManager.toPublicDomain(doc) : null };
},
),
);
// Create dcrouter-hosted domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDomain>(
'createDomain',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {
const id = await dnsManager.createDcrouterDomain({
name: dataArg.name,
description: dataArg.description,
createdBy: userId,
});
return { success: true, id };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Import domains from a provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportDomain>(
'importDomain',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {
const importedIds = await dnsManager.importDomainsFromProvider({
providerId: dataArg.providerId,
domainNames: dataArg.domainNames,
createdBy: userId,
});
return { success: true, importedIds };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Update domain metadata
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDomain>(
'updateDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
const ok = await dnsManager.updateDomain(dataArg.id, {
description: dataArg.description,
});
return ok ? { success: true } : { success: false, message: 'Domain not found' };
},
),
);
// Delete domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDomain>(
'deleteDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
const ok = await dnsManager.deleteDomain(dataArg.id);
return ok ? { success: true } : { success: false, message: 'Domain not found' };
},
),
);
// Force-resync provider domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomain>(
'syncDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.syncDomain(dataArg.id);
},
),
);
// Migrate domain between dcrouter-hosted and provider-managed
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
'migrateDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.migrateDomain({
id: dataArg.id,
targetSource: dataArg.targetSource,
targetProviderId: dataArg.targetProviderId,
deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords,
});
},
),
);
}
}
@@ -0,0 +1,178 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD + DNS provisioning handler for email domains.
*
* Auth: admin JWT or API token with `email-domains:read` / `email-domains:write` scope.
*/
export class EmailDomainHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private get manager() {
return this.opsServerRef.dcRouterRef.emailDomainManager;
}
private registerHandlers(): void {
// List all email domains
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomains>(
'getEmailDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) return { domains: [] };
return { domains: await this.manager.getAll() };
},
),
);
// Get single email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomain>(
'getEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) return { domain: null };
return { domain: await this.manager.getById(dataArg.id) };
},
),
);
// Create email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateEmailDomain>(
'createEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
const domain = await this.manager.createEmailDomain({
linkedDomainId: dataArg.linkedDomainId,
subdomain: dataArg.subdomain,
dkimSelector: dataArg.dkimSelector,
dkimKeySize: dataArg.dkimKeySize,
rotateKeys: dataArg.rotateKeys,
rotationIntervalDays: dataArg.rotationIntervalDays,
});
return { success: true, domain };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Update email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailDomain>(
'updateEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
await this.manager.updateEmailDomain(dataArg.id, {
rotateKeys: dataArg.rotateKeys,
rotationIntervalDays: dataArg.rotationIntervalDays,
rateLimits: dataArg.rateLimits,
});
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Delete email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteEmailDomain>(
'deleteEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
await this.manager.deleteEmailDomain(dataArg.id);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Validate DNS records
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ValidateEmailDomain>(
'validateEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
const records = await this.manager.validateDns(dataArg.id);
const domain = await this.manager.getById(dataArg.id);
return { success: true, domain: domain ?? undefined, records };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Get required DNS records
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomainDnsRecords>(
'getEmailDomainDnsRecords',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) return { records: [] };
return { records: await this.manager.getRequiredDnsRecords(dataArg.id) };
},
),
);
// Auto-provision DNS records
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ProvisionEmailDomainDns>(
'provisionEmailDomainDns',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
const provisioned = await this.manager.provisionDnsRecords(dataArg.id);
return { success: true, provisioned };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}
+12 -19
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class EmailOpsHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getAllEmails',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
const emails = this.getAllQueueEmails();
return { emails };
}
@@ -29,6 +31,7 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
const email = this.getEmailDetail(dataArg.emailId);
return { email };
}
@@ -42,13 +45,17 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
'resendEmail',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'emails:write',
requireAdminIdentity: true,
});
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return { success: false, error: 'Email server not available' };
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(dataArg.emailId);
const item = emailServer.getQueueItem(dataArg.emailId);
if (!item) {
return { success: false, error: 'Email not found in queue' };
@@ -82,22 +89,10 @@ export class EmailOpsHandler {
*/
private getAllQueueEmails(): interfaces.requests.IEmail[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
if (!emailServer) {
return [];
}
const queue = emailServer.deliveryQueue;
const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) {
return [];
}
const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) {
emails.push(this.mapQueueItemToEmail(item));
}
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
// Sort by createdAt descending (newest first)
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
@@ -110,12 +105,10 @@ export class EmailOpsHandler {
*/
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
if (!emailServer) {
return null;
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(emailId);
const item = emailServer.getQueueItem(emailId);
if (!item) {
return null;
+10 -2
View File
@@ -10,5 +10,13 @@ export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';
export * from './vpn.handler.js';
export * from './security-profile.handler.js';
export * from './network-target.handler.js';
export * from './source-profile.handler.js';
export * from './target-profile.handler.js';
export * from './network-target.handler.js';
export * from './users.handler.js';
export * from './dns-provider.handler.js';
export * from './domain.handler.js';
export * from './dns-record.handler.js';
export * from './acme-config.handler.js';
export * from './email-domain.handler.js';
export * from './workhoster.handler.js';
+52 -37
View File
@@ -2,6 +2,7 @@ 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';
import { requireOpsAuth } from '../helpers/auth.js';
// Module-level singleton: the log push destination is added once and reuses
// the current OpsServer reference so it survives OpsServer restarts without
@@ -40,6 +41,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
'getRecentLogs',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'logs:read' });
const logs = await this.getRecentLogs(
dataArg.level,
dataArg.category,
@@ -63,6 +65,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
'getLogStream',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'logs:read' });
// Create a virtual stream for log streaming
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
@@ -255,7 +258,7 @@ export class LogsHandler {
} {
let intervalId: NodeJS.Timeout | null = null;
let stopped = false;
let logIndex = 0;
let lastTimestamp = Date.now();
const stop = () => {
stopped = true;
@@ -284,53 +287,65 @@ export class LogsHandler {
return;
}
// For follow mode, simulate real-time log streaming
// For follow mode, tail real log entries from the in-memory buffer
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'];
// Fetch new entries since last poll
const rawEntries = logBuffer.getEntries({
since: lastTimestamp,
limit: 50,
});
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
if (rawEntries.length === 0) return;
// Filter by requested criteria
if (levelFilter && !levelFilter.includes(mockLevel)) return;
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
for (const raw of rawEntries) {
const mappedLevel = LogsHandler.mapLogLevel(raw.level);
const mappedCategory = LogsHandler.deriveCategory(
(raw as any).context?.zone,
raw.message,
);
const logEntry = {
timestamp: Date.now(),
level: mockLevel,
category: mockCategory,
message: `Real-time log ${logIndex++} from ${mockCategory}`,
metadata: {
requestId: plugins.uuid.v4(),
},
};
// Apply filters
if (levelFilter && !levelFilter.includes(mappedLevel)) continue;
if (categoryFilter && !categoryFilter.includes(mappedCategory)) continue;
const logData = JSON.stringify(logEntry);
const encoder = new TextEncoder();
try {
// Use a timeout to detect hung streams (sendData can hang if the
// VirtualStream's keepAlive loop has ended)
let timeoutHandle: ReturnType<typeof setTimeout>;
await Promise.race([
virtualStream.sendData(encoder.encode(logData)).then((result) => {
clearTimeout(timeoutHandle);
return result;
}),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
}),
]);
} catch {
// Stream closed, errored, or timed out — clean up
stop();
const logEntry = {
timestamp: raw.timestamp || Date.now(),
level: mappedLevel,
category: mappedCategory,
message: raw.message,
metadata: (raw as any).data,
};
const logData = JSON.stringify(logEntry);
const encoder = new TextEncoder();
try {
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();
return;
}
}
// Advance the watermark past all entries we just processed
const newest = rawEntries[rawEntries.length - 1];
if (newest.timestamp && newest.timestamp >= lastTimestamp) {
lastTimestamp = newest.timestamp + 1;
}
}, 2000);
};
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class NetworkTargetHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -14,29 +15,11 @@ export class NetworkTargetHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
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 */ }
}
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');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
@@ -135,7 +118,7 @@ export class NetworkTargetHandler {
const result = await resolver.deleteTarget(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
manager.getRoutes(),
);
if (result.success && dataArg.force) {
@@ -158,7 +141,7 @@ export class NetworkTargetHandler {
if (!resolver || !manager) {
return { routes: [] };
}
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
},
),
+31
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RadiusHandler {
constructor(private opsServerRef: OpsServer) {
@@ -19,6 +20,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
'getRadiusClients',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -43,6 +45,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
'setRadiusClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -64,6 +70,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
'removeRadiusClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -88,6 +98,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
'getVlanMappings',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -124,6 +135,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
'setVlanMapping',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -156,6 +171,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
'removeVlanMapping',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -177,6 +196,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
'updateVlanConfig',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -209,6 +232,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
'testVlanAssignment',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -243,6 +267,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
'getRadiusSessions',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -292,6 +317,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
'disconnectRadiusSession',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -317,6 +346,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
'getRadiusAccountingSummary',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -354,6 +384,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
'getRadiusStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RemoteIngressHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
'getRemoteIngresses',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { edges: [] };
@@ -29,6 +31,7 @@ export class RemoteIngressHandler {
...e,
secret: '********', // Never expose secrets via API
effectiveListenPorts: manager.getEffectiveListenPorts(e),
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(e),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
};
@@ -45,6 +48,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -77,6 +84,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -102,6 +113,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -133,6 +148,7 @@ export class RemoteIngressHandler {
...edge,
secret: '********',
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(edge),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
},
@@ -146,6 +162,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -173,6 +193,7 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!tunnelManager) {
return { statuses: [] };
@@ -187,6 +208,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
'getRemoteIngressConnectionToken',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };

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