Compare commits

...

153 Commits

Author SHA1 Message Date
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
185 changed files with 23404 additions and 8195 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
+3 -11
View File
@@ -30,7 +30,7 @@
"@git.zone/cli": {
"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.",
@@ -67,19 +67,11 @@
"accessLevel": "public"
}
},
"@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"],
"registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter",
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
"code.foss.global": "serve.zone/dcrouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
}
}
+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"
+483
View File
@@ -1,5 +1,488 @@
# Changelog
## 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
+32 -30
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "12.8.1",
"version": "13.28.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -12,7 +12,7 @@
"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)",
@@ -23,11 +23,12 @@
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tsbundle": "^2.10.1",
"@git.zone/tsdocker": "^2.2.5",
"@git.zone/tsrun": "^2.0.3",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.2"
"@git.zone/tswatch": "^3.3.3",
"@types/node": "^25.6.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
@@ -35,40 +36,41 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.55.1",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.4.0",
"@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^2.1.1",
"@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.0",
"@push.rocks/smartdns": "^7.9.2",
"@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.3.1",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.7.1",
"@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.10.0",
"@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.19.2",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.11.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.5.0",
"@serve.zone/remoteingress": "^4.17.1",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.2.7",
"lru-cache": "^11.3.6",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"keywords": [
"mail service",
+2355 -2500
View File
File diff suppressed because it is too large Load Diff
+176 -1581
View File
File diff suppressed because it is too large Load Diff
+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();
+200
View File
@@ -0,0 +1,200 @@
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: {
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();
+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();
+386
View File
@@ -0,0 +1,386 @@
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, 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 route of await RouteDoc.findAll()) {
await route.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
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();
+242
View File
@@ -0,0 +1,242 @@
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;
}) {
return {
connections: {
active: () => 0,
total: () => 0,
byRoute: () => args.connectionsByRoute,
byIP: () => new Map<string, number>(),
topIPs: () => [],
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: () => new Map<string, { in: number; out: number }>(),
},
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();
});
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);
+129
View File
@@ -0,0 +1,129 @@
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();
}
};
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('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 {
+110
View File
@@ -0,0 +1,110 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-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 = {
setVpnClientIpsResolver: (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();
});
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();
+562
View File
@@ -0,0 +1,562 @@
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: {
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
+5 -5
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,9 +44,9 @@ 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' },
],
},
dbConfig: { enabled: true },
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '12.8.1',
version: '13.28.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,
};
}
}
+615 -273
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,
+426 -205
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,66 +11,109 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: 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 getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
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 setVpnClientIpsResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
): void {
this.getVpnClientIpsForRoute = 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,
});
}
@@ -79,11 +121,11 @@ export class RouteConfigManager {
}
// =========================================================================
// Programmatic route CRUD
// Route CRUD
// =========================================================================
public async createRoute(
route: plugins.smartproxy.IRouteConfig,
route: IDcRouterRouteConfig,
createdBy: string,
enabled = true,
metadata?: IRouteMetadata,
@@ -93,28 +135,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;
@@ -123,187 +166,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`,
});
}
}
@@ -327,12 +536,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);
}
@@ -342,64 +551,76 @@ 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);
return this.injectVpnSecurity(preparedRoute, routeId);
}
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes);
}
private injectVpnSecurity(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const vpnCallback = this.getVpnClientIpsForRoute;
if (!vpnCallback) return route;
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingEntries, ...vpnEntries],
},
};
}
}
+538
View File
@@ -0,0 +1,538 @@
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';
/**
* 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[];
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,
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);
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 → client IPs
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns IP allow entries for injection into ipAllowList.
*
* 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.
*/
public getMatchingClientIps(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(),
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
if (!client.enabled || !client.assignedIp) 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 (fullAccess) {
entries.push(client.assignedIp);
} else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, 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;
if (this.routeMatchesProfile(
route.route as IDcRouterRouteConfig,
routeId,
profile,
routeNameIndex,
)) {
const routeDomains = (route.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
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: string[] = (route.match as any)?.domains || [];
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 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,
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.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.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,48 @@
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 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';
+208 -35
View File
@@ -553,12 +553,16 @@ 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 }>,
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 +576,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 +594,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 +622,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 +682,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,16 +706,178 @@ 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 }));
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
// 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,
totalDataTransferred,
throughputHistory,
throughputByIP,
requestsPerSecond,
requestsTotal,
backends,
frontendProtocols,
backendProtocols,
domainActivity,
};
}, 1000); // 1s cache — matches typical dashboard poll interval
}
@@ -851,4 +1024,4 @@ export class MetricsManager {
return { queries };
}
}
}
+19 -3
View File
@@ -29,8 +29,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;
@@ -90,8 +98,16 @@ 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');
}
@@ -105,4 +121,4 @@ export class OpsServer {
await this.server.stop();
}
}
}
}
@@ -0,0 +1,94 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.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> {
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');
}
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 };
}
},
),
);
}
}
+23 -4
View File
@@ -43,14 +43,33 @@ 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 users Map — excludes password fields.
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public listUsers(): Array<{ id: string; username: string; role: string }> {
return Array.from(this.users.values()).map((user) => ({
id: user.id,
username: user.username,
role: user.role,
}));
}
private registerHandlers(): void {
@@ -237,4 +256,4 @@ export class AdminHandler {
name: 'adminIdentityGuard',
}
);
}
}
@@ -26,6 +26,7 @@ export class ApiTokenHandler {
dataArg.scopes,
dataArg.expiresInDays ?? null,
dataArg.identity.userId,
dataArg.policy,
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
+243 -45
View File
@@ -2,23 +2,75 @@ 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';
/**
* 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> {
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 */ }
}
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
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 +78,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) => {
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 +231,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 +245,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 +307,11 @@ export class CertificateHandler {
}
}
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
status = 'failed';
error = error || backoffInfo.lastError;
}
certificates.push({
domain,
routeNames: info.routeNames,
@@ -291,7 +354,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 +369,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,7 +390,16 @@ 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, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
@@ -331,31 +414,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, { forceRenew: forceRenew ?? false });
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}` };
// 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 +559,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();
}
+11 -1
View File
@@ -123,6 +123,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 +139,7 @@ export class ConfigHandler {
scopes: opts.dnsScopes || [],
recordCount: dnsRecords.length,
records: dnsRecords,
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
dnsChallenge: dnsChallengeEnabled,
};
// --- TLS ---
@@ -197,6 +206,7 @@ export class ConfigHandler {
hubDomain: riCfg?.hubDomain || null,
tlsMode,
connectedEdgeIps,
performance: riCfg?.performance,
};
return {
@@ -0,0 +1,197 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.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> {
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');
}
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 };
}
},
),
);
}
}
+127
View File
@@ -0,0 +1,127 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.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> {
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');
}
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);
},
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.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> {
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');
}
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,195 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.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> {
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');
}
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 };
}
},
),
);
}
}
+5 -19
View File
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
}
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 +82,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 +98,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';
@@ -135,7 +135,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 +158,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 })) };
},
),
@@ -29,6 +29,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,
};
@@ -133,6 +134,7 @@ export class RemoteIngressHandler {
...edge,
secret: '********',
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(edge),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
},
@@ -72,7 +72,7 @@ export class RouteManagementHandler {
return { success: false, message: 'Route management not initialized' };
}
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
return { success: true, storedRouteId: id };
return { success: true, routeId: id };
},
),
);
@@ -87,12 +87,12 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.updateRoute(dataArg.id, {
const result = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any,
enabled: dataArg.enabled,
metadata: dataArg.metadata,
});
return { success: ok, message: ok ? undefined : 'Route not found' };
return result;
},
),
);
@@ -107,45 +107,12 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.deleteRoute(dataArg.id);
return { success: ok, message: ok ? undefined : 'Route not found' };
return manager.deleteRoute(dataArg.id);
},
),
);
// Set override on a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
return { success: true };
},
),
);
// Remove override from a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.removeOverride(dataArg.routeName);
return { success: ok, message: ok ? undefined : 'Override not found' };
},
),
);
// Toggle programmatic route
// Toggle route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute',
@@ -155,8 +122,7 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Route not found' };
return manager.toggleRoute(dataArg.id, dataArg.enabled);
},
),
);
+145 -28
View File
@@ -50,19 +50,21 @@ export class SecurityHandler {
localAddress: conn.destination.ip,
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any,
bytesReceived: Math.floor(conn.bytesTransferred / 2),
bytesSent: Math.floor(conn.bytesTransferred / 2),
state: conn.status === 'active' ? 'connected' : conn.status as any,
bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0,
connectionCount: conn.bytesTransferred || 1,
}));
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
const summary = {
total: connectionInfos.length,
total: totalConnections,
byProtocol: connectionInfos.reduce((acc, conn) => {
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1;
acc[conn.protocol] = (acc[conn.protocol] || 0) + (conn.connectionCount || 1);
return acc;
}, {} as { [protocol: string]: number }),
byState: connectionInfos.reduce((acc, conn) => {
acc[conn.state] = (acc[conn.state] || 0) + 1;
acc[conn.state] = (acc[conn.state] || 0) + (conn.connectionCount || 1);
return acc;
}, {} as { [state: string]: number }),
};
@@ -96,12 +98,16 @@ export class SecurityHandler {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
domainActivity: networkStats.domainActivity || [],
requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [],
frontendProtocols: networkStats.frontendProtocols || null,
backendProtocols: networkStats.backendProtocols || null,
};
}
@@ -110,12 +116,16 @@ export class SecurityHandler {
connectionsByIP: [],
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
topIPsByBandwidth: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
domainActivity: [],
requestsPerSecond: 0,
requestsTotal: 0,
backends: [],
frontendProtocols: null,
backendProtocols: null,
};
}
)
@@ -147,6 +157,113 @@ export class SecurityHandler {
}
)
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityBlockRules>(
'listSecurityBlockRules',
async () => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { rules: manager ? await manager.listBlockRules() : [] };
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListIpIntelligence>(
'listIpIntelligence',
async () => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { records: manager ? await manager.listIpIntelligence() : [] };
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCompiledSecurityPolicy>(
'getCompiledSecurityPolicy',
async () => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return {
policy: manager
? await manager.compilePolicy()
: { blockedIps: [], blockedCidrs: [] },
};
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityPolicyAudit>(
'listSecurityPolicyAudit',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { events: manager ? await manager.listAuditEvents(dataArg.limit || 100) : [] };
},
),
);
const adminRouter = this.opsServerRef.adminRouter;
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityBlockRule>(
'createSecurityBlockRule',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const rule = await manager.createBlockRule({
type: dataArg.type,
value: dataArg.value,
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, dataArg.identity.userId);
return { success: true, rule };
},
),
);
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityBlockRule>(
'updateSecurityBlockRule',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const rule = await manager.updateBlockRule(dataArg.id, {
value: dataArg.value,
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, dataArg.identity.userId);
return rule ? { success: true, rule } : { success: false, message: 'Rule not found' };
},
),
);
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityBlockRule>(
'deleteSecurityBlockRule',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const success = await manager.deleteBlockRule(dataArg.id, dataArg.identity.userId);
return { success, message: success ? undefined : 'Rule not found' };
},
),
);
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshIpIntelligence>(
'refreshIpIntelligence',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const record = await manager.refreshIpIntelligence(dataArg.ipAddress);
return record
? { success: true, record }
: { success: false, message: 'IP address is invalid or not public' };
},
),
);
}
private async collectSecurityMetrics(): Promise<{
@@ -251,31 +368,31 @@ export class SecurityHandler {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Use IP-based connection data from the new metrics API
// One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
for (const [ip, count] of networkStats.connectionsByIP) {
// Create a connection entry for each active IP connection
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
connections.push({
id: `conn-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: Math.floor(Math.random() * 50000) + 10000, // High port range
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
status: 'active',
});
}
const tp = networkStats.throughputByIP?.get(ip);
connections.push({
id: `ip-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: 0,
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: 0,
bytesTransferred: count, // Store connection count here
status: 'active',
// Attach real throughput for the handler mapping
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} as any);
}
} else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available
@@ -331,4 +448,4 @@ export class SecurityHandler {
limits: [],
};
}
}
}
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class SecurityProfileHandler {
export class SourceProfileHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
@@ -40,12 +40,12 @@ export class SecurityProfileHandler {
}
private registerHandlers(): void {
// Get all security profiles
// Get all source profiles
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>(
'getSecurityProfiles',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfiles>(
'getSourceProfiles',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
await this.requireAuth(dataArg, 'source-profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { profiles: [] };
@@ -55,12 +55,12 @@ export class SecurityProfileHandler {
),
);
// Get a single security profile
// Get a single source profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>(
'getSecurityProfile',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfile>(
'getSourceProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
await this.requireAuth(dataArg, 'source-profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { profile: null };
@@ -70,12 +70,12 @@ export class SecurityProfileHandler {
),
);
// Create a security profile
// Create a source profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>(
'createSecurityProfile',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSourceProfile>(
'createSourceProfile',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'profiles:write');
const userId = await this.requireAuth(dataArg, 'source-profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { success: false, message: 'Reference resolver not initialized' };
@@ -92,12 +92,12 @@ export class SecurityProfileHandler {
),
);
// Update a security profile
// Update a source profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>(
'updateSecurityProfile',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSourceProfile>(
'updateSourceProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write');
await this.requireAuth(dataArg, 'source-profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
@@ -121,12 +121,12 @@ export class SecurityProfileHandler {
),
);
// Delete a security profile
// Delete a source profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>(
'deleteSecurityProfile',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSourceProfile>(
'deleteSourceProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write');
await this.requireAuth(dataArg, 'source-profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
@@ -136,7 +136,7 @@ export class SecurityProfileHandler {
const result = await resolver.deleteProfile(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
manager.getRoutes(),
);
// If force-deleted with affected routes, re-apply
@@ -149,18 +149,18 @@ export class SecurityProfileHandler {
),
);
// Get routes using a security profile
// Get routes using a source profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>(
'getSecurityProfileUsage',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfileUsage>(
'getSourceProfileUsage',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
await this.requireAuth(dataArg, 'source-profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { routes: [] };
}
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
},
),
+68 -9
View File
@@ -291,6 +291,21 @@ export class StatsHandler {
}
}
// Build connectionDetails from real per-IP data
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
for (const [ip, count] of stats.connectionsByIP) {
const tp = stats.throughputByIP?.get(ip);
connectionDetails.push({
remoteAddress: ip,
protocol: 'https',
state: 'connected',
startTime: 0,
bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0,
connectionCount: count,
});
}
metrics.network = {
totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond,
@@ -301,16 +316,24 @@ export class StatsHandler {
out: stats.totalDataTransferred.bytesOut,
},
activeConnections: serverStats.activeConnections,
connectionDetails: [],
connectionDetails,
topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip,
requests: ip.count,
connections: ip.count,
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
})),
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
endpoint: ip.ip,
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0,
backends: stats.backends || [],
frontendProtocols: stats.frontendProtocols || null,
backendProtocols: stats.backendProtocols || null,
};
})()
);
@@ -508,13 +531,49 @@ export class StatsHandler {
nextRetry?: number;
}>;
}> {
// TODO: Implement actual queue status collection
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
};
}
const queueStats = emailServer.getQueueStats();
const items = emailServer.getQueueItems()
.sort((a, b) => {
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
return right - left;
})
.slice(0, 50)
.map((item) => {
const emailLike = item.processingResult;
const recipients = Array.isArray(emailLike?.to)
? emailLike.to
: Array.isArray(emailLike?.email?.to)
? emailLike.email.to
: [];
const subject = emailLike?.subject || emailLike?.email?.subject || '';
return {
id: item.id,
recipient: recipients[0] || '',
subject,
status: item.status,
attempts: item.attempts,
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
};
});
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
pending: queueStats.status.pending,
active: queueStats.status.processing,
failed: queueStats.status.failed,
retrying: queueStats.status.deferred,
items,
};
}
@@ -578,4 +637,4 @@ export class StatsHandler {
],
};
}
}
}
@@ -0,0 +1,157 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class TargetProfileHandler {
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> {
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');
}
private registerHandlers(): void {
// Get all target profiles
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfiles>(
'getTargetProfiles',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:read');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { profiles: [] };
}
return { profiles: manager.listProfiles() };
},
),
);
// Get a single target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfile>(
'getTargetProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:read');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { profile: null };
}
return { profile: manager.getProfile(dataArg.id) || null };
},
),
);
// Create a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateTargetProfile>(
'createTargetProfile',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'target-profiles:write');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { success: false, message: 'Target profile manager not initialized' };
}
const id = await manager.createProfile({
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateTargetProfile>(
'updateTargetProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:write');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { success: false, message: 'Not initialized' };
}
await manager.updateProfile(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
});
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
return { success: true };
},
),
);
// Delete a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteTargetProfile>(
'deleteTargetProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:write');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { success: false, message: 'Not initialized' };
}
const result = await manager.deleteProfile(dataArg.id, dataArg.force);
if (result.success) {
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
}
return result;
},
),
);
// Get VPN clients using a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfileUsage>(
'getTargetProfileUsage',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:read');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { clients: [] };
}
return { clients: await manager.getProfileUsage(dataArg.id) };
},
),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
* so admin middleware enforces auth + role check before the handler runs.
* User data is owned by AdminHandler; this handler just exposes a safe
* projection of it via TypedRequest.
*/
export class UsersHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const router = this.opsServerRef.adminRouter;
// List users (admin-only, read-only)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
'listUsers',
async (_dataArg) => {
const users = this.opsServerRef.adminHandler.listUsers();
return { users };
},
),
);
}
}
+4 -8
View File
@@ -25,13 +25,12 @@ export class VpnHandler {
const clients = manager.listClients().map((c) => ({
clientId: c.clientId,
enabled: c.enabled,
serverDefinedClientTags: c.serverDefinedClientTags,
targetProfileIds: c.targetProfileIds,
description: c.description,
assignedIp: c.assignedIp,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
expiresAt: c.expiresAt,
forceDestinationSmartproxy: c.forceDestinationSmartproxy ?? true,
destinationAllowList: c.destinationAllowList,
destinationBlockList: c.destinationBlockList,
useHostIp: c.useHostIp,
@@ -120,9 +119,8 @@ export class VpnHandler {
try {
const bundle = await manager.createClient({
clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
targetProfileIds: dataArg.targetProfileIds,
description: dataArg.description,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,
@@ -142,13 +140,12 @@ export class VpnHandler {
client: {
clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true,
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
targetProfileIds: persistedClient?.targetProfileIds,
description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp,
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt,
forceDestinationSmartproxy: persistedClient?.forceDestinationSmartproxy ?? true,
destinationAllowList: persistedClient?.destinationAllowList,
destinationBlockList: persistedClient?.destinationBlockList,
useHostIp: persistedClient?.useHostIp,
@@ -179,8 +176,7 @@ export class VpnHandler {
try {
await manager.updateClient(dataArg.clientId, {
description: dataArg.description,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
targetProfileIds: dataArg.targetProfileIds,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,
+664
View File
@@ -0,0 +1,664 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
type TAuthContext = {
userId: string;
isAdmin: boolean;
token?: interfaces.data.IStoredApiToken;
};
export class WorkHosterHandler {
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<TAuthContext> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return { userId: request.identity.userId, isAdmin: true };
} 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 { userId: token.createdBy, isAdmin: token.policy?.role === 'admin', token };
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private async requireAdmin(request: { identity?: interfaces.data.IIdentity }): Promise<string> {
if (request.identity?.jwt) {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
}
throw new plugins.typedrequest.TypedResponseError('admin identity required');
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
'getGatewayCapabilities',
async (dataArg) => {
await this.requireAuth(dataArg, 'gateway-clients:read');
return { capabilities: this.getGatewayCapabilities() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientContext>(
'getGatewayClientContext',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
return {
context: this.getGatewayClientContext(auth),
capabilities: this.getGatewayCapabilities(),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListGatewayClients>(
'listGatewayClients',
async (dataArg) => {
await this.requireAdmin(dataArg);
return { gatewayClients: await this.listManagedGatewayClients() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClient>(
'createGatewayClient',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
try {
const gatewayClient = await manager.createClient({
id: dataArg.id,
type: dataArg.type,
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
createdBy: userId,
});
return { success: true, gatewayClient };
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateGatewayClient>(
'updateGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const gatewayClient = await manager.updateClient(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
enabled: dataArg.enabled,
});
return gatewayClient
? { success: true, gatewayClient }
: { success: false, message: 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteGatewayClient>(
'deleteGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const success = await manager.deleteClient(dataArg.id);
return { success, message: success ? undefined : 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClientToken>(
'createGatewayClientToken',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId);
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!gatewayClient || !gatewayClient.enabled) {
return { success: false, message: 'Gateway client not found or disabled' };
}
if (!tokenManager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await tokenManager.createToken(
dataArg.name?.trim() || `${gatewayClient.name} Token`,
['gateway-clients:read', 'gateway-clients:write'],
dataArg.expiresInDays ?? null,
userId,
{
role: 'gatewayClient',
scopes: ['gateway-clients:read', 'gateway-clients:write'],
gatewayClient: { type: gatewayClient.type, id: gatewayClient.id },
hostnamePatterns: gatewayClient.hostnamePatterns,
allowedRouteTargets: gatewayClient.allowedRouteTargets,
capabilities: gatewayClient.capabilities,
},
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>(
'getGatewayClientDomains',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
this.assertCapability(auth, 'readDomains');
return { domains: await this.listGatewayClientDomains(auth, dataArg.gatewayClientId) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDnsRecords>(
'getGatewayClientDnsRecords',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
this.assertCapability(auth, 'readDnsRecords');
return { records: await this.listGatewayClientDnsRecords(auth, dataArg.gatewayClientId) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkHosterDomains>(
'getWorkHosterDomains',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'workhosters:read');
this.assertCapability(auth, 'readDomains');
return { domains: await this.listGatewayClientDomains(auth) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncGatewayClientRoute>(
'syncGatewayClientRoute',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:write');
this.assertCapability(auth, 'syncRoutes');
return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete);
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppRoute>(
'syncWorkAppRoute',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'workhosters:write');
this.assertCapability(auth, 'syncRoutes');
const ownership: interfaces.data.IGatewayClientOwnership = {
gatewayClientType: dataArg.ownership.workHosterType,
gatewayClientId: dataArg.ownership.workHosterId,
appId: dataArg.ownership.workAppId,
hostname: dataArg.ownership.hostname,
};
return await this.syncGatewayClientRoute(auth, ownership, dataArg.route, dataArg.enabled, dataArg.delete);
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkAppMailIdentities>(
'getWorkAppMailIdentities',
async (dataArg) => {
await this.requireAuth(dataArg, 'workhosters:read');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) return { identities: [] };
return { identities: await manager.listMailIdentities(dataArg.ownership) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppMailIdentity>(
'syncWorkAppMailIdentity',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'workhosters:write');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) {
return { success: false, message: 'WorkApp mail manager not initialized' };
}
try {
return await manager.syncMailIdentity(dataArg, auth.userId);
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
}
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
const dcRouter = this.opsServerRef.dcRouterRef;
return {
routes: {
read: Boolean(dcRouter.routeConfigManager),
write: Boolean(dcRouter.routeConfigManager),
idempotentSync: Boolean(dcRouter.routeConfigManager),
},
domains: {
read: Boolean(dcRouter.dnsManager),
write: Boolean(dcRouter.dnsManager),
},
certificates: {
read: Boolean(dcRouter.smartProxy),
export: Boolean(dcRouter.smartProxy),
forceRenew: Boolean(dcRouter.smartProxy),
},
email: {
domains: Boolean(dcRouter.emailDomainManager),
inbound: Boolean(dcRouter.emailServer),
outbound: Boolean(dcRouter.emailServer),
},
remoteIngress: {
enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
},
dns: {
authoritative: Boolean(dcRouter.options.dnsScopes?.length),
providerManaged: Boolean(dcRouter.dnsManager),
},
http3: {
enabled: dcRouter.options.http3?.enabled !== false,
},
};
}
private getGatewayClientContext(auth: TAuthContext): interfaces.data.IGatewayClientContext {
const policy = auth.token?.policy;
const role = auth.isAdmin ? 'admin' : policy?.role || 'operator';
return {
role,
scopes: auth.token?.scopes || ['*'],
gatewayClient: policy?.gatewayClient,
hostnamePatterns: policy?.hostnamePatterns || [],
allowedRouteTargets: policy?.allowedRouteTargets || [],
capabilities: policy?.capabilities || {},
};
}
private async listManagedGatewayClients(): Promise<interfaces.data.IGatewayClient[]> {
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return [];
const clients = await manager.listClients();
const tokens = this.opsServerRef.dcRouterRef.apiTokenManager?.listTokens() || [];
return clients.map((client) => ({
...client,
tokenCount: tokens.filter((token) => token.policy?.gatewayClient?.id === client.id).length,
}));
}
private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string {
return [
ownership.workHosterType,
ownership.workHosterId,
ownership.workAppId,
ownership.hostname,
].map((part) => part.trim()).join(':');
}
private assertCapability(
auth: TAuthContext,
capability: keyof NonNullable<interfaces.data.IApiTokenPolicy['capabilities']>,
): void {
if (auth.isAdmin) return;
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
if (policy.capabilities?.[capability] === true) return;
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
}
private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
const policyClient = auth.token?.policy?.gatewayClient;
if (!policyClient) return requestedId;
if (requestedId && requestedId !== policyClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot access another gateway client');
}
return policyClient.id;
}
private resolveGatewayClientOwnership(
auth: TAuthContext,
ownership: interfaces.data.IGatewayClientOwnership,
): Required<interfaces.data.IGatewayClientOwnership> {
const policy = auth.token?.policy;
if (policy?.role === 'gatewayClient') {
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (ownership.gatewayClientType && ownership.gatewayClientType !== policy.gatewayClient.type) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (ownership.gatewayClientId && ownership.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
return {
gatewayClientType: policy.gatewayClient.type,
gatewayClientId: policy.gatewayClient.id,
appId: ownership.appId,
hostname: ownership.hostname,
};
}
if (!ownership.gatewayClientType || !ownership.gatewayClientId) {
throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id');
}
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
}
}
private assertRouteTargetsAllowed(auth: TAuthContext, route?: interfaces.data.IDcRouterRouteConfig): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient' || !route) return;
const allowedTargets = policy.allowedRouteTargets || [];
if (allowedTargets.length === 0) {
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
}
const targets = ((route.action as any)?.targets || []) as Array<{ host?: string; port?: number }>;
for (const target of targets) {
const host = String(target.host || '').trim().toLowerCase();
const port = Number(target.port);
const allowed = allowedTargets.some((allowedTarget) => {
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
});
if (!allowed) {
throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`);
}
}
}
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
const normalizedHostname = hostname.trim().toLowerCase();
if (!normalizedHostname) return false;
for (const pattern of patterns) {
const normalizedPattern = pattern.trim().toLowerCase();
if (!normalizedPattern) continue;
if (normalizedPattern === normalizedHostname) return true;
if (normalizedPattern.startsWith('*.')) {
const suffix = normalizedPattern.slice(2);
if (!normalizedHostname.endsWith(`.${suffix}`)) continue;
const prefix = normalizedHostname.slice(0, -(suffix.length + 1));
if (prefix && !prefix.includes('.')) return true;
}
}
return false;
}
private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains;
if (Array.isArray(domains)) {
return domains.map((domain) => String(domain).trim().toLowerCase()).filter(Boolean);
}
if (typeof domains === 'string') {
return domains.split(',').map((domain) => domain.trim().toLowerCase()).filter(Boolean);
}
return [];
}
private getOwnedRoutes(gatewayClientId?: string): interfaces.data.IMergedRoute[] {
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) return [];
return manager.getMergedRoutes().routes.filter((route) => {
const metadata = route.metadata;
if (!metadata) return false;
const ownerType = metadata.ownerType;
const isGatewayOwned = ownerType === 'gatewayClient' || ownerType === 'workhoster';
if (!isGatewayOwned) return false;
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId;
return gatewayClientId ? routeGatewayClientId === gatewayClientId : true;
});
}
private async listGatewayClientDomains(
auth: TAuthContext,
requestedGatewayClientId?: string,
): Promise<interfaces.data.IGatewayClientDomain[]> {
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return [];
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
const routeHostnames = ownedRoutes.flatMap((route) => this.getRouteHostnames(route.route));
const docs = await dnsManager.listDomains();
return docs
.filter((domainDoc) => {
if (!auth.token?.policy || auth.token.policy.role !== 'gatewayClient') return true;
return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name));
})
.map((domainDoc) => {
const domain = dnsManager.toPublicDomain(domainDoc);
const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId);
const serviceCount = routeHostnames.filter((hostname) => this.isHostnameInDomain(hostname, domain.name)).length;
return {
...domain,
serviceCount,
managePath: `/domains/${domain.id}`,
capabilities: {
canCreateSubdomains: canManageDnsRecords,
canManageDnsRecords,
canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy),
canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager),
},
} satisfies interfaces.data.IGatewayClientDomain;
});
}
private async listGatewayClientDnsRecords(
auth: TAuthContext,
requestedGatewayClientId?: string,
): Promise<interfaces.data.IGatewayClientDnsRecord[]> {
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return [];
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
const domains = await dnsManager.listDomains();
const records: interfaces.data.IGatewayClientDnsRecord[] = [];
for (const route of ownedRoutes) {
const metadata = route.metadata;
if (!metadata) continue;
const gatewayClientType = metadata.gatewayClientType || metadata.workHosterType || 'custom';
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId || '';
const appId = metadata.gatewayClientAppId || metadata.workAppId || '';
for (const hostname of this.getRouteHostnames(route.route)) {
if (auth.token?.policy?.role === 'gatewayClient' && !this.matchesHostnamePatterns(hostname, auth.token.policy.hostnamePatterns || [])) {
continue;
}
const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name));
const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : [];
const matchingRecords = domainRecords.filter((record) => record.name === hostname);
if (matchingRecords.length === 0) {
records.push({
id: `missing:${hostname}`,
domainId: domainDoc?.id || '',
domainName: domainDoc?.name,
name: hostname,
type: 'MISSING',
value: '',
ttl: 0,
source: 'local',
status: 'missing',
gatewayClientType,
gatewayClientId: routeGatewayClientId,
appId,
hostname,
routeId: route.id,
managePath: domainDoc ? `/domains/${domainDoc.id}/dns` : '/domains',
createdAt: route.createdAt || 0,
updatedAt: route.updatedAt || 0,
createdBy: '',
});
continue;
}
for (const recordDoc of matchingRecords) {
const record = dnsManager.toPublicRecord(recordDoc);
records.push({
...record,
domainName: domainDoc?.name,
status: 'active',
gatewayClientType,
gatewayClientId: routeGatewayClientId,
appId,
hostname,
routeId: route.id,
managePath: `/dns-records/${record.id}`,
});
}
}
}
return records;
}
private isHostnameInDomain(hostname: string, domainName: string): boolean {
const normalizedHostname = hostname.trim().toLowerCase();
const normalizedDomainName = domainName.trim().toLowerCase();
return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`);
}
private async syncGatewayClientRoute(
auth: TAuthContext,
ownership: interfaces.data.IGatewayClientOwnership,
route?: interfaces.data.IDcRouterRouteConfig,
enabled?: boolean,
deleteRoute?: boolean,
): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
this.assertGatewayClientOwnership(auth, resolvedOwnership);
this.assertRouteTargetsAllowed(auth, route);
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
if (deleteRoute) {
if (!existingRoute) {
return { success: true, action: 'unchanged' };
}
const result = await manager.deleteRoute(existingRoute.id);
return result.success
? { success: true, action: 'deleted', routeId: existingRoute.id }
: { success: false, message: result.message };
}
if (!route) {
return { success: false, message: 'route is required unless delete=true' };
}
const metadata: interfaces.data.IRouteMetadata = {
ownerType: 'gatewayClient',
gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: resolvedOwnership.gatewayClientId,
gatewayClientAppId: resolvedOwnership.appId,
workHosterType: resolvedOwnership.gatewayClientType,
workHosterId: resolvedOwnership.gatewayClientId,
workAppId: resolvedOwnership.appId,
externalKey,
};
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) {
const result = await manager.updateRoute(existingRoute.id, {
route: normalizedRoute,
enabled: enabled ?? true,
metadata,
});
return result.success
? { success: true, action: 'updated', routeId: existingRoute.id }
: { success: false, message: result.message };
}
const routeId = await manager.createRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
return { success: true, action: 'created', routeId };
}
private buildGatewayClientExternalKey(ownership: Required<interfaces.data.IGatewayClientOwnership>): string {
return [
ownership.gatewayClientType,
ownership.gatewayClientId,
ownership.appId,
ownership.hostname,
].map((part) => part.trim()).join(':');
}
private normalizeWorkAppRoute(
route: interfaces.data.IDcRouterRouteConfig,
ownership: interfaces.data.IWorkAppRouteOwnership,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
if (!normalizedRoute.name) {
normalizedRoute.name = `workapp-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
}
return normalizedRoute;
}
private normalizeGatewayClientRoute(
route: interfaces.data.IDcRouterRouteConfig,
ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
if (!normalizedRoute.name) {
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
}
return normalizedRoute;
}
}
+44 -101
View File
@@ -1,8 +1,6 @@
# @serve.zone/dcrouter
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
The `ts/` directory is the main dcrouter runtime package. It exposes the `DcRouter` orchestrator, `IDcRouterOptions`, `runCli()`, and the server-side exports that matter when you want to boot the full router stack from code.
## Issue Reporting and Security
@@ -14,7 +12,19 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter
```
## Usage
## Core Exports
| Export | Purpose |
| --- | --- |
| `DcRouter` | Main orchestrator for proxying, DNS, email, VPN, RADIUS, remote ingress, DB, and OpsServer |
| `IDcRouterOptions` | Top-level configuration shape |
| `runCli()` | Bootstrap helper; uses OCI env-driven config when `DCROUTER_MODE=OCI_CONTAINER` |
| `UnifiedEmailServer` and smartmta types | Re-exported email server primitives |
| `RadiusServer` and related types | RADIUS server runtime exports |
| `RemoteIngressManager` and `TunnelManager` | Remote ingress orchestration exports |
| `IHttp3Config` | HTTP/3 configuration for qualifying HTTPS routes |
## Quick Start
```typescript
import { DcRouter } from '@serve.zone/dcrouter';
@@ -23,116 +33,49 @@ const router = new DcRouter({
smartProxyConfig: {
routes: [
{
name: 'web-app',
match: { domains: ['example.com'], ports: [443] },
name: 'local-app',
match: {
domains: ['localhost'],
ports: [18080],
},
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
}
targets: [{ host: '127.0.0.1', port: 3001 }],
},
},
],
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
}
},
opsServerPort: 3000,
});
await router.start();
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
// Graceful shutdown
await router.stop();
```
## Module Structure
## What `DcRouter` Manages
```
ts/
├── index.ts # Main exports (DcRouter, re-exported smartmta types)
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
├── logger.ts # Structured logging utility
├── paths.ts # Centralized data directory paths
├── plugins.ts # All dependency imports
├── cache/ # Cache database (smartdata + LocalTsmDb)
│ ├── classes.cachedb.ts # CacheDb singleton
│ ├── classes.cachecleaner.ts # TTL-based cleanup
│ └── documents/ # Cached document models
├── config/ # Configuration utilities
├── errors/ # Error classes and retry logic
├── http3/ # HTTP/3 (QUIC) route augmentation
│ ├── index.ts # Barrel export
│ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
├── monitoring/ # MetricsManager (SmartMetrics integration)
├── opsserver/ # OpsServer dashboard + API handlers
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
│ └── handlers/ # TypedRequest handlers by domain
│ ├── admin.handler.ts # Auth (login/logout/verify)
│ ├── stats.handler.ts # Statistics + health
│ ├── config.handler.ts # Configuration (read-only)
│ ├── logs.handler.ts # Log retrieval
│ ├── email.handler.ts # Email operations
│ ├── certificate.handler.ts # Certificate management
│ ├── radius.handler.ts # RADIUS management
│ ├── remoteingress.handler.ts # Remote ingress edge + token management
│ ├── route-management.handler.ts # Programmatic route CRUD
│ ├── api-token.handler.ts # API token management
│ └── security.handler.ts # Security metrics + connections
├── radius/ # RADIUS server integration
├── remoteingress/ # Remote ingress hub integration
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
├── security/ # Security utilities
├── sms/ # SMS integration
└── storage/ # StorageManager (filesystem/custom/memory)
```
- SmartProxy for HTTP/HTTPS/TCP routes
- `UnifiedEmailServer` for SMTP ingress and delivery when `emailConfig` is present
- DB-backed managers for routes, API tokens, target profiles, domains, records, ACME config, and email domains when the DB is enabled
- embedded authoritative DNS and DoH route generation from `dnsNsDomains` and `dnsScopes`
- VPN, RADIUS, and remote ingress services when their config blocks are enabled
- OpsServer and the dashboard, which start on every boot
## Exports
## Important Runtime Behavior
```typescript
// Main class
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
- The DB is enabled by default and uses an embedded local database when no external MongoDB URL is provided.
- System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
- API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
- `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
// Re-exported from smartmta
export { UnifiedEmailServer } from '@push.rocks/smartmta';
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
## Use Another Module When...
// RADIUS
export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
// Remote Ingress
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
// HTTP/3
export type { IHttp3Config } from './http3/index.js';
```
## Key Classes
### `DcRouter`
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
| Config Section | Service Started | Package |
|----------------|----------------|---------|
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
| `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
### `RemoteIngressManager`
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
### `TunnelManager`
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
| Need | Module |
| --- | --- |
| A higher-level client SDK for a running router | `@serve.zone/dcrouter-apiclient` or `@serve.zone/dcrouter/apiclient` |
| Raw TypedRequest request/data contracts | `@serve.zone/dcrouter-interfaces` or `@serve.zone/dcrouter/interfaces` |
| The standalone migration runner | `@serve.zone/dcrouter-migrations` |
| The browser dashboard module boundary | `@serve.zone/dcrouter-web` |
## License and Legal Information
@@ -2,6 +2,10 @@ import * as plugins from '../plugins.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc } from '../db/index.js';
interface IRemoteIngressFirewallConfig {
blockedIps?: string[];
}
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
@@ -31,6 +35,7 @@ function extractPorts(portRange: number | Array<number | { from: number; to: num
export class RemoteIngressManager {
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
private firewallConfig?: IRemoteIngressFirewallConfig;
constructor() {
}
@@ -69,6 +74,13 @@ export class RemoteIngressManager {
this.routes = routes;
}
/**
* Set the full desired firewall snapshot pushed to all edges.
*/
public setFirewallConfig(firewallConfig?: IRemoteIngressFirewallConfig): void {
this.firewallConfig = firewallConfig;
}
/**
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
@@ -305,8 +317,8 @@ export class RemoteIngressManager {
* Get the list of allowed edges (enabled only) for the Rust hub.
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
*/
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
@@ -315,6 +327,7 @@ export class RemoteIngressManager {
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
});
}
}
+25 -5
View File
@@ -9,6 +9,7 @@ export interface ITunnelManagerConfig {
certPem?: string;
keyPem?: string;
};
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
}
/**
@@ -20,6 +21,7 @@ export class TunnelManager {
private config: ITunnelManagerConfig;
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
private syncChain: Promise<void> = Promise.resolve();
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
this.manager = manager;
@@ -66,7 +68,8 @@ export class TunnelManager {
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
});
...(this.config.performance ? { performance: this.config.performance } : {}),
} as any);
// Send allowed edges to the hub
await this.syncAllowedEdges();
@@ -107,20 +110,23 @@ export class TunnelManager {
if (existing) {
existing.activeTunnels = rustEdge.activeStreams;
existing.lastHeartbeat = Date.now();
this.applyRustStatus(existing, rustEdge);
// Update peer address if available from Rust hub
if (rustEdge.peerAddr) {
existing.publicIp = rustEdge.peerAddr;
}
} else {
// Missed edgeConnected event — add entry
this.edgeStatuses.set(rustEdge.edgeId, {
const status: IRemoteIngressStatus = {
edgeId: rustEdge.edgeId,
connected: true,
publicIp: rustEdge.peerAddr || null,
activeTunnels: rustEdge.activeStreams,
lastHeartbeat: Date.now(),
connectedAt: rustEdge.connectedAt * 1000,
});
};
this.applyRustStatus(status, rustEdge);
this.edgeStatuses.set(rustEdge.edgeId, status);
}
}
@@ -137,8 +143,22 @@ export class TunnelManager {
* Call this after creating/deleting/updating edges.
*/
public async syncAllowedEdges(): Promise<void> {
const edges = this.manager.getAllowedEdges();
await this.hub.updateAllowedEdges(edges);
const run = this.syncChain.catch(() => {}).then(async () => {
const edges = this.manager.getAllowedEdges();
await this.hub.updateAllowedEdges(edges as any);
});
this.syncChain = run;
await run;
}
private applyRustStatus(status: IRemoteIngressStatus, rustEdge: any): void {
status.transportMode = rustEdge.transportMode;
status.fallbackUsed = rustEdge.fallbackUsed;
status.performance = rustEdge.performance;
status.flowControl = rustEdge.flowControl;
status.queues = rustEdge.queues;
status.traffic = rustEdge.traffic;
status.udp = rustEdge.udp;
}
/**
@@ -0,0 +1,422 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js';
import type {
IIpIntelligenceRecord,
ISecurityBlockRule,
ISecurityCompiledPolicy,
ISecurityPolicyAuditEvent,
TSecurityBlockRuleMatchMode,
TSecurityBlockRuleType,
} from '../../ts_interfaces/data/security-policy.js';
export interface ISecurityPolicyManagerOptions {
intelligenceRefreshMs?: number;
onPolicyChanged?: () => void | Promise<void>;
}
export interface IRemoteIngressFirewallSnapshot {
blockedIps: string[];
}
export class SecurityPolicyManager {
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
cacheTtl: 24 * 60 * 60 * 1000,
});
private readonly intelligenceRefreshMs: number;
private readonly inFlightObservations = new Set<string>();
private readonly onPolicyChanged?: () => void | Promise<void>;
constructor(options: ISecurityPolicyManagerOptions = {}) {
this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000;
this.onPolicyChanged = options.onPolicyChanged;
}
public async start(): Promise<void> {
logger.log('info', 'SecurityPolicyManager started');
}
public async stop(): Promise<void> {
await this.smartNetwork.stop();
}
public async observeIps(ips: string[]): Promise<void> {
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
}
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
const ip = this.normalizeIp(ipAddress);
if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
return;
}
this.inFlightObservations.add(ip);
try {
const now = Date.now();
let doc = await IpIntelligenceDoc.findByIp(ip);
if (doc && !options.force && now - doc.updatedAt < this.intelligenceRefreshMs) {
if (now - doc.lastSeenAt > 60_000) {
doc.lastSeenAt = now;
doc.seenCount = (doc.seenCount || 0) + 1;
await doc.save();
}
return;
}
const intelligence = await this.smartNetwork.getIpIntelligence(ip);
if (!doc) {
doc = new IpIntelligenceDoc();
doc.ipAddress = ip;
doc.firstSeenAt = now;
}
Object.assign(doc, intelligence);
doc.lastSeenAt = now;
doc.updatedAt = now;
doc.seenCount = (doc.seenCount || 0) + 1;
await doc.save();
if (await this.matchesAnyReactiveRule(doc)) {
await this.notifyPolicyChanged();
}
} catch (err) {
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
} finally {
this.inFlightObservations.delete(ip);
}
}
public async listBlockRules(): Promise<ISecurityBlockRule[]> {
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
}
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc));
}
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
const ip = this.normalizeIp(ipAddress);
if (!ip || !this.isPublicIp(ip)) {
return null;
}
await this.observeIp(ip, { force: true });
const doc = await IpIntelligenceDoc.findByIp(ip);
return doc ? this.intelligenceFromDoc(doc) : null;
}
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
id: doc.id,
action: doc.action,
actor: doc.actor,
details: doc.details,
createdAt: doc.createdAt,
}));
}
private intelligenceFromDoc(doc: IpIntelligenceDoc): IIpIntelligenceRecord {
return {
ipAddress: doc.ipAddress,
asn: doc.asn,
asnOrg: doc.asnOrg,
registrantOrg: doc.registrantOrg,
registrantCountry: doc.registrantCountry,
networkRange: doc.networkRange,
networkCidrs: doc.networkCidrs,
abuseContact: doc.abuseContact,
country: doc.country,
countryCode: doc.countryCode,
city: doc.city,
latitude: doc.latitude,
longitude: doc.longitude,
accuracyRadius: doc.accuracyRadius,
timezone: doc.timezone,
firstSeenAt: doc.firstSeenAt,
lastSeenAt: doc.lastSeenAt,
updatedAt: doc.updatedAt,
seenCount: doc.seenCount,
};
}
public async createBlockRule(input: {
type: TSecurityBlockRuleType;
value: string;
matchMode?: TSecurityBlockRuleMatchMode;
reason?: string;
enabled?: boolean;
}, actor = 'system'): Promise<ISecurityBlockRule> {
const now = Date.now();
const doc = new SecurityBlockRuleDoc();
doc.id = plugins.uuid.v4();
doc.type = input.type;
doc.value = input.value.trim();
doc.matchMode = input.matchMode;
doc.reason = input.reason;
doc.enabled = input.enabled ?? true;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = actor;
await doc.save();
await this.writeAudit('createBlockRule', actor, { rule: this.ruleFromDoc(doc) });
await this.notifyPolicyChanged();
return this.ruleFromDoc(doc);
}
public async updateBlockRule(id: string, patch: Partial<Pick<ISecurityBlockRule, 'value' | 'matchMode' | 'reason' | 'enabled'>>, actor = 'system'): Promise<ISecurityBlockRule | null> {
const doc = await SecurityBlockRuleDoc.findById(id);
if (!doc) {
return null;
}
if (patch.value !== undefined) doc.value = patch.value.trim();
if (patch.matchMode !== undefined) doc.matchMode = patch.matchMode;
if (patch.reason !== undefined) doc.reason = patch.reason;
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
doc.updatedAt = Date.now();
await doc.save();
await this.writeAudit('updateBlockRule', actor, { id, patch });
await this.notifyPolicyChanged();
return this.ruleFromDoc(doc);
}
public async deleteBlockRule(id: string, actor = 'system'): Promise<boolean> {
const doc = await SecurityBlockRuleDoc.findById(id);
if (!doc) {
return false;
}
await doc.delete();
await this.writeAudit('deleteBlockRule', actor, { id });
await this.notifyPolicyChanged();
return true;
}
public async compilePolicy(): Promise<ISecurityCompiledPolicy> {
const rules = await SecurityBlockRuleDoc.findEnabled();
const intelligenceDocs = await IpIntelligenceDoc.findAll();
const blockedIps = new Set<string>();
const blockedCidrs = new Set<string>();
for (const rule of rules) {
const normalizedValue = rule.value.trim();
if (!normalizedValue) continue;
if (rule.type === 'ip') {
const ip = this.normalizeIp(normalizedValue);
if (ip && plugins.net.isIP(ip)) blockedIps.add(ip);
continue;
}
if (rule.type === 'cidr') {
for (const cidr of this.normalizeNetworkEntries(normalizedValue)) {
blockedCidrs.add(cidr);
}
continue;
}
for (const doc of intelligenceDocs) {
if (!this.ruleMatchesIntelligence(rule, doc)) continue;
const networkEntries = this.normalizeNetworkEntryList([
...(doc.networkCidrs || []),
doc.networkRange,
]);
if (networkEntries.length > 0) {
for (const cidr of networkEntries) {
blockedCidrs.add(cidr);
}
} else if (this.normalizeIp(doc.ipAddress)) {
blockedIps.add(this.normalizeIp(doc.ipAddress)!);
}
}
}
return {
blockedIps: [...blockedIps].sort(),
blockedCidrs: [...blockedCidrs].sort(),
};
}
public async compileSmartProxyPolicy(): Promise<ISecurityCompiledPolicy> {
return await this.compilePolicy();
}
public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot> {
const policy = await this.compilePolicy();
const blockedIps = [
...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4),
...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4),
];
return { blockedIps };
}
private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> {
const rules = await SecurityBlockRuleDoc.findEnabled();
return rules.some((rule) => rule.type === 'asn' || rule.type === 'organization'
? this.ruleMatchesIntelligence(rule, doc)
: false);
}
private ruleMatchesIntelligence(rule: SecurityBlockRuleDoc, doc: IpIntelligenceDoc): boolean {
const value = rule.value.trim().toLowerCase();
if (!value) return false;
if (rule.type === 'asn') {
return String(doc.asn ?? '') === value.replace(/^as/i, '');
}
if (rule.type === 'organization') {
const candidates = [doc.asnOrg, doc.registrantOrg]
.filter(Boolean)
.map((candidate) => candidate!.toLowerCase());
if (rule.matchMode === 'exact') {
return candidates.some((candidate) => candidate === value);
}
return candidates.some((candidate) => candidate.includes(value));
}
return false;
}
private normalizeIp(ipAddress: string): string | undefined {
const ip = ipAddress.trim();
if (ip.startsWith('::ffff:')) {
return ip.slice('::ffff:'.length);
}
return plugins.net.isIP(ip) ? ip : undefined;
}
private normalizeCidr(value: string): string | undefined {
const [rawIp, rawPrefix] = value.trim().split('/');
if (!rawIp || !rawPrefix) return undefined;
const ip = this.normalizeIp(rawIp);
if (!ip) return undefined;
const prefix = Number(rawPrefix);
const maxPrefix = plugins.net.isIP(ip) === 4 ? 32 : 128;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) return undefined;
return `${ip}/${prefix}`;
}
private normalizeNetworkEntries(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const cidr = this.normalizeCidr(trimmed);
if (cidr) return [cidr];
const rangeParts = trimmed.split(/\s+-\s+/);
if (rangeParts.length === 2) {
return this.ipv4RangeToCidrs(rangeParts[0], rangeParts[1]);
}
return [];
}
private normalizeNetworkEntryList(values: Array<string | null | undefined>): string[] {
const cidrs = new Set<string>();
for (const value of values) {
if (!value) continue;
for (const entry of value.split(',').map((part) => part.trim()).filter(Boolean)) {
for (const cidr of this.normalizeNetworkEntries(entry)) {
cidrs.add(cidr);
}
}
}
return [...cidrs];
}
private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
const start = this.ipv4ToBigInt(startIp);
const end = this.ipv4ToBigInt(endIp);
if (start === undefined || end === undefined || start > end) return [];
const cidrs: string[] = [];
let current = start;
while (current <= end) {
let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
const remaining = end - current + 1n;
while (maxBlockSize > remaining) {
maxBlockSize = maxBlockSize / 2n;
}
const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
current += maxBlockSize;
}
return cidrs;
}
private ipv4ToBigInt(ip: string): bigint | undefined {
const normalized = this.normalizeIp(ip);
if (!normalized || plugins.net.isIP(normalized) !== 4) return undefined;
return normalized
.split('.')
.reduce((sum, part) => (sum * 256n) + BigInt(Number(part)), 0n);
}
private numberToIpv4(value: bigint): string {
return [
Number((value >> 24n) & 255n),
Number((value >> 16n) & 255n),
Number((value >> 8n) & 255n),
Number(value & 255n),
].join('.');
}
private powerOfTwoExponent(value: bigint): number {
let exponent = 0;
let remaining = value;
while (remaining > 1n) {
remaining >>= 1n;
exponent++;
}
return exponent;
}
private isPublicIp(ip: string): boolean {
const family = plugins.net.isIP(ip);
if (family === 4) {
const parts = ip.split('.').map((part) => Number(part));
const [a, b] = parts;
if (a === 10 || a === 127 || a === 0 || a >= 224) return false;
if (a === 100 && b >= 64 && b <= 127) return false;
if (a === 169 && b === 254) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
return true;
}
if (family === 6) {
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return false;
if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return false;
return true;
}
return false;
}
private ruleFromDoc(doc: SecurityBlockRuleDoc): ISecurityBlockRule {
return {
id: doc.id,
type: doc.type,
value: doc.value,
matchMode: doc.matchMode,
enabled: doc.enabled,
reason: doc.reason,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
private async writeAudit(action: string, actor: string, details: Record<string, unknown>): Promise<void> {
const doc = new SecurityPolicyAuditDoc();
doc.id = plugins.uuid.v4();
doc.action = action;
doc.actor = actor;
doc.details = details;
doc.createdAt = Date.now();
await doc.save();
}
private async notifyPolicyChanged(): Promise<void> {
if (this.onPolicyChanged) {
await this.onPolicyChanged();
}
}
}
+7 -1
View File
@@ -18,4 +18,10 @@ export {
ThreatCategory,
type IScanResult,
type IContentScannerOptions
} from './classes.contentscanner.js';
} from './classes.contentscanner.js';
export {
SecurityPolicyManager,
type ISecurityPolicyManagerOptions,
type IRemoteIngressFirewallSnapshot,
} from './classes.security-policy-manager.js';

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